Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,14 @@ jobs:
- name: Construction guards on the variant's engine
run: |
set -euo pipefail
for event in autumn_budget_2024 autumn_statement_2023 spring_budget_2024 spring_statement_2025 spring_budget_2023; do
for event in autumn_budget_2024 autumn_statement_2023 spring_budget_2024 spring_statement_2025 spring_budget_2023 autumn_budget_2025; do
.venv-variant/bin/python -m pipeline.compute_uk_event --event "$event" --variant "$VARIANT" --guards > /dev/null
echo "guards hold: $event"
done
# A namespace's premises cover the events it holds results for, or
# every registered event when it holds none (namespace_events).
- name: Construction premises regenerate
run: .venv-variant/bin/python -m pipeline.audit_uk_event_premises --all --expect-engine "$ENGINE" --output "$RESULT_ROOT/CONSTRUCTION_PREMISES.json" --check
run: .venv-variant/bin/python -m pipeline.audit_uk_event_premises --namespace "$RESULT_ROOT" --expect-engine "$ENGINE" --output "$RESULT_ROOT/CONSTRUCTION_PREMISES.json" --check
- name: Re-verified diagnostics regenerate
if: env.LEGACY == 'False'
run: |
Expand Down
36,108 changes: 36,108 additions & 0 deletions data/uk/events/autumn_budget_2025_measures.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion docs/uk_replay/QUALIFICATION_INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ that file through the namespace manifest.
"runner": "github-actions ubuntu-latest"
},
"corpus": {
"corpus_version": "uk-fiscal-event-replay/1",
"corpus_version": "uk-fiscal-event-replay/2",
"events": {
"autumn_budget_2024": {
"registry_sha256": "…",
Expand Down Expand Up @@ -232,6 +232,12 @@ that file through the namespace manifest.
}
```

`corpus_version` names the event set the run replayed.
`uk-fiscal-event-replay/1` is the five events from Spring Budget 2023 to
Spring Statement 2025; `uk-fiscal-event-replay/2` adds Autumn Budget 2025.
In a diff against a base replayed under an earlier version, an added event
has `present.base` false and its rows carry no base values.

A complete receipt requires `variant_identity_sha256` and
`results.manifest_sha256`. A failed receipt has `corpus: null` when no event
produced outputs. If receipt building, sealing or result copying crashes,
Expand Down
4 changes: 2 additions & 2 deletions docs/uk_replay/RECIPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,8 @@ only when the host has capacity and no other lane is simulating. Each worker
holds one live managed simulation and reuses one certified baseline
extraction per year across measures. Keep independently launched event
processes within that same global two-simulation cap. Follow the requested event order: Autumn Budget 2024,
Autumn Statement 2023, Spring Budget 2024, Spring Statement 2025, then Spring
Budget 2023.
Autumn Statement 2023, Spring Budget 2024, Spring Statement 2025, Spring
Budget 2023, then Autumn Budget 2025.

Expected run time is proportional to supported years times changed worlds,
plus one baseline per year. Historical mode-2 runs recorded a 36.99s median
Expand Down
16 changes: 15 additions & 1 deletion docs/uk_replay/RELEASE_REPLAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ FY2023–24 counterparts; that loss is shown, never hidden.
* Legacy results stay where they are, in `results/uk/events/<event>/`.
* Every other variant writes to `results/uk/replays/<variant_id>/`:
`<event>/` (the same files as a legacy event directory), `SUMMARY.md`,
`SUPPORT.json`, `CONSTRUCTION_PREMISES.json`, `MODEL_DIAGNOSTICS.json`,
`SUPPORT.json`, `CORPUS.json` (a qualification run's corpus version and
events, written before any event runs), `CONSTRUCTION_PREMISES.json`,
`MODEL_DIAGNOSTICS.json`,
`REQUEST.json` (the re-serialised qualification request the run answers),
`INSTALL.json` (whether installation used the certified requirements,
certified constraints or free resolution), and finally
Expand Down Expand Up @@ -262,6 +264,18 @@ measured issue candidates as current only when the re-verified status is

## Construction premises

A namespace's premises and its `SUMMARY.md` cover the same events
(`uk_replay_variants.namespace_events`). A qualification run records its
corpus in `CORPUS.json` (corpus version and events) before replaying any
event, and that record decides. Without one (the legacy namespace, or a
namespace holding no run), they cover the events it holds results for (an
event directory with `RUN_MANIFEST.json`), or every registered replay event
when it holds none. A namespace whose bytes a seal or a release diff binds (a
diff records each side's premises digest) therefore keeps them when a later
event is registered, even if every event of its run failed. A qualification
run writes its summary last, after the re-verified diagnostics, which the
summary renders.

`pipeline/audit_uk_event_premises.py` records, for each executable measure,
year and parameter path the construction touches, on a given engine:
current-law value at the annual lookup (1 January of the year), the raw
Expand Down
3 changes: 2 additions & 1 deletion docs/uk_replay/YEARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
The supported calendar-year window for this lane is **2023 through 2030**,
inclusive. Calendar year Y proxies OBR fiscal year Y–(Y+1). Autumn Budget
2024 can therefore be run across its complete 2024–25 through 2029–30
costing window. This is support for a projection of one population, with
costing window, and Autumn Budget 2025, the sixth replayed event, across its
2025–26 through 2030–31 window. This is support for a projection of one population, with
named vintage differences; it does not establish a population or forecast
matched to each historical event.

Expand Down
21 changes: 20 additions & 1 deletion pipeline/audit_uk_event_premises.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,23 @@ def canonical_bytes(value: Any) -> bytes:
).encode()


def namespace_events(root: Path) -> list[str]:
"""The events a namespace's premises cover (uk_replay_variants.namespace_events)."""
from pipeline import uk_replay_variants as variants

return variants.namespace_events(root)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
selection = parser.add_mutually_exclusive_group(required=True)
selection.add_argument("--events", nargs="+", choices=EVENTS)
selection.add_argument("--all", action="store_true")
selection.add_argument(
"--namespace",
type=Path,
help="cover the events this namespace holds results for (every event if none)",
)
parser.add_argument("--expect-engine", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--check", action="store_true")
Expand All @@ -354,7 +366,14 @@ def main(argv: list[str] | None = None) -> int:
f"policyengine-uk {installed} installed; expected {args.expect_engine}"
)
payload = canonical_bytes(
audit(list(EVENTS) if args.all else args.events, EngineView())
audit(
list(EVENTS)
if args.all
else namespace_events(args.namespace)
if args.namespace
else args.events,
EngineView(),
)
)
if args.check:
if not args.output.is_file() or args.output.read_bytes() != payload:
Expand Down
151 changes: 148 additions & 3 deletions pipeline/build_uk_event_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import gzip
import hashlib
import json
import math
import os
import re
from collections import Counter, defaultdict
Expand All @@ -36,6 +37,30 @@
"spring_budget_2024": "Spring Budget 2024",
"spring_statement_2025": "Spring Statement 2025",
"spring_budget_2023": "Spring Budget 2023",
"autumn_budget_2025": "Autumn Budget 2025",
}
# Autumn Budget 2025's constructions are not re-authored here: the OBR
# costings lane's reviewed reversals (#56) are matched by verbatim PMD title,
# and #136's high-value council tax surcharge reversal by its registry key.
AB2025_COSTINGS = ROOT / "data/uk/obr_measure_reforms.yaml"
AB2025_REGISTRY = ROOT / "data/uk/ab2025_measures.json"
INVENTORY = ROOT / "data/uk/events/inventory.jsonl"
AB2025_GAPS = {
"Fuel Duty: Cancel uprating for 2026-27": (
"The announcement is a within-year path (per the PMD title: the 5p cut extended to 31 August 2026, then 1p, 2p and 2p increases from 1 September 2026, 1 December 2026 and 1 March 2027). The replay executor takes only annual windows read at 1 January (pipeline/compute_uk_event.py annual_reform), which cannot express it, so an annual counterfactual is not guessed. #136 records a dated pre-Budget path from the OBR pre-measures forecast (ab2025__fuel_duty_freeze_extension_2026_27 in data/uk/ab2025_measures.json) for a later part-year construction.",
"fuel_duty",
r"fuel_duty|petrol|diesel",
),
"Carer’s Allowance Review: Reassess overpayments": (
"Administrative reassessment of past Carer's Allowance overpayments (per the PMD title, 2015 to 2025); liability rules do not identify recovered debts or the reassessment process, so there is no household tax-benefit construction.",
"administration",
r"carers_allowance|overpayment|debt",
),
"Universal Credit: Changes to the standard allowance and health element": (
"A reversal that restores the pre-Budget protections has not been established. #136's construction switches UC rebalancing off (ab2025__uc_standard_allowance_and_health_element_rebalancing in data/uk/ab2025_measures.json); the replay inventory's note at policyengine-uk 2.125.1 (data/uk/events/inventory.jsonl) records that this leaves the 217.26 health element for every claimant, which is not the pre-measure world. The OBR costings lane leaves the measure without a construction for the same reason (data/uk/obr_measure_reforms.yaml).",
"universal_credit",
r"rebalancing|health_element|severe_conditions",
),
}
CLASSES = ("expressible", "partial", "not_expressible", "out_of_household_scope")
AXES = [
Expand Down Expand Up @@ -98,6 +123,14 @@ def canonical_bytes(value):
).encode()


def json_scalar(value):
"""An engine value JSON can hold: infinities named as in the premises
audit (an abolished limit is +inf), so registry bytes stay strict JSON."""
if isinstance(value, float) and math.isinf(value):
return "inf" if value > 0 else "-inf"
return value


def slugify(text):
return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")

Expand Down Expand Up @@ -258,12 +291,116 @@ def _head(name, variables, channel="tax"):
return {"obr_head": name, "pe_variables": variables, "channel": channel}


def inventory_registry_keys(event_slug):
"""Autumn Budget 2025's measure keys are the replay inventory's: #136's
key where the inventory records one (existing_registry_key), else the
inventory's own measure_key. The scorecard DB ingest joins registry
measures to inventory rows on them and keys the published scores by them
(scorecard_db/ingest_obr_pmd.py), so any other key would re-key scores
that already publish. The five earlier events keep their frozen keys."""
if event_slug != "autumn_budget_2025":
return {}
keys = defaultdict(set)
with INVENTORY.open() as lines:
for line in lines:
row = json.loads(line)
if row["event_key"] == event_slug:
keys[row["measure"].strip()].add(
row.get("existing_registry_key") or row["measure_key"]
)
if any(len(k) != 1 for k in keys.values()):
raise ValueError("an inventory measure has more than one registry key")
canonical = {title: key for title, (key,) in keys.items()}
if len(set(canonical.values())) != len(canonical):
raise ValueError("two inventory measures share a registry key")
return canonical


def first_window_year(spec):
"""The first calendar year any of a construction's dated windows reads."""
return min(int(window[:4]) for windows in spec.values() for window in windows)


def ab2025_construction(title):
"""The reviewed Autumn Budget 2025 construction for a PMD title, if any.

A measure commences in the first year its reversal windows read: the
costings lane's start_fy is the first year with a nonzero OBR costing,
which can precede the mechanism (an anticipatory row), and staging
treats identical worlds from commencement on as inert.
"""
import yaml

for measure in yaml.safe_load(AB2025_COSTINGS.read_text())["measures"]:
# The costings lane keeps the OBR description verbatim, padding
# included; the PMD title is matched without it.
if (measure.get("obr_description") or "").strip() != title.strip():
continue
if not measure.get("pe_reform"):
return None
heads = [
{
"obr_head": h["obr_head"],
"pe_variables": h["pe_variables"],
"channel": h["channel"],
}
for h in measure.get("heads", [])
]
partial = measure["computability"] == "partial"
note = " ".join(str(measure.get("notes", "")).split())
return _construct(
measure["measure_key"].removeprefix("autumn_budget_2025__"),
{"Income tax": "income_tax", "NICs": "national_insurance"}.get(
heads[0]["obr_head"] if heads else "", "tax_benefit"
),
measure["pe_reform"],
heads,
partial=[note] if partial else None,
note=note
+ " Construction from the OBR costings lane (data/uk/obr_measure_reforms.yaml, #56).",
start=first_window_year(measure["pe_reform"]),
)
if title.startswith("High Value Council Tax Surcharge:"):
registry = json.loads(AB2025_REGISTRY.read_text())
(entry,) = [
e
for e in registry["measures"]
if e["measure_key"] == "ab2025__high_value_council_tax_surcharge"
]
# #136 keys values by year; the executor takes dated windows.
spec = {}
for path, by_year in entry["pe_baseline_modifier"].items():
years = sorted(int(y) for y in by_year)
if (
years != list(range(years[0], years[-1] + 1))
or len({by_year[str(y)] for y in years}) != 1
):
raise ValueError(f"{path}: #136 values are not one constant run")
spec[path] = {f"{years[0]}-01-01.{years[-1]}-12-31": by_year[str(years[0])]}
return _construct(
"high_value_council_tax_surcharge",
"council_tax",
spec,
[
_head(
"High-value council tax surcharge",
["high_value_council_tax_surcharge"],
)
],
note="Reverses the four surcharge band amounts to zero, as #136's registry does (ab2025__high_value_council_tax_surcharge in data/uk/ab2025_measures.json), from 2028, the PMD title's 1 April 2028 start.",
start=first_window_year(spec),
)
return None


def authored_construction(event_slug, title, resolve):
"""Announcement levers, applied as marginal reversals on the certified world.

Later policy already in that world is retained. This is an explicit
construction and is never labelled the OBR's announcement baseline.
"""
if event_slug == "autumn_budget_2025":
return ab2025_construction(title)
rate1 = "gov.hmrc.national_insurance.class_1.rates.employee.main"
rate4 = "gov.hmrc.national_insurance.class_4.rates.main"
tax_nic_heads = [
Expand Down Expand Up @@ -608,6 +745,9 @@ def authored_construction(event_slug, title, resolve):


def gap_reason(title, rows):
for prefix, (why, program, search) in AB2025_GAPS.items():
if title.startswith(prefix):
return "not_expressible", why, program, search
low = title.lower()
if (
low.startswith("special education needs and disabilities:")
Expand Down Expand Up @@ -748,6 +888,7 @@ def build_registry(event_slug, engine_parameters, engine_variables, resolve):
grouped[row["title"]].append(row)
years = sorted({int(r["fy"][:4]) for r in rows if 2023 <= int(r["fy"][:4]) <= 2030})
measures = []
canonical_keys = inventory_registry_keys(event_slug)
for title, rs in grouped.items():
authored = authored_construction(event_slug, title, resolve)
if authored:
Expand Down Expand Up @@ -800,7 +941,9 @@ def build_registry(event_slug, engine_parameters, engine_variables, resolve):
vh = sorted(v for v in engine_variables if rx.search(v))
m.update(
{
"measure_key": f"{event_slug}__{key}",
"measure_key": canonical_keys.get(
title.strip(), f"{event_slug}__{key}"
),
"title": title,
"reported_status": "announced",
"fiscal_event": event_name,
Expand Down Expand Up @@ -846,7 +989,9 @@ def build_registry(event_slug, engine_parameters, engine_variables, resolve):
spec = m.get("pe_baseline_modifier") or m.get("pe_reform_delta") or {}
if spec:
m["engine_baseline_by_year"] = {
str(y): {path: resolve(path, f"{y}-06-01") for path in spec}
str(y): {
path: json_scalar(resolve(path, f"{y}-06-01")) for path in spec
}
for y in years
}
for path in spec:
Expand Down Expand Up @@ -912,7 +1057,7 @@ def main():
p.add_argument(
"--event",
required=True,
help="Slug of any fiscal event in the pinned source; 'all' builds the five pilot events.",
help="Slug of any fiscal event in the pinned source; 'all' builds every replayed event.",
)
p.add_argument("--check", action="store_true")
args = p.parse_args()
Expand Down
9 changes: 9 additions & 0 deletions pipeline/compare_uk_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,15 @@ def write_summary(events_root: Path, *, artifact_root: Path = ROOT) -> None:
(artifact_root / "data" / "uk" / "events").glob("*_measures.json")
)
}
# A namespace summarises the events its premises cover
# (uk_replay_variants.namespace_events): a sealed or diff-bound
# namespace's summary then regenerates byte for byte after a later event
# is registered, and historical registries (source accounting only)
# appear in no replay namespace's summary.
covered = set(variants.namespace_events(events_root))
registries = {
event: registry for event, registry in registries.items() if event in covered
}
identities = {
json.dumps(
{
Expand Down
1 change: 1 addition & 0 deletions pipeline/modal_uk_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"spring_budget_2024",
"spring_statement_2025",
"spring_budget_2023",
"autumn_budget_2025",
)
ENGINE_PINS = {
"policyengine": "5.0.2",
Expand Down
Loading
Loading