diff --git a/.github/dispatch-policyengine-release.sh b/.github/dispatch-policyengine-release.sh index fa2b3579..b41f22e0 100755 --- a/.github/dispatch-policyengine-release.sh +++ b/.github/dispatch-policyengine-release.sh @@ -1,13 +1,19 @@ #!/usr/bin/env bash # # Notify consumer repositories that a policyengine release is available on -# PyPI by sending a policyengine-release repository dispatch, which triggers -# their update workflows to open single-version bump PRs. +# PyPI by sending a repository dispatch, which triggers their update +# workflows. # # Environment: -# VERSION Exact policyengine version that was released. -# GH_TOKEN PolicyEngine GitHub App token with Contents write access to the -# consumer repositories. +# VERSION Exact policyengine version that was released. +# GH_TOKEN PolicyEngine GitHub App token with Contents write access to +# the consumer repositories. +# CONSUMER_REPOS Space-separated repositories to notify (default: +# policyengine-api policyengine-sim-api). +# PAYLOAD_FILE Optional JSON file holding the whole dispatch body +# ({"event_type": ..., "client_payload": {...}}), as written +# by scripts/release_consumers.py. Without it, a +# policyengine-release event carrying only the version is sent. set -euo pipefail if [[ -z "${VERSION:-}" ]]; then @@ -15,14 +21,31 @@ if [[ -z "${VERSION:-}" ]]; then exit 1 fi -CONSUMER_REPOS=( - policyengine-api - policyengine-sim-api +read -r -a REPOS <<< "${CONSUMER_REPOS:-policyengine-api policyengine-sim-api}" + +if [[ -n "${PAYLOAD_FILE:-}" ]]; then + payload_version=$(python - "$PAYLOAD_FILE" <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1]))["client_payload"] +request = payload.get("request") +print(payload.get("version") or request["candidate"]["policyengine"]["version"]) +PY ) + if [[ "$payload_version" != "$VERSION" ]]; then + echo "ERROR: ${PAYLOAD_FILE} is for ${payload_version}, not ${VERSION}." >&2 + exit 1 + fi +fi -for repo in "${CONSUMER_REPOS[@]}"; do - gh api "repos/PolicyEngine/${repo}/dispatches" \ - -f event_type=policyengine-release \ - -f "client_payload[version]=${VERSION}" - echo "Dispatched policyengine-release ${VERSION} to PolicyEngine/${repo}." +for repo in "${REPOS[@]}"; do + if [[ -n "${PAYLOAD_FILE:-}" ]]; then + gh api "repos/PolicyEngine/${repo}/dispatches" --method POST --input "$PAYLOAD_FILE" + else + gh api "repos/PolicyEngine/${repo}/dispatches" \ + -f event_type=policyengine-release \ + -f "client_payload[version]=${VERSION}" + fi + echo "Dispatched policyengine ${VERSION} to PolicyEngine/${repo}." done diff --git a/.github/qualification-ledger.sh b/.github/qualification-ledger.sh new file mode 100755 index 00000000..b42186d6 --- /dev/null +++ b/.github/qualification-ledger.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# Check out, or publish to, the qualification ledger branch. +# +# .github/qualification-ledger.sh checkout +# .github/qualification-ledger.sh publish "" +# +# The ledger is an orphan branch that only grows: each candidate pair writes +# its own directory, so concurrent publishes never touch the same file and a +# rejected push is retried by replaying the single new commit on the latest +# tip. The new commit may be the branch's first (two runs can both find no +# ledger yet), so it is replayed with cherry-pick, which needs no parent. +set -euo pipefail + +BRANCH="${LEDGER_BRANCH:-uk-qualification-ledger}" +command="${1:?usage: qualification-ledger.sh checkout|publish [message]}" +dir="${2:?ledger directory required}" + +case "$command" in + checkout) + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch --quiet origin "$BRANCH" + git worktree add --quiet --force -B "$BRANCH" "$dir" FETCH_HEAD + else + git worktree add --quiet --detach "$dir" + ( + cd "$dir" + git switch --quiet --orphan "$BRANCH" + cat > README.md <<'README' +# UK candidate qualification ledger + +Written by `.github/workflows/certify-uk-release.yaml` in PolicyEngine/policyengine.py. +Each `uk//` directory holds a candidate pair, every qualification +attempt's gate evidence, the replay request and receipt, and the latest +qualification receipt. Nothing is deleted. +README + ) + fi + ;; + publish) + message="${3:?commit message required}" + cd "$dir" + git add -A + if git diff --cached --quiet; then + echo "Ledger unchanged." + exit 0 + fi + identity=(-c user.name="policyengine-qualification[bot]" + -c user.email="qualification@policyengine.org") + git "${identity[@]}" commit --quiet -m "$message" + new_commit="$(git rev-parse HEAD)" + delay="${LEDGER_RETRY_DELAY:-5}" + for attempt in 1 2 3 4 5 6; do + if git push --quiet origin "HEAD:refs/heads/$BRANCH"; then + echo "Published ledger: $message" + exit 0 + fi + # Someone else published first: replay this one commit on their tip. + if ! git fetch --quiet origin "$BRANCH"; then + sleep $((attempt * delay)) + continue + fi + git reset --quiet --hard FETCH_HEAD + if ! git "${identity[@]}" cherry-pick --allow-empty "$new_commit" >/dev/null; then + git cherry-pick --abort || true + echo "::error::The ledger commit conflicts with a concurrent publish." + exit 1 + fi + sleep $((attempt * delay)) + done + echo "::error::Could not publish the qualification ledger after 6 attempts." + exit 1 + ;; + *) + echo "Unknown command: $command" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml new file mode 100644 index 00000000..3c13b969 --- /dev/null +++ b/.github/workflows/certify-uk-release.yaml @@ -0,0 +1,426 @@ +# Qualify each new policyengine-uk wheel or uk-data release before promotion. +# +# A candidate pair (newest model wheel, the core it needs, newest data +# release) is resolved and deduplicated against the ledger, certified into a +# candidate commit on candidate/uk/- (manifest, pins, uv.lock, +# TRO, test constants and snapshots regenerated together), qualified by the +# 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. +# +# Triggers: a policyengine-uk publish or uk-data release (repository +# dispatch), a two-hourly catch-up, or a manual run. A manual run with +# accept_pair_id records a reviewer's acceptance of a needs-review receipt. + +name: Certify UK release candidates + +on: + repository_dispatch: + types: [policyengine-uk-release, policyengine-uk-data-release] + schedule: + - cron: "23 */2 * * *" + workflow_dispatch: + inputs: + model_version: + description: policyengine-uk version (default newest on PyPI) + required: false + data_version: + description: uk-data release (default newest) + required: false + force: + description: Qualify even if the ledger already has a terminal receipt + type: boolean + default: false + accept_pair_id: + description: Accept the needs-review receipt of this pair (reviewer = you) + required: false + accept_receipt_sha256: + description: unreviewed_sha256 of the exact receipt being accepted + required: false + accept_explanation: + description: Why the flagged moves are expected + required: false + +permissions: + contents: write + pull-requests: write + +env: + LEDGER_BRANCH: uk-qualification-ledger + UV_VERSION: "0.12.14" + PYTHON_VERSION: "3.13" + +jobs: + resolve: + if: github.event_name != 'workflow_dispatch' || inputs.accept_pair_id == '' + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + action: ${{ steps.resolve.outputs.action }} + pair_id: ${{ steps.resolve.outputs.pair_id }} + branch: ${{ steps.resolve.outputs.branch }} + base_commit: ${{ steps.resolve.outputs.base_commit }} + attempt_key: ${{ steps.resolve.outputs.attempt_key }} + steps: + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + version: ${{ env.UV_VERSION }} + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install policyengine without country models + run: uv pip install --system -e . + - name: Check out the ledger + run: .github/qualification-ledger.sh checkout ledger + - name: Resolve and deduplicate the candidate + id: resolve + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + EVENT_TYPE: ${{ github.event.action }} + PAYLOAD_VERSION: ${{ github.event.client_payload.version }} + INPUT_MODEL: ${{ inputs.model_version }} + INPUT_DATA: ${{ inputs.data_version }} + INPUT_FORCE: ${{ inputs.force }} + run: | + model="${INPUT_MODEL:-}" + data="${INPUT_DATA:-}" + if [[ "$EVENT_NAME" == "repository_dispatch" && "$EVENT_TYPE" == "policyengine-uk-release" ]]; then + model="$PAYLOAD_VERSION" + elif [[ "$EVENT_NAME" == "repository_dispatch" && "$EVENT_TYPE" == "policyengine-uk-data-release" ]]; then + data="$PAYLOAD_VERSION" + fi + args=(resolve --out candidate.json --ledger ledger) + [[ -n "$model" ]] && args+=(--model-version "$model") + [[ -n "$data" ]] && args+=(--data-version "$data") + [[ "${INPUT_FORCE:-false}" == "true" ]] && args+=(--force) + python scripts/check_uk_candidate.py "${args[@]}" + - uses: actions/upload-artifact@v4 + with: + name: candidate + path: candidate.json + + prepare: + needs: resolve + if: needs.resolve.outputs.action == 'qualify' + runs-on: ubuntu-latest + timeout-minutes: 60 + concurrency: + group: uk-candidate-${{ needs.resolve.outputs.pair_id }} + cancel-in-progress: false + outputs: + candidate_commit: ${{ steps.stage.outputs.candidate_commit }} + duplicate: ${{ steps.duplicate.outputs.duplicate }} + steps: + - 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: ${{ 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 pair's 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 }} + - name: Certify the pair and regenerate pins, lock, TROs and constants + if: steps.duplicate.outputs.duplicate == 'false' + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + BRANCH: ${{ needs.resolve.outputs.branch }} + run: | + git switch -C "$BRANCH" + uv run python scripts/check_uk_candidate.py prepare \ + --candidate "$RUNNER_TEMP/candidate/candidate.json" \ + --out "$RUNNER_TEMP/prepared.json" --skip-snapshots + - name: Install the candidate environment from the regenerated lock + if: steps.duplicate.outputs.duplicate == 'false' + run: uv sync --frozen --extra uk --extra dev --python ${{ env.PYTHON_VERSION }} + - name: Refresh household snapshots on the candidate pair + if: steps.duplicate.outputs.duplicate == 'false' + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + PE_UPDATE_SNAPSHOTS: "1" + run: uv run pytest tests/test_household_calculator_snapshot.py -q + - name: Check the regenerated bundle + if: steps.duplicate.outputs.duplicate == 'false' + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + run: uv run python scripts/bundle.py check --include-tros --strict-tros + - name: Commit and push the candidate commit + if: steps.duplicate.outputs.duplicate == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ needs.resolve.outputs.branch }} + PAIR_ID: ${{ needs.resolve.outputs.pair_id }} + 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') + if [[ -n "$ready_pr" ]]; then + gh pr ready "$ready_pr" --undo + fi + git add -A + git -c user.name="policyengine-qualification[bot]" \ + -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" + - name: Stage the attempt in the ledger + id: stage + if: steps.duplicate.outputs.duplicate == 'false' + run: | + .github/qualification-ledger.sh checkout ledger + uv run python scripts/check_uk_candidate.py stage \ + --candidate "$RUNNER_TEMP/candidate/candidate.json" --ledger ledger + .github/qualification-ledger.sh publish ledger \ + "Stage UK candidate ${{ needs.resolve.outputs.pair_id }} attempt ${{ needs.resolve.outputs.attempt_key }}" + - name: Open or update the draft candidate PR + if: steps.duplicate.outputs.duplicate == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH: ${{ needs.resolve.outputs.branch }} + PAIR_ID: ${{ needs.resolve.outputs.pair_id }} + run: | + title="Certify UK candidate ${BRANCH#candidate/uk/}" + body="Automated certification candidate for pair \`$PAIR_ID\`. Qualification is running: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} + + 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" + else + gh pr create --draft --base main --head "$BRANCH" --title "$title" --body "$body" + fi + + qualify: + needs: [resolve, prepare] + if: needs.prepare.outputs.duplicate == 'false' + runs-on: ubuntu-latest + timeout-minutes: 120 + concurrency: + group: uk-candidate-${{ needs.resolve.outputs.pair_id }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.candidate_commit }} + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + version: ${{ env.UV_VERSION }} + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install the candidate environment + run: uv sync --frozen --extra uk --extra dev --python ${{ env.PYTHON_VERSION }} + - name: Check out the ledger + run: .github/qualification-ledger.sh checkout ledger + - name: Run the mandatory local gates + env: + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} + run: uv run python scripts/check_uk_candidate.py gates --attempt "$ATTEMPT" --work-dir "$RUNNER_TEMP/qualification" + - name: Generate a GitHub App token for the scorecard + id: scorecard-token + continue-on-error: true + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PolicyEngine + repositories: policyengine-scorecard + - name: Send the pre-promotion replay request + if: steps.scorecard-token.outcome == 'success' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.scorecard-token.outputs.token }} + ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} + run: uv run python scripts/check_uk_candidate.py request-replay --attempt "$ATTEMPT" --dispatch + - name: Publish the gate evidence + if: always() + run: | + .github/qualification-ledger.sh publish ledger \ + "Gates for UK candidate ${{ needs.resolve.outputs.pair_id }} attempt ${{ needs.resolve.outputs.attempt_key }}" + + replay: + needs: [resolve, prepare, qualify] + if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.duplicate == 'false' + runs-on: ubuntu-latest + timeout-minutes: 355 + steps: + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + version: ${{ env.UV_VERSION }} + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install policyengine without country models + run: uv pip install --system -e . + - name: Check out the ledger + run: .github/qualification-ledger.sh checkout ledger + - name: Wait for the replay receipt + env: + GH_TOKEN: ${{ github.token }} + ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} + run: | + if [[ -f "$ATTEMPT/replay/request.json" ]]; then + python scripts/check_uk_candidate.py poll-replay --attempt "$ATTEMPT" --wait-minutes 340 + else + echo "No replay request was sent; the replay gate stays unavailable." + fi + - name: Finalize and publish the qualification receipt + id: finalize + env: + ATTEMPT: ledger/uk/${{ needs.resolve.outputs.pair_id }}/attempts/${{ needs.resolve.outputs.attempt_key }} + run: | + python scripts/check_uk_candidate.py finalize --attempt "$ATTEMPT" --ledger ledger --pr-body "$RUNNER_TEMP/pr-body.md" + .github/qualification-ledger.sh publish ledger \ + "Qualification receipt for UK candidate ${{ needs.resolve.outputs.pair_id }}" + - 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: Update the candidate PR with the verdict + 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" + + catch-up: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + version: ${{ env.UV_VERSION }} + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install policyengine without country models + run: uv pip install --system -e . + - name: Finish attempts whose replay outlasted their run + env: + GH_TOKEN: ${{ github.token }} + run: | + .github/qualification-ledger.sh checkout ledger + python scripts/check_uk_candidate.py waiting --ledger ledger > waiting.txt + while read -r attempt; do + [[ -z "$attempt" ]] && continue + python scripts/check_uk_candidate.py poll-replay --attempt "$attempt" + python scripts/check_uk_candidate.py finalize --attempt "$attempt" --ledger ledger + done < waiting.txt + .github/qualification-ledger.sh publish ledger "Catch up waiting UK replay receipts" + + accept: + if: github.event_name == 'workflow_dispatch' && inputs.accept_pair_id != '' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: astral-sh/setup-uv@v8.1.0 + with: + version: ${{ env.UV_VERSION }} + - name: Install policyengine without country models + run: uv pip install --system -e . + - name: Record the reviewer's acceptance + env: + PAIR_ID: ${{ inputs.accept_pair_id }} + RECEIPT: ${{ inputs.accept_receipt_sha256 }} + EXPLANATION: ${{ inputs.accept_explanation }} + REVIEWER: ${{ github.actor }} + run: | + .github/qualification-ledger.sh checkout ledger + python scripts/check_uk_candidate.py accept --ledger ledger \ + --pair-id "$PAIR_ID" --receipt-sha256 "$RECEIPT" \ + --explanation "$EXPLANATION" --reviewer "$REVIEWER" + .github/qualification-ledger.sh publish ledger "Accept UK candidate $PAIR_ID on review by $REVIEWER" + + promote: + needs: [resolve, replay, catch-up, accept] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: promote-uk + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + - uses: astral-sh/setup-uv@v8.1.0 + with: + version: ${{ env.UV_VERSION }} + - uses: actions/setup-python@v6 + with: + 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 }} + run: | + .github/qualification-ledger.sh checkout ledger + python scripts/check_uk_candidate.py promote --ledger ledger --apply diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 6328c266..0d634c30 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -7,8 +7,11 @@ # Phase 2 (sentinel commit): Publish to PyPI + GitHub Release # Skips Lint + Test since the code is identical to Phase 1. # After Publish, NotifyConsumers waits for the release to be visible on -# PyPI and sends a policyengine-release repository dispatch to consumer -# repos so they open version-bump PRs. +# PyPI and sends a policyengine-release repository dispatch (version plus +# the certified UK bundle identity and qualification digests) to consumer +# repos so they open version-bump PRs, then a released-bundle replay +# request to policyengine-scorecard. New consumers are dispatched in +# separate, non-blocking steps. name: Push to main @@ -247,6 +250,19 @@ jobs: run: bash .github/wait-for-pypi.sh - name: Validate published SPM bundle before consumer updates run: python -S scripts/bundle.py check --published-spm + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + version: "0.12.14" + - name: Install policyengine without country models + run: uv pip install -e . --system + # The payload carries the certified UK bundle identity and, for an + # automatically qualified pair, the qualification receipt digest. + # client_payload.version stays first, as existing updaters read only it. + - name: Build the release dispatch payload + env: + VERSION: ${{ needs.Publish.outputs.version }} + run: python scripts/release_consumers.py payload --version "$VERSION" > "$RUNNER_TEMP/release-payload.json" - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v1 @@ -259,4 +275,58 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} VERSION: ${{ needs.Publish.outputs.version }} + PAYLOAD_FILE: ${{ runner.temp }}/release-payload.json + run: bash .github/dispatch-policyengine-release.sh + # New consumers are dispatched separately, so a missing App + # installation on one of them can never block the consumers above. + - name: Generate GitHub App token for the Budget dashboard + id: dashboard-token + continue-on-error: true + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PolicyEngine + repositories: uk-autumn-budget-dashboard-2026 + - name: Dispatch policyengine-release to the Budget dashboard + if: steps.dashboard-token.outcome == 'success' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} + VERSION: ${{ needs.Publish.outputs.version }} + PAYLOAD_FILE: ${{ runner.temp }}/release-payload.json + CONSUMER_REPOS: uk-autumn-budget-dashboard-2026 + run: bash .github/dispatch-policyengine-release.sh + - name: Install the released bundle for the replay request + continue-on-error: true + id: released + env: + VERSION: ${{ needs.Publish.outputs.version }} + run: uv pip install --system "policyengine[uk]==$VERSION" + - name: Build the scorecard's released-bundle replay request + if: steps.released.outcome == 'success' + id: scorecard-request + continue-on-error: true + env: + VERSION: ${{ needs.Publish.outputs.version }} + HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} + run: python scripts/release_consumers.py scorecard-request --version "$VERSION" > "$RUNNER_TEMP/scorecard-request.json" + - name: Generate GitHub App token for the scorecard + if: steps.scorecard-request.outcome == 'success' + id: scorecard-token + continue-on-error: true + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: PolicyEngine + repositories: policyengine-scorecard + - name: Send the scorecard a released-bundle replay request + if: steps.scorecard-token.outcome == 'success' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.scorecard-token.outputs.token }} + VERSION: ${{ needs.Publish.outputs.version }} + PAYLOAD_FILE: ${{ runner.temp }}/scorecard-request.json + CONSUMER_REPOS: policyengine-scorecard run: bash .github/dispatch-policyengine-release.sh diff --git a/changelog.d/570.added.md b/changelog.d/570.added.md new file mode 100644 index 00000000..267ecbf2 --- /dev/null +++ b/changelog.d/570.added.md @@ -0,0 +1 @@ +Add execution receipt schema v1 (`policyengine.provenance.receipt`), which records exactly what produced a UK result. It records the installed wrapper, model and core versions, with wheel hashes only when the builder verified them against the wheel RECORD, plus the data release and the SHA-256 of the simulated dataset bytes. The certificate digest is attached only when the certificate covers that model, core, dataset and wrapper. The receipt also carries reform and baseline digests, which refuse modifiers they cannot bind, and the fiscal-year convention. Digests use RFC 8785 canonical JSON over strict JSON values (`policyengine.provenance.canonical`), and the JSON Schema ships at `policyengine/data/schemas/execution_receipt.v1.schema.json`. diff --git a/changelog.d/573.added.md b/changelog.d/573.added.md new file mode 100644 index 00000000..73c9d238 --- /dev/null +++ b/changelog.d/573.added.md @@ -0,0 +1 @@ +Qualify each new policyengine-uk wheel or uk-data release automatically before promotion. The new `certify-uk-release` workflow and `scripts/check_uk_candidate.py` resolve and deduplicate the candidate pair. They build a candidate commit with the manifest, pins, lock, TRO and test constants regenerated together, and run mandatory gates on it, none of which can be skipped: installed versions, wheel hashes, core compatibility, data access, dataset bytes, supported years, Budget inputs, the reform contract and metric review bounds. They also send policyengine-scorecard an immutable replay request, record a qualification receipt in the `uk-qualification-ledger` branch, and promote in order. Certification gains an exact `qualified_exact_pair` basis backed by the ledger receipt, together with a core compatibility check. The release dispatch now carries the certified UK bundle identity and goes to the Budget dashboard and the scorecard as well. diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 179f8834..1599a5af 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -54,6 +54,7 @@ website: - reference/index.md - countries.md - release-bundles.md + - receipts.md - data-publishing-design.md - examples.md - dev.md diff --git a/docs/receipts.md b/docs/receipts.md new file mode 100644 index 00000000..685bed39 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,107 @@ +# Execution receipts + +An execution receipt travels with a published number, such as a dashboard row, a briefing figure or an API response. It names everything that determined that number, so a reader can tell exactly which model, data and certificate produced it, and two numbers can be compared knowing whether they came from the same run. + +Schema v1 covers UK results and is defined by `policyengine.provenance.receipt.ExecutionReceipt`. The JSON Schema ships in the wheel at `policyengine/data/schemas/execution_receipt.v1.schema.json`, and the same file is in the repository at [`src/policyengine/data/schemas/execution_receipt.v1.schema.json`](https://github.com/PolicyEngine/policyengine.py/blob/main/src/policyengine/data/schemas/execution_receipt.v1.schema.json). + +## What a receipt records + +| Field | Contents | +|---|---| +| `wrapper`, `model`, `core` | The `policyengine`, `policyengine-uk` and `policyengine-core` versions that actually ran, read from the installed distributions rather than from the bundle manifest. `wheel_sha256` appears only when the receipt builder verified it: either by checking the installed files against a wheel's RECORD (`wheel_paths=`, evidence `installed_record_verified`), or from the installer's recorded archive hash (`installer_archive_hash`). | +| `data` | The data release and build, and the model version the data was built with (`build_model_version`, distinct from `model.version`). Also the dataset's logical name, URI, revision and the SHA-256 of the bytes that were simulated. | +| `certification_status`, `certificate` | `certified` only when the bundle certifies all of: the installed model and core versions, that dataset name and those bytes, and the running `policyengine` version. Otherwise the receipt says `uncertified` and carries no certificate. `certificate_source` records whether the builder read the certificate from the installed bundle (`packaged`) or from a manifest the caller passed (`caller_supplied`). It is the producer's statement, not evidence: trust a certificate only after `verify_certificate` (below). With the packaged bundle, the composition fingerprint of its TRACE TRO is recorded as well. | +| `reform`, `baseline` | Both are required. Each is `current_law` or a digest of the reform bound to its representation (see below). | +| `fiscal_year` | What a year label means: year 2027 is the fiscal year from 6 April 2027 to 5 April 2028, shown as `2027-28`. v1 allows only this convention. | +| `years` | The reported years, sorted and unique. | +| `outputs_sha256` | Optional digest of the output payload the receipt binds. | +| `runtime` | Python version, platform and CI run URL. It is for diagnosis and is part of no digest. | + +## Building one + +```python +from policyengine.provenance import materialize_dataset +from policyengine.provenance.receipt import build_execution_receipt, reform_identity + +source = materialize_dataset("uk") # the certified release artifact, hash-verified +reform = {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}} + +receipt = build_execution_receipt( + country_id="uk", + dataset_path=source.path, + years=[2027, 2028], + reform=reform_identity( + reform, "policyengine.reform_dict/v1", name="basic rate 21p" + ), + baseline=reform_identity(None), + outputs={"budgetary_impact": {"2027": 6.1e9, "2028": 6.4e9}}, +) +print(receipt.certification_status, receipt.execution_identity_sha256) +receipt_json = receipt.to_canonical_json() +``` + +For a country `Microsimulation` built with `managed_microsimulation()`, call `build_execution_receipt_for_microsimulation(sim, years=..., reform=..., baseline=...)`. It reads the dataset that was loaded from `sim.policyengine_bundle` and hashes those bytes. The microsimulation does not record its reform, so pass the reform you built it with. + +There are two ways to bind a policyengine-uk `Scenario`: +- A scenario built from `parameter_changes` is bound by content with `scenario_reform_identity(scenario)`. +- `Scenario.from_reform(d)` wraps `d` in a closure that cannot be bound by its code, so bind `d` itself with `reform_identity(d, "policyengine-uk.reform_dict/v1")`. + +A scenario with a module-level `simulation_modifier` needs `code_revision={"repository": ..., "git_sha": }`. The identity then says `binding: "code_revision"` and binds: +- the function's module and qualified name; +- the SHA-256 of its source and of its module's source file; +- its default arguments, which must have an exact JSON form; +- that revision. + +None of these depends on where the code is checked out or which Python version runs it, so CI, a laptop and the server compute the same digest for the same code. Before binding, the function's source is recompiled and compared with the code that was imported, ignoring file names and line numbers. A file edited after import is therefore refused rather than misdescribed. Closures, lambdas, nested functions, `functools.partial`, bound methods and builtins are refused, because their behaviour depends on values the digest cannot bind. Anything that is not a `Scenario` is refused too, rather than read as current law. The digest does not cover module state changed while the process runs, such as a global reassigned after import: `code_revision` names the code, not the process. + +To check a certificate, call `verify_certificate(receipt, bundle)` with the policyengine manifest for `certificate.bundle_version`. It rebuilds the certificate from that manifest's certification record, the same way the builder does. Then it compares every identity member: the record digest, the basis, the certified model, core and dataset digest, the certifier and the bundle version. It also checks that the receipt's whole `data` identity is the certified dataset with the certified bytes. An empty list means the bundle certifies exactly what the receipt claims, and through the receipt's own rules that pins `model.version`, `core.version` and `wrapper.version` too. `certificate_source`, `qualification_receipt_sha256`, `tro_composition_fingerprint` and wheel hashes are not determined by the bundle, so it does not check them. + +Pass the release artifact that was loaded as `dataset_path`. A derived per-year file such as `enhanced_frs_2024_25_year_2026.h5` has different bytes, so a receipt built from it is `uncertified`. + +## Digests + +Every receipt digest is the lowercase hexadecimal SHA-256 of the [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON of a value, computed in two steps: +1. **Strict JSON conversion.** Only JSON-native values are accepted: strings, booleans, null, integers within ±(2^53−1), finite doubles, arrays and objects with string keys. Numpy scalars are converted to their values, integer keys to their decimal strings, and `datetime.date` to `YYYY-MM-DD`. Anything else is refused rather than silently stringified, including sets, `Decimal`, arrays, NaN and colliding keys. +2. **Null removal.** Object members whose value is `null` are removed. Array elements are kept. + +RFC 8785 fixes member order, string escaping and number formatting, so Python and JavaScript produce the same bytes. Removing nulls means a producer that writes `"field": null` and one that omits the field compute the same digest. In Python use `policyengine.provenance.receipt.content_sha256`. In JavaScript: + +```javascript +import canonicalize from "canonicalize"; +import { createHash } from "node:crypto"; + +const dropNulls = (v) => + Array.isArray(v) ? v.map(dropNulls) + : v && typeof v === "object" + ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== null).map(([k, x]) => [k, dropNulls(x)])) + : v; +const digest = (value) => createHash("sha256").update(canonicalize(dropNulls(value)), "utf8").digest("hex"); +``` + +The pre-promotion replay interface with policyengine-scorecard follows a different rule, set by its own schemas: plain RFC 8785 over the document as sent, nulls included. `policyengine.provenance.canonical.canonical_sha256` computes that. + +| Digest | Value hashed | +|---|---| +| `execution_identity_sha256` | `schema_version`, `country_id`, `wrapper`, `model`, `core`, `data`, `certification_status`, and the certificate's content: bundle version, record digest, basis, certified versions, dataset digest and certifier. Results with the same execution identity ran on the same runtime and data under the same certificate, however the receipt was built. | +| `receipt_sha256` | The whole receipt except `runtime` and the reform and baseline `name` labels. Renaming a measure therefore does not change it. | +| `certificate.certificate_sha256` | The bundle manifest's `data_releases.uk` record. | +| `reform.reform_sha256` | `{"representation": ..., "payload": ...}`, with the payload exactly as given. | +| `outputs_sha256` | The output payload. | + +Reform digests are never normalized across representations. The same keys can legitimately mean different things in different representations: for example, a bare year in a policyengine-uk country dictionary changes that one year only. So each digest is tied to the representation that was submitted. The v1 representations are `policyengine.parameter_values/v1`, `policyengine.reform_dict/v1`, `policyengine-uk.reform_dict/v1`, `policyengine-uk.scenario/v1` and `policyengine-sim-api.policy/v1`. + +## Rules JSON Schema cannot express + +Consumers that validate with the JSON Schema rather than the Python model must also check the rules listed under `x-policyengine-rules` in the schema file: + +- `years` is sorted with no duplicates, and each year is written as an integer (`2027`, not `2027.0`); +- a certificate is present exactly when the receipt is certified, and it names the receipt's country, model and core versions, dataset digest and `policyengine` version; +- a `current_law` reform has no digest, while any other reform has both a representation and a digest; +- a wheel digest always comes with its evidence; +- a certificate is trusted only after it, and the receipt's `data`, have been checked against the published manifest it names (`verify_certificate`). + +The schema itself fixes the package names (`policyengine`, `policyengine-uk`, `policyengine-core`) and the UK fiscal-year convention, which starts on 6 April. + +## Compatibility + +Adding an optional field is a compatible v1 change. Optional fields are omitted from digests when absent, so existing digests stay valid. Removing or redefining a field, or changing what a digest covers, requires schema v2. diff --git a/pyproject.toml b/pyproject.toml index 3d427ee0..8bf7da00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "pydantic>=2.0.0", + "pydantic>=2.10.0", "pandas>=2.0.0", "h5py>=3.0.0", "microdf_python>=1.3.0,<1.4", @@ -69,6 +69,7 @@ dev = [ "mypy>=1.11.0", "pytest-cov>=5.0.0", "hypothesis>=6.100.0", + "rfc8785>=0.1.4", "policyengine-core==3.33.0", "policyengine-us==2.2.1", "policyengine-uk==2.125.1", diff --git a/scripts/bundle.py b/scripts/bundle.py index 121ab8e2..072b2cb3 100644 --- a/scripts/bundle.py +++ b/scripts/bundle.py @@ -50,6 +50,10 @@ def _certify_data(args: argparse.Namespace) -> int: argv.append("--no-changelog") if args.skip_artifact_check: argv.append("--skip-artifact-check") + if args.core_version: + argv.extend(["--core-version", args.core_version]) + if args.qualified_pair_id: + argv.extend(["--qualified-pair-id", args.qualified_pair_id]) return certify_data_release_main(argv) @@ -226,6 +230,14 @@ def _parser() -> argparse.ArgumentParser: "vendored/regional artifacts." ), ) + certify.add_argument( + "--core-version", + help="policyengine-core version to check against core claims and pin.", + ) + certify.add_argument( + "--qualified-pair-id", + help="Admit the exact pair on automated qualification (needs --core-version).", + ) certify.set_defaults(func=_certify_data) packages = subparsers.add_parser( diff --git a/scripts/certify_data_release.py b/scripts/certify_data_release.py index 8842adbb..7a55ca2f 100644 --- a/scripts/certify_data_release.py +++ b/scripts/certify_data_release.py @@ -72,6 +72,22 @@ def main(argv=None) -> int: default=None, help="Model package version to certify for (default: installed).", ) + parser.add_argument( + "--core-version", + default=None, + help=( + "policyengine-core version to check against the release's core " + "claims and pin in the bundle." + ), + ) + parser.add_argument( + "--qualified-pair-id", + default=None, + help=( + "Admit the exact model, core and data release on automated " + "qualification of this pair (requires --core-version)." + ), + ) parser.add_argument("--no-generate", action="store_true") parser.add_argument("--no-changelog", action="store_true") parser.add_argument( @@ -102,6 +118,8 @@ def main(argv=None) -> int: regional_manifest_uri=args.regional_manifest_uri, regional_artifact_prefix=args.regional_artifact_prefix, regional_path_template=args.regional_path_template, + core_version=args.core_version, + qualified_pair_id=args.qualified_pair_id, ) print(result.summary()) diff --git a/scripts/check_uk_candidate.py b/scripts/check_uk_candidate.py new file mode 100644 index 00000000..59a281fc --- /dev/null +++ b/scripts/check_uk_candidate.py @@ -0,0 +1,588 @@ +"""Qualify a UK model/data candidate before it can be promoted. + +Run by ``.github/workflows/certify-uk-release.yaml``; every step also runs +locally. Network access: PyPI, Hugging Face (``HUGGING_FACE_TOKEN``) and, for +the replay and promotion steps, GitHub (``GH_TOKEN``). + +Each attempt lives in one ledger directory, ``/uk//attempts/ +/``, which every step after ``resolve`` reads and writes:: + + python scripts/check_uk_candidate.py resolve --out candidate.json \\ + [--model-version 2.126.8] [--data-version 1.58.0] [--ledger ledger/] [--force] + python scripts/check_uk_candidate.py prepare --candidate candidate.json --skip-snapshots + python scripts/check_uk_candidate.py stage --candidate candidate.json --ledger ledger/ + python scripts/check_uk_candidate.py gates --attempt + python scripts/check_uk_candidate.py request-replay --attempt [--dispatch] + python scripts/check_uk_candidate.py poll-replay --attempt [--wait-minutes 340] + python scripts/check_uk_candidate.py finalize --attempt --ledger ledger/ + python scripts/check_uk_candidate.py waiting --ledger ledger/ + python scripts/check_uk_candidate.py promote --ledger ledger/ [--apply] + python scripts/check_uk_candidate.py accept --ledger ledger/ --pair-id ... \\ + --receipt-sha256 ... --explanation "..." [--reviewer login] + +Steps that the workflow branches on also write ``key=value`` lines to +``$GITHUB_OUTPUT`` when it is set. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Optional + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) +os.environ.setdefault("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + +import requests # noqa: E402 + +from policyengine.provenance.qualification import ( # noqa: E402 + CandidatePair, + IssuedBy, + QualificationHarness, + QualificationReceipt, + ReplayQualificationRequest, +) +from policyengine.provenance.qualification_ledger import ( # noqa: E402 + LEDGER_BRANCH, + LEDGER_REPOSITORY, + attempt_directory, + latest_receipts, + pair_directory, + read_json, + write_json, +) +from policyengine.provenance.uk_qualification import ( # noqa: E402 + ResolvedCandidate, + WheelFile, + accept_review, + attempt_is_waiting_for_replay, + attempt_key, + build_replay_request, + candidate_action, + candidate_branch, + candidate_pr_body, + expected_release_bundle, + finalize_attempt, + packaged_manifest_sha256, + plan_promotion, + prepare_candidate_commit, + previous_certificate, + read_replay, + replay_dispatch_payload, + resolve_candidate, + stage_attempt, + write_gate_results, +) + +BUNDLE_PATH = REPO_ROOT / "src" / "policyengine" / "data" / "bundle" / "manifest.json" +CONFIG_PATH = REPO_ROOT / "src" / "policyengine" / "data" / "qualification" / "uk.json" +TIMEOUT = 60 + + +def utc_now() -> str: + return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def config() -> dict[str, Any]: + return json.loads(CONFIG_PATH.read_text()) + + +def hf_headers() -> dict[str, str]: + token = os.environ.get("HUGGING_FACE_TOKEN") or os.environ.get("HF_TOKEN") + return {"Authorization": f"Bearer {token}"} if token else {} + + +def pypi_project(path: str) -> dict[str, Any]: + response = requests.get(f"https://pypi.org/pypi/{path}/json", timeout=TIMEOUT) + response.raise_for_status() + return response.json() + + +def hf_revision_sha(repo_id: str, revision: str) -> str: + response = requests.get( + f"https://huggingface.co/api/models/{repo_id}/revision/{revision}", + headers=hf_headers(), + timeout=TIMEOUT, + ) + response.raise_for_status() + return str(response.json()["sha"]) + + +def hf_tags(repo_id: str) -> list[str]: + response = requests.get( + f"https://huggingface.co/api/models/{repo_id}/refs", + headers=hf_headers(), + timeout=TIMEOUT, + ) + response.raise_for_status() + return [tag["name"] for tag in response.json().get("tags", [])] + + +def hf_file(repo_id: str, revision: str, path: str) -> bytes: + response = requests.get( + f"https://huggingface.co/{repo_id}/resolve/{revision}/{path}", + headers=hf_headers(), + timeout=TIMEOUT, + ) + response.raise_for_status() + return response.content + + +def scorecard_file(repository: str): + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + headers = {"Accept": "application/vnd.github.raw+json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + def fetch(ref: str, path: str) -> Optional[bytes]: + response = requests.get( + f"https://api.github.com/repos/{repository}/contents/{path}", + params={"ref": ref}, + headers=headers, + timeout=TIMEOUT, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.content + + return fetch + + +def issued_by() -> IssuedBy: + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + repository = os.environ.get("GITHUB_REPOSITORY", LEDGER_REPOSITORY) + run_id = int(os.environ.get("GITHUB_RUN_ID", "1")) + return IssuedBy( + repository=repository, + workflow_run_id=run_id, + run_attempt=int(os.environ.get("GITHUB_RUN_ATTEMPT", "1")), + run_url=f"{server}/{repository}/actions/runs/{run_id}", + ) + + +def git(*args: str, cwd: Path = REPO_ROOT) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +def set_output(**values: str) -> None: + path = os.environ.get("GITHUB_OUTPUT") + if not path: + return + with open(path, "a") as stream: + for key, value in values.items(): + stream.write(f"{key}={value}\n") + + +def resolved_from(payload: dict[str, Any]) -> ResolvedCandidate: + return ResolvedCandidate( + pair=CandidatePair.model_validate(payload["pair"]), + model_wheel=WheelFile(**payload["model_wheel"]), + core_wheel=WheelFile(**payload["core_wheel"]), + manifest_uri=payload["manifest_uri"], + model_requires_core=payload["model_requires_core"], + ) + + +def ledger_url(pair_id: str) -> str: + return f"https://github.com/{LEDGER_REPOSITORY}/tree/{LEDGER_BRANCH}/uk/{pair_id}" + + +# --- Commands ----------------------------------------------------------------- + + +def cmd_resolve(args: argparse.Namespace) -> int: + bundle = json.loads(BUNDLE_PATH.read_text()) + resolved = resolve_candidate( + current_core=bundle["packages"]["policyengine-core"]["version"], + pypi_project=pypi_project, + hf_revision_sha=hf_revision_sha, + hf_tags=hf_tags, + hf_file=hf_file, + model_version=args.model_version or None, + data_version=args.data_version or None, + ) + base_commit = git("rev-parse", "HEAD") + action, reason = candidate_action( + resolved.pair, + bundle, + Path(args.ledger) if args.ledger else None, + base_commit=base_commit, + force=args.force, + ) + key = attempt_key( + resolved.pair, + Path(args.ledger) if args.ledger else None, + base_commit=base_commit, + force=args.force, + ) + payload = { + "pair": resolved.pair.canonical_payload(), + "pair_id": resolved.pair.pair_id, + "model_wheel": resolved.model_wheel.__dict__, + "core_wheel": resolved.core_wheel.__dict__, + "manifest_uri": resolved.manifest_uri, + "model_requires_core": resolved.model_requires_core, + "base_commit": base_commit, + "attempt_key": key, + "branch": candidate_branch(resolved.pair), + "action": action, + "reason": reason, + "previous_certificate": previous_certificate(bundle).canonical_payload(), + } + write_json(Path(args.out), payload) + print( + f"{action}: {reason} (pair {resolved.pair.pair_id[:12]}, {resolved.pair.order_key})" + ) + set_output( + action=action, + pair_id=resolved.pair.pair_id, + branch=candidate_branch(resolved.pair), + base_commit=base_commit, + attempt_key=key, + ) + return 0 + + +def cmd_prepare(args: argparse.Namespace) -> int: + payload = json.loads(Path(args.candidate).read_text()) + prepared = prepare_candidate_commit( + REPO_ROOT, + resolved_from(payload), + python=sys.executable, + refresh_snapshots=not args.skip_snapshots, + ) + write_json(Path(args.out), prepared) + print(json.dumps(prepared, indent=2)) + return 0 + + +def cmd_stage(args: argparse.Namespace) -> int: + from importlib.metadata import version + + payload = json.loads(Path(args.candidate).read_text()) + harness = QualificationHarness( + policyengine_version=version("policyengine"), + git_commit=git("rev-parse", "HEAD"), + packaged_bundle_manifest_sha256=packaged_manifest_sha256(REPO_ROOT), + ) + attempt = attempt_directory( + Path(args.ledger), "uk", payload["pair_id"], payload["attempt_key"] + ) + stage_attempt(attempt, payload, harness) + print(f"staged {attempt} at {harness.git_commit}") + set_output(attempt=str(attempt), candidate_commit=harness.git_commit) + return 0 + + +def _attempt( + args: argparse.Namespace, +) -> tuple[Path, dict[str, Any], QualificationHarness]: + attempt = Path(args.attempt) + payload = read_json(attempt / "candidate.json") + harness = QualificationHarness.model_validate(read_json(attempt / "harness.json")) + return attempt, payload, harness + + +def cmd_gates(args: argparse.Namespace) -> int: + from policyengine.provenance import uk_candidate + + attempt, payload, harness = _attempt(args) + if git("rev-parse", "HEAD") != harness.git_commit: + raise SystemExit( + "Gates must run on the candidate commit named in harness.json." + ) + ledger_root = attempt.parents[3] + results, evidence = uk_candidate.run_candidate_gates( + resolved_from(payload), + harness, + config=config(), + work_dir=Path(args.work_dir), + previous_certificate=payload.get("previous_certificate"), + ledger_root=ledger_root, + ) + write_gate_results(attempt, results, evidence) + for result in results: + print(f"{result.gate_id:20s} {result.status:13s} {result.summary}") + return 0 + + +def cmd_request_replay(args: argparse.Namespace) -> int: + attempt, payload, harness = _attempt(args) + request = build_replay_request( + CandidatePair.model_validate(payload["pair"]), + harness, + release_bundle=expected_release_bundle(sys.executable, cwd=REPO_ROOT), + issued_by=issued_by(), + attempt=args.replay_attempt, + compare_to=config()["replay"]["compare_to"], + ) + print(f"request_id {request.request_id}") + if args.dispatch: + settings = config()["replay"] + subprocess.run( + [ + "gh", + "api", + f"repos/{settings['repository']}/dispatches", + "--method", + "POST", + "--input", + "-", + ], + input=json.dumps( + replay_dispatch_payload(request, event_type=settings["event_type"]) + ), + text=True, + check=True, + ) + print(f"dispatched {settings['event_type']} to {settings['repository']}") + # Recorded only once sent: an attempt with a request waits for its replay. + write_json(attempt / "replay" / "request.json", request.document()) + set_output(request_id=request.request_id) + return 0 + + +def cmd_poll_replay(args: argparse.Namespace) -> int: + attempt = Path(args.attempt) + settings = config()["replay"] + request = ReplayQualificationRequest.model_validate( + read_json(attempt / "replay" / "request.json") + ) + fetch = scorecard_file(settings["repository"]) + deadline = time.monotonic() + 60 * args.wait_minutes + while True: + gate, status, receipt = read_replay( + request, fetch=fetch, state_ref=settings["state_ref"] + ) + if (status is not None and status.terminal) or time.monotonic() >= deadline: + break + time.sleep(args.interval_seconds) + write_json(attempt / "replay" / "replay.result.json", gate.canonical_payload()) + if status is not None: + write_json(attempt / "replay" / "status.json", status.document()) + if receipt is not None: + write_json(attempt / "replay" / "receipt.json", receipt.document()) + terminal = status is not None and status.terminal + print(f"replay {gate.status}: {gate.summary}") + set_output(replay_terminal="true" if terminal else "false") + return 0 + + +def cmd_finalize(args: argparse.Namespace) -> int: + attempt = Path(args.attempt) + receipt = finalize_attempt( + attempt, Path(args.ledger), issued_by=issued_by(), completed_at=utc_now() + ) + payload = read_json(attempt / "candidate.json") + body = candidate_pr_body( + receipt, receipt.pair, ledger_url=ledger_url(receipt.pair_id) + ) + if args.pr_body: + Path(args.pr_body).write_text(body) + print(f"verdict {receipt.verdict}; receipt {receipt.receipt_sha256}") + set_output( + verdict=receipt.verdict, + receipt_sha256=receipt.receipt_sha256, + branch=payload["branch"], + ) + return 0 + + +def cmd_waiting(args: argparse.Namespace) -> int: + base = Path(args.ledger) / "uk" + waiting = sorted( + str(path) + for path in base.glob("*/attempts/*") + if path.is_dir() and attempt_is_waiting_for_replay(path) + ) + for path in waiting: + print(path) + set_output(waiting=json.dumps(waiting)) + return 0 + + +def _open_candidate_branches() -> dict[str, str]: + """Open candidate branches and the commit each one's pull request is at.""" + + listing = subprocess.run( + [ + "gh", + "pr", + "list", + "--repo", + LEDGER_REPOSITORY, + "--state", + "open", + "--search", + "head:candidate/uk/", + "--json", + "headRefName,headRefOid", + ], + check=True, + capture_output=True, + text=True, + ).stdout + return {item["headRefName"]: item["headRefOid"] for item in json.loads(listing)} + + +def cmd_promote(args: argparse.Namespace) -> int: + bundle = json.loads(BUNDLE_PATH.read_text()) + receipts = latest_receipts(Path(args.ledger)) + branches = _open_candidate_branches() if args.apply else {} + plan = plan_promotion(receipts, bundle, branches) + print(f"promotion: {plan.reason}") + if plan.target is not None: + print( + f"target {plan.ready_branch or 'not promotable now'} " + f"(pair {plan.target.pair_id[:12]}, commit {plan.ready_commit})" + ) + for branch in plan.close_branches: + print(f"superseded {branch}") + if not args.apply: + return 0 + for branch in plan.close_branches: + subprocess.run( + [ + "gh", + "pr", + "close", + branch, + "--repo", + LEDGER_REPOSITORY, + "--comment", + f"Superseded: {plan.reason}. The ledger keeps this candidate's evidence.", + ], + 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, + ) + 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, + ) + return 0 + + +def cmd_accept(args: argparse.Namespace) -> int: + path = ( + pair_directory(Path(args.ledger), "uk", args.pair_id) + / "qualification_receipt.json" + ) + receipt = QualificationReceipt.model_validate(read_json(path)) + reviewed = accept_review( + receipt, + reviewer=args.reviewer or os.environ.get("GITHUB_ACTOR", ""), + accepted_receipt_sha256=args.receipt_sha256, + explanation=args.explanation, + accepted_at=utc_now(), + ) + write_json(path, reviewed.canonical_payload()) + write_json( + path.parent / "reviews" / f"{reviewed.receipt_sha256}.json", + reviewed.canonical_payload(), + ) + print( + f"accepted {args.pair_id[:12]} by {reviewed.review.reviewer}; promotable={reviewed.promotable}" + ) + return 0 + + +def parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest="command", required=True) + + resolve = commands.add_parser("resolve") + resolve.add_argument("--model-version") + resolve.add_argument("--data-version") + resolve.add_argument("--ledger") + resolve.add_argument("--force", action="store_true") + resolve.add_argument("--out", required=True) + resolve.set_defaults(func=cmd_resolve) + + prepare = commands.add_parser("prepare") + prepare.add_argument("--candidate", required=True) + prepare.add_argument("--out", default="prepared.json") + prepare.add_argument("--skip-snapshots", action="store_true") + prepare.set_defaults(func=cmd_prepare) + + stage = commands.add_parser("stage") + stage.add_argument("--candidate", required=True) + stage.add_argument("--ledger", required=True) + stage.set_defaults(func=cmd_stage) + + gates = commands.add_parser("gates") + gates.add_argument("--attempt", required=True) + gates.add_argument("--work-dir", default="qualification-work") + gates.set_defaults(func=cmd_gates) + + request = commands.add_parser("request-replay") + request.add_argument("--attempt", required=True) + request.add_argument("--replay-attempt", type=int, default=1) + request.add_argument("--dispatch", action="store_true") + request.set_defaults(func=cmd_request_replay) + + poll = commands.add_parser("poll-replay") + poll.add_argument("--attempt", required=True) + poll.add_argument("--wait-minutes", type=float, default=0) + poll.add_argument("--interval-seconds", type=float, default=300) + poll.set_defaults(func=cmd_poll_replay) + + finalize = commands.add_parser("finalize") + finalize.add_argument("--attempt", required=True) + finalize.add_argument("--ledger", required=True) + finalize.add_argument("--pr-body") + finalize.set_defaults(func=cmd_finalize) + + waiting = commands.add_parser("waiting") + waiting.add_argument("--ledger", required=True) + waiting.set_defaults(func=cmd_waiting) + + promote = commands.add_parser("promote") + promote.add_argument("--ledger", required=True) + promote.add_argument("--apply", action="store_true") + promote.set_defaults(func=cmd_promote) + + accept = commands.add_parser("accept") + accept.add_argument("--ledger", required=True) + accept.add_argument("--pair-id", required=True) + accept.add_argument("--receipt-sha256", required=True) + accept.add_argument("--explanation", required=True) + accept.add_argument("--reviewer") + accept.set_defaults(func=cmd_accept) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + args = parser().parse_args(argv) + return int(args.func(args) or 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_receipt_schemas.py b/scripts/generate_receipt_schemas.py new file mode 100644 index 00000000..8340948e --- /dev/null +++ b/scripts/generate_receipt_schemas.py @@ -0,0 +1,60 @@ +"""Write the published JSON Schemas for policyengine.py receipt contracts. + +The Pydantic models in ``policyengine.provenance.receipt`` are the source; +the schema files under ``src/policyengine/data/schemas/`` are what other +languages consume. ``tests/test_execution_receipt.py`` checks that the two +accept and reject the same receipts. + +Usage:: + + python scripts/generate_receipt_schemas.py # write + python scripts/generate_receipt_schemas.py --check # fail if stale +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = REPO_ROOT / "src" / "policyengine" / "data" / "schemas" +os.environ.setdefault("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") +sys.path.insert(0, str(REPO_ROOT / "src")) + +from policyengine.provenance.receipt import ExecutionReceipt # noqa: E402 + +SCHEMAS = { + "execution_receipt.v1.schema.json": ExecutionReceipt.json_schema, +} + + +def render(builder) -> str: + return json.dumps(builder(), indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + stale = [] + for filename, builder in SCHEMAS.items(): + path = SCHEMA_DIR / filename + content = render(builder) + if path.exists() and path.read_text() == content: + continue + if args.check: + stale.append(path) + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print(f"wrote {path.relative_to(REPO_ROOT)}") + for path in stale: + print(f"{path.relative_to(REPO_ROOT)} is not up to date.", file=sys.stderr) + return 1 if stale else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_trace_tros.py b/scripts/generate_trace_tros.py index a9541b46..b34562f8 100644 --- a/scripts/generate_trace_tros.py +++ b/scripts/generate_trace_tros.py @@ -76,7 +76,17 @@ def _validate_reviewed_inputs( ): raise ValueError(f"{prefix} requires certification for the pinned model") validate_release_manifest( - data, country.model_package.name, country.model_package.version + data, + country.model_package.name, + country.model_package.version, + # Revalidate the certificate's exact qualification context, including + # qualification-only models/cores. Never substitute a publisher range. + core_version=country.certification.certified_for_core_version, + qualified_pair_id=( + country.certification.qualification_pair_id + if country.certification.compatibility_basis == "qualified_exact_pair" + else None + ), ) certified = country.certified_data_artifact artifact = data.artifacts.get(certified.dataset) if certified else None @@ -127,6 +137,17 @@ def _validate_runtime_model(bundle: dict, country: CountryReleaseManifest) -> No component = bundle.get("packages", {}).get(key, {}) model = country.model_package prefix = f"{country.country_id}: strict TRACE release" + certification = country.certification + if certification and certification.compatibility_basis == "qualified_exact_pair": + core = bundle.get("packages", {}).get("policyengine-core", {}) + if ( + not certification.qualification_pair_id + or not certification.certified_for_core_version + or core.get("version") != certification.certified_for_core_version + ): + raise ValueError( + f"{prefix}: runtime core differs from the qualified exact pair" + ) if ( model.name != key or component.get("name") != model.name diff --git a/scripts/release_consumers.py b/scripts/release_consumers.py new file mode 100644 index 00000000..57ab70cf --- /dev/null +++ b/scripts/release_consumers.py @@ -0,0 +1,164 @@ +"""Build the post-release dispatches for policyengine.py consumers. + +Run by the NotifyConsumers job in ``.github/workflows/push.yaml`` after a +release is visible on PyPI:: + + python scripts/release_consumers.py payload --version 6.2.7 > payload.json + python scripts/release_consumers.py scorecard-request --version 6.2.7 > request.json + +``payload`` is the ``policyengine-release`` repository dispatch (version, +certified UK bundle identity, qualification digests). ``scorecard-request`` +is the scorecard's ``released_bundle`` replay request; it needs the release +installed (``pip install "policyengine[uk]=="``) and +``HUGGING_FACE_TOKEN``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) +os.environ.setdefault("POLICYENGINE_SKIP_COUNTRY_IMPORTS", "1") + +import requests # noqa: E402 + +from policyengine.provenance.qualification_ledger import ( # noqa: E402 + fetch_ledger_receipt, +) +from policyengine.provenance.uk_qualification import ( # noqa: E402 + release_dispatch_payload, + released_bundle_request, +) + +BUNDLE_PATH = REPO_ROOT / "src" / "policyengine" / "data" / "bundle" / "manifest.json" +TIMEOUT = 60 +TOKEN_VARIABLES = ("HF_TOKEN", "HUGGING_FACE_TOKEN", "HUGGINGFACE_HUB_TOKEN") + + +def _bundle() -> dict: + return json.loads(BUNDLE_PATH.read_text()) + + +def _pypi(path: str) -> dict: + response = requests.get(f"https://pypi.org/pypi/{path}/json", timeout=TIMEOUT) + response.raise_for_status() + return response.json() + + +def _hf_file(repo_id: str, revision: str, path: str) -> bytes: + token = os.environ.get("HUGGING_FACE_TOKEN") or os.environ.get("HF_TOKEN") + response = requests.get( + f"https://huggingface.co/{repo_id}/resolve/{revision}/{path}", + headers={"Authorization": f"Bearer {token}"} if token else {}, + timeout=TIMEOUT, + ) + response.raise_for_status() + return response.content + + +def cmd_payload(args: argparse.Namespace) -> int: + payload = release_dispatch_payload( + _bundle(), version=args.version, fetch_receipt=fetch_ledger_receipt + ) + print(json.dumps(payload, sort_keys=True)) + return 0 + + +def _installed(code: str, *, strip_tokens: bool) -> str: + """Run ``code`` against the installed release, outside the checkout.""" + + env = dict(os.environ) + env.pop("POLICYENGINE_SKIP_COUNTRY_IMPORTS", None) + if strip_tokens: + for key in TOKEN_VARIABLES: + env.pop(key, None) + return subprocess.run( + [sys.executable, "-c", code], + env=env, + cwd="/", + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def cmd_scorecard_request(args: argparse.Namespace) -> int: + release = _pypi(f"policyengine/{args.version}") + wheels = [item for item in release["urls"] if item["packagetype"] == "bdist_wheel"] + if len(wheels) != 1: + raise SystemExit(f"policyengine {args.version} must publish exactly one wheel.") + installed_version = _installed( + "from importlib.metadata import version; print(version('policyengine'))", + strip_tokens=False, + ) + if installed_version != args.version: + raise SystemExit( + f"policyengine {installed_version} is installed, not {args.version}." + ) + manifest_path = _installed( + "from importlib.resources import files; " + "print(files('policyengine').joinpath('data', 'bundle', 'manifest.json'))", + strip_tokens=False, + ) + release_bundle = json.loads( + _installed( + "import json, policyengine as pe; " + "print(json.dumps(dict(pe.uk.uk_latest.release_bundle), sort_keys=True))", + strip_tokens=True, + ).splitlines()[-1] + ) + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + repository = os.environ.get("GITHUB_REPOSITORY", "PolicyEngine/policyengine.py") + run_id = os.environ.get("GITHUB_RUN_ID", "1") + request = released_bundle_request( + json.loads(Path(manifest_path).read_text()), + version=args.version, + wheel_sha256=wheels[0]["digests"]["sha256"], + packaged_manifest_sha256=hashlib.sha256( + Path(manifest_path).read_bytes() + ).hexdigest(), + release_bundle=release_bundle, + hf_file=_hf_file, + pypi_project=_pypi, + requested_by={ + "repository": repository, + "workflow_run_id": int(run_id), + "run_attempt": int(os.environ.get("GITHUB_RUN_ATTEMPT", "1")), + "commit": os.environ.get("GITHUB_SHA", "0" * 40), + "run_url": f"{server}/{repository}/actions/runs/{run_id}", + }, + ) + print( + json.dumps( + { + "event_type": "uk-replay-qualification-request", + "client_payload": {"request": request.document()}, + }, + sort_keys=True, + ) + ) + return 0 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest="command", required=True) + payload = commands.add_parser("payload") + payload.add_argument("--version", required=True) + payload.set_defaults(func=cmd_payload) + scorecard = commands.add_parser("scorecard-request") + scorecard.add_argument("--version", required=True) + scorecard.set_defaults(func=cmd_scorecard_request) + args = parser.parse_args(argv) + return int(args.func(args) or 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/policyengine/data/qualification/uk.json b/src/policyengine/data/qualification/uk.json new file mode 100644 index 00000000..00281419 --- /dev/null +++ b/src/policyengine/data/qualification/uk.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "country_id": "uk", + "required_years": [2025, 2026, 2027, 2028, 2029, 2030], + "budget_horizon_years": [2026, 2027, 2028, 2029, 2030, 2031], + "reform_contract": { + "entrypoint": "policyengine.tax_benefit_models.uk.budget_reform_contract:run_budget_reform_contract", + "seed": 0, + "max_examples": 20, + "_comment": "W1's runner (policyengine.py#577); its pass also needs policyengine-uk#2305 and policyengine.py#575, so certified 2.125.1 fails the dated cases by design." + }, + "metrics": { + "tool": { + "repository": "PolicyEngine/policyengine-uk", + "commit": "9b33fe19f80676fc5b81d2d80b36072e1c2fc1df", + "path": "policyengine_uk/utils/aggregate_impact", + "_comment": "W3's aggregate-impact tool, pinned so both pairs use the same definitions (draft policyengine-uk#2335). Move the pin to the merged commit when it lands." + }, + "years": [2025, 2026, 2027, 2028, 2029, 2030, 2031], + "bounds": "uk_review_bounds.json" + }, + "budget_inputs": { + "critical": "uk_budget_critical_inputs.json" + }, + "replay": { + "repository": "PolicyEngine/policyengine-scorecard", + "event_type": "uk-replay-qualification-request", + "state_ref": "replay-state", + "compare_to": "latest_certified", + "poll_timeout_minutes": 360 + }, + "reform_responsiveness": { + "_comment": "Plan invariant 'Reform responsiveness' (11 Oct 2026): a reform the model silently ignores must fail qualification. The standard set, each reform's target variable, direction and yearly minimum are the metrics tool's own (standard_reforms.yaml in the pinned tool; the minimums are W3's proposals pending cos d1344). The tool costs the set by default and emits reform_runs: and responsive: checks. This gate requires both checks to be present and passing for every standard reform in the candidate's report, and recomputes each responsive verdict from the report's reform_targets. values.", + "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." + } +} diff --git a/src/policyengine/data/qualification/uk_budget_critical_inputs.json b/src/policyengine/data/qualification/uk_budget_critical_inputs.json new file mode 100644 index 00000000..90d2e422 --- /dev/null +++ b/src/policyengine/data/qualification/uk_budget_critical_inputs.json @@ -0,0 +1,58 @@ +{ + "_comment": "Proposed Budget-critical input variables, pending review under cos d1344. Each name was checked on 11 October 2026 to be an input variable (Variable.is_input_variable()) in policyengine-uk 2.125.1 and a non-degenerate column of uk-data 1.58.0 enhanced_frs_2024_25.h5. A candidate fails the budget_inputs gate if any is missing or degenerate when it was usable in the previous certified pair; a gap present in both pairs needs review unless listed under accepted_gaps with an issue.", + "critical": [ + "accommodation_type", + "age", + "alcohol_and_tobacco_consumption", + "carers_allowance_reported", + "child_benefit_reported", + "child_tax_credit_reported", + "childcare_expenses", + "clothing_and_footwear_consumption", + "communication_consumption", + "corporate_wealth", + "council_tax", + "council_tax_band", + "diesel_spending", + "dividend_income", + "domestic_energy_consumption", + "education_consumption", + "employer_pension_contributions", + "food_and_non_alcoholic_beverages_consumption", + "gender", + "gross_financial_wealth", + "health_consumption", + "hours_worked", + "household_furnishings_consumption", + "household_owns_tv", + "household_weight", + "housing_benefit_reported", + "housing_water_and_electricity_consumption", + "is_disabled_for_benefits", + "main_residence_value", + "miscellaneous_consumption", + "net_financial_wealth", + "num_vehicles", + "other_residential_property_value", + "pension_credit_reported", + "personal_pension_contributions", + "petrol_spending", + "private_pension_income", + "property_income", + "property_purchased", + "recreation_consumption", + "region", + "rent", + "restaurants_and_hotels_consumption", + "savings", + "savings_interest_income", + "self_employment_income", + "tenure_type", + "transport_consumption", + "universal_credit_reported", + "working_tax_credit_reported", + "would_claim_child_benefit", + "would_claim_uc" + ], + "accepted_gaps": {} +} diff --git a/src/policyengine/data/qualification/uk_review_bounds.json b/src/policyengine/data/qualification/uk_review_bounds.json new file mode 100644 index 00000000..c1c7718b --- /dev/null +++ b/src/policyengine/data/qualification/uk_review_bounds.json @@ -0,0 +1,79 @@ +{ + "_comment": "Proposed per-kind review bounds for qualification metrics, pending cos d1344. A move needs review when |candidate - previous| exceeds max(relative * |previous|, absolute). Units follow the metric report: gbp (pounds), count (people or units), rate and share (fractions, so 0.005 is half a percentage point). They are review triggers, not acceptance criteria: a move inside the bounds is still in the evidence.", + "default": { + "relative": 0.02, + "absolute": 500000000.0 + }, + "kinds": { + "receipt": { + "relative": 0.02, + "absolute": 500000000.0, + "why": "2% or \u00a30.5bn: the Budget scorecard reports measures to \u00a30.1bn, and model fixes moving a head by more than 2% should be explained." + }, + "spending": { + "relative": 0.02, + "absolute": 500000000.0, + "why": "As receipts." + }, + "balance": { + "relative": 0.02, + "absolute": 500000000.0, + "why": "As receipts." + }, + "model_total": { + "relative": 0.02, + "absolute": 500000000.0, + "why": "As receipts." + }, + "household_total": { + "relative": 0.02, + "absolute": 500000000.0, + "why": "As receipts." + }, + "caseload": { + "relative": 0.03, + "absolute": 50000.0, + "why": "3% or 50,000 claimants: caseloads are calibrated targets; larger moves signal data or eligibility changes." + }, + "population": { + "relative": 0.01, + "absolute": 100000.0, + "why": "Population totals should barely move between pairs." + }, + "poverty": { + "relative": 0.0, + "absolute": 0.005, + "why": "Half a percentage point on a rate." + }, + "poverty_count": { + "relative": 0.03, + "absolute": 100000.0, + "why": "3% or 100,000 people." + }, + "inequality": { + "relative": 0.0, + "absolute": 0.005, + "why": "0.005 on a Gini-type index." + }, + "decile": { + "relative": 0.0, + "absolute": 0.005, + "why": "Half a point on a decile share." + }, + "double_count": { + "relative": 0.0, + "absolute": 1.0, + "why": "Any change in a counted-once check needs review." + }, + "reform": { + "relative": 0.05, + "absolute": 100000000.0, + "why": "5% or \u00a30.1bn on a reference reform costing." + }, + "reform_target": { + "relative": 0.05, + "absolute": 100000000.0, + "why": "5% or \u00a30.1bn on a standard reform's change in its target variable." + } + } +} diff --git a/src/policyengine/data/schemas/execution_receipt.v1.schema.json b/src/policyengine/data/schemas/execution_receipt.v1.schema.json new file mode 100644 index 00000000..e147ac2a --- /dev/null +++ b/src/policyengine/data/schemas/execution_receipt.v1.schema.json @@ -0,0 +1,651 @@ +{ + "$defs": { + "CertificateIdentity": { + "additionalProperties": false, + "description": "The bundle certification that admitted this model, core and data.", + "properties": { + "bundle_version": { + "description": "policyengine.py version carrying the certificate.", + "title": "Bundle Version", + "type": "string" + }, + "certificate_sha256": { + "description": "RFC 8785 SHA-256 of the bundle manifest's data_releases. record (see certificate_sha256()).", + "pattern": "^[0-9a-f]{64}$", + "title": "Certificate Sha256", + "type": "string" + }, + "certificate_source": { + "description": "packaged: read from the installed policyengine bundle; caller_supplied: a bundle manifest passed by the caller.", + "enum": [ + "packaged", + "caller_supplied" + ], + "title": "Certificate Source", + "type": "string" + }, + "certified_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Certified By" + }, + "certified_dataset_sha256": { + "pattern": "^[0-9a-f]{64}$", + "title": "Certified Dataset Sha256", + "type": "string" + }, + "certified_for_core_version": { + "title": "Certified For Core Version", + "type": "string" + }, + "certified_for_model_version": { + "title": "Certified For Model Version", + "type": "string" + }, + "compatibility_basis": { + "description": "How the pair was admitted, e.g. built_with_model_package.", + "title": "Compatibility Basis", + "type": "string" + }, + "country_id": { + "const": "uk", + "title": "Country Id", + "type": "string" + }, + "qualification_receipt_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Digest of the candidate qualification receipt, when one exists.", + "title": "Qualification Receipt Sha256" + }, + "tro_composition_fingerprint": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Composition fingerprint of the packaged bundle TRACE TRO.", + "title": "Tro Composition Fingerprint" + } + }, + "required": [ + "bundle_version", + "country_id", + "certificate_sha256", + "compatibility_basis", + "certified_for_model_version", + "certified_for_core_version", + "certified_dataset_sha256", + "certificate_source" + ], + "title": "CertificateIdentity", + "type": "object" + }, + "CoreIdentity": { + "additionalProperties": false, + "description": "The policyengine-core distribution.", + "properties": { + "name": { + "const": "policyengine-core", + "title": "Name", + "type": "string" + }, + "version": { + "description": "Installed version (importlib.metadata).", + "title": "Version", + "type": "string" + }, + "wheel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the wheel the installed files came from. Present only when verified by the receipt builder: by checking the installed files against the wheel's RECORD, or from the installer's recorded archive hash.", + "title": "Wheel Sha256" + }, + "wheel_sha256_evidence": { + "anyOf": [ + { + "enum": [ + "installed_record_verified", + "installer_archive_hash" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How wheel_sha256 was established.", + "title": "Wheel Sha256 Evidence" + } + }, + "required": [ + "name", + "version" + ], + "title": "CoreIdentity", + "type": "object" + }, + "DataIdentity": { + "additionalProperties": false, + "description": "The data release and the exact dataset bytes that were simulated.", + "properties": { + "build_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Producer data-build fingerprint, when published.", + "title": "Build Fingerprint" + }, + "build_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Producer build identity, e.g. policyengine-uk-data-1.58.0.", + "title": "Build Id" + }, + "build_model_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Country model version the data was built with (not the one that ran).", + "title": "Build Model Version" + }, + "data_package": { + "description": "Data package, e.g. policyengine-uk-data.", + "title": "Data Package", + "type": "string" + }, + "dataset": { + "description": "Logical dataset name, e.g. enhanced_frs_2024_25.", + "title": "Dataset", + "type": "string" + }, + "dataset_revision": { + "description": "Revision the dataset was read at.", + "title": "Dataset Revision", + "type": "string" + }, + "dataset_sha256": { + "description": "SHA-256 of the dataset bytes that were simulated.", + "pattern": "^[0-9a-f]{64}$", + "title": "Dataset Sha256", + "type": "string" + }, + "dataset_uri": { + "description": "Pinned dataset URI, e.g. hf://...@1.58.0.", + "title": "Dataset Uri", + "type": "string" + }, + "release_manifest_revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Hugging Face commit of the release manifest.", + "title": "Release Manifest Revision" + }, + "release_version": { + "description": "Data release version or artifact revision.", + "title": "Release Version", + "type": "string" + } + }, + "required": [ + "data_package", + "release_version", + "dataset", + "dataset_uri", + "dataset_revision", + "dataset_sha256" + ], + "title": "DataIdentity", + "type": "object" + }, + "FiscalYearConvention": { + "additionalProperties": false, + "description": "What an annual period label means in this result.\n\nv1 receipts are UK results, so the convention is the UK fiscal year: year\nY is the period from 6 April Y to 5 April Y+1. Another convention needs a\nnew schema version.", + "properties": { + "convention": { + "const": "uk_fiscal_year", + "title": "Convention", + "type": "string" + }, + "parameter_annualisation": { + "description": "Identifier of the annualisation rule the recorded model version implements, e.g. policyengine-uk/fiscal-year-parameters.", + "title": "Parameter Annualisation", + "type": "string" + }, + "period_start": { + "const": "04-06", + "description": "Month-day the labelled period starts (6 April).", + "title": "Period Start", + "type": "string" + }, + "year_label": { + "const": "start_year", + "default": "start_year", + "description": "Year Y labels the period that starts in Y (2027 is 2027-28).", + "title": "Year Label", + "type": "string" + } + }, + "required": [ + "convention", + "period_start", + "parameter_annualisation" + ], + "title": "FiscalYearConvention", + "type": "object" + }, + "ModelIdentity": { + "additionalProperties": false, + "description": "The country model distribution (v1: policyengine-uk).", + "properties": { + "name": { + "const": "policyengine-uk", + "title": "Name", + "type": "string" + }, + "version": { + "description": "Installed version (importlib.metadata).", + "title": "Version", + "type": "string" + }, + "wheel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the wheel the installed files came from. Present only when verified by the receipt builder: by checking the installed files against the wheel's RECORD, or from the installer's recorded archive hash.", + "title": "Wheel Sha256" + }, + "wheel_sha256_evidence": { + "anyOf": [ + { + "enum": [ + "installed_record_verified", + "installer_archive_hash" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How wheel_sha256 was established.", + "title": "Wheel Sha256 Evidence" + } + }, + "required": [ + "name", + "version" + ], + "title": "ModelIdentity", + "type": "object" + }, + "ReformIdentity": { + "additionalProperties": false, + "description": "A reform or baseline, bound by the digest of its canonical form.", + "properties": { + "binding": { + "default": "content", + "description": "content: the digest determines the reform's behaviour. code_revision: the reform runs a module-level Python function, bound by its source, its module's source, its default arguments and the caller's code revision.", + "enum": [ + "content", + "code_revision" + ], + "title": "Binding", + "type": "string" + }, + "kind": { + "description": "current_law means no parameter or structural change.", + "enum": [ + "current_law", + "reform" + ], + "title": "Kind", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Human label; part of no digest.", + "title": "Name" + }, + "reform_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "content_sha256 of {'representation': ..., 'payload': ...}.", + "title": "Reform Sha256" + }, + "representation": { + "anyOf": [ + { + "enum": [ + "policyengine.parameter_values/v1", + "policyengine.reform_dict/v1", + "policyengine-uk.reform_dict/v1", + "policyengine-sim-api.policy/v1", + "policyengine-uk.scenario/v1" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Representation" + } + }, + "required": [ + "kind" + ], + "title": "ReformIdentity", + "type": "object" + }, + "RuntimeContext": { + "additionalProperties": false, + "description": "Where the execution ran. Part of no digest.", + "properties": { + "ci_run_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ci Run Url" + }, + "platform": { + "title": "Platform", + "type": "string" + }, + "python_version": { + "title": "Python Version", + "type": "string" + } + }, + "required": [ + "python_version", + "platform" + ], + "title": "RuntimeContext", + "type": "object" + }, + "WrapperIdentity": { + "additionalProperties": false, + "description": "The policyengine.py distribution, optionally pinned to a source commit.", + "properties": { + "git_sha": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{40}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "policyengine.py commit, when running from a checkout.", + "title": "Git Sha" + }, + "name": { + "const": "policyengine", + "title": "Name", + "type": "string" + }, + "version": { + "description": "Installed version (importlib.metadata).", + "title": "Version", + "type": "string" + }, + "wheel_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "SHA-256 of the wheel the installed files came from. Present only when verified by the receipt builder: by checking the installed files against the wheel's RECORD, or from the installer's recorded archive hash.", + "title": "Wheel Sha256" + }, + "wheel_sha256_evidence": { + "anyOf": [ + { + "enum": [ + "installed_record_verified", + "installer_archive_hash" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "How wheel_sha256 was established.", + "title": "Wheel Sha256 Evidence" + } + }, + "required": [ + "name", + "version" + ], + "title": "WrapperIdentity", + "type": "object" + } + }, + "$id": "https://policyengine.org/schemas/execution_receipt.v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Schema v1 execution receipt. See the module docstring for its rules.", + "properties": { + "baseline": { + "$ref": "#/$defs/ReformIdentity" + }, + "certificate": { + "anyOf": [ + { + "$ref": "#/$defs/CertificateIdentity" + }, + { + "type": "null" + } + ], + "default": null + }, + "certification_status": { + "enum": [ + "certified", + "uncertified" + ], + "title": "Certification Status", + "type": "string" + }, + "core": { + "$ref": "#/$defs/CoreIdentity" + }, + "country_id": { + "const": "uk", + "title": "Country Id", + "type": "string" + }, + "data": { + "$ref": "#/$defs/DataIdentity" + }, + "fiscal_year": { + "$ref": "#/$defs/FiscalYearConvention" + }, + "model": { + "$ref": "#/$defs/ModelIdentity" + }, + "outputs_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "content_sha256 of the canonical output payload this receipt binds.", + "title": "Outputs Sha256" + }, + "receipt_type": { + "const": "policyengine.execution_receipt", + "title": "Receipt Type", + "type": "string" + }, + "reform": { + "$ref": "#/$defs/ReformIdentity" + }, + "runtime": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimeContext" + }, + { + "type": "null" + } + ], + "default": null + }, + "schema_version": { + "const": 1, + "title": "Schema Version", + "type": "integer" + }, + "wrapper": { + "$ref": "#/$defs/WrapperIdentity" + }, + "years": { + "items": { + "type": "integer" + }, + "minItems": 1, + "title": "Years", + "type": "array" + } + }, + "required": [ + "schema_version", + "receipt_type", + "country_id", + "wrapper", + "model", + "core", + "data", + "certification_status", + "reform", + "baseline", + "fiscal_year", + "years" + ], + "title": "ExecutionReceipt", + "type": "object", + "x-policyengine-digests": { + "canonicalization": "RFC 8785 (JSON Canonicalization Scheme) over the stated value after removing object members whose value is null (array elements are kept). Numbers are IEEE 754 doubles; integers must lie within +/-(2**53 - 1).", + "certificate.certificate_sha256": "the bundle manifest's data_releases. record", + "execution_identity_sha256": "{schema_version, country_id, wrapper, model, core, data, certification_status, certificate?} where certificate keeps only bundle_version, country_id, certificate_sha256, compatibility_basis, certified_for_model_version, certified_for_core_version, certified_dataset_sha256 and certified_by", + "outputs_sha256": "the canonical output payload the receipt binds", + "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", + "reform.reform_sha256": "{representation, payload} where payload is the reform exactly as given in that representation" + }, + "x-policyengine-rules": [ + "years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).", + "certification_status certified requires certificate; uncertified forbids it.", + "certificate.country_id equals country_id.", + "certificate.certified_for_model_version equals model.version.", + "certificate.certified_for_core_version equals core.version.", + "certificate.certified_dataset_sha256 equals data.dataset_sha256.", + "certificate.bundle_version equals wrapper.version.", + "reform/baseline kind current_law has no representation or reform_sha256 and binding content; kind reform has both.", + "wheel_sha256 and wheel_sha256_evidence are present together or not at all.", + "A consumer trusts a certificate only after checking it against the data_releases. record of the published policyengine manifest for certificate.bundle_version (verify_certificate()): certificate_sha256 is that record's digest, every other member of certificate except certificate_source, qualification_receipt_sha256 and tro_composition_fingerprint equals what the record states, and data equals the data identity the record determines. certificate_source is the producer's statement and proves nothing." + ] +} diff --git a/src/policyengine/data/schemas/vendor/policyengine-scorecard/README.md b/src/policyengine/data/schemas/vendor/policyengine-scorecard/README.md new file mode 100644 index 00000000..b8a52769 --- /dev/null +++ b/src/policyengine/data/schemas/vendor/policyengine-scorecard/README.md @@ -0,0 +1,9 @@ +# Vendored policyengine-scorecard schemas + +Copied unchanged from PolicyEngine/policyengine-scorecard, branch +`w6/replay-variants`, commit `dc040de` (`docs/uk_replay/schemas/`): the v1 +pre-promotion replay interface between policyengine.py qualification (W4) +and the replay corpus (W6). `policyengine.provenance.qualification` mirrors +them, and `tests/test_qualification_contracts.py` checks the mirrors and the +schemas accept and reject the same documents. Refresh by copying from a newer +scorecard commit and updating the commit above; never edit them by hand. diff --git a/src/policyengine/data/schemas/vendor/policyengine-scorecard/qualification_request.v1.json b/src/policyengine/data/schemas/vendor/policyengine-scorecard/qualification_request.v1.json new file mode 100644 index 00000000..5865ca3e --- /dev/null +++ b/src/policyengine/data/schemas/vendor/policyengine-scorecard/qualification_request.v1.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PolicyEngine/policyengine-scorecard/blob/main/docs/uk_replay/schemas/qualification_request.v1.json", + "title": "UK fiscal-event replay qualification request, v1", + "description": "Sent by policyengine.py release qualification (W4) to policyengine-scorecard (W6). request_id is the SHA-256 of the RFC 8785 (JCS) bytes of the whole request; it is never a field. Additive optional fields keep v1; renamed or removed fields bump the version.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "kind", "attempt", "candidate", "compare_to", "requested_by"], + "properties": { + "schema": {"const": "policyengine.uk-replay-qualification-request/v1"}, + "kind": { + "description": "candidate: a pair not yet promoted; released_bundle: a published policyengine.py release.", + "enum": ["candidate", "released_bundle"] + }, + "attempt": { + "description": "A retry of a failed request is a new request with a higher attempt.", + "type": "integer", + "minimum": 1 + }, + "candidate": { + "type": "object", + "additionalProperties": false, + "required": ["policyengine", "policyengine_uk", "policyengine_core", "data", "expected_release_bundle"], + "properties": { + "policyengine": { + "type": "object", + "additionalProperties": false, + "required": ["version", "source", "packaged_bundle_manifest_sha256"], + "properties": { + "version": {"$ref": "#/$defs/version"}, + "source": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["git"], + "properties": { + "git": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "commit"], + "properties": { + "repository": {"const": "PolicyEngine/policyengine.py"}, + "commit": {"$ref": "#/$defs/sha1"} + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["pypi"], + "properties": { + "pypi": { + "type": "object", + "additionalProperties": false, + "required": ["wheel_sha256"], + "properties": {"wheel_sha256": {"$ref": "#/$defs/sha256"}} + } + } + } + ] + }, + "packaged_bundle_manifest_sha256": {"$ref": "#/$defs/sha256"} + } + }, + "policyengine_uk": {"$ref": "#/$defs/pypi_package"}, + "policyengine_core": {"$ref": "#/$defs/pypi_package"}, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["build_id", "dataset", "repo_id", "repo_type", "revision", "path", "sha256", "size_bytes", "release_manifest_sha256"], + "properties": { + "build_id": {"type": "string", "minLength": 1}, + "dataset": {"type": "string", "minLength": 1}, + "repo_id": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "repo_type": {"enum": ["model", "dataset"]}, + "revision": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1, "pattern": "^(?!.*(^|/)\\.\\.(/|$))[^/][^\\\\]*$"}, + "sha256": {"$ref": "#/$defs/sha256"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "release_manifest_sha256": {"$ref": "#/$defs/sha256"} + } + }, + "expected_release_bundle": { + "description": "The exact dict(pe.uk.uk_latest.release_bundle) under packaged certification: computed in a subprocess with HF_TOKEN, HUGGING_FACE_TOKEN, HUGGINGFACE_HUB_TOKEN, NO_PROXY and no_proxy removed and HTTP_PROXY, HTTPS_PROXY, ALL_PROXY (and their lowercase forms) set to http://127.0.0.1:9, so the wrapper's release-manifest request cannot reach the network and the packaged certification is used for a private or public data repository (pipeline/uk_replay_variants.py, isolate_certification_environment).", + "type": "object", + "required": ["policyengine_version", "model_version", "certified_data_build_id", "certified_data_artifact_sha256", "default_dataset_uri"], + "additionalProperties": {"type": ["string", "null"]}, + "properties": { + "policyengine_version": {"$ref": "#/$defs/version"}, + "model_version": {"$ref": "#/$defs/version"}, + "certified_data_build_id": {"type": "string", "minLength": 1}, + "certified_data_artifact_sha256": {"$ref": "#/$defs/sha256"}, + "default_dataset_uri": {"type": "string", "pattern": "^hf://"} + } + } + } + }, + "compare_to": { + "description": "latest_certified, or an explicit scorecard variant_id.", + "type": "string", + "minLength": 1 + }, + "requested_by": { + "type": "object", + "additionalProperties": true, + "required": ["repository", "commit"], + "properties": { + "repository": {"type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"}, + "workflow_run_id": {"type": "integer", "minimum": 1}, + "run_attempt": {"type": "integer", "minimum": 1}, + "commit": {"$ref": "#/$defs/sha1"} + } + } + }, + "allOf": [ + { + "if": {"properties": {"kind": {"const": "released_bundle"}}}, + "then": { + "properties": { + "candidate": { + "properties": { + "policyengine": {"properties": {"source": {"required": ["pypi"]}}} + } + } + } + } + } + ], + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "sha1": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "version": {"type": "string", "pattern": "^[0-9]+(\\.[0-9]+)*([a-z0-9.+-]*)$"}, + "pypi_package": { + "type": "object", + "additionalProperties": false, + "required": ["version", "wheel_sha256"], + "properties": { + "version": {"$ref": "#/$defs/version"}, + "wheel_sha256": {"$ref": "#/$defs/sha256"} + } + } + } +} diff --git a/src/policyengine/data/schemas/vendor/policyengine-scorecard/replay_receipt.v1.json b/src/policyengine/data/schemas/vendor/policyengine-scorecard/replay_receipt.v1.json new file mode 100644 index 00000000..a9d52614 --- /dev/null +++ b/src/policyengine/data/schemas/vendor/policyengine-scorecard/replay_receipt.v1.json @@ -0,0 +1,638 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PolicyEngine/policyengine-scorecard/blob/main/docs/uk_replay/schemas/replay_receipt.v1.json", + "title": "UK fiscal-event replay receipt, v1", + "description": "Returned by policyengine-scorecard for one qualification request. receipt_sha256 is the SHA-256 of the RFC 8785 (JCS) bytes of the receipt without that field. Flags describe movements against the base variant; they carry no verdict.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "request_id", + "status", + "rejection", + "variant_id", + "variant_identity_sha256", + "execution", + "corpus", + "integrity", + "diff", + "results", + "receipt_sha256" + ], + "properties": { + "schema": { + "const": "policyengine.uk-replay-receipt/v1" + }, + "request_id": { + "$ref": "#/$defs/sha256" + }, + "status": { + "enum": [ + "complete", + "rejected", + "failed" + ] + }, + "rejection": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "detail" + ], + "properties": { + "code": { + "enum": [ + "schema_invalid", + "artifact_hash_mismatch", + "release_bundle_mismatch", + "incompatible_pair", + "data_access_failed", + "no_supported_years" + ] + }, + "detail": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "variant_id": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "variant_identity_sha256": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/sha256" + } + ] + }, + "execution": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "packages", + "packaged_bundle_manifest_sha256", + "release_bundle", + "dataset_sha256", + "requirements_sha256", + "python", + "platform", + "runner" + ], + "properties": { + "packages": { + "type": "object", + "additionalProperties": false, + "required": [ + "policyengine", + "policyengine-uk", + "policyengine-core" + ], + "properties": { + "policyengine": { + "$ref": "#/$defs/installed_package" + }, + "policyengine-uk": { + "$ref": "#/$defs/installed_package" + }, + "policyengine-core": { + "$ref": "#/$defs/installed_package" + } + } + }, + "packaged_bundle_manifest_sha256": { + "$ref": "#/$defs/sha256" + }, + "release_bundle": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "dataset_sha256": { + "$ref": "#/$defs/sha256" + }, + "requirements_sha256": { + "$ref": "#/$defs/sha256" + }, + "python": { + "type": "string", + "pattern": "^3\\.[0-9]+\\.[0-9]+$" + }, + "platform": { + "type": "string", + "minLength": 1 + }, + "runner": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "corpus": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "corpus_version", + "events" + ], + "properties": { + "corpus_version": { + "type": "string", + "minLength": 1 + }, + "events": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/event_result" + } + } + } + } + ] + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "failures" + ], + "properties": { + "status": { + "description": "complete: every supported measure-year ran and every receipt verified. incomplete: some supported pairs are missing. failed: a crash, a non-finite value, an identity mismatch after the run started, or a broken accounting identity.", + "enum": [ + "complete", + "incomplete", + "failed", + "not_run" + ] + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "detail" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string", + "minLength": 1 + }, + "event": { + "type": "string" + }, + "measure_key": { + "type": "string" + }, + "year": { + "type": "integer" + } + } + } + } + } + }, + "diff": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "base_variant_id", + "diff_sha256", + "flag_counts", + "flagged_rows" + ], + "properties": { + "base_variant_id": { + "type": "string", + "minLength": 1 + }, + "diff_sha256": { + "$ref": "#/$defs/sha256" + }, + "flag_counts": { + "type": "object", + "additionalProperties": false, + "required": [ + "numerical", + "structural", + "coverage", + "mapping", + "support", + "inertness", + "construction" + ], + "properties": { + "numerical": { + "type": "integer", + "minimum": 0 + }, + "structural": { + "type": "integer", + "minimum": 0 + }, + "coverage": { + "type": "integer", + "minimum": 0 + }, + "mapping": { + "type": "integer", + "minimum": 0 + }, + "support": { + "type": "integer", + "minimum": 0 + }, + "inertness": { + "type": "integer", + "minimum": 0 + }, + "construction": { + "type": "integer", + "minimum": 0 + } + } + }, + "flagged_rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "measure_key", + "year", + "level", + "key", + "flags", + "base_gbp", + "head_gbp" + ], + "properties": { + "event": { + "type": "string", + "minLength": 1 + }, + "measure_key": { + "type": "string", + "minLength": 1 + }, + "year": { + "type": "integer" + }, + "level": { + "enum": [ + "source_row", + "measure_year", + "head" + ] + }, + "key": { + "type": "string", + "minLength": 1 + }, + "flags": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "numerical", + "structural", + "coverage", + "mapping", + "support", + "inertness", + "construction" + ] + } + }, + "base_gbp": { + "type": [ + "number", + "null" + ] + }, + "head_gbp": { + "type": [ + "number", + "null" + ] + } + } + } + } + } + } + ] + }, + "results": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "ref", + "path" + ], + "properties": { + "repository": { + "const": "PolicyEngine/policyengine-scorecard" + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "commit": { + "$ref": "#/$defs/sha1" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "manifest_sha256": { + "description": "SHA-256 of NAMESPACE_MANIFEST.json, which lists the SHA-256 of every file in the sealed result namespace except itself and the receipt.", + "$ref": "#/$defs/sha256" + } + } + }, + "receipt_sha256": { + "$ref": "#/$defs/sha256" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "rejected" + } + } + }, + "then": { + "properties": { + "rejection": { + "type": "object" + }, + "corpus": { + "type": "null" + }, + "diff": { + "type": "null" + }, + "variant_id": { + "type": "null" + }, + "variant_identity_sha256": { + "type": "null" + }, + "execution": { + "type": "null" + }, + "integrity": { + "properties": { + "status": { + "const": "not_run" + } + } + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "complete" + } + } + }, + "then": { + "properties": { + "rejection": { + "type": "null" + }, + "variant_id": { + "type": "string" + }, + "execution": { + "type": "object" + }, + "variant_identity_sha256": { + "$ref": "#/$defs/sha256" + }, + "results": { + "required": [ + "manifest_sha256" + ] + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "complete" + } + } + }, + "then": { + "properties": { + "integrity": { + "properties": { + "status": { + "enum": [ + "complete", + "incomplete" + ] + } + } + }, + "corpus": { + "type": "object" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "failed" + } + } + }, + "then": { + "properties": { + "rejection": { + "type": "null" + }, + "integrity": { + "properties": { + "status": { + "const": "failed" + } + } + } + } + } + } + ], + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "sha1": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "installed_package": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "record_sha256" + ], + "properties": { + "version": { + "type": "string", + "minLength": 1 + }, + "record_sha256": { + "$ref": "#/$defs/sha256" + }, + "wheel_sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "event_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "registry_sha256", + "supported_years", + "unsupported_years", + "run_manifest_sha256", + "staging_manifest_sha256", + "comparison_provenance_sha256", + "artifacts", + "full_supported_grid_complete", + "inert_measure_years" + ], + "properties": { + "registry_sha256": { + "$ref": "#/$defs/sha256" + }, + "supported_years": { + "type": "array", + "items": { + "type": "integer" + }, + "uniqueItems": true + }, + "unsupported_years": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "year", + "reason" + ], + "properties": { + "year": { + "type": "integer" + }, + "reason": { + "enum": [ + "before_data_year", + "after_extension_end" + ] + } + } + } + }, + "run_manifest_sha256": { + "$ref": "#/$defs/sha256" + }, + "staging_manifest_sha256": { + "$ref": "#/$defs/sha256" + }, + "comparison_provenance_sha256": { + "$ref": "#/$defs/sha256" + }, + "artifacts": { + "type": "integer", + "minimum": 0 + }, + "full_supported_grid_complete": { + "type": "boolean" + }, + "inert_measure_years": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "measure_key", + "year" + ], + "properties": { + "measure_key": { + "type": "string" + }, + "year": { + "type": "integer" + } + } + } + } + } + } + } +} diff --git a/src/policyengine/data/schemas/vendor/policyengine-scorecard/replay_status.v1.json b/src/policyengine/data/schemas/vendor/policyengine-scorecard/replay_status.v1.json new file mode 100644 index 00000000..3694ca4e --- /dev/null +++ b/src/policyengine/data/schemas/vendor/policyengine-scorecard/replay_status.v1.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PolicyEngine/policyengine-scorecard/blob/main/docs/uk_replay/schemas/replay_status.v1.json", + "title": "UK fiscal-event replay request status, v1", + "description": "replay-state:requests//STATUS.json. A terminal status names the receipt's digest and the replay-state commit that added REPLAY_RECEIPT.json, so one contents read binds the status to the receipt bytes.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "request_id", "status", "history"], + "properties": { + "schema": {"const": "policyengine.uk-replay-status/v1"}, + "request_id": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "status": {"enum": ["queued", "running", "complete", "rejected", "failed"]}, + "history": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["status", "at", "run_url"], + "properties": { + "status": {"enum": ["queued", "running", "complete", "rejected", "failed"]}, + "at": {"type": "string", "format": "date-time"}, + "run_url": {"type": "string", "pattern": "^https://github\\.com/PolicyEngine/policyengine-scorecard/actions/runs/[0-9]+"} + } + } + }, + "receipt_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "receipt_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + }, + "allOf": [ + { + "if": {"properties": {"status": {"enum": ["complete", "rejected", "failed"]}}}, + "then": {"required": ["receipt_sha256", "receipt_commit"]} + } + ] +} diff --git a/src/policyengine/provenance/__init__.py b/src/policyengine/provenance/__init__.py index fe1f8046..91d0bdca 100644 --- a/src/policyengine/provenance/__init__.py +++ b/src/policyengine/provenance/__init__.py @@ -15,6 +15,12 @@ ) """ +from .canonical import ( + canonical_json as canonical_json, +) +from .canonical import ( + canonical_sha256 as canonical_sha256, +) from .certification import ( CertificationError as CertificationError, ) @@ -87,6 +93,18 @@ from .manifest import ( resolve_managed_dataset_reference as resolve_managed_dataset_reference, ) +from .receipt import ( + ExecutionReceipt as ExecutionReceipt, +) +from .receipt import ( + build_execution_receipt as build_execution_receipt, +) +from .receipt import ( + reform_identity as reform_identity, +) +from .receipt import ( + verify_certificate as verify_certificate, +) from .trace import ( build_simulation_trace_tro as build_simulation_trace_tro, ) diff --git a/src/policyengine/provenance/canonical.py b/src/policyengine/provenance/canonical.py new file mode 100644 index 00000000..cf77cad4 --- /dev/null +++ b/src/policyengine/provenance/canonical.py @@ -0,0 +1,200 @@ +"""Cross-language canonical JSON and content digests for receipts. + +Receipts are hashed in Python (this package, the scorecard), JavaScript (the +Budget dashboard) and other consumers, so their digests use the JSON +Canonicalization Scheme (RFC 8785) rather than Python's ``json.dumps`` +spelling. Two implementations that agree on RFC 8785 produce identical bytes +for the same JSON value, so a digest computed anywhere can be checked +anywhere. + +RFC 8785 in brief: + +- object members are sorted by the UTF-16 code units of their names; +- no insignificant whitespace; +- strings are escaped as ECMAScript ``JSON.stringify`` escapes them, and + everything else is written as literal UTF-8; +- numbers are written as ECMAScript ``Number.prototype.toString`` writes + IEEE 754 doubles. + +The TRACE TRO digests in :mod:`policyengine.provenance.trace` keep their own +documented serialization; this module is for receipt contracts. +""" + +from __future__ import annotations + +import hashlib +import math +from collections.abc import Mapping, Sequence +from typing import Any + +__all__ = [ + "CanonicalJSONError", + "canonical_json", + "canonical_sha256", + "drop_null_members", + "MAX_SAFE_INTEGER", +] + +#: Largest integer every IEEE 754 double consumer reads exactly (2**53 - 1). +#: Larger integers are refused, as the rfc8785 reference implementation does. +MAX_SAFE_INTEGER = 2**53 - 1 + +_SHORT_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", +} + + +class CanonicalJSONError(ValueError): + """Raised for values RFC 8785 cannot serialize identically everywhere.""" + + +def _string(value: str) -> str: + parts = ['"'] + for character in value: + escaped = _SHORT_ESCAPES.get(character) + if escaped is not None: + parts.append(escaped) + continue + code_point = ord(character) + if code_point < 0x20: + parts.append(f"\\u{code_point:04x}") + elif 0xD800 <= code_point <= 0xDFFF: + raise CanonicalJSONError( + "Strings with unpaired surrogate code points have no canonical " + "UTF-8 form." + ) + else: + parts.append(character) + parts.append('"') + return "".join(parts) + + +def _number(value: float) -> str: + """Write a double as ECMAScript ``Number.prototype.toString`` does.""" + if math.isnan(value) or math.isinf(value): + raise CanonicalJSONError("NaN and infinite numbers are not valid JSON.") + if value == 0: + return "0" + sign = "-" if value < 0 else "" + # ``repr`` gives the shortest digit string that round-trips, which is the + # digit string ECMAScript uses; only the layout around it differs. + # ``float(...)`` first: subclasses such as ``numpy.float64`` have their own + # ``repr`` (``np.float64(1.5)``), which is not a JSON number. + mantissa, _, exponent_text = repr(float(abs(value))).partition("e") + integer_part, _, fraction_part = mantissa.partition(".") + all_digits = integer_part + fraction_part + # ``point`` counts digits before the decimal point in ``all_digits``. + point = len(integer_part) + (int(exponent_text) if exponent_text else 0) + significant = all_digits.lstrip("0") + point -= len(all_digits) - len(significant) + digits = significant.rstrip("0") + # ECMAScript names: value = digits * 10 ** (n - k). + k = len(digits) + n = point + if k <= n <= 21: + text = digits + "0" * (n - k) + elif 0 < n <= 21: + text = digits[:n] + "." + digits[n:] + elif -6 < n <= 0: + text = "0." + "0" * (-n) + digits + else: + exponent_sign = "+" if n - 1 >= 0 else "-" + tail = digits[0] if k == 1 else digits[0] + "." + digits[1:] + text = f"{tail}e{exponent_sign}{abs(n - 1)}" + return sign + text + + +def _serialize(value: Any, out: list[str]) -> None: + if value is None: + out.append("null") + elif value is True: + out.append("true") + elif value is False: + out.append("false") + elif isinstance(value, str): + out.append(_string(value)) + elif isinstance(value, int): + if abs(value) > MAX_SAFE_INTEGER: + # Matches the rfc8785 reference implementation, which refuses + # integers it cannot be sure every consumer reads identically. + raise CanonicalJSONError( + f"Integer {value} is outside +/-(2**53 - 1); write it as a " + "string or a float." + ) + # ``int.__repr__`` ignores an int subclass's own ``__str__``/``__repr__`` + # (``IntEnum`` members are written as their value). + out.append(int.__repr__(int(value))) + elif isinstance(value, float): + out.append(_number(value)) + elif isinstance(value, Mapping): + items = [] + for key, item in value.items(): + if not isinstance(key, str): + raise CanonicalJSONError( + f"Object member names must be strings, got {type(key).__name__}." + ) + items.append((key, item)) + items.sort(key=lambda pair: pair[0].encode("utf-16-be", "surrogatepass")) + out.append("{") + for index, (key, item) in enumerate(items): + if index: + out.append(",") + out.append(_string(key)) + out.append(":") + _serialize(item, out) + out.append("}") + elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + out.append("[") + for index, item in enumerate(value): + if index: + out.append(",") + _serialize(item, out) + out.append("]") + else: + raise CanonicalJSONError( + f"{type(value).__name__} is not a JSON value; serialize it first." + ) + + +def canonical_json(value: Any) -> bytes: + """Return the RFC 8785 canonical UTF-8 bytes of a JSON value.""" + + out: list[str] = [] + try: + _serialize(value, out) + except RecursionError as exc: + raise CanonicalJSONError( + "The value is nested too deeply to serialize." + ) from exc + return "".join(out).encode("utf-8") + + +def canonical_sha256(value: Any) -> str: + """Return the lowercase hex SHA-256 of a value's canonical JSON bytes.""" + + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def drop_null_members(value: Any) -> Any: + """Remove object members whose value is ``null``, recursively. + + Receipt digests are taken after this step, so a producer that writes + ``"field": null`` and one that omits the field compute the same digest. + Array elements are kept as they are. + """ + + if isinstance(value, Mapping): + return { + key: drop_null_members(item) + for key, item in value.items() + if item is not None + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [drop_null_members(item) for item in value] + return value diff --git a/src/policyengine/provenance/certification.py b/src/policyengine/provenance/certification.py index 3b4b27f1..e31762f9 100644 --- a/src/policyengine/provenance/certification.py +++ b/src/policyengine/provenance/certification.py @@ -69,6 +69,14 @@ # fallback there exempts exactly this string from the fingerprint identity # check, so a claims-certified bundle stays importable without network access. BASIS_PUBLISHER_CLAIM = "legacy_compatible_model_package" +# The pair passed policyengine.py's automated candidate qualification. The +# claim is exact (``==`` model and core versions, one data release) and is +# valid only with a promotable qualification receipt for ``qualification_pair_id`` +# in the ledger; ``tests/test_certification_evidence.py`` refuses it otherwise. +BASIS_QUALIFIED_PAIR = "qualified_exact_pair" +QUALIFICATION_LEDGER_URL = ( + "https://github.com/PolicyEngine/policyengine.py/tree/uk-qualification-ledger" +) POPULACE_US_SOURCE_COVERAGE_FILE = "us_source_coverage.json" # The Populace US producer publishes under `microcosm-data` since the # source-enrichment releases; `populace-data` is its earlier name. Both denote @@ -226,15 +234,26 @@ def validate_release_manifest( manifest: DataReleaseManifest, model_package: str, model_version: str, + *, + core_version: Optional[str] = None, + qualified_pair_id: Optional[str] = None, ) -> tuple[str, list[str]]: """Hard checks raise; returns ``(compatibility_basis, warnings)``. The certification gate (docs/release-bundles.md): a data release may be certified for a model version only when the model exactly matches - the build-time model, or the publisher's ``compatible_model_packages`` - covers it. The publisher-claim basis is recorded and warned about — - it is the data publisher's assertion, made good only by this repo's - test suite passing on the pinned pair. + the build-time model, the publisher's ``compatible_model_packages`` + covers it, or (``qualified_pair_id``) policyengine.py's automated + qualification tested exactly this model, core and data release. The + publisher-claim basis is recorded and warned about — it is the data + publisher's assertion, made good only by this repo's test suite passing + on the pinned pair. A qualified pair is never widened into a range. + + When ``core_version`` is given, the core must also be the build-time + core, be covered by ``compatible_core_packages``, or be part of the + qualified pair. A core admitted only by the qualification makes the + basis ``qualified_exact_pair`` whatever the model's own claim, so the + certificate names the pair and its ledger evidence is enforced. """ warnings: list[str] = [] if "national" not in manifest.default_datasets: @@ -267,6 +286,21 @@ def validate_release_manifest( _specifier_matches(model_version, specifier) for specifier in claim_specifiers ) + core_rests_on_qualification = core_version is not None and ( + _check_core_compatibility(manifest, core_version, qualified_pair_id, warnings) + ) + if core_rests_on_qualification: + # The publisher never claimed this core, so the model's own claim + # cannot carry the certificate: it is the exact pair that was tested, + # and the certificate must name it so its evidence is checked. + warnings.append( + f"certifying exactly {model_package}=={model_version} with " + f"policyengine-core=={core_version} on automated qualification of " + f"pair {qualified_pair_id}; the certificate stands only with a " + "promotable qualification receipt for that pair in the ledger" + ) + return BASIS_QUALIFIED_PAIR, warnings + if built_with == model_version: return BASIS_BUILT_WITH, warnings if claim_matches: @@ -277,11 +311,65 @@ def validate_release_manifest( "suite is the arbiter" ) return BASIS_PUBLISHER_CLAIM, warnings + if qualified_pair_id is not None: + if core_version is None: + raise CertificationError( + "A qualified exact pair names its core version; pass core_version." + ) + warnings.append( + f"certifying exactly {model_package}=={model_version} with " + f"policyengine-core=={core_version} on automated qualification of " + f"pair {qualified_pair_id}; the certificate stands only with a " + "promotable qualification receipt for that pair in the ledger" + ) + return BASIS_QUALIFIED_PAIR, warnings raise CertificationError( f"{model_package} {model_version} matches neither the build-time " f"model ({built_with or 'unrecorded'}) nor any publisher " f"compatibility claim {claim_specifiers or '(none declared)'}; " - "a new data build or a published compatibility claim is required." + "a new data build, a published compatibility claim or an automated " + "qualification of the exact pair is required." + ) + + +def _check_core_compatibility( + manifest: DataReleaseManifest, + core_version: str, + qualified_pair_id: Optional[str], + warnings: list[str], +) -> bool: + """The core must be the build core, publisher-claimed, or qualified. + + Returns whether the core is admitted only by the qualification, in which + case the certificate's basis must be the qualified exact pair. + """ + + build = manifest.build + built_with_core = ( + build.built_with_core_package.version + if build is not None and build.built_with_core_package is not None + else None + ) + core_claims = [ + package.specifier + for package in manifest.compatible_core_packages + if package.name == "policyengine-core" + ] + if built_with_core == core_version or any( + _specifier_matches(core_version, specifier) for specifier in core_claims + ): + return False + if qualified_pair_id is not None: + warnings.append( + f"policyengine-core {core_version} is outside the publisher's core " + f"claims {core_claims or '(none declared)'}; covered by qualification " + f"of pair {qualified_pair_id}" + ) + return True + raise CertificationError( + f"policyengine-core {core_version} matches neither the build-time core " + f"({built_with_core or 'unrecorded'}) nor any publisher core claim " + f"{core_claims or '(none declared)'}." ) @@ -591,6 +679,8 @@ def build_country_manifest_payload( model_version: str, model_wheel: dict, compatibility_basis: str = BASIS_BUILT_WITH, + core_version: Optional[str] = None, + qualified_pair_id: Optional[str] = None, ) -> dict: """Map the data release manifest to the vendored country manifest.""" default_dataset = manifest.default_datasets["national"] @@ -651,6 +741,13 @@ def build_country_manifest_payload( "certified_for_model_version": model_version, "certified_by": CERTIFIED_BY, } + if core_version is not None: + certification["certified_for_core_version"] = core_version + if qualified_pair_id is not None: + certification["qualification_pair_id"] = qualified_pair_id + certification["qualification_ledger_url"] = ( + f"{QUALIFICATION_LEDGER_URL}/{country}/{qualified_pair_id}" + ) certified_artifact: dict = { "data_package": { "name": manifest.data_package.name, @@ -747,6 +844,8 @@ def certify( regional_manifest_uri: Optional[str] = None, regional_artifact_prefix: str = "states/", regional_path_template: str = "states/{state_code}.h5", + core_version: Optional[str] = None, + qualified_pair_id: Optional[str] = None, ) -> tuple[dict, str, list[str]]: raise NotImplementedError @@ -766,6 +865,8 @@ def certify( regional_manifest_uri: Optional[str] = None, regional_artifact_prefix: str = "states/", regional_path_template: str = "states/{state_code}.h5", + core_version: Optional[str] = None, + qualified_pair_id: Optional[str] = None, ) -> tuple[dict, str, list[str]]: raise CertificationError( "Legacy data-producer certification updates are not implemented in " @@ -788,6 +889,8 @@ def certify( regional_manifest_uri: Optional[str] = None, regional_artifact_prefix: str = "states/", regional_path_template: str = "states/{state_code}.h5", + core_version: Optional[str] = None, + qualified_pair_id: Optional[str] = None, ) -> tuple[dict, str, list[str]]: manifest, manifest_sha256, uri_parts = fetch_release_manifest( manifest_uri, token=token @@ -810,7 +913,11 @@ def certify( path_template=regional_path_template, ) compatibility_basis, warnings = validate_release_manifest( - manifest, model_package, model_version + manifest, + model_package, + model_version, + core_version=core_version, + qualified_pair_id=qualified_pair_id, ) for filename in required_supplemental_release_files( @@ -838,6 +945,12 @@ def certify( model_version=model_version, model_wheel=model_wheel or {}, compatibility_basis=compatibility_basis, + core_version=core_version, + qualified_pair_id=( + qualified_pair_id + if compatibility_basis == BASIS_QUALIFIED_PAIR + else None + ), ) if check_artifacts and should_validate_vendored_artifacts( country, manifest, uri_parts @@ -898,7 +1011,17 @@ def certify_data_release( regional_manifest_uri: Optional[str] = None, regional_artifact_prefix: str = "states/", regional_path_template: str = "states/{state_code}.h5", + core_version: Optional[str] = None, + qualified_pair_id: Optional[str] = None, ) -> CertificationResult: + """Certify a data release into the bundle manifest. + + ``core_version`` adds the core compatibility check and pins the core in + the bundle's ``packages``. ``qualified_pair_id`` admits the exact model, + core and data release when neither the build-time model nor a publisher + claim covers them; the certificate then names the pair, whose promotable + qualification receipt must be in the ledger. + """ if country not in COUNTRY_MODEL_PACKAGES: raise CertificationError(f"Unknown country {country!r}.") model_package = COUNTRY_MODEL_PACKAGES[country] @@ -915,6 +1038,8 @@ def certify_data_release( regional_manifest_uri=regional_manifest_uri, regional_artifact_prefix=regional_artifact_prefix, regional_path_template=regional_path_template, + core_version=core_version, + qualified_pair_id=qualified_pair_id, ) bundle_path = bundle_path or default_bundle_source_path() bundle = json.loads(bundle_path.read_text()) @@ -924,6 +1049,8 @@ def certify_data_release( ) if model_package in bundle.get("packages", {}): bundle["packages"][model_package]["version"] = model_version + if core_version is not None and "policyengine-core" in bundle.get("packages", {}): + bundle["packages"]["policyengine-core"]["version"] = core_version bundle_path.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n") return CertificationResult( diff --git a/src/policyengine/provenance/manifest.py b/src/policyengine/provenance/manifest.py index 0995c0d5..05d062fe 100644 --- a/src/policyengine/provenance/manifest.py +++ b/src/policyengine/provenance/manifest.py @@ -155,6 +155,16 @@ class DataCertification(BaseModel): built_with_model_git_sha: Optional[str] = None data_build_fingerprint: Optional[str] = None certified_by: Optional[str] = None + certified_for_core_version: Optional[str] = None + qualification_pair_id: Optional[str] = None + qualification_ledger_url: Optional[str] = None + + +#: Bases whose runtime model legitimately differs from the data's build-time +#: model, so the build fingerprint is not expected to match. +CLAIM_BASED_COMPATIBILITY = frozenset( + {"legacy_compatible_model_package", "qualified_exact_pair"} +) class CertifiedDataArtifact(BaseModel): @@ -392,10 +402,32 @@ def _specifier_matches(version: str, specifier: str) -> bool: return False +def _runtime_core_is_certified( + certification: DataCertification, runtime_core_version: Optional[str] +) -> bool: + """Whether the running core is the one an exact-pair certificate tested. + + Other bases make no claim about the core at run time. An exact pair was + tested with one core, so it holds only for that core. + """ + + if certification.compatibility_basis != "qualified_exact_pair": + return True + if runtime_core_version is None: + from importlib import metadata + + try: + runtime_core_version = metadata.version("policyengine-core") + except metadata.PackageNotFoundError: + return False + return certification.certified_for_core_version == runtime_core_version + + def certify_data_release_compatibility( country_id: str, runtime_model_version: str, runtime_data_build_fingerprint: Optional[str] = None, + runtime_core_version: Optional[str] = None, ) -> DataCertification: country_manifest = get_release_manifest(country_id) try: @@ -407,7 +439,7 @@ def certify_data_release_compatibility( runtime_data_build_fingerprint is not None and bundled_certification.data_build_fingerprint is not None and bundled_certification.compatibility_basis - != "legacy_compatible_model_package" + not in CLAIM_BASED_COMPATIBILITY and runtime_data_build_fingerprint != bundled_certification.data_build_fingerprint ): @@ -418,6 +450,9 @@ def certify_data_release_compatibility( if ( bundled_certification.certified_for_model_version == runtime_model_version + and _runtime_core_is_certified( + bundled_certification, runtime_core_version + ) ): return bundled_certification return DataCertification( @@ -434,6 +469,43 @@ def certify_data_release_compatibility( certified_by=bundled_certification.certified_by, ) raise exc + + # A remote build-model, fingerprint or publisher claim cannot broaden an + # exact-pair certificate beyond the model, core and recorded data build + # that qualification tested. Check them before any acceptance path returns. + bundled_certification = country_manifest.certification + remote_build_id = ( + data_release_manifest.build.build_id + if data_release_manifest.build is not None + else None + ) + if ( + bundled_certification is not None + and bundled_certification.compatibility_basis == "qualified_exact_pair" + ): + if bundled_certification.certified_for_model_version != runtime_model_version: + raise ValueError( + "Data release manifest is not certified for the runtime model " + f"version {runtime_model_version} in country '{country_id}'; " + "the exact pair requires " + f"{bundled_certification.certified_for_model_version}." + ) + if not _runtime_core_is_certified(bundled_certification, runtime_core_version): + raise ValueError( + "Data release manifest is not certified for the runtime core " + f"version {runtime_core_version} in country '{country_id}'; " + "the exact pair requires " + f"{bundled_certification.certified_for_core_version}." + ) + if ( + bundled_certification.data_build_id is not None + and bundled_certification.data_build_id != remote_build_id + ): + raise ValueError( + "Data release manifest is not certified for the remote data " + f"build {remote_build_id} in country '{country_id}'; the exact " + f"pair requires {bundled_certification.data_build_id}." + ) built_with_model = ( data_release_manifest.build.built_with_model_package if data_release_manifest.build is not None @@ -514,6 +586,20 @@ def certify_data_release_compatibility( ), ) + # An automated qualification of exactly this model and data build: the + # bundled certificate names the pair, and the ledger evidence for it is + # enforced when the bundle is built (tests/test_certification_evidence.py). + bundled_certification = country_manifest.certification + if ( + bundled_certification is not None + and bundled_certification.compatibility_basis == "qualified_exact_pair" + and bundled_certification.certified_for_model_version == runtime_model_version + and bundled_certification.qualification_pair_id is not None + and bundled_certification.data_build_id == remote_build_id + and _runtime_core_is_certified(bundled_certification, runtime_core_version) + ): + return bundled_certification + raise ValueError( "Data release manifest is not certified for the runtime model version " f"{runtime_model_version} in country '{country_id}'." diff --git a/src/policyengine/provenance/qualification.py b/src/policyengine/provenance/qualification.py new file mode 100644 index 00000000..45a04ccf --- /dev/null +++ b/src/policyengine/provenance/qualification.py @@ -0,0 +1,1029 @@ +"""Candidate qualification contracts and pure decision rules. + +A *candidate* is a country model wheel, its core wheel and a data release +that are not yet certified together. Qualification: + +1. prepares a policyengine.py commit whose packaged manifest, pins, lock and + TRO certify the pair (the candidate commit, which is also what promotion + merges); +2. runs mandatory local gates on that commit (:data:`REQUIRED_GATES`); +3. sends the replay corpus owner (policyengine-scorecard) an immutable + :class:`ReplayQualificationRequest`, polls its :class:`ReplayStatus` and + checks the :class:`ReplayReceipt` against the request + (:func:`evaluate_replay_receipt`); +4. records a :class:`QualificationReceipt` whose verdict is a pure function of + the gate results (:func:`qualification_verdict`). + +Promotion then picks the newest promotable pair that strictly advances the +certified pair (:func:`select_promotion_target`), so a stale completion +cannot displace a newer accepted candidate. + +The replay request, status and receipt mirror the JSON Schemas published by +policyengine-scorecard (vendored, unchanged, in +``policyengine/data/schemas/vendor/policyengine-scorecard/``). Their digests +are plain RFC 8785 over the document as sent, nulls included, which is what +the scorecard computes. W4's own receipts use the receipt-v1 rule of +:func:`policyengine.provenance.receipt.content_sha256` (nulls dropped). +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from typing import Any, ClassVar, Literal, Optional + +from packaging.version import Version +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PrivateAttr, + field_validator, + model_validator, +) + +from policyengine.provenance.canonical import canonical_sha256 +from policyengine.provenance.receipt import ( + GIT_SHA_PATTERN, + SHA256_PATTERN, + _Contract, + content_sha256, +) + +__all__ = [ + "BLOCKING_REPLAY_FLAGS", + "REQUIRED_GATES", + "REVIEW_REPLAY_FLAGS", + "CandidatePair", + "GateResult", + "QualificationHarness", + "QualificationReceipt", + "ReplayQualificationRequest", + "ReplayReceipt", + "ReplayStatus", + "ReviewAcceptance", + "dominates", + "evaluate_replay_receipt", + "qualification_verdict", + "select_promotion_target", +] + +# --- Gate results and verdicts ------------------------------------------ + +GateStatus = Literal["pass", "needs_review", "unavailable", "fail", "error"] +Verdict = Literal["qualified", "needs_review", "incomplete", "rejected"] + +#: Every mandatory gate. A gate has no "skipped" status: a gate whose harness +#: is missing reports ``unavailable`` (the candidate is then incomplete, not +#: promotable), a gate that crashed while evaluating the candidate reports +#: ``error`` (the candidate is rejected), and a gate that never reported +#: leaves the qualification incomplete. +REQUIRED_GATES: tuple[str, ...] = ( + "installed_versions", + "wheel_hashes", + "core_compatibility", + "data_access", + "dataset_bytes", + "supported_years", + "budget_inputs", + "reform_contract", + "metrics_review", + "reform_responsiveness", + "replay", +) + +_SEVERITY: dict[str, int] = { + "pass": 0, + "needs_review": 1, + "unavailable": 2, + "fail": 3, + "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"} +) +REVIEW_REPLAY_FLAGS: frozenset[str] = frozenset({"numerical", "support"}) + + +class GateResult(_Contract): + """One mandatory qualification gate.""" + + gate_id: str + status: GateStatus + summary: str = Field(min_length=1) + evidence_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="content_sha256 of the evidence JSON stored with the receipt.", + ) + details: dict[str, Any] = Field(default_factory=dict) + + +def qualification_verdict(gates: Iterable[GateResult]) -> Verdict: + """The verdict a set of gate results determines. + + - ``rejected`` if any gate failed or errored; + - otherwise ``incomplete`` if any gate is unavailable or a required gate + did not report; + - otherwise ``needs_review`` if any gate needs review; + - otherwise ``qualified``. + + Making any gate worse never makes the verdict better. + """ + + gates = list(gates) + worst = max((_SEVERITY[gate.status] for gate in gates), default=0) + if worst >= _SEVERITY["fail"]: + return "rejected" + reported = {gate.gate_id for gate in gates} + if worst == _SEVERITY["unavailable"] or any( + gate_id not in reported for gate_id in REQUIRED_GATES + ): + return "incomplete" + if worst == _SEVERITY["needs_review"]: + return "needs_review" + return "qualified" + + +# --- What is qualified ---------------------------------------------------- + + +class PinnedWheel(_Contract): + name: str + version: str + wheel_sha256: str = Field(pattern=SHA256_PATTERN) + + +class PinnedData(_Contract): + data_package: str + release_version: str + build_id: str + repo_id: str + repo_type: Literal["model", "dataset"] + release_manifest_path: str + release_manifest_revision: str + release_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + dataset: str + dataset_path: str + dataset_revision: str + dataset_sha256: str = Field(pattern=SHA256_PATTERN) + size_bytes: int = Field(ge=1) + + +class CandidatePair(_Contract): + """The model, core and data that would be certified together. + + ``pair_id`` (its receipt-v1 digest) does not depend on the policyengine.py + commit, so the candidate commit's manifest can name the pair it certifies. + """ + + country_id: Literal["uk"] + model: PinnedWheel + core: PinnedWheel + data: PinnedData + + @property + def pair_id(self) -> str: + return content_sha256(self.canonical_payload()) + + @property + def order_key(self) -> tuple[str, str, str]: + """(model version, data release, core version): what promotion orders.""" + + return (self.model.version, self.data.release_version, self.core.version) + + +class QualificationHarness(_Contract): + """The policyengine.py commit that carried and ran the qualification.""" + + policyengine_version: str + git_commit: str = Field(pattern=GIT_SHA_PATTERN) + packaged_bundle_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + + +class IssuedBy(_Contract): + repository: str = Field(pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + workflow_run_id: int = Field(ge=1) + run_attempt: int = Field(default=1, ge=1) + run_url: str + + +class PreviousCertificate(_Contract): + """The certified pair a candidate would replace.""" + + bundle_version: str + model_version: str + core_version: str + data_release_version: str + dataset_sha256: str = Field(pattern=SHA256_PATTERN) + certificate_sha256: str = Field(pattern=SHA256_PATTERN) + qualification_pair_id: Optional[str] = Field(default=None, pattern=SHA256_PATTERN) + model_wheel_sha256: Optional[str] = Field(default=None, pattern=SHA256_PATTERN) + core_wheel_sha256: Optional[str] = Field(default=None, pattern=SHA256_PATTERN) + + +class ReplayReference(_Contract): + request_id: str = Field(pattern=SHA256_PATTERN) + receipt_sha256: Optional[str] = Field(default=None, pattern=SHA256_PATTERN) + receipt_commit: Optional[str] = Field(default=None, pattern=GIT_SHA_PATTERN) + + +class ReviewAcceptance(_Contract): + """A reviewer's acceptance of one exact needs-review receipt.""" + + reviewer: str = Field(min_length=1) + accepted_at: str + accepted_receipt_sha256: str = Field( + pattern=SHA256_PATTERN, + description="unreviewed_sha256 of the receipt being accepted.", + ) + explanation: str = Field(min_length=1) + + +class QualificationReceipt(_Contract): + """The qualification outcome recorded in the ledger for one candidate.""" + + SCHEMA_ID: ClassVar[str] = ( + "https://policyengine.org/schemas/candidate_qualification_receipt.v1.json" + ) + + schema_version: Literal[1] + receipt_type: Literal["policyengine.candidate_qualification_receipt"] + pair_id: str = Field(pattern=SHA256_PATTERN) + pair: CandidatePair + harness: QualificationHarness + previous_certificate: Optional[PreviousCertificate] = None + replay: Optional[ReplayReference] = None + gates: list[GateResult] + verdict: Verdict + review: Optional[ReviewAcceptance] = None + issued_by: IssuedBy + completed_at: str = Field(description="ISO 8601 UTC, e.g. 2026-10-15T09:30:00Z.") + + @model_validator(mode="after") + def _consistent(self) -> QualificationReceipt: + if self.pair_id != self.pair.pair_id: + raise ValueError("pair_id is not the digest of pair.") + ids = [gate.gate_id for gate in self.gates] + if len(ids) != len(set(ids)): + raise ValueError("Each gate reports at most once.") + expected = qualification_verdict(self.gates) + if self.verdict != expected: + raise ValueError( + f"verdict {self.verdict!r} does not follow from the gates ({expected!r})." + ) + if self.review is not None and self.verdict != "needs_review": + raise ValueError("Only a needs-review verdict can be accepted on review.") + return self + + @property + def unreviewed_sha256(self) -> str: + """Digest of the receipt without ``review``: what a review accepts.""" + + payload = self.canonical_payload() + payload.pop("review", None) + return content_sha256(payload) + + @property + def receipt_sha256(self) -> str: + return content_sha256(self.canonical_payload()) + + @property + def promotable(self) -> bool: + """Qualified, or needs review with an acceptance of exactly this receipt.""" + + if self.verdict == "qualified": + return True + return ( + self.verdict == "needs_review" + and self.review is not None + and self.review.accepted_receipt_sha256 == self.unreviewed_sha256 + ) + + +# --- Promotion order ------------------------------------------------------ + + +def _versions(key: Sequence[str]) -> tuple[Version, ...]: + return tuple(Version(part) for part in key) + + +def dominates(newer: Sequence[str], older: Sequence[str]) -> bool: + """``newer`` is at least ``older`` in every component and differs from it.""" + + a, b = _versions(newer), _versions(older) + if len(a) != len(b): + raise ValueError("Order keys must have the same length.") + return a != b and all(x >= y for x, y in zip(a, b)) + + +def select_promotion_target( + receipts: Iterable[QualificationReceipt], + current: Optional[Sequence[str]], +) -> Optional[QualificationReceipt]: + """The promotable candidate to promote next, or ``None``. + + Eligible receipts are promotable and strictly advance ``current`` (model + version, data release, core version). Among them the target is the one + whose pair dominates every other eligible pair. If eligible pairs have no + single newest member (a newer model with an older data release, say), + nothing is promoted automatically. The result depends only on the set of + receipts, never on the order they arrive in. + """ + + eligible: dict[tuple[str, ...], QualificationReceipt] = {} + for receipt in receipts: + if not receipt.promotable: + continue + key = tuple(receipt.pair.order_key) + if current is not None and not dominates(key, current): + continue + incumbent = eligible.get(key) + if incumbent is None or (receipt.completed_at, receipt.receipt_sha256) > ( + incumbent.completed_at, + incumbent.receipt_sha256, + ): + eligible[key] = receipt + keys = list(eligible) + for key in keys: + if all(other == key or dominates(key, other) for other in keys): + return eligible[key] + return None + + +# --- Replay interface (mirrors policyengine-scorecard schemas v1) --------- + + +class _Mirror(BaseModel): + """Closed, immutable mirror of a scorecard JSON Schema object.""" + + model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True) + + def document(self) -> dict[str, Any]: + """The JSON document exactly as the scorecard schema spells it.""" + + return self.model_dump(mode="json", by_alias=True, exclude_none=True) + + +class _Open(_Mirror): + model_config = ConfigDict(frozen=True, extra="allow", populate_by_name=True) + + +_SHA1 = GIT_SHA_PATTERN +_VERSION = r"^[0-9]+(\.[0-9]+)*([a-z0-9.+-]*)$" + + +class GitSource(_Mirror): + repository: Literal["PolicyEngine/policyengine.py"] + commit: str = Field(pattern=_SHA1) + + +class PypiSource(_Mirror): + wheel_sha256: str = Field(pattern=SHA256_PATTERN) + + +class WrapperSource(_Mirror): + git: Optional[GitSource] = None + pypi: Optional[PypiSource] = None + + @model_validator(mode="after") + def _one_source(self) -> WrapperSource: + if (self.git is None) == (self.pypi is None): + raise ValueError("The wrapper source is exactly one of git or pypi.") + return self + + +class RequestWrapper(_Mirror): + version: str = Field(pattern=_VERSION) + source: WrapperSource + packaged_bundle_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + + +class RequestPackage(_Mirror): + version: str = Field(pattern=_VERSION) + wheel_sha256: str = Field(pattern=SHA256_PATTERN) + + +class RequestData(_Mirror): + build_id: str = Field(min_length=1) + dataset: str = Field(min_length=1) + repo_id: str = Field(pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + repo_type: Literal["model", "dataset"] + revision: str = Field(min_length=1) + path: str = Field(min_length=1, pattern=r"^[^/][^\\]*$") + sha256: str = Field(pattern=SHA256_PATTERN) + size_bytes: int = Field(ge=1) + release_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + + @field_validator("path") + @classmethod + def _no_parent_segments(cls, path: str) -> str: + # The schema says ^(?!.*(^|/)\.\.(/|$))[^/][^\\]*$; pydantic's regex + # engine has no look-ahead, so the '..' rule is checked here. + if ".." in path.split("/"): + raise ValueError("data.path must not contain '..' segments.") + return path + + +class RequestCandidate(_Mirror): + policyengine: RequestWrapper + policyengine_uk: RequestPackage + policyengine_core: RequestPackage + data: RequestData + expected_release_bundle: dict[str, Optional[str]] + + @field_validator("expected_release_bundle") + @classmethod + def _required_bundle_fields( + cls, bundle: dict[str, Optional[str]] + ) -> dict[str, Optional[str]]: + for key in ( + "policyengine_version", + "model_version", + "certified_data_build_id", + "certified_data_artifact_sha256", + "default_dataset_uri", + ): + if not bundle.get(key): + raise ValueError(f"expected_release_bundle needs {key}.") + return bundle + + +class RequestedBy(_Open): + repository: str = Field(pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + workflow_run_id: Optional[int] = Field(default=None, ge=1) + run_attempt: Optional[int] = Field(default=None, ge=1) + commit: str = Field(pattern=_SHA1) + + +class ReplayQualificationRequest(_Mirror): + """``policyengine.uk-replay-qualification-request/v1``.""" + + schema_id: Literal["policyengine.uk-replay-qualification-request/v1"] = Field( + alias="schema" + ) + kind: Literal["candidate", "released_bundle"] + attempt: int = Field(ge=1) + candidate: RequestCandidate + compare_to: str = Field(min_length=1) + requested_by: RequestedBy + + @model_validator(mode="after") + def _released_bundles_come_from_pypi(self) -> ReplayQualificationRequest: + if ( + self.kind == "released_bundle" + and self.candidate.policyengine.source.pypi is None + ): + raise ValueError("A released_bundle request names the PyPI wheel.") + return self + + @property + def request_id(self) -> str: + """Plain RFC 8785 SHA-256 of the request document, as the scorecard computes.""" + + return canonical_sha256(self.document()) + + +class StatusEvent(_Mirror): + status: Literal["queued", "running", "complete", "rejected", "failed"] + at: str + run_url: str = Field( + pattern=r"^https://github\.com/PolicyEngine/policyengine-scorecard/actions/runs/[0-9]+" + ) + + +class ReplayStatus(_Mirror): + """``policyengine.uk-replay-status/v1`` (``requests//STATUS.json``).""" + + schema_id: Literal["policyengine.uk-replay-status/v1"] = Field(alias="schema") + request_id: str = Field(pattern=SHA256_PATTERN) + status: Literal["queued", "running", "complete", "rejected", "failed"] + history: list[StatusEvent] = Field(min_length=1) + receipt_sha256: Optional[str] = Field(default=None, pattern=SHA256_PATTERN) + receipt_commit: Optional[str] = Field(default=None, pattern=_SHA1) + + @model_validator(mode="after") + def _terminal_names_receipt(self) -> ReplayStatus: + if self.terminal and ( + self.receipt_sha256 is None or self.receipt_commit is None + ): + raise ValueError("A terminal status names its receipt digest and commit.") + return self + + @property + def terminal(self) -> bool: + return self.status in ("complete", "rejected", "failed") + + +class Rejection(_Mirror): + code: Literal[ + "schema_invalid", + "artifact_hash_mismatch", + "release_bundle_mismatch", + "incompatible_pair", + "data_access_failed", + "no_supported_years", + ] + detail: str = Field(min_length=1) + + +class InstalledPackage(_Mirror): + version: str = Field(min_length=1) + record_sha256: str = Field(pattern=SHA256_PATTERN) + wheel_sha256: Optional[str] = Field(default=None, pattern=SHA256_PATTERN) + + +class ExecutedPackages(_Mirror): + policyengine: InstalledPackage + policyengine_uk: InstalledPackage = Field(alias="policyengine-uk") + policyengine_core: InstalledPackage = Field(alias="policyengine-core") + + +class Execution(_Mirror): + packages: ExecutedPackages + packaged_bundle_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + release_bundle: dict[str, Optional[str]] + dataset_sha256: str = Field(pattern=SHA256_PATTERN) + requirements_sha256: str = Field(pattern=SHA256_PATTERN) + python: str = Field(pattern=r"^3\.[0-9]+\.[0-9]+$") + platform: str = Field(min_length=1) + runner: str = Field(min_length=1) + + +class UnsupportedYear(_Mirror): + year: int + reason: Literal["before_data_year", "after_extension_end"] + + +class InertMeasureYear(_Mirror): + measure_key: str + year: int + + +class EventResult(_Mirror): + registry_sha256: str = Field(pattern=SHA256_PATTERN) + supported_years: list[int] + unsupported_years: list[UnsupportedYear] + run_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + staging_manifest_sha256: str = Field(pattern=SHA256_PATTERN) + comparison_provenance_sha256: str = Field(pattern=SHA256_PATTERN) + artifacts: int = Field(ge=0) + full_supported_grid_complete: bool + inert_measure_years: list[InertMeasureYear] + + +class Corpus(_Mirror): + corpus_version: str = Field(min_length=1) + events: dict[str, EventResult] = Field(min_length=1) + + +class IntegrityFailure(_Mirror): + code: str = Field(min_length=1) + detail: str = Field(min_length=1) + event: Optional[str] = None + measure_key: Optional[str] = None + year: Optional[int] = None + + +class Integrity(_Mirror): + status: Literal["complete", "incomplete", "failed", "not_run"] + failures: list[IntegrityFailure] + + +FlagKind = Literal[ + "numerical", + "structural", + "coverage", + "mapping", + "support", + "inertness", + "construction", +] + + +class FlagCounts(_Mirror): + numerical: int = Field(ge=0) + structural: int = Field(ge=0) + coverage: int = Field(ge=0) + mapping: int = Field(ge=0) + support: int = Field(ge=0) + inertness: int = Field(ge=0) + construction: int = Field(ge=0) + + +class FlaggedRow(_Mirror): + event: str = Field(min_length=1) + measure_key: str = Field(min_length=1) + year: int + level: Literal["source_row", "measure_year", "head"] + key: str = Field(min_length=1) + flags: list[FlagKind] = Field(min_length=1) + base_gbp: Optional[float] = None + head_gbp: Optional[float] = None + + +class Diff(_Mirror): + base_variant_id: str = Field(min_length=1) + diff_sha256: str = Field(pattern=SHA256_PATTERN) + flag_counts: FlagCounts + flagged_rows: list[FlaggedRow] + + +class ResultsLocation(_Mirror): + repository: Literal["PolicyEngine/policyengine-scorecard"] + ref: str = Field(min_length=1) + commit: Optional[str] = Field(default=None, pattern=_SHA1) + path: str = Field(min_length=1) + manifest_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="Digest of NAMESPACE_MANIFEST.json for the sealed result namespace.", + ) + + +class ReplayReceipt(_Mirror): + """``policyengine.uk-replay-receipt/v1`` (``requests//REPLAY_RECEIPT.json``). + + Nullable members are kept as ``null`` in :meth:`document`, because the + scorecard's ``receipt_sha256`` covers them. + """ + + schema_id: Literal["policyengine.uk-replay-receipt/v1"] = Field(alias="schema") + request_id: str = Field(pattern=SHA256_PATTERN) + status: Literal["complete", "rejected", "failed"] + rejection: Optional[Rejection] + variant_id: Optional[str] + variant_identity_sha256: Optional[str] = Field(pattern=SHA256_PATTERN) + execution: Optional[Execution] + corpus: Optional[Corpus] + integrity: Integrity + diff: Optional[Diff] + results: ResultsLocation + receipt_sha256: str = Field(pattern=SHA256_PATTERN) + + _received: Optional[dict[str, Any]] = PrivateAttr(default=None) + + @classmethod + def from_document(cls, document: Mapping[str, Any]) -> ReplayReceipt: + """Parse a received receipt, keeping its bytes for digest checks.""" + + receipt = cls.model_validate(dict(document)) + receipt._received = dict(document) + return receipt + + def document(self) -> dict[str, Any]: + """The received document if there is one, else this model's spelling.""" + + if self._received is not None: + return dict(self._received) + top = self.model_dump(mode="json", by_alias=True, exclude_none=False) + for key in ("rejection", "execution", "corpus", "diff", "results", "integrity"): + value = getattr(self, key) + top[key] = value.document() if value is not None else None + return top + + @property + def computed_receipt_sha256(self) -> str: + """Plain RFC 8785 SHA-256 of the receipt without ``receipt_sha256``.""" + + document = self.document() + document.pop("receipt_sha256", None) + return canonical_sha256(document) + + @model_validator(mode="after") + def _status_shape(self) -> ReplayReceipt: + if self.status == "rejected": + if ( + self.rejection is None + or self.corpus is not None + or self.diff is not None + or self.variant_id is not None + or self.variant_identity_sha256 is not None + or self.execution is not None + or self.integrity.status != "not_run" + ): + raise ValueError( + "A rejected receipt has a rejection, no variant, execution, " + "corpus or diff, and integrity not_run." + ) + elif self.status == "complete": + if ( + self.rejection is not None + or self.variant_id is None + or self.variant_identity_sha256 is None + or self.execution is None + or self.results.manifest_sha256 is None + ): + raise ValueError( + "A complete receipt has a variant and its identity, an " + "execution and a sealed results manifest." + ) + # A failed receipt can come from a step before the candidate's + # identity was known (for example a download error). + elif self.rejection is not None or self.integrity.status != "failed": + raise ValueError( + "A failed receipt has no rejection and a failed integrity." + ) + if self.status == "complete" and ( + self.integrity.status not in ("complete", "incomplete") + or self.corpus is None + ): + raise ValueError("A complete receipt has a corpus and a run integrity.") + return self + + +def evaluate_replay_receipt( + request: ReplayQualificationRequest, + status: ReplayStatus, + receipt: ReplayReceipt, +) -> GateResult: + """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. + + 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. + """ + + request_id = request.request_id + problems: list[str] = [] + if status.request_id != request_id or receipt.request_id != request_id: + problems.append("status or receipt answers a different request") + if not status.terminal: + problems.append(f"replay is still {status.status}") + if status.status != receipt.status and status.terminal: + problems.append("status and receipt disagree on the outcome") + if receipt.computed_receipt_sha256 != receipt.receipt_sha256: + problems.append("receipt_sha256 does not match the receipt bytes") + if status.receipt_sha256 != receipt.receipt_sha256: + problems.append("status names different receipt bytes") + + details: dict[str, Any] = { + "request_id": request_id, + "receipt_sha256": receipt.receipt_sha256, + "receipt_commit": status.receipt_commit, + "status": receipt.status, + "integrity": receipt.integrity.status, + } + if problems: + return GateResult( + gate_id="replay", + status="fail", + summary="Replay receipt is not bound to this request: " + + "; ".join(problems), + details={**details, "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()}, + ) + + 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: " + + (", ".join(codes) or "no failure codes"), + details={ + **details, + "integrity_failures": [ + failure.document() for failure in receipt.integrity.failures + ], + }, + ) + candidate = request.candidate + mismatches: list[str] = [] + packages = { + "policyengine": ( + execution.packages.policyengine, + candidate.policyengine.version, + ( + candidate.policyengine.source.pypi.wheel_sha256 + if candidate.policyengine.source.pypi is not None + else None + ), + ), + "policyengine-uk": ( + execution.packages.policyengine_uk, + candidate.policyengine_uk.version, + candidate.policyengine_uk.wheel_sha256, + ), + "policyengine-core": ( + execution.packages.policyengine_core, + candidate.policyengine_core.version, + candidate.policyengine_core.wheel_sha256, + ), + } + for name, (installed, version, wheel) in packages.items(): + if installed.version != version: + mismatches.append(f"{name} {installed.version} ran, not {version}") + if wheel is not None and installed.wheel_sha256 != wheel: + mismatches.append( + f"{name} wheel hash is missing from the receipt" + if installed.wheel_sha256 is None + else f"{name} wheel hash differs from the request" + ) + if ( + execution.packaged_bundle_manifest_sha256 + != candidate.policyengine.packaged_bundle_manifest_sha256 + ): + mismatches.append("packaged bundle manifest differs from the request") + if execution.dataset_sha256 != candidate.data.sha256: + mismatches.append("dataset bytes differ from the request") + 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}, + ) + + 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 + ], + }, + ) + corpus = receipt.corpus + assert corpus is not None + incomplete_events = sorted( + name + for name, event in corpus.events.items() + if not event.full_supported_grid_complete + ) + unsupported = sorted( + (name, item.year, item.reason) + for name, event in corpus.events.items() + for item in event.unsupported_years + ) + inert = sorted( + (name, item.measure_key, item.year) + for name, event in corpus.events.items() + for item in event.inert_measure_years + ) + details.update( + { + "corpus_version": corpus.corpus_version, + "events": sorted(corpus.events), + "unsupported_years": [list(item) for item in unsupported], + "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: + codes = sorted({failure.code for failure in receipt.integrity.failures}) + contradictions.append( + f"integrity is complete but lists failures ({', '.join(codes)})" + ) + replayed = 0 + for name, event in sorted(corpus.events.items()): + replayed += event.artifacts + if event.supported_years and event.artifacts == 0: + contradictions.append(f"{name} has supported years but no artifacts") + if not event.supported_years and not event.unsupported_years: + contradictions.append(f"{name} names no years at all") + if replayed == 0: + contradictions.append("no measure-year was replayed") + counts = receipt.diff.flag_counts.document() if receipt.diff is not None else {} + rows = receipt.diff.flagged_rows if receipt.diff is not None else [] + row_counts: dict[str, int] = {} + for row in rows: + for kind in row.flags: + row_counts[kind] = row_counts.get(kind, 0) + 1 + for kind, seen in sorted(row_counts.items()): + if counts.get(kind, 0) < seen: + contradictions.append( + f"{seen} flagged row(s) carry {kind} but flag_counts says " + f"{counts.get(kind, 0)}" + ) + details["flag_counts"] = counts + details["flagged_row_counts"] = row_counts + if receipt.diff is not None: + details["base_variant_id"] = receipt.diff.base_variant_id + details["diff_sha256"] = receipt.diff.diff_sha256 + if request.compare_to != "latest_certified": + base = receipt.diff.base_variant_id if receipt.diff is not None else None + if base != request.compare_to: + contradictions.append( + f"the request asked for a comparison with {request.compare_to}, " + f"but the receipt compares with {base}" + ) + if contradictions: + return GateResult( + gate_id="replay", + status="fail", + summary="Replay receipt contradicts itself or the request: " + + "; ".join(contradictions), + details={**details, "contradictions": contradictions}, + ) + # Flags come from every place the receipt can state them, never from the + # summary counters alone. + 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 + ) + + if blocking: + return GateResult( + gate_id="replay", + status="fail", + summary="Blocking replay flags: " + tally(blocking), + details=details, + ) + if review: + 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]) + ), + details=details, + ) + if receipt.diff is None: + return GateResult( + gate_id="replay", + status="needs_review", + summary="Replay completed but reported no diff against a certified variant.", + details=details, + ) + return GateResult( + gate_id="replay", + status="pass", + summary=f"Replayed {len(corpus.events)} event(s) with no flags against {receipt.diff.base_variant_id}.", + details=details, + ) + + +def replay_gate_while_pending(status: Optional[ReplayStatus]) -> GateResult: + """Gate result for a replay that has not reached a terminal status.""" + + state = status.status if status is not None else "not yet queued" + return GateResult( + gate_id="replay", + status="unavailable", + summary=f"Replay is {state}; the candidate stays incomplete until it finishes.", + details={"status": state}, + ) + + +def mirror_from_document(model: type[_Mirror], document: Mapping[str, Any]) -> Any: + """Parse a scorecard document into its mirror model.""" + + return model.model_validate(dict(document)) diff --git a/src/policyengine/provenance/qualification_ledger.py b/src/policyengine/provenance/qualification_ledger.py new file mode 100644 index 00000000..d38d2a63 --- /dev/null +++ b/src/policyengine/provenance/qualification_ledger.py @@ -0,0 +1,198 @@ +"""The qualification ledger: durable evidence for every candidate attempt. + +The ledger is the orphan branch ``uk-qualification-ledger`` of +PolicyEngine/policyengine.py. Each candidate pair owns one directory, so +concurrent qualifications never write the same file:: + + // + pair.json the CandidatePair + qualification_receipt.json the latest terminal receipt for the pair + attempts// + harness.json the candidate commit that ran it + gates/.json each gate's evidence + replay/request.json the request sent to the scorecard + replay/status.json the terminal status read back + replay/receipt.json the receipt bytes read back + qualification_receipt.json this attempt's receipt + +Failed and incomplete attempts are kept: the ledger never deletes evidence. +A bundle certificate with basis ``qualified_exact_pair`` must point at a pair +whose latest receipt is promotable and matches the certificate exactly +(:func:`verify_certification_evidence`). +""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Iterable, Mapping +from pathlib import Path +from typing import Any, Optional + +from policyengine.provenance.qualification import QualificationReceipt + +LEDGER_REPOSITORY = "PolicyEngine/policyengine.py" +LEDGER_BRANCH = "uk-qualification-ledger" +RAW_LEDGER_URL = ( + f"https://raw.githubusercontent.com/{LEDGER_REPOSITORY}/{LEDGER_BRANCH}" +) + +__all__ = [ + "LEDGER_BRANCH", + "LEDGER_REPOSITORY", + "attempt_directory", + "fetch_ledger_receipt", + "latest_receipts", + "pair_directory", + "pending_attempts", + "verify_certification_evidence", + "write_json", +] + + +def write_json(path: Path, value: Any) -> None: + """Write readable, deterministic JSON (digests are over the JSON value).""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +def read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def pair_directory(root: Path, country_id: str, pair_id: str) -> Path: + return root / country_id / pair_id + + +def attempt_directory( + root: Path, country_id: str, pair_id: str, attempt_key: str +) -> Path: + if "/" in attempt_key or attempt_key in ("", ".", ".."): + raise ValueError(f"Invalid attempt key {attempt_key!r}.") + return pair_directory(root, country_id, pair_id) / "attempts" / attempt_key + + +def latest_receipts(root: Path, country_id: str = "uk") -> list[QualificationReceipt]: + """Every pair's latest terminal receipt in a checked-out ledger.""" + + base = root / country_id + if not base.is_dir(): + return [] + receipts = [] + for path in sorted(base.glob("*/qualification_receipt.json")): + receipts.append(QualificationReceipt.model_validate(read_json(path))) + return receipts + + +def pending_attempts(root: Path, country_id: str = "uk") -> list[Path]: + """Attempts that sent a replay request and have no receipt yet.""" + + base = root / country_id + if not base.is_dir(): + return [] + return sorted( + path.parent.parent + for path in base.glob("*/attempts/*/replay/request.json") + if not (path.parent.parent / "qualification_receipt.json").exists() + ) + + +def fetch_ledger_receipt( + country_id: str, + pair_id: str, + *, + get: Optional[Callable[..., Any]] = None, +) -> Optional[QualificationReceipt]: + """Read a pair's latest receipt from the published ledger branch.""" + + if get is None: + import requests + + get = requests.get + response = get( + f"{RAW_LEDGER_URL}/{country_id}/{pair_id}/qualification_receipt.json", + timeout=30, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return QualificationReceipt.model_validate_json(response.text) + + +def verify_certification_evidence( + record: Mapping[str, Any], + receipt: Optional[QualificationReceipt], +) -> list[str]: + """Problems with a bundle certification record's qualification evidence. + + Records on any basis other than ``qualified_exact_pair`` need no ledger + evidence and return no problems. A qualified exact pair needs a + promotable receipt for exactly the model wheel (its hash must be stated), + core, data release and dataset the record certifies, including the + default dataset's reference (repository, path, revision and bytes), which + is what the runtime resolves. + """ + + certification = record.get("certification") or {} + if certification.get("compatibility_basis") != "qualified_exact_pair": + return [] + pair_id = certification.get("qualification_pair_id") + if not pair_id: + return ["qualified_exact_pair certificate names no qualification_pair_id"] + if receipt is None: + return [f"no qualification receipt in the ledger for pair {pair_id}"] + + problems: list[str] = [] + if receipt.pair_id != pair_id: + problems.append("ledger receipt is for a different pair") + if not receipt.promotable: + problems.append( + f"ledger receipt verdict is {receipt.verdict} without an accepted review" + ) + pair = receipt.pair + model = record.get("model_package") or {} + data = record.get("data_package") or {} + artifact = record.get("certified_data_artifact") or {} + reference = (record.get("datasets") or {}).get(record.get("default_dataset")) or {} + expected: Iterable[tuple[str, Any, Any]] = ( + ( + "model version", + pair.model.version, + certification.get("certified_for_model_version"), + ), + ("model version in bundle", pair.model.version, model.get("version")), + ("model wheel sha256", pair.model.wheel_sha256, model.get("sha256")), + ( + "core version", + pair.core.version, + certification.get("certified_for_core_version"), + ), + ("data release", pair.data.release_version, data.get("version")), + ( + "release manifest revision", + pair.data.release_manifest_revision, + data.get("release_manifest_revision"), + ), + ("default dataset", pair.data.dataset, record.get("default_dataset")), + ("certified dataset", pair.data.dataset, artifact.get("dataset")), + ("dataset sha256", pair.data.dataset_sha256, artifact.get("sha256")), + # The reference the runtime actually resolves and downloads. + ("dataset reference repo", pair.data.repo_id, reference.get("repo_id")), + ("dataset reference path", pair.data.dataset_path, reference.get("path")), + ( + "dataset reference revision", + pair.data.dataset_revision, + reference.get("revision"), + ), + ("dataset reference sha256", pair.data.dataset_sha256, reference.get("sha256")), + ("data build id", pair.data.build_id, certification.get("data_build_id")), + ) + for label, qualified, certified in expected: + if qualified != certified: + problems.append( + f"{label}: qualified {qualified!r}, certified {certified!r}" + ) + return problems diff --git a/src/policyengine/provenance/receipt.py b/src/policyengine/provenance/receipt.py new file mode 100644 index 00000000..62c33ad4 --- /dev/null +++ b/src/policyengine/provenance/receipt.py @@ -0,0 +1,1310 @@ +"""Execution receipts: exactly what produced a PolicyEngine result (schema v1). + +A receipt travels with a published number (a dashboard row, a briefing +figure, an API response) and names everything that determined it: + +- the wrapper, country model and core versions that actually ran, read + from the installed distributions rather than from the bundle manifest; +- the data release, its build identity, and the dataset's revision and + SHA-256 as read from the bytes that were simulated; +- the certificate that admitted that model, core and data, as a digest of + the bundle's certification record; +- the reform and baseline, as digests of their canonical representation; +- the fiscal-year convention that gives the reported years their meaning. + +Schema v1 covers UK results. Digests use RFC 8785 canonical JSON +(:mod:`policyengine.provenance.canonical`) over strict JSON values, so a +JavaScript or Python consumer can recompute them. The JSON Schema ships in +the wheel at ``policyengine/data/schemas/execution_receipt.v1.schema.json`` +and is regenerated by ``scripts/generate_receipt_schemas.py``. + +Rules every valid v1 receipt satisfies: + +- ``certificate`` is present only when the certificate covers the installed + model and core versions, the simulated dataset bytes and the running + wrapper version; otherwise the receipt is ``uncertified``. +- ``years`` is sorted and unique, and each reported year has the meaning + ``fiscal_year`` gives it. +- The execution identity digest covers only what determines the execution + (wrapper, model, core, data and the certificate's content); two results + with the same digest ran on the same runtime, data and certificate. The + receipt digest also covers reform, baseline, years, convention and + outputs, but not runtime context or human labels. +- A reform digest binds content (``binding="content"``) unless the reform + runs Python code, in which case it binds that function's source, its + module's source and its default arguments plus the caller's code revision, + and says so (``binding="code_revision"``). The digest is the same wherever + the code is checked out and whichever Python runs it. +- A certificate is trusted only after :func:`verify_certificate` has checked + it, and the receipt's data identity, against the published bundle manifest + it names. ``certificate_source`` is the producer's statement, not evidence. + +Adding an optional field is a compatible v1 change. Removing or redefining a +field, or changing what a digest covers, requires schema v2. +""" + +from __future__ import annotations +import __future__ + +import datetime +import functools +import hashlib +import inspect +import json +import math +import operator +import platform +import re +import sys +from collections.abc import Mapping, Sequence +from importlib import metadata +from importlib.metadata import PackageNotFoundError +from pathlib import Path +from types import CodeType +from typing import Any, ClassVar, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from policyengine.provenance.canonical import ( + CanonicalJSONError, + canonical_json, + canonical_sha256, + drop_null_members, +) +from policyengine.utils.hashing import sha256_file + +__all__ = [ + "RECEIPT_SCHEMA_VERSION", + "CertificateIdentity", + "CoreIdentity", + "DataIdentity", + "ExecutionReceipt", + "FiscalYearConvention", + "ModelIdentity", + "PackageIdentity", + "ReceiptError", + "ReformIdentity", + "RuntimeContext", + "WrapperIdentity", + "build_execution_receipt", + "build_execution_receipt_for_microsimulation", + "certificate_sha256", + "content_sha256", + "fiscal_year_label", + "installed_package_identity", + "reform_identity", + "scenario_reform_identity", + "strict_json_value", + "uk_fiscal_year_convention", + "verify_certificate", +] + +RECEIPT_SCHEMA_VERSION: Literal[1] = 1 + +SHA256_PATTERN = r"^[0-9a-f]{64}$" +GIT_SHA_PATTERN = r"^[0-9a-f]{40}$" +#: PEP 503 normalized distribution name. +DISTRIBUTION_PATTERN = r"^[a-z0-9]+(-[a-z0-9]+)*$" + +UK_PACKAGES = { + "wrapper": "policyengine", + "model": "policyengine-uk", + "core": "policyengine-core", +} + +#: Reform representations whose digests a receipt can carry. Different +#: representations can legitimately mean different things for the same +#: keys (a country-dict bare year changes one year; ``Reform.from_dict`` +#: continues after it), so a digest is always taken together with its +#: representation and never normalized across representations. +ReformRepresentation = Literal[ + # ``policyengine.core.run_record.reform_specification`` without ``name``: + # sorted ``{parameter, value, start_date, end_date}`` entries. + "policyengine.parameter_values/v1", + # A wrapper dictionary policy: ``{parameter_path: {date_key: value}}``. + "policyengine.reform_dict/v1", + # A policyengine-uk country dictionary reform, as passed to + # ``Scenario.from_reform`` / ``Simulation(reform=...)``. + "policyengine-uk.reform_dict/v1", + # A request body sent to the simulation API. + "policyengine-sim-api.policy/v1", + # A policyengine-uk ``Scenario`` built from parameter changes and, at + # most, a module-level modifier: see scenario_reform_identity(). + "policyengine-uk.scenario/v1", +] + +#: v1 rules that JSON Schema cannot express. Published in the schema file. +CROSS_FIELD_RULES: tuple[str, ...] = ( + "years is sorted ascending with no duplicates, and each year is written as an integer (2027, not 2027.0).", + "certification_status certified requires certificate; uncertified forbids it.", + "certificate.country_id equals country_id.", + "certificate.certified_for_model_version equals model.version.", + "certificate.certified_for_core_version equals core.version.", + "certificate.certified_dataset_sha256 equals data.dataset_sha256.", + "certificate.bundle_version equals wrapper.version.", + "reform/baseline kind current_law has no representation or reform_sha256 and binding content; kind reform has both.", + "wheel_sha256 and wheel_sha256_evidence are present together or not at all.", + "A consumer trusts a certificate only after checking it against the data_releases. record of the published policyengine manifest for certificate.bundle_version (verify_certificate()): certificate_sha256 is that record's digest, every other member of certificate except certificate_source, qualification_receipt_sha256 and tro_composition_fingerprint equals what the record states, and data equals the data identity the record determines. certificate_source is the producer's statement and proves nothing.", +) + +#: How each digest is computed. +DIGEST_RULES: dict[str, str] = { + "canonicalization": "RFC 8785 (JSON Canonicalization Scheme) over the stated value after removing object members whose value is null (array elements are kept). Numbers are IEEE 754 doubles; integers must lie within +/-(2**53 - 1).", + "execution_identity_sha256": "{schema_version, country_id, wrapper, model, core, data, certification_status, certificate?} where certificate keeps only bundle_version, country_id, certificate_sha256, compatibility_basis, certified_for_model_version, certified_for_core_version, certified_dataset_sha256 and certified_by", + "receipt_sha256": "the whole receipt without runtime and without reform.name and baseline.name", + "certificate.certificate_sha256": "the bundle manifest's data_releases. record", + "reform.reform_sha256": "{representation, payload} where payload is the reform exactly as given in that representation", + "outputs_sha256": "the canonical output payload the receipt binds", +} + +HashEvidence = Literal["installed_record_verified", "installer_archive_hash"] + + +class ReceiptError(ValueError): + """Raised when a receipt would claim something the runtime cannot back.""" + + +class _Contract(BaseModel): + """Strict, immutable, JSON round-trippable contract model. + + Strict mode refuses to coerce (``"2027"`` is not a year), so a parsed + receipt digests to exactly what was received. + """ + + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + def canonical_payload(self) -> dict[str, Any]: + """The JSON value canonicalized for digests (``None`` members omitted).""" + + return self.model_dump(mode="json", exclude_none=True) + + +class PackageIdentity(_Contract): + """A Python distribution that took part in the execution.""" + + name: str = Field( + pattern=DISTRIBUTION_PATTERN, + description="PEP 503 normalized distribution name, e.g. policyengine-uk.", + ) + version: str = Field(description="Installed version (importlib.metadata).") + wheel_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description=( + "SHA-256 of the wheel the installed files came from. Present only " + "when verified by the receipt builder: by checking the installed " + "files against the wheel's RECORD, or from the installer's recorded " + "archive hash." + ), + ) + wheel_sha256_evidence: Optional[HashEvidence] = Field( + default=None, description="How wheel_sha256 was established." + ) + + @model_validator(mode="after") + def _hash_has_evidence(self) -> PackageIdentity: + if (self.wheel_sha256 is None) != (self.wheel_sha256_evidence is None): + raise ValueError( + "wheel_sha256 and wheel_sha256_evidence must be given together." + ) + return self + + +class WrapperIdentity(PackageIdentity): + """The policyengine.py distribution, optionally pinned to a source commit.""" + + name: Literal["policyengine"] + git_sha: Optional[str] = Field( + default=None, + pattern=GIT_SHA_PATTERN, + description="policyengine.py commit, when running from a checkout.", + ) + + +class ModelIdentity(PackageIdentity): + """The country model distribution (v1: policyengine-uk).""" + + name: Literal["policyengine-uk"] + + +class CoreIdentity(PackageIdentity): + """The policyengine-core distribution.""" + + name: Literal["policyengine-core"] + + +class DataIdentity(_Contract): + """The data release and the exact dataset bytes that were simulated.""" + + data_package: str = Field(description="Data package, e.g. policyengine-uk-data.") + release_version: str = Field( + description="Data release version or artifact revision." + ) + build_id: Optional[str] = Field( + default=None, + description="Producer build identity, e.g. policyengine-uk-data-1.58.0.", + ) + build_fingerprint: Optional[str] = Field( + default=None, description="Producer data-build fingerprint, when published." + ) + build_model_version: Optional[str] = Field( + default=None, + description="Country model version the data was built with (not the one that ran).", + ) + release_manifest_revision: Optional[str] = Field( + default=None, description="Hugging Face commit of the release manifest." + ) + dataset: str = Field(description="Logical dataset name, e.g. enhanced_frs_2024_25.") + dataset_uri: str = Field(description="Pinned dataset URI, e.g. hf://...@1.58.0.") + dataset_revision: str = Field(description="Revision the dataset was read at.") + dataset_sha256: str = Field( + pattern=SHA256_PATTERN, + description="SHA-256 of the dataset bytes that were simulated.", + ) + + +class CertificateIdentity(_Contract): + """The bundle certification that admitted this model, core and data.""" + + bundle_version: str = Field( + description="policyengine.py version carrying the certificate." + ) + country_id: Literal["uk"] + certificate_sha256: str = Field( + pattern=SHA256_PATTERN, + description=( + "RFC 8785 SHA-256 of the bundle manifest's data_releases. " + "record (see certificate_sha256())." + ), + ) + compatibility_basis: str = Field( + description="How the pair was admitted, e.g. built_with_model_package." + ) + certified_for_model_version: str + certified_for_core_version: str + certified_dataset_sha256: str = Field(pattern=SHA256_PATTERN) + certified_by: Optional[str] = None + certificate_source: Literal["packaged", "caller_supplied"] = Field( + description=( + "packaged: read from the installed policyengine bundle; " + "caller_supplied: a bundle manifest passed by the caller." + ) + ) + qualification_receipt_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="Digest of the candidate qualification receipt, when one exists.", + ) + tro_composition_fingerprint: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="Composition fingerprint of the packaged bundle TRACE TRO.", + ) + + #: Members that identify the certificate's content (execution identity). + IDENTITY_FIELDS: ClassVar[tuple[str, ...]] = ( + "bundle_version", + "country_id", + "certificate_sha256", + "compatibility_basis", + "certified_for_model_version", + "certified_for_core_version", + "certified_dataset_sha256", + "certified_by", + ) + + +class ReformIdentity(_Contract): + """A reform or baseline, bound by the digest of its canonical form.""" + + kind: Literal["current_law", "reform"] = Field( + description="current_law means no parameter or structural change." + ) + representation: Optional[ReformRepresentation] = None + reform_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="content_sha256 of {'representation': ..., 'payload': ...}.", + ) + binding: Literal["content", "code_revision"] = Field( + default="content", + description=( + "content: the digest determines the reform's behaviour. " + "code_revision: the reform runs a module-level Python function, " + "bound by its source, its module's source, its default arguments " + "and the caller's code revision." + ), + ) + name: Optional[str] = Field( + default=None, description="Human label; part of no digest." + ) + + @model_validator(mode="after") + def _current_law_has_no_digest(self) -> ReformIdentity: + if self.kind == "current_law": + if self.reform_sha256 is not None or self.representation is not None: + raise ValueError("current_law carries no representation or digest.") + if self.binding != "content": + raise ValueError("current_law is bound by content.") + elif self.reform_sha256 is None or self.representation is None: + raise ValueError("A reform needs a representation and a reform_sha256.") + return self + + +class FiscalYearConvention(_Contract): + """What an annual period label means in this result. + + v1 receipts are UK results, so the convention is the UK fiscal year: year + Y is the period from 6 April Y to 5 April Y+1. Another convention needs a + new schema version. + """ + + convention: Literal["uk_fiscal_year"] + year_label: Literal["start_year"] = Field( + default="start_year", + description="Year Y labels the period that starts in Y (2027 is 2027-28).", + ) + period_start: Literal["04-06"] = Field( + description="Month-day the labelled period starts (6 April)." + ) + parameter_annualisation: str = Field( + description=( + "Identifier of the annualisation rule the recorded model version " + "implements, e.g. policyengine-uk/fiscal-year-parameters." + ) + ) + + +class RuntimeContext(_Contract): + """Where the execution ran. Part of no digest.""" + + python_version: str + platform: str + ci_run_url: Optional[str] = None + + +class ExecutionReceipt(_Contract): + """Schema v1 execution receipt. See the module docstring for its rules.""" + + SCHEMA_ID: ClassVar[str] = ( + "https://policyengine.org/schemas/execution_receipt.v1.json" + ) + + schema_version: Literal[1] + receipt_type: Literal["policyengine.execution_receipt"] + country_id: Literal["uk"] + wrapper: WrapperIdentity + model: ModelIdentity + core: CoreIdentity + data: DataIdentity + certification_status: Literal["certified", "uncertified"] + certificate: Optional[CertificateIdentity] = None + reform: ReformIdentity + baseline: ReformIdentity + fiscal_year: FiscalYearConvention + years: list[int] = Field(min_length=1) + outputs_sha256: Optional[str] = Field( + default=None, + pattern=SHA256_PATTERN, + description="content_sha256 of the canonical output payload this receipt binds.", + ) + runtime: Optional[RuntimeContext] = None + + @field_validator("years") + @classmethod + def _sorted_unique_years(cls, years: list[int]) -> list[int]: + if years != sorted(set(years)): + raise ValueError("years must be sorted and unique.") + return years + + @model_validator(mode="after") + def _identity_rules(self) -> ExecutionReceipt: + if self.certification_status == "uncertified": + if self.certificate is not None: + raise ValueError("An uncertified receipt carries no certificate.") + return self + certificate = self.certificate + if certificate is None: + raise ValueError("A certified receipt needs a certificate.") + if certificate.country_id != self.country_id: + raise ValueError("Certificate country differs from the receipt country.") + if certificate.certified_for_model_version != self.model.version: + raise ValueError( + f"The certificate covers model {certificate.certified_for_model_version}, " + f"but {self.model.version} ran." + ) + if certificate.certified_for_core_version != self.core.version: + raise ValueError( + f"The certificate covers core {certificate.certified_for_core_version}, " + f"but {self.core.version} ran." + ) + if certificate.certified_dataset_sha256 != self.data.dataset_sha256: + raise ValueError( + "The certificate covers different dataset bytes from those simulated." + ) + if certificate.bundle_version != self.wrapper.version: + raise ValueError( + "The certificate belongs to a different policyengine.py version." + ) + return self + + # --- Digests --------------------------------------------------------- + def execution_identity(self) -> dict[str, Any]: + """The canonical value that identifies the runtime, data and certificate.""" + + identity: dict[str, Any] = { + "schema_version": self.schema_version, + "country_id": self.country_id, + "wrapper": self.wrapper.canonical_payload(), + "model": self.model.canonical_payload(), + "core": self.core.canonical_payload(), + "data": self.data.canonical_payload(), + "certification_status": self.certification_status, + } + if self.certificate is not None: + payload = self.certificate.canonical_payload() + identity["certificate"] = { + key: payload[key] + for key in CertificateIdentity.IDENTITY_FIELDS + if key in payload + } + return identity + + @property + def execution_identity_sha256(self) -> str: + return canonical_sha256(self.execution_identity()) + + def receipt_payload(self) -> dict[str, Any]: + """What the receipt digest covers: no runtime context, no labels.""" + + payload = self.canonical_payload() + payload.pop("runtime", None) + for key in ("reform", "baseline"): + payload[key].pop("name", None) + return payload + + @property + def receipt_sha256(self) -> str: + return canonical_sha256(self.receipt_payload()) + + def to_canonical_json(self) -> bytes: + """RFC 8785 bytes of the full receipt, including runtime context.""" + + return canonical_json(self.canonical_payload()) + + @classmethod + def json_schema(cls) -> dict[str, Any]: + """JSON Schema (2020-12), with the rules it cannot express attached.""" + + schema = cls.model_json_schema() + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": cls.SCHEMA_ID, + **schema, + "x-policyengine-rules": list(CROSS_FIELD_RULES), + "x-policyengine-digests": dict(DIGEST_RULES), + } + + +# --- Strict JSON values ------------------------------------------------------- + + +def _numpy_scalar(value: Any) -> bool: + module = type(value).__module__ + return ( + module.startswith("numpy") + and hasattr(value, "item") + and getattr(value, "ndim", 0) == 0 + ) + + +def strict_json_value(value: Any, path: str = "$") -> Any: + """Convert to a JSON value, refusing anything without one exact JSON form. + + Accepted: ``None``, booleans, strings, integers (including ``IntEnum`` and + numpy integer scalars), finite floats (including numpy float scalars), + ``datetime.date`` (as ISO 8601, ``YYYY-MM-DD``), lists and tuples, and + mappings whose keys are strings or integers (integers become their + decimal string; a key collision is refused). Everything else (sets, + ``Decimal``, bytes, arrays, objects, numpy ``longdouble``, values nested + too deeply to walk) raises :class:`ReceiptError`, so no value is silently + stringified. + """ + + try: + return _strict_json_value(value, path) + except RecursionError as exc: + raise ReceiptError(f"{path}: the value is nested too deeply.") from exc + + +def _strict_json_value(value: Any, path: str) -> Any: + if value is None or isinstance(value, (bool, str)): + return value + if _numpy_scalar(value): + item = value.item() + if _numpy_scalar(item): + # longdouble (and similar) has no Python equivalent to unwrap to. + raise ReceiptError( + f"{path}: {type(value).__name__} has no exact JSON form; convert it first." + ) + return _strict_json_value(item, path) + if isinstance(value, int): + return int(value) + if isinstance(value, float): + number = float(value) + if not math.isfinite(number): + raise ReceiptError(f"{path}: {number} is not a finite number.") + return number + if isinstance(value, datetime.datetime): + raise ReceiptError(f"{path}: datetimes are ambiguous; pass an ISO 8601 string.") + if isinstance(value, datetime.date): + return value.isoformat() + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, item in value.items(): + if isinstance(key, bool) or not isinstance(key, (str, int)): + raise ReceiptError(f"{path}: object keys must be strings, not {key!r}.") + text = key if isinstance(key, str) else str(int(key)) + if text in result: + raise ReceiptError(f"{path}: keys {key!r} and {text!r} collide.") + result[text] = _strict_json_value(item, f"{path}.{text}") + return result + if isinstance(value, (list, tuple)): + return [ + _strict_json_value(item, f"{path}[{index}]") + for index, item in enumerate(value) + ] + raise ReceiptError( + f"{path}: {type(value).__name__} has no exact JSON form; convert it first." + ) + + +def content_sha256(value: Any) -> str: + """Receipt digest of a value: strict JSON, nulls dropped, RFC 8785, SHA-256. + + Raises :class:`ReceiptError` for anything without one exact JSON form, + including integers outside +/-(2**53 - 1). + """ + + converted = strict_json_value(value) + try: + return canonical_sha256(drop_null_members(converted)) + except RecursionError as exc: + raise ReceiptError("The value is nested too deeply.") from exc + except CanonicalJSONError as exc: + raise ReceiptError(str(exc)) from exc + + +# --- Helpers ------------------------------------------------------------- + + +def normalize_distribution_name(name: str) -> str: + """PEP 503 normalization: lowercase, runs of -_. become -.""" + + return re.sub(r"[-_.]+", "-", name).lower() + + +def uk_fiscal_year_convention() -> FiscalYearConvention: + """The UK convention: year Y is the fiscal year 6 April Y to 5 April Y+1.""" + + return FiscalYearConvention( + convention="uk_fiscal_year", + period_start="04-06", + parameter_annualisation="policyengine-uk/fiscal-year-parameters", + ) + + +def fiscal_year_label(year: int, convention: FiscalYearConvention) -> str: + """Display label for a reported year under ``convention``: ``2027-28``.""" + + if convention.convention != "uk_fiscal_year": # pragma: no cover - v1 is UK + raise ReceiptError(f"Unknown fiscal year convention {convention.convention}.") + return f"{year}-{(year + 1) % 100:02d}" + + +def certificate_sha256(certification_record: Mapping[str, Any]) -> str: + """Digest of a bundle manifest's ``data_releases.`` record.""" + + return content_sha256(certification_record) + + +def reform_identity( + payload: Optional[Union[Mapping[str, Any], Sequence[Any]]], + representation: Optional[ReformRepresentation] = None, + *, + name: Optional[str] = None, +) -> ReformIdentity: + """Bind a reform payload (a JSON object or array) by digest. + + ``None`` or an empty payload means current law. The payload is digested + exactly as given in its representation, after strict JSON conversion; no + normalization across representations is attempted. A policyengine-uk + ``Scenario`` goes through :func:`scenario_reform_identity`; for + ``Scenario.from_reform(d)`` bind ``d`` with + ``"policyengine-uk.reform_dict/v1"``. + """ + + if payload is None: + return ReformIdentity(kind="current_law", name=name) + if isinstance(payload, (str, bytes)) or not isinstance( + payload, (Mapping, list, tuple) + ): + raise ReceiptError( + f"A reform payload is a JSON object or array, not {type(payload).__name__}." + ) + if not payload: + return ReformIdentity(kind="current_law", name=name) + if representation is None: + raise ReceiptError("A non-empty reform needs its representation.") + if representation == "policyengine-uk.scenario/v1": + raise ReceiptError("Bind a Scenario with scenario_reform_identity().") + digest = content_sha256({"representation": representation, "payload": payload}) + return ReformIdentity( + kind="reform", representation=representation, reform_sha256=digest, name=name + ) + + +#: Compiler flags ``from __future__`` imports set. A function's source is +#: recompiled with the flags its module was compiled with. +_FUTURE_FLAGS = functools.reduce( + operator.or_, + (getattr(__future__, name).compiler_flag for name in __future__.all_feature_names), + 0, +) + +_SCENARIO_ATTRIBUTES = ( + "applied_before_data_load", + "parameter_changes", + "simulation_modifier", +) + + +def _code_shape(code: CodeType) -> tuple[Any, ...]: + """A code object without where it was compiled: no file, no line numbers.""" + + return ( + code.co_name, + code.co_qualname, + code.co_argcount, + code.co_posonlyargcount, + code.co_kwonlyargcount, + code.co_flags, + code.co_stacksize, + code.co_code, + code.co_names, + code.co_varnames, + code.co_freevars, + code.co_cellvars, + code.co_exceptiontable, + tuple( + _code_shape(const) if isinstance(const, CodeType) else (type(const), const) + for const in code.co_consts + ), + ) + + +def _source_matches_loaded_code(source: str, code: CodeType, qualname: str) -> bool: + """Whether compiling ``source`` reproduces the loaded code (nested code too).""" + + try: + module_code = compile( + source, + code.co_filename, + "exec", + flags=code.co_flags & _FUTURE_FLAGS, + dont_inherit=True, + ) + except SyntaxError: + return False + compiled = [ + const + for const in module_code.co_consts + if isinstance(const, CodeType) and const.co_qualname == qualname + ] + return len(compiled) == 1 and _code_shape(compiled[0]) == _code_shape(code) + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _modifier_identity( + modifier: Any, code_revision: Mapping[str, str] +) -> dict[str, Any]: + """What binds a module-level function's behaviour, or refuse it. + + The identity is the function's module and qualified name, the SHA-256 of + its source and of its module's source, its default arguments, and + ``code_revision``. None of these depends on where the code is checked out + or which Python runs it. The source is recompiled and compared with the + loaded code (ignoring file name and line numbers, recursing into nested + code), so a file edited after import is refused rather than misdescribed. + + Closures, lambdas, nested functions, partials, bound methods, builtins + and functions whose defaults have no exact JSON form are refused: their + behaviour depends on values the digest cannot bind (two + ``Scenario.from_reform`` closures share one source but apply different + reforms). What the identity cannot bind is module state changed at run + time (a global reassigned after import); ``code_revision`` names the code, + not the process. + """ + + if isinstance(modifier, functools.partial): + raise ReceiptError( + "A functools.partial modifier cannot be bound; use parameter_changes." + ) + if inspect.ismethod(modifier) or not inspect.isfunction(modifier): + raise ReceiptError( + "Only module-level functions can be bound as simulation modifiers, " + f"not {type(modifier).__name__}." + ) + code = modifier.__code__ + if modifier.__closure__ or code.co_freevars: + raise ReceiptError( + "A modifier that closes over values (for example Scenario.from_reform's) " + "cannot be bound by its code; receipt the reform dictionary instead." + ) + if modifier.__name__ == "" or "" in modifier.__qualname__: + raise ReceiptError( + "Lambdas and nested functions cannot be bound; define it at module level." + ) + module = sys.modules.get(modifier.__module__) + if module is None or modifier.__globals__ is not vars(module): + raise ReceiptError( + "The modifier does not belong to an imported module, so it cannot be bound." + ) + try: + source = inspect.getsource(modifier) + module_source = inspect.getsource(module) + except (OSError, TypeError) as exc: + raise ReceiptError( + "The modifier's source is unavailable, so it cannot be bound." + ) from exc + if not _source_matches_loaded_code(source, code, modifier.__qualname__): + raise ReceiptError( + f"{modifier.__module__}.{modifier.__qualname__} no longer matches its " + "source file (edited after import?), so it cannot be bound." + ) + try: + defaults = ( + strict_json_value(list(modifier.__defaults__)) + if modifier.__defaults__ + else None + ) + kwdefaults = ( + strict_json_value(dict(modifier.__kwdefaults__)) + if modifier.__kwdefaults__ + else None + ) + except ReceiptError as exc: + raise ReceiptError( + f"The modifier's default arguments cannot be bound: {exc}" + ) from exc + return { + "module": modifier.__module__, + "qualname": modifier.__qualname__, + "source_sha256": _sha256_text(source), + "module_source_sha256": _sha256_text(module_source), + "defaults": defaults, + "kwdefaults": kwdefaults, + "code_revision": strict_json_value(dict(code_revision)), + } + + +def scenario_reform_identity( + scenario: Any, + *, + code_revision: Optional[Mapping[str, str]] = None, + name: Optional[str] = None, +) -> ReformIdentity: + """Bind a policyengine-uk ``Scenario`` by digest. + + The payload is ``{applied_before_data_load, parameter_changes, + simulation_modifier}``. A ``simulation_modifier`` must be a module-level + function: it is bound as :func:`_modifier_identity` describes, together + with ``code_revision`` (``{"repository": ..., "git_sha": ...}``), and + marked ``binding="code_revision"``. Closures, lambdas, partials and bound + methods are refused, which includes ``Scenario.from_reform(d)``: bind + ``d`` with :func:`reform_identity` and ``"policyengine-uk.reform_dict/v1"``. + Anything that is not a Scenario (a dictionary, a ``Reform`` class, a + policyengine.py ``Policy``) is refused rather than read as current law. + """ + + missing = [ + attribute + for attribute in _SCENARIO_ATTRIBUTES + if not hasattr(scenario, attribute) + ] + if missing or isinstance(scenario, (Mapping, str, bytes, list, tuple, type)): + raise ReceiptError( + "scenario_reform_identity binds a policyengine-uk Scenario, not " + f"{type(scenario).__name__}; bind a reform dictionary with " + "reform_identity()." + ) + applied_before_data_load = scenario.applied_before_data_load + parameter_changes = scenario.parameter_changes + modifier = scenario.simulation_modifier + if not isinstance(applied_before_data_load, bool): + raise ReceiptError("Scenario.applied_before_data_load must be a bool.") + if parameter_changes is not None and not isinstance(parameter_changes, Mapping): + raise ReceiptError("Scenario.parameter_changes must be a mapping or None.") + if modifier is not None and not callable(modifier): + raise ReceiptError("Scenario.simulation_modifier must be callable or None.") + if not parameter_changes and modifier is None: + return ReformIdentity(kind="current_law", name=name) + payload: dict[str, Any] = { + "applied_before_data_load": applied_before_data_load, + "parameter_changes": strict_json_value(parameter_changes) + if parameter_changes + else None, + "simulation_modifier": None, + } + binding: Literal["content", "code_revision"] = "content" + if modifier is not None: + if ( + not code_revision + or not isinstance(code_revision.get("repository"), str) + or not code_revision["repository"] + or not isinstance(code_revision.get("git_sha"), str) + or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", code_revision["git_sha"]) + ): + raise ReceiptError( + "A scenario with a simulation_modifier can only be receipted with " + "code_revision={'repository': ..., 'git_sha': } " + "naming the code that defines the modifier." + ) + payload["simulation_modifier"] = _modifier_identity(modifier, code_revision) + binding = "code_revision" + digest = content_sha256( + {"representation": "policyengine-uk.scenario/v1", "payload": payload} + ) + return ReformIdentity( + kind="reform", + representation="policyengine-uk.scenario/v1", + reform_sha256=digest, + binding=binding, + name=name, + ) + + +def installed_package_identity( + name: str, + *, + wheel_path: Optional[Path] = None, +) -> PackageIdentity: + """Identity of an installed distribution, with a hash only when verified. + + With ``wheel_path``, the installed files are checked against that wheel's + RECORD; any difference raises :class:`ReceiptError`, and a match records + the wheel's SHA-256 as ``installed_record_verified``. Otherwise the + installer's PEP 610 ``direct_url.json`` archive hash is used when present. + A bundle manifest's wheel hash is never assumed to describe installed + files. Versions always come from :mod:`importlib.metadata`; a caller + cannot supply them. + """ + + normalized = normalize_distribution_name(name) + version = metadata.version(name) + if wheel_path is not None: + from policyengine.provenance.wheels import verify_installed_against_wheel + + evidence = verify_installed_against_wheel( + name, Path(wheel_path), metadata=metadata + ) + if evidence["problems"]: + raise ReceiptError( + f"Installed {name} differs from {Path(wheel_path).name}: " + + "; ".join(evidence["problems"][:5]) + ) + return PackageIdentity( + name=normalized, + version=version, + wheel_sha256=evidence["wheel_sha256"], + wheel_sha256_evidence="installed_record_verified", + ) + archive_hash = _installer_archive_sha256(name) + return PackageIdentity( + name=normalized, + version=version, + wheel_sha256=archive_hash, + wheel_sha256_evidence="installer_archive_hash" if archive_hash else None, + ) + + +def _installer_archive_sha256(name: str) -> Optional[str]: + try: + text = metadata.distribution(name).read_text("direct_url.json") + except PackageNotFoundError: + return None + if not text: + return None + try: + archive = json.loads(text).get("archive_info") or {} + except ValueError: + return None + candidate = (archive.get("hashes") or {}).get("sha256") + if candidate is None: + legacy = archive.get("hash") or "" + if legacy.startswith("sha256="): + candidate = legacy.removeprefix("sha256=") + if candidate and re.fullmatch(SHA256_PATTERN[1:-1], candidate): + return str(candidate) + return None + + +def _packaged_tro_fingerprint(path: Path) -> Optional[str]: + if not path.is_file(): + return None + from policyengine.provenance.trace import extract_bundle_tro_reference + + try: + fingerprint = extract_bundle_tro_reference(json.loads(path.read_text()))[ + "fingerprint" + ] + except (ValueError, KeyError): + return None + return str(fingerprint) if fingerprint else None + + +def build_execution_receipt( + *, + country_id: Literal["uk"], + dataset_path: Union[str, Path], + years: Sequence[int], + reform: ReformIdentity, + baseline: ReformIdentity, + dataset: Optional[str] = None, + outputs: Optional[Any] = None, + wheel_paths: Optional[Mapping[str, Union[str, Path]]] = None, + wrapper_git_sha: Optional[str] = None, + qualification_receipt_sha256: Optional[str] = None, + bundle: Optional[Mapping[str, Any]] = None, + ci_run_url: Optional[str] = None, +) -> ExecutionReceipt: + """Build a receipt for a UK result computed in this process. + + ``reform`` and ``baseline`` are required: a receipt never assumes current + law. Versions come from the installed distributions. The dataset digest + comes from hashing ``dataset_path``, which must be the release artifact + that was loaded (what :func:`policyengine.provenance.dataset_materialization. + materialize_dataset` returns), not a derived per-year file. The + certificate is attached only when the bundle certifies the installed + model and core versions, those dataset bytes, that dataset name and the + running wrapper version; otherwise the receipt is ``uncertified``. + ``bundle`` defaults to the installed policyengine bundle + (``certificate_source="packaged"``); a caller-supplied bundle is recorded + as ``caller_supplied``. ``wheel_paths`` maps distribution names to wheels + whose RECORD the installed files are verified against. + """ + + if country_id != "uk": + raise ReceiptError("Execution receipt schema v1 covers UK results only.") + source: Literal["packaged", "caller_supplied"] = "caller_supplied" + tro_fingerprint: Optional[str] = None + if bundle is None: + from policyengine.provenance.certification import default_bundle_source_path + + manifest_path = default_bundle_source_path() + bundle = json.loads(manifest_path.read_text()) + source = "packaged" + tro_fingerprint = _packaged_tro_fingerprint( + manifest_path.parent / f"{country_id}.trace.tro.jsonld" + ) + try: + return _build( + country_id=country_id, + dataset_path=Path(dataset_path), + years=years, + reform=reform, + baseline=baseline, + dataset=dataset, + outputs=outputs, + wheel_paths=wheel_paths or {}, + wrapper_git_sha=wrapper_git_sha, + qualification_receipt_sha256=qualification_receipt_sha256, + bundle=bundle, + source=source, + tro_fingerprint=tro_fingerprint, + ci_run_url=ci_run_url, + ) + except KeyError as exc: + raise ReceiptError( + f"The bundle manifest lacks {exc}; it cannot back a receipt." + ) from exc + + +def _certificate_fields( + country_id: str, record: Mapping[str, Any], bundle: Mapping[str, Any] +) -> dict[str, Any]: + """The certificate identity a bundle's certification record determines. + + One derivation for both :func:`build_execution_receipt` and + :func:`verify_certificate`, keyed by ``CertificateIdentity.IDENTITY_FIELDS``. + """ + + certification = record.get("certification") or {} + certified_artifact = record.get("certified_data_artifact") or {} + core = ((bundle.get("packages") or {}).get(UK_PACKAGES["core"]) or {}).get( + "version" + ) + return { + "bundle_version": bundle.get("policyengine_version"), + "country_id": country_id, + "certificate_sha256": certificate_sha256(record), + "compatibility_basis": certification.get("compatibility_basis"), + "certified_for_model_version": certification.get("certified_for_model_version"), + "certified_for_core_version": certification.get("certified_for_core_version") + or core, + "certified_dataset_sha256": certified_artifact.get("sha256"), + "certified_by": certification.get("certified_by"), + } + + +def _data_identity( + country_id: str, + record: Mapping[str, Any], + bundle: Mapping[str, Any], + dataset_name: str, + dataset_sha256: str, +) -> DataIdentity: + """The data identity of ``dataset_name`` with those bytes, from the bundle. + + Release-level members (build, fingerprint, manifest revision) are filled + only for datasets in the certified release, never for overlays. + """ + + from policyengine.provenance.manifest import _apply_dataset_overlays + + resolved = _apply_dataset_overlays(country_id, dict(record), dict(bundle)) + reference = (resolved.get("datasets") or {}).get(dataset_name) + if reference is None: + raise ReceiptError( + f"Dataset {dataset_name!r} is not in the {country_id} bundle." + ) + is_certified_release = dataset_name in (record.get("datasets") or {}) + data_package = record["data_package"] + certified_artifact = record.get("certified_data_artifact") or {} + certification = record.get("certification") or {} + revision = reference.get("revision") or data_package.get("version") + repo_id = reference.get("repo_id") or data_package.get("repo_id") + if not revision or not repo_id: + raise ReceiptError( + f"Dataset {dataset_name!r} has no repository or revision pin." + ) + release = is_certified_release + return DataIdentity( + data_package=( + data_package["name"] + if release + else reference.get("data_package_name") or data_package["name"] + ), + release_version=data_package["version"] if release else revision, + build_id=( + certification.get("data_build_id") or certified_artifact.get("build_id") + if release + else None + ), + build_fingerprint=certification.get("data_build_fingerprint") + if release + else None, + build_model_version=certification.get("built_with_model_version") + if release + else None, + release_manifest_revision=data_package.get("release_manifest_revision") + if release + else None, + dataset=dataset_name, + dataset_uri=f"hf://{repo_id}/{reference['path']}@{revision}", + dataset_revision=revision, + dataset_sha256=dataset_sha256, + ) + + +def _build( + *, + country_id: Literal["uk"], + dataset_path: Path, + years: Sequence[int], + reform: ReformIdentity, + baseline: ReformIdentity, + dataset: Optional[str], + outputs: Optional[Any], + wheel_paths: Mapping[str, Union[str, Path]], + wrapper_git_sha: Optional[str], + qualification_receipt_sha256: Optional[str], + bundle: Mapping[str, Any], + source: Literal["packaged", "caller_supplied"], + tro_fingerprint: Optional[str], + ci_run_url: Optional[str], +) -> ExecutionReceipt: + record = bundle["data_releases"][country_id] + wheels = {normalize_distribution_name(k): Path(v) for k, v in wheel_paths.items()} + + def identity(name: str) -> dict[str, Any]: + return installed_package_identity( + name, wheel_path=wheels.get(normalize_distribution_name(name)) + ).model_dump() + + wrapper = WrapperIdentity( + **identity(UK_PACKAGES["wrapper"]), git_sha=wrapper_git_sha + ) + model = ModelIdentity(**identity(UK_PACKAGES["model"])) + core = CoreIdentity(**identity(UK_PACKAGES["core"])) + + dataset_name = dataset or record["default_dataset"] + actual_sha256 = sha256_file(dataset_path) + data = _data_identity(country_id, record, bundle, dataset_name, actual_sha256) + + fields = _certificate_fields(country_id, record, bundle) + certified_artifact = record.get("certified_data_artifact") or {} + certificate = None + status: Literal["certified", "uncertified"] = "uncertified" + if ( + fields["certified_for_model_version"] == model.version + and fields["certified_for_core_version"] == core.version + and fields["certified_dataset_sha256"] == actual_sha256 + and dataset_name == certified_artifact.get("dataset") + and fields["bundle_version"] == wrapper.version + and record.get("policyengine_version", wrapper.version) == wrapper.version + ): + if not fields["compatibility_basis"]: + raise ReceiptError( + "The bundle certifies this run but states no compatibility_basis." + ) + certificate = CertificateIdentity( + **fields, + certificate_source=source, + qualification_receipt_sha256=qualification_receipt_sha256, + tro_composition_fingerprint=tro_fingerprint, + ) + status = "certified" + + return ExecutionReceipt( + schema_version=RECEIPT_SCHEMA_VERSION, + receipt_type="policyengine.execution_receipt", + country_id=country_id, + wrapper=wrapper, + model=model, + core=core, + data=data, + certification_status=status, + certificate=certificate, + reform=reform, + baseline=baseline, + fiscal_year=uk_fiscal_year_convention(), + years=sorted(set(int(year) for year in years)), + outputs_sha256=content_sha256(outputs) if outputs is not None else None, + runtime=RuntimeContext( + python_version=platform.python_version(), + platform=sys.platform, + ci_run_url=ci_run_url, + ), + ) + + +def verify_certificate( + receipt: ExecutionReceipt, bundle: Mapping[str, Any] +) -> list[str]: + """Problems with a receipt's certificate against a published bundle manifest. + + ``bundle`` is the policyengine manifest for ``certificate.bundle_version`` + (from the release assets, or the installed wheel of that version). The + certificate is rebuilt from that bundle's certification record exactly as + :func:`build_execution_receipt` builds it, and compared member by member: + + - every member in ``CertificateIdentity.IDENTITY_FIELDS``, including the + record digest, the basis and the certified model, core and dataset; + - the receipt's whole ``data`` identity, which must be the certified + dataset with the certified bytes. + + Through the receipt's own rules, that also pins ``model.version``, + ``core.version`` and ``wrapper.version``. ``certificate_source``, + ``qualification_receipt_sha256``, ``tro_composition_fingerprint`` and + wheel hashes are not determined by the bundle and are not checked here. + An empty list means the receipt's certificate and data are what the bundle + certifies. + """ + + certificate = receipt.certificate + if certificate is None: + return ["the receipt is uncertified"] + record = (bundle.get("data_releases") or {}).get(receipt.country_id) + if not isinstance(record, Mapping): + return [f"the bundle has no {receipt.country_id} data release"] + problems = [] + expected = _certificate_fields(receipt.country_id, record, bundle) + claimed = certificate.model_dump(include=set(CertificateIdentity.IDENTITY_FIELDS)) + for field in CertificateIdentity.IDENTITY_FIELDS: + if claimed.get(field) != expected[field]: + problems.append( + f"certificate.{field} is {claimed.get(field)!r}, " + f"but the bundle states {expected[field]!r}" + ) + record_version = record.get("policyengine_version", expected["bundle_version"]) + if record_version != certificate.bundle_version: + problems.append( + f"the bundle's {receipt.country_id} record belongs to policyengine " + f"{record_version}, not {certificate.bundle_version}" + ) + certified_dataset = (record.get("certified_data_artifact") or {}).get("dataset") + if receipt.data.dataset != certified_dataset: + problems.append( + f"data.dataset is {receipt.data.dataset!r}, " + f"but the bundle certifies {certified_dataset!r}" + ) + return problems + try: + expected_data = _data_identity( + receipt.country_id, + record, + bundle, + certified_dataset, + expected["certified_dataset_sha256"] or receipt.data.dataset_sha256, + ) + except (KeyError, ReceiptError, ValueError) as exc: + return problems + [f"the bundle cannot determine the data identity: {exc}"] + claimed_data = receipt.data.model_dump() + for field, value in expected_data.model_dump().items(): + if claimed_data[field] != value: + problems.append( + f"data.{field} is {claimed_data[field]!r}, " + f"but the bundle determines {value!r}" + ) + return problems + + +def build_execution_receipt_for_microsimulation( + microsimulation: Any, + *, + years: Sequence[int], + reform: ReformIdentity, + baseline: ReformIdentity, + outputs: Optional[Any] = None, + **kwargs: Any, +) -> ExecutionReceipt: + """Receipt for a country Microsimulation built by ``managed_microsimulation``. + + Reads the dataset that was loaded from ``microsimulation.policyengine_bundle`` + and hashes those bytes. ``reform`` and ``baseline`` must describe what the + microsimulation was built with (``scenario_reform_identity`` or + ``reform_identity``); the microsimulation itself does not record them. + """ + + bundle_fields = getattr(microsimulation, "policyengine_bundle", None) + if not bundle_fields or not bundle_fields.get("runtime_dataset_source"): + raise ReceiptError( + "This microsimulation records no runtime dataset; build it with " + "managed_microsimulation()." + ) + if bundle_fields.get("country_id") != "uk": + raise ReceiptError("Execution receipt schema v1 covers UK results only.") + return build_execution_receipt( + country_id="uk", + dataset_path=bundle_fields["runtime_dataset_source"], + dataset=bundle_fields.get("runtime_dataset"), + years=years, + reform=reform, + baseline=baseline, + outputs=outputs, + **kwargs, + ) diff --git a/src/policyengine/provenance/uk_candidate.py b/src/policyengine/provenance/uk_candidate.py new file mode 100644 index 00000000..16ddb053 --- /dev/null +++ b/src/policyengine/provenance/uk_candidate.py @@ -0,0 +1,2336 @@ +"""Mandatory local qualification gates for a UK model/data candidate. + +Each gate returns a :class:`~policyengine.provenance.qualification.GateResult` +and an evidence dict whose ``content_sha256`` the result records. No gate has +a skip path: a gate whose harness is missing reports ``unavailable`` (the +candidate is incomplete), a gate that crashes reports ``error`` (rejected), +and a failed check reports ``fail`` (rejected). The replay gate is evaluated +separately (:func:`policyengine.provenance.qualification.evaluate_replay_receipt`). + +I/O is injected (metadata readers, HTTP getters, importers, year projectors) +so every gate is testable offline. :func:`run_candidate_gates` wires the real +implementations for the qualification workflow. +""" + +from __future__ import annotations + +import hashlib +import importlib +import inspect +import io +import json +import math +import re +import traceback +from collections import Counter +from collections.abc import Callable, Iterable, Mapping, Sequence +from importlib import metadata as importlib_metadata +from pathlib import Path +from typing import Any, Optional + +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +from policyengine.provenance.manifest import DataReleaseManifest, _specifier_matches +from policyengine.provenance.qualification import ( + CandidatePair, + GateResult, + QualificationHarness, + QualificationReceipt, +) +from policyengine.provenance.receipt import content_sha256 +from policyengine.provenance.wheels import ( + verify_installed_against_wheel, +) +from policyengine.utils.hashing import sha256_file + +Evidence = dict[str, Any] +GateOutcome = tuple[GateResult, Evidence] + +UK_SINGLE_YEAR_KEYS = ("time_period", "person", "benunit", "household") +ENTITY_TABLES = ("person", "benunit", "household") + + +def _json_safe(value: Any) -> Any: + """Evidence as strict JSON: non-finite numbers become their names.""" + + if isinstance(value, float) and not math.isfinite(value): + return ( + "NaN" if math.isnan(value) else ("Infinity" if value > 0 else "-Infinity") + ) + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + return value + + +def evidence_sha256(evidence: Any) -> str: + """Digest of evidence that may contain non-finite numbers.""" + + return content_sha256(_json_safe(evidence)) + + +def _result( + gate_id: str, + status: str, + summary: str, + evidence: Evidence, + details: Optional[Mapping[str, Any]] = None, +) -> GateOutcome: + result = GateResult( + gate_id=gate_id, + status=status, # type: ignore[arg-type] + summary=summary, + evidence_sha256=evidence_sha256(evidence), + details=_json_safe(dict(details or {})), + ) + return result, _json_safe(evidence) + + +# --- 1. Installed versions --------------------------------------------------- + + +def installed_versions( + pair: CandidatePair, + harness: QualificationHarness, + *, + metadata: Any = importlib_metadata, +) -> GateOutcome: + """Installed wrapper, model and core equal the candidate; requirements hold.""" + + expected = { + "policyengine": harness.policyengine_version, + pair.model.name: pair.model.version, + pair.core.name: pair.core.version, + } + installed: dict[str, Optional[str]] = {} + problems: list[str] = [] + for name, version in expected.items(): + try: + installed[name] = metadata.version(name) + except metadata.PackageNotFoundError: + installed[name] = None + problems.append(f"{name} is not installed") + continue + if installed[name] != version: + problems.append(f"{name} {installed[name]} is installed, not {version}") + + unmet: list[str] = [] + for name in expected: + if installed.get(name) is None: + continue + for line in metadata.requires(name) or []: + requirement = Requirement(line) + if requirement.marker is not None and not requirement.marker.evaluate( + {"extra": ""} + ): + continue + try: + found = metadata.version(requirement.name) + except metadata.PackageNotFoundError: + unmet.append(f"{name} needs {requirement}, which is not installed") + continue + if requirement.specifier and not requirement.specifier.contains( + found, prereleases=True + ): + unmet.append(f"{name} needs {requirement}; {found} is installed") + problems.extend(unmet) + evidence = { + "expected": expected, + "installed": installed, + "unmet_requirements": unmet, + } + if problems: + return _result("installed_versions", "fail", "; ".join(problems), evidence) + return _result( + "installed_versions", + "pass", + f"policyengine {harness.policyengine_version}, {pair.model.name} {pair.model.version} and {pair.core.name} {pair.core.version} are installed and " + "their requirements hold.", + evidence, + ) + + +# --- 2. Wheel hashes ----------------------------------------------------------- + + +def wheel_hashes( + pair: CandidatePair, + *, + wheel_paths: Mapping[str, Path], + pypi_digests: Mapping[str, str], + metadata: Any = importlib_metadata, +) -> GateOutcome: + """Downloaded wheels match PyPI and the pin; installed files match the wheels.""" + + evidence: Evidence = {} + problems: list[str] = [] + for package in (pair.model, pair.core): + path = wheel_paths.get(package.name) + if path is None or not Path(path).is_file(): + problems.append(f"no downloaded wheel for {package.name}") + continue + actual = sha256_file(path) + if actual != package.wheel_sha256: + problems.append( + f"{package.name} wheel is {actual}, pinned {package.wheel_sha256}" + ) + if pypi_digests.get(package.name) != package.wheel_sha256: + problems.append(f"PyPI publishes a different {package.name} wheel digest") + verification = verify_installed_against_wheel( + package.name, Path(path), metadata=metadata + ) + evidence[package.name] = verification + problems.extend(f"{package.name} {item}" for item in verification["problems"]) + if problems: + return _result("wheel_hashes", "fail", "; ".join(problems[:10]), evidence) + counts = ", ".join( + f"{name}: {item['archive_members_verified']} wheel members, " + f"{item['files_verified']} installed files" + for name, item in evidence.items() + ) + return _result( + "wheel_hashes", + "pass", + f"The pinned PyPI wheels match their RECORDs and the installed files ({counts}).", + evidence, + ) + + +def receipt_wheel_paths( + pair: CandidatePair, wheel_paths: Mapping[str, Path] +) -> dict[str, Path]: + """``wheel_paths`` for ``build_execution_receipt``, which re-verifies them.""" + + return { + package.name: Path(wheel_paths[package.name]) + for package in (pair.model, pair.core) + if package.name in wheel_paths + } + + +# --- 3. Core compatibility ----------------------------------------------------- + + +def core_compatibility( + pair: CandidatePair, + release_manifest: DataReleaseManifest, + *, + model_requires_core: str, +) -> GateOutcome: + """The core satisfies the model; publisher claims are recorded, not required.""" + + problems: list[str] = [] + if not SpecifierSet(model_requires_core).contains( + pair.core.version, prereleases=True + ): + problems.append( + f"{pair.model.name} {pair.model.version} requires policyengine-core" + f"{model_requires_core}; the candidate core is {pair.core.version}" + ) + build = release_manifest.build + built_with = build.built_with_model_package if build is not None else None + if built_with is not None and built_with.name != pair.model.name: + problems.append( + f"the data was built with {built_with.name}, not {pair.model.name}" + ) + model_claims = [ + package.specifier + for package in release_manifest.compatible_model_packages + if package.name == pair.model.name + ] + core_claims = [ + package.specifier + for package in release_manifest.compatible_core_packages + if package.name == pair.core.name + ] + evidence = { + "model_requires_core": model_requires_core, + "built_with_model_version": built_with.version if built_with else None, + "built_with_core_version": ( + build.built_with_core_package.version + if build is not None and build.built_with_core_package is not None + else None + ), + "publisher_model_claims": model_claims, + "publisher_core_claims": core_claims, + "publisher_claim_covers_model": any( + _specifier_matches(pair.model.version, item) for item in model_claims + ), + "publisher_claim_covers_core": any( + _specifier_matches(pair.core.version, item) for item in core_claims + ), + } + if problems: + return _result("core_compatibility", "fail", "; ".join(problems), evidence) + coverage = ( + "the publisher's claims cover both" + if evidence["publisher_claim_covers_model"] + and evidence["publisher_claim_covers_core"] + else "certification will record an exact qualified pair, since the publisher's claims do not cover both" + ) + return _result( + "core_compatibility", + "pass", + f"policyengine-core {pair.core.version} satisfies {pair.model.name}{model_requires_core}; {coverage}.", + evidence, + details={ + "publisher_claim_covers_model": evidence["publisher_claim_covers_model"], + "publisher_claim_covers_core": evidence["publisher_claim_covers_core"], + }, + ) + + +# --- 4. Data access ------------------------------------------------------------ + + +def _hf_url(repo_id: str, repo_type: str, revision: str, path: str) -> str: + prefix = "datasets/" if repo_type == "dataset" else "" + return f"https://huggingface.co/{prefix}{repo_id}/resolve/{revision}/{path}" + + +def data_access( + pair: CandidatePair, + *, + get: Callable[..., Any], + head: Callable[..., Any], + token: Optional[str], +) -> tuple[GateResult, Evidence, Optional[DataReleaseManifest]]: + """Authenticated access to the exact manifest and every artifact.""" + + data = pair.data + evidence: Evidence = {"artifacts": {}} + if not token: + result, evidence = _result( + "data_access", + "fail", + "No Hugging Face token is configured; the UK data is private.", + evidence, + ) + return result, evidence, None + headers = {"Authorization": f"Bearer {token}"} + manifest_url = _hf_url( + data.repo_id, + data.repo_type, + data.release_manifest_revision, + data.release_manifest_path, + ) + response = get(manifest_url, headers=headers, timeout=60) + evidence["manifest_status"] = response.status_code + if response.status_code != 200: + result, evidence = _result( + "data_access", + "fail", + f"The release manifest returned HTTP {response.status_code}.", + evidence, + ) + return result, evidence, None + manifest_sha256 = hashlib.sha256(response.content).hexdigest() + evidence["manifest_sha256"] = manifest_sha256 + problems = [] + if manifest_sha256 != data.release_manifest_sha256: + problems.append("the release manifest bytes differ from the candidate's") + manifest = DataReleaseManifest.model_validate_json(response.content) + for name, artifact in sorted(manifest.artifacts.items()): + url = _hf_url( + artifact.repo_id, + artifact.repo_type or data.repo_type, + artifact.revision, + artifact.path, + ) + status = head( + url, headers=headers, timeout=60, allow_redirects=True + ).status_code + evidence["artifacts"][name] = status + if status != 200: + problems.append(f"{name} returned HTTP {status}") + dataset_url = _hf_url( + data.repo_id, data.repo_type, data.dataset_revision, data.dataset_path + ) + anonymous = head(dataset_url, timeout=60, allow_redirects=True).status_code + evidence["anonymous_dataset_status"] = anonymous + if anonymous == 200: + problems.append( + "the dataset is readable without credentials, so the authenticated path was not tested" + ) + if problems: + result, evidence = _result("data_access", "fail", "; ".join(problems), evidence) + return result, evidence, manifest + result, evidence = _result( + "data_access", + "pass", + f"Read the release manifest and {len(manifest.artifacts)} artifacts with the " + f"configured credential; anonymous access returned HTTP {anonymous}.", + evidence, + ) + return result, evidence, manifest + + +# --- 5. Dataset bytes ---------------------------------------------------------- + + +def h5_keys(path: Path) -> list[str]: + import h5py + + with h5py.File(path, "r") as stream: + return sorted(stream.keys()) + + +def dataset_bytes(pair: CandidatePair, dataset_path: Path) -> GateOutcome: + """The simulated file is the candidate's bytes and has the UK layout.""" + + data = pair.data + actual = sha256_file(dataset_path) + size = dataset_path.stat().st_size + keys = h5_keys(dataset_path) + evidence = {"sha256": actual, "size_bytes": size, "h5_keys": keys} + problems = [] + if actual != data.dataset_sha256: + problems.append(f"sha256 {actual}, expected {data.dataset_sha256}") + if size != data.size_bytes: + problems.append(f"{size} bytes, expected {data.size_bytes}") + missing_keys = [key for key in UK_SINGLE_YEAR_KEYS if key not in keys] + if missing_keys: + problems.append(f"missing H5 keys {missing_keys}") + if problems: + return _result("dataset_bytes", "fail", "; ".join(problems), evidence) + return _result( + "dataset_bytes", + "pass", + f"{data.dataset} is the pinned {size:,} bytes with the UK single-year layout.", + evidence, + ) + + +# --- 6. Supported years -------------------------------------------------------- + + +def data_year(dataset_path: Path) -> int: + import pandas as pd + + with pd.HDFStore(dataset_path, mode="r") as store: + return int(str(store["time_period"].iloc[0])[:4]) + + +def projected_years_from_model(dataset_path: Path) -> list[int]: + """The years policyengine-uk projects a single-year dataset to. + + Builds the multi-year dataset exactly as ``policyengine_uk.Simulation`` + does for a single-year file (``extend_single_year_dataset`` with the + system's parameters), without running any formula. + """ + + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + from policyengine_uk.system import system + + multi_year = extend_single_year_dataset( + UKSingleYearDataset(str(dataset_path)), system.parameters + ) + years = getattr(multi_year, "years", None) + if years is None: + years = list(multi_year.datasets) + return sorted(int(year) for year in years) + + +def supported_years( + pair: CandidatePair, + dataset_path: Path, + *, + required_years: Sequence[int], + budget_horizon_years: Sequence[int], + previous_supported_years: Optional[Sequence[int]], + projected_years: Callable[[Path], list[int]] = projected_years_from_model, +) -> GateOutcome: + """Required years are inside the data's projection; support never shrinks. + + Support comes from the data and the model's projection, never from a + calculation succeeding: policyengine-core fills years outside the + projection (carrying or uprating the latest inputs), so a result there + is not evidence of support. + """ + + first = data_year(dataset_path) + years = projected_years(dataset_path) + supported = sorted(year for year in years if year >= first) + missing_required = sorted(set(required_years) - set(supported)) + lost = sorted(set(previous_supported_years or []) - set(supported)) + outside_horizon = sorted(set(budget_horizon_years) - set(supported)) + evidence = { + "data_year": first, + "supported_years": supported, + "required_years": sorted(required_years), + "budget_horizon_years": sorted(budget_horizon_years), + "previous_supported_years": sorted(previous_supported_years or []), + "lost_years": lost, + "outside_projection": outside_horizon, + } + details = {"supported_years": supported, "outside_projection": outside_horizon} + if not supported: + return _result( + "supported_years", + "fail", + f"The dataset supports no year: its data year is {first} and the model " + f"projects only to {max(years) if years else 'nothing'}.", + evidence, + details, + ) + if missing_required or lost: + parts = [] + if missing_required: + parts.append( + f"required years {missing_required} are outside the projection" + ) + if lost: + parts.append(f"years {lost} were supported by the previous certificate") + return _result("supported_years", "fail", "; ".join(parts), evidence, details) + if outside_horizon: + return _result( + "supported_years", + "needs_review", + f"Budget horizon years {outside_horizon} are outside the dataset's projection " + f"({supported[0]}–{supported[-1]}); results there are provisional.", + evidence, + details, + ) + return _result( + "supported_years", + "pass", + f"Supports {supported[0]}–{supported[-1]}, covering the required and horizon years.", + evidence, + details, + ) + + +# --- 7. Budget inputs ---------------------------------------------------------- + + +def dataset_columns(dataset_path: Path) -> dict[str, Any]: + """``{entity: DataFrame}`` for the UK entity tables.""" + + import pandas as pd + + with pd.HDFStore(dataset_path, mode="r") as store: + return { + entity: store[entity] for entity in ENTITY_TABLES if f"/{entity}" in store + } + + +def _degenerate(series: Any) -> bool: + values = series.dropna() + if len(values) == 0 or values.nunique() <= 1: + return True + if values.dtype.kind in "biuf": + return bool((values.astype(float) == 0).all()) + return False + + +def budget_inputs( + pair: CandidatePair, + dataset_path: Path, + *, + model_input_variables: Iterable[str], + critical_inputs: Sequence[str], + accepted_gaps: Mapping[str, str], + previous_missing_inputs: Optional[Sequence[str]], + previous_unusable_critical: Optional[Sequence[str]], + tables: Optional[Mapping[str, Any]] = None, +) -> GateOutcome: + """Critical inputs are present and usable; no input goes newly missing.""" + + tables = dict(tables) if tables is not None else dataset_columns(dataset_path) + columns: dict[str, str] = {} + for entity, table in tables.items(): + for column in table.columns: + columns.setdefault(column, entity) + inputs = set(model_input_variables) + missing = sorted(inputs - set(columns)) + newly_missing = ( + sorted(set(missing) - set(previous_missing_inputs)) + if previous_missing_inputs is not None + else [] + ) + unusable = [] + for name in critical_inputs: + if name not in columns: + unusable.append((name, "missing")) + elif _degenerate(tables[columns[name]][name]): + unusable.append((name, "degenerate")) + previous_bad = set(previous_unusable_critical or []) + regressions = [(n, why) for n, why in unusable if n not in previous_bad] + standing = [(n, why) for n, why in unusable if n in previous_bad] + unaccepted_standing = [(n, why) for n, why in standing if n not in accepted_gaps] + evidence = { + "model_input_variables": len(inputs), + "dataset_columns": len(columns), + "missing_inputs": missing, + "newly_missing_inputs": newly_missing, + "unusable_critical": [list(item) for item in unusable], + "accepted_gaps": dict(accepted_gaps), + } + details = { + "missing_inputs": len(missing), + "newly_missing_inputs": newly_missing, + "unusable_critical": [name for name, _ in unusable], + } + if regressions: + return _result( + "budget_inputs", + "fail", + "Budget-critical inputs became unusable: " + + ", ".join(f"{name} ({why})" for name, why in regressions), + evidence, + details, + ) + if unaccepted_standing or newly_missing: + parts = [] + if newly_missing: + parts.append( + f"{len(newly_missing)} model inputs are newly missing from the data " + f"({', '.join(newly_missing[:8])}{'…' if len(newly_missing) > 8 else ''})" + ) + if unaccepted_standing: + parts.append( + "critical inputs unusable in both pairs: " + + ", ".join(f"{name} ({why})" for name, why in unaccepted_standing) + ) + return _result( + "budget_inputs", "needs_review", "; ".join(parts), evidence, details + ) + return _result( + "budget_inputs", + "pass", + f"All {len(critical_inputs)} critical inputs are present and usable; " + f"no model input is newly missing ({len(missing)} optional inputs absent, as before).", + evidence, + details, + ) + + +# --- 8. Reform contract -------------------------------------------------------- + + +def _load_entrypoint( + entrypoint: str, importer: Callable[[str], Any] +) -> tuple[Callable[..., Any], Any]: + """The entrypoint's callable and the module that defines it.""" + + module_name, _, attribute = entrypoint.partition(":") + if not attribute: + module_name, _, attribute = entrypoint.rpartition(".") + module = importer(module_name) + function: Callable[..., Any] = getattr(module, attribute) + return function, module + + +def _contract_coverage(module: Any) -> Optional[dict[str, Any]]: + """What a full contract run covers, as the runner's own module declares it. + + ``ROUTES`` (route ids), ``CASES`` (objects with an ``id`` and optional + ``routes`` limiting where the case runs and ``checks`` describing each + expected value), ``UNSUPPORTED_ROUTES`` and ``PROPERTIES`` (keyed by + property id). ``None`` when the module does not declare the essentials. + """ + + try: + routes = [str(route) for route in module.ROUTES] + pairs = [ + (str(case.id), str(route)) + for case in module.CASES + for route in ( + case.routes if getattr(case, "routes", None) is not None else routes + ) + ] + checks: list[tuple[str, str, Optional[str]]] = [] + pending: dict[tuple[str, str, Optional[str]], str] = {} + unsupported = getattr(module, "UNSUPPORTED_ROUTES", {}) + country_routes = set( + getattr( + module, + "COUNTRY_ROUTES", + [route for route in routes if route.startswith("country.")], + ) + ) + unsupported_checks = {} + for case in module.CASES: + for route in ( + case.routes if getattr(case, "routes", None) is not None else routes + ): + if route in unsupported: + key = (str(case.id), str(route), None) + checks.append(key) + unsupported_checks[key] = unsupported[route] + elif getattr(case, "expect_error", None) is not None: + checks.append((str(case.id), str(route), "rejected")) + else: + for check in getattr(case, "checks", ()): + key = ( + str(case.id), + str(route), + f"{check.target}:{check.name}:{check.year}", + ) + checks.append(key) + if getattr(check, "pending", None) is not None: + pending[key] = str(check.pending) + # evaluate_case declares these per-check exemptions; + # route-wide exemptions above replace the entire case. + if check.target == "parameter" and route not in country_routes: + unsupported_checks[key] = ( + "this route exposes outputs, not parameter values" + ) + elif ( + check.target == "baseline_equals" + and route == "wrapper.policy" + ): + unsupported_checks[key] = ( + "the policy route's output dataset holds the reformed run only" + ) + + # The runner has no machine-readable isolation matrix. + # Derive its strongest coverage from _CountryRoute.run, + # _WrapperHouseholdRoute.run and _WrapperPolicyRoute.run: + # deduplicate requests; country baselines cover variable + # requests and after-runs cover parameters and variables; + # wrappers' after-runs cover exposed variables only. + # baseline_equals also requests its expected variable on + # country/household routes, but policy excludes that target. + # Rejected/error and whole-route unsupported cases emit no + # isolation rows. Every supported run also emits the shared + # module parameter check, required independently below. + requests = [] + for check in getattr(case, "checks", ()): + if route not in country_routes and ( + check.target == "parameter" + or ( + route == "wrapper.policy" + and check.target == "baseline_equals" + ) + ): + continue + kind = ( + "parameter" if check.target == "parameter" else "variable" + ) + requests.append((kind, str(check.name), check.year)) + if check.target == "baseline_equals": + requests.append( + ("variable", str(check.expected), check.year) + ) + for kind, name, year in dict.fromkeys(requests): + if route in country_routes and kind == "variable": + checks.append( + ( + str(case.id), + str(route), + f"baseline_isolation:{name}:{year}", + ) + ) + checks.append( + ( + str(case.id), + str(route), + f"unreformed_after_reform:{name}:{year}", + ) + ) + return { + "routes": routes, + "pairs": pairs, + "properties": [str(name) for name in module.PROPERTIES], + "checks": checks, + "isolation_checks": { + (case, route, check) + for case, route, check in checks + if isinstance(check, str) + and check.startswith( + ("baseline_isolation:", "unreformed_after_reform:") + ) + }, + "pending_checks": pending, + "pending_decisions": { + str(decision) for decision in getattr(module, "PENDING_DECISIONS", ()) + }, + "unsupported_routes": unsupported, + "unsupported_checks": unsupported_checks, + "run_pairs": ( + [("module_parameters_unchanged", "all")] + if any(route not in unsupported for route in routes) + else [] + ), + "population": getattr(module, "POPULATION_CHECKS", None), + } + except AttributeError: + return None + + +def _declared_population_checks( + module_name: str, importer: Callable[[str], Any] +) -> list[str]: + """Population check IDs documented by W1's population runner itself. + + W1 currently exposes no machine-readable per-route population coverage + matrix. Its module docstring declares the checks by ID, which supports + requiring every declared check, but not inventing route counts. Prefer + an explicit declaration if the runner adds one. + """ + + module = importer(module_name.rsplit(".", 1)[0] + ".budget_reform_population") + declared = getattr(module, "POPULATION_CHECKS", None) + if declared is not None: + return [str(check) for check in declared] + return re.findall( + r"^``([a-z][a-z0-9_]+)``$", inspect.getdoc(module) or "", re.MULTILINE + ) + + +#: Report sections: the key that names each row, and what a row is called. +_CONTRACT_SECTIONS = ( + ("cases", "case_id", "case"), + ("properties", "property_id", "property"), + ("population", "check_id", "population check"), +) +_CONTRACT_STATUSES = ("pass", "fail", "error", "unsupported", "pending") + + +def reform_contract( + *, + entrypoint: Optional[str], + dataset_path: Optional[Path], + seed: int, + max_examples: int, + expected_versions: Optional[Mapping[str, str]] = None, + expected_dataset_sha256: Optional[str] = None, + pending_decisions: Sequence[str] = (), + importer: Callable[[str], Any] = importlib.import_module, +) -> GateOutcome: + """Run the Budget reform contract (W1) on every route and gate on its report. + + ``fail`` when any case, sampled property or population check fails or + errors. ``error`` when the report cannot be trusted as a full run of this + candidate: + - it does not cover every declared case check on each route, the runner's + isolation checks and run-level shared-parameter check, every declared + population check and every sampled property, or a property passed on + fewer examples than requested; + - a property is pending, a pending row names no authorized decision, + or an unsupported case or population check has no runner-declared + exemption with the same ``reason``; + - a sampled property did not run; + - population checks are missing although a dataset was given; + - the report names other versions, another dataset, seed or sample size + than requested; + - its own ``holds`` or ``summary`` disagrees with its rows. + """ + + if not entrypoint: + return _result( + "reform_contract", + "unavailable", + "No reform contract runner is configured.", + {}, + ) + try: + runner, runner_module = _load_entrypoint(entrypoint, importer) + except (ImportError, AttributeError) as exc: + return _result( + "reform_contract", + "unavailable", + f"The reform contract runner {entrypoint} is not importable: {exc}", + {"entrypoint": entrypoint}, + ) + try: + report = runner( + routes=None, + dataset_path=str(dataset_path) if dataset_path else None, + seed=seed, + max_examples=max_examples, + ) + except Exception as exc: # the candidate made the contract crash + return _result( + "reform_contract", + "error", + f"The reform contract raised {type(exc).__name__}: {exc}", + {"entrypoint": entrypoint, "traceback": traceback.format_exc()}, + ) + report = json.loads(json.dumps(_json_safe(report), default=str)) + failures: list[str] = [] + malformed: list[str] = [] + pending: list[str] = [] + unsupported: list[str] = [] + counts = dict.fromkeys(_CONTRACT_STATUSES, 0) + coverage = _contract_coverage(runner_module) + population_exemptions = {} + population_module = None + if dataset_path is not None: + module_name = ( + entrypoint.partition(":")[0] + if ":" in entrypoint + else entrypoint.rpartition(".")[0] + ) + try: + population_module = importer( + module_name.rsplit(".", 1)[0] + ".budget_reform_population" + ) + population_exemptions = getattr(population_module, "UNSUPPORTED_CHECKS", {}) + except (ImportError, AttributeError): + pass + allowed_pending = set(pending_decisions) | ( + coverage["pending_decisions"] if coverage else set() + ) + for section, key, noun in _CONTRACT_SECTIONS: + for row in report.get(section) or []: + status = row.get("status") + label = f"{noun} {row.get(key)}" + ( + f"@{row['route']}" if row.get("route") else "" + ) + if status in counts: + counts[status] += 1 + if status in ("fail", "error"): + failures.append(label) + elif status == "pending": + decision = row.get("decision_id") + declared_decision = ( + coverage["pending_checks"].get( + ( + str(row.get("case_id")), + str(row.get("route")), + row.get("check"), + ) + ) + if coverage and section == "cases" + else None + ) + if section == "properties": + malformed.append( + f"{label} did not run: properties cannot be pending" + ) + elif not decision: + malformed.append(f"{label} is pending without a decision_id") + elif decision != declared_decision and decision not in allowed_pending: + malformed.append( + f"{label} is pending for undeclared decision {decision}" + ) + pending.append(label) + elif status == "unsupported": + if section == "properties": + malformed.append( + f"{label} did not run: {row.get('reason') or 'no reason given'}" + ) + elif not row.get("reason"): + malformed.append(f"{label} is unsupported without a reason") + elif section == "cases": + declared_reason = ( + coverage["unsupported_checks"].get( + ( + str(row.get("case_id")), + str(row.get("route")), + row.get("check"), + ) + ) + if coverage + else None + ) + if declared_reason is None: + malformed.append(f"{label} is an undeclared unsupported check") + elif row["reason"] != declared_reason: + malformed.append( + f"{label} has a different reason than its declared exemption" + ) + elif section == "population": + # The population runner currently declares no per-check + # exemptions. Its no-country-route sentinel cannot arise + # in a full run; only explicit runner declarations exempt + # a check, never a reason supplied by the report itself. + declared_reason = population_exemptions.get( + (str(row.get("check_id")), str(row.get("route"))) + ) + if declared_reason is None: + malformed.append(f"{label} is an undeclared unsupported check") + elif row["reason"] != declared_reason: + malformed.append( + f"{label} has a different reason than its declared exemption" + ) + unsupported.append(label) + elif status != "pass": + malformed.append(f"{label} has unknown status {status!r}") + if not report.get("cases"): + malformed.append("the report has no cases") + # A full run covers what the runner itself declares: every route, every + # case on each route it runs on, and every sampled property with the + # examples asked for. A report that drops any of it does not qualify. + if coverage is None: + # Lead with the missing runner declarations rather than truncating + # that root cause behind individual rows whose exemptions are unknown. + malformed.insert( + 0, + "the runner declares no ROUTES, CASES and PROPERTIES, so the " + "report's coverage cannot be checked", + ) + else: + reported_routes = [str(route) for route in report.get("routes") or []] + if sorted(reported_routes) != sorted(coverage["routes"]): + malformed.append( + f"the report covers routes {sorted(reported_routes)}, not " + f"{sorted(coverage['routes'])}" + ) + covered = { + (str(row.get("case_id")), str(row.get("route"))) + for row in report.get("cases") or [] + } + absent = [ + f"{case}@{route}" + for case, route in coverage["pairs"] + if (case, route) not in covered + ] + if absent: + malformed.append( + f"{len(absent)} case-route pair(s) have no row: " + + ", ".join(absent[:4]) + ) + reported_checks = Counter( + (str(row.get("case_id")), str(row.get("route")), row.get("check")) + for row in report.get("cases") or [] + ) + # W1 replaces a case-route's checks with a single error row when + # constructing its simulation crashes. That is a complete attempted + # run and already fails the gate; it cannot exempt a passing report. + crashed = { + (str(row.get("case_id")), str(row.get("route"))) + for row in report.get("cases") or [] + if row.get("status") == "error" + and row.get("check") is None + and row.get("detail") + } + absent_checks = ( + Counter(key for key in coverage["checks"] if key[:2] not in crashed) + - reported_checks + ) + if absent_checks: + malformed.append( + f"{sum(absent_checks.values())} declared case check(s) have no row: " + + ", ".join( + f"{case}@{route}:{check}" + for case, route, check in list(absent_checks)[:4] + ) + ) + for case, route in coverage["run_pairs"]: + rows = [ + row + for row in report.get("cases") or [] + if row.get("case_id") == case and row.get("route") == route + ] + if len(rows) != 1: + malformed.append( + f"run-level check {case}@{route} must have exactly one row" + ) + elif rows[0].get("status") not in ("pass", "fail", "error"): + malformed.append(f"run-level check {case}@{route} did not run") + elif not (rows[0].get("check") or "").startswith( + "every parameter's dated values and metadata in " + ): + malformed.append( + f"run-level check {case}@{route} does not identify the shared parameter check" + ) + for row in report.get("cases") or []: + key = (str(row.get("case_id")), str(row.get("route")), row.get("check")) + if key in coverage["isolation_checks"] and row.get("status") not in ( + "pass", + "fail", + "error", + ): + # The runner emits pass/fail for isolation, never a methodology + # exemption. An explicit pending-decision override must not + # excuse a missing executed isolation check. + malformed.append( + f"case {key[0]}@{key[1]} isolation check {key[2]} did not run" + ) + if row.get("route") in coverage["unsupported_routes"] and ( + row.get("check") is not None or row.get("status") != "unsupported" + ): + malformed.append( + f"case {row.get('case_id')}@{row.get('route')} is not the declared unsupported route row" + ) + if dataset_path is not None: + declared_population = coverage["population"] + if declared_population is None: + try: + module_name = ( + entrypoint.partition(":")[0] + if ":" in entrypoint + else entrypoint.rpartition(".")[0] + ) + declared_population = _declared_population_checks( + module_name, importer + ) + except (ImportError, AttributeError): + declared_population = [] + if not declared_population: + malformed.append( + "the runner declares no population checks, so population coverage cannot be checked" + ) + else: + population = { + str(row.get("check_id")) for row in report.get("population") or [] + } + absent_population = sorted(set(declared_population) - population) + if absent_population: + malformed.append( + "declared population check(s) have no row: " + + ", ".join(absent_population) + ) + properties = { + str(row.get("property_id")): row for row in report.get("properties") or [] + } + for name in coverage["properties"]: + row = properties.get(name) + if row is None: + malformed.append(f"property {name} has no row") + elif row.get("status") == "pass": + examples = row.get("examples_run") + if ( + isinstance(examples, bool) + or not isinstance(examples, int) + or examples < max_examples + or examples < 1 + ): + malformed.append( + f"property {name} passed on {examples} example(s), " + f"not the {max_examples} requested" + ) + if dataset_path is not None and not report.get("population"): + malformed.append("a dataset was given but no population checks ran") + for field, requested in (("seed", seed), ("max_examples", max_examples)): + if field in report and report[field] != requested: + malformed.append(f"the report ran {field}={report[field]}, not {requested}") + reported_versions = report.get("versions") or {} + for package, version in (expected_versions or {}).items(): + if reported_versions.get(package) != version: + malformed.append( + f"the report ran {package} {reported_versions.get(package)}, " + f"not the candidate's {version}" + ) + if expected_dataset_sha256 is not None: + reported = (report.get("dataset") or {}).get("sha256") + if reported != expected_dataset_sha256: + malformed.append( + f"the report ran dataset {reported}, not the candidate's " + f"{expected_dataset_sha256}" + ) + if "holds" in report and report["holds"] != ( + counts["fail"] == 0 and counts["error"] == 0 + ): + malformed.append("the report's holds disagrees with its rows") + summary = report.get("summary") + if ( + isinstance(summary, Mapping) + and {status: summary.get(status, 0) for status in _CONTRACT_STATUSES} != counts + ): + malformed.append("the report's summary disagrees with its rows") + details = { + "contract_id": report.get("contract_id"), + "contract_version": report.get("contract_version"), + "report_sha256": evidence_sha256(report), + "counts": counts, + "pending": pending, + "unsupported": unsupported, + "problems": malformed, + } + if failures: + return _result( + "reform_contract", + "fail", + f"{len(failures)} contract row(s) failed: {', '.join(failures[:8])}", + report, + details, + ) + if malformed: + return _result( + "reform_contract", "error", "; ".join(malformed[:8]), report, details + ) + return _result( + "reform_contract", + "pass", + f"Contract {details['contract_version']}: every case, property and " + f"population check passed ({counts['pass']})" + + (f"; {len(pending)} pending a methodology call" if pending else "") + + (f"; {len(unsupported)} unsupported by design" if unsupported else "") + + ".", + report, + details, + ) + + +# --- 9. Metrics review --------------------------------------------------------- + + +def _metric_key(row: Mapping[str, Any]) -> tuple[str, Optional[int]]: + return (str(row["metric_id"]), row.get("year")) + + +def _bound(bounds: Mapping[str, Any], kind: Optional[str]) -> tuple[float, float]: + spec = (bounds.get("kinds") or {}).get(kind) or bounds.get("default") or {} + return float(spec.get("relative", 0.0)), float(spec.get("absolute", 0.0)) + + +#: Metric-report check prefixes (``identities[].identity_id``) that fail a +#: candidate whatever the previous pair did: none fail on main. +HARD_CHECK_PREFIXES = ( + "finite:", + "weights:", + "closure", + "identity:", + "determinism:", + "reform_runs:", + "responsive:", +) +#: Checks that already fail on main for known model defects (for example +#: ``counted_once:statutory_sick_pay``): they fail a candidate only when the +#: previous certified pair passed them. +REGRESSION_CHECK_PREFIXES = ( + "counted_once:", + "double_count_present:", + "overlap_guard:", + "sign:", + "bounds:", +) + + +#: The statuses the metrics tool gives a check. Anything else fails. +CHECK_STATUSES = frozenset({"pass", "warn", "fail", "skipped"}) + + +def classify_check(identity_id: str) -> str: + """``hard``, ``regression`` or ``hard`` for an unknown check (no skips).""" + + if identity_id.startswith(REGRESSION_CHECK_PREFIXES): + return "regression" + return "hard" + + +def _wider_failure(candidate: Mapping[str, Any], previous: Mapping[str, Any]) -> bool: + """Whether a check failing in both reports fails in a scope it did not before. + + Scopes are years, or ``""`` for a check with no year. Without + ``failing_scopes`` on both rows the two failures cannot be told apart, so + the failure counts as carried over. + """ + + now, before = candidate.get("failing_scopes"), previous.get("failing_scopes") + if not isinstance(now, list) or not isinstance(before, list): + return False + return not {str(scope) for scope in now} <= {str(scope) for scope in before} + + +def compare_metric_reports( + candidate: Mapping[str, Any], + previous: Mapping[str, Any], + bounds: Mapping[str, Any], +) -> tuple[str, str, Evidence]: + """``(status, summary, evidence)`` for a candidate against a previous report. + + Checks (``identities``) follow the metric tool's classification: hard + checks fail outright; known-defect checks fail only when they worsen + against the previous pair, meaning the previous pair did not fail them, + or failed them in fewer scopes (``failing_scopes``, when the tool reports + them); ``warn`` is recorded, and a check that newly warns, or is skipped + where the previous pair passed it, needs review. ``skipped`` is neither a + pass nor a failure. + + A report with a status other than ``ok``, no metrics or no checks fails, + as does any check with a status outside ``CHECK_STATUSES``. A hard check + the previous pair ran that is missing from the candidate fails (the two + reports share one tool and one set of definitions); other missing checks + need review. + """ + + problems: list[str] = [] + review: list[str] = [] + # An empty or unfinished report measures nothing; it must never read as + # "no moves beyond the bounds". + for name, report in (("candidate", candidate), ("previous", previous)): + if report.get("status") != "ok": + problems.append(f"the {name} report status is {report.get('status')!r}") + if not report.get("metrics"): + problems.append(f"the {name} report has no metrics") + if not report.get("identities"): + problems.append(f"the {name} report has no checks") + previous_rows = { + str(row.get("identity_id")): row for row in previous.get("identities", []) + } + hard_failures: list[str] = [] + regressions: list[str] = [] + preexisting: list[str] = [] + new_warnings: list[str] = [] + warnings_: list[str] = [] + newly_skipped: list[str] = [] + unknown_statuses: list[str] = [] + candidate_checks = set() + for row in candidate.get("identities", []): + check_id = str(row.get("identity_id")) + candidate_checks.add(check_id) + status = row.get("status") + earlier = previous_rows.get(check_id) or {} + before = earlier.get("status") + if status not in CHECK_STATUSES: + unknown_statuses.append(f"{check_id} ({status!r})") + elif status == "fail": + if classify_check(check_id) == "hard": + hard_failures.append(check_id) + elif before != "fail": + regressions.append(check_id) + elif _wider_failure(row, earlier): + regressions.append(f"{check_id} (fails in more scopes)") + else: + preexisting.append(check_id) + elif status == "warn": + warnings_.append(check_id) + if before == "pass": + new_warnings.append(check_id) + elif status == "skipped" and before == "pass": + newly_skipped.append(check_id) + if unknown_statuses: + # A status this gate does not know is not a pass. + problems.append( + f"checks with an unknown status: {', '.join(unknown_statuses[:8])}" + ) + # A check the previous pair ran and the candidate did not: the tool and + # its definitions are the same (metrics_version), so coverage was lost. + lost_checks = sorted(set(previous_rows) - candidate_checks) + lost_hard = [ + check_id + for check_id in lost_checks + if classify_check(check_id) == "hard" + and candidate.get("partition_version") == previous.get("partition_version") + ] + if lost_hard: + problems.append(f"checks disappeared: {', '.join(lost_hard[:8])}") + elif lost_checks: + review.append(f"checks disappeared: {', '.join(lost_checks[:8])}") + if hard_failures: + problems.append(f"checks failed: {', '.join(hard_failures[:8])}") + if regressions: + problems.append( + f"checks the previous pair passed now fail: {', '.join(regressions[:8])}" + ) + if new_warnings: + review.append(f"checks newly warn: {', '.join(new_warnings[:8])}") + if newly_skipped: + review.append( + "checks the previous pair passed were skipped: " + + ", ".join(newly_skipped[:8]) + ) + if candidate.get("metrics_version") != previous.get("metrics_version"): + problems.append( + "metric definitions differ between the reports " + f"({previous.get('metrics_version')} → {candidate.get('metrics_version')})" + ) + if candidate.get("partition_version") != previous.get("partition_version"): + review.append("the model's fiscal partition changed") + current = {_metric_key(row): row for row in candidate.get("metrics", [])} + before_metrics = {_metric_key(row): row for row in previous.get("metrics", [])} + # Bounds are meaningful only when both sides contain usable values. + # In particular, NaN in the baseline must not silently skip a large move. + report_metric_rows = ( + ("candidate", candidate.get("metrics", [])), + ("previous", previous.get("metrics", [])), + ) + for name, rows in report_metric_rows: + metric_counts = Counter(_metric_key(row) for row in rows) + duplicate_metrics = sorted( + f"{key[0]} {key[1]}" for key, count in metric_counts.items() if count > 1 + ) + if duplicate_metrics: + problems.append( + f"the {name} report has duplicate metrics: " + + ", ".join(duplicate_metrics[:8]) + ) + nonfinite = [ + f"{row.get('metric_id')} {row.get('year')}" + for row in rows + if row.get("value") is None + or not isinstance(row.get("value"), (int, float)) + or not math.isfinite(float(row["value"])) + ] + if nonfinite: + problems.append( + f"the {name} report has non-finite values: {', '.join(nonfinite[:8])}" + ) + lost = sorted(f"{key[0]} {key[1]}" for key in set(before_metrics) - set(current)) + if lost: + problems.append(f"metrics disappeared: {', '.join(lost[:8])}") + without_baseline = sorted( + f"{key[0]} {key[1]}" for key in set(current) - set(before_metrics) + ) + if without_baseline: + review.append(f"metrics have no baseline: {', '.join(without_baseline[:8])}") + moves = [] + for key, row in sorted( + current.items(), key=lambda item: (item[0][0], item[0][1] or 0) + ): + old = before_metrics.get(key) + if old is None: + continue + try: + new_value, old_value = float(row["value"]), float(old["value"]) + except (KeyError, TypeError, ValueError): + continue + if not (math.isfinite(new_value) and math.isfinite(old_value)): + continue + relative, absolute = _bound(bounds, row.get("kind")) + threshold = max(relative * abs(old_value), absolute) + change = new_value - old_value + if abs(change) > threshold: + moves.append( + { + "metric_id": key[0], + "year": key[1], + "kind": row.get("kind"), + "previous": old_value, + "candidate": new_value, + "change": change, + "threshold": threshold, + } + ) + if moves: + review.append(f"{len(moves)} metric move(s) exceed their review bounds") + evidence = { + "metrics_version": candidate.get("metrics_version"), + "partition_version": candidate.get("partition_version"), + "previous_partition_version": previous.get("partition_version"), + "hard_check_failures": hard_failures, + "regressed_checks": regressions, + "preexisting_check_failures": preexisting, + "warn_identities": warnings_, + "new_warnings": new_warnings, + "newly_skipped_checks": newly_skipped, + "unknown_status_checks": unknown_statuses, + "lost_checks": lost_checks, + "metrics_without_baseline": without_baseline, + "moves_beyond_bounds": moves, + "candidate_report_sha256": evidence_sha256(candidate), + "previous_report_sha256": evidence_sha256(previous), + } + if problems: + return "fail", "; ".join(problems), evidence + if review: + return "needs_review", "; ".join(review), evidence + note = ( + f" ({len(preexisting)} known check failures carried over)" + if preexisting + else "" + ) + return "pass", f"{len(current)} metrics within their review bounds{note}.", evidence + + +def metrics_review( + *, + candidate_report: Optional[Mapping[str, Any]], + previous_report: Optional[Mapping[str, Any]], + bounds: Mapping[str, Any], + cross_report: Optional[Mapping[str, Any]] = None, + unavailable_reason: Optional[str] = None, +) -> GateOutcome: + """Compare metrics against the previous certificate under review bounds. + + With ``cross_report`` (the candidate model on the previous data), the + change splits into a model-only step (cross against previous) and a + data-only step (candidate against cross); the gate takes the worse. + """ + + if unavailable_reason or candidate_report is None or previous_report is None: + return _result( + "metrics_review", + "unavailable", + unavailable_reason or "Metric reports for both pairs are not available.", + {}, + ) + order = {"pass": 0, "needs_review": 1, "fail": 2} + steps: dict[str, tuple[str, str, Evidence]] = {} + if cross_report is not None: + steps["model_only"] = compare_metric_reports( + cross_report, previous_report, bounds + ) + steps["data_only"] = compare_metric_reports( + candidate_report, cross_report, bounds + ) + else: + steps["total"] = compare_metric_reports( + candidate_report, previous_report, bounds + ) + worst = max(steps.values(), key=lambda step: order[step[0]]) + evidence = {name: step[2] for name, step in steps.items()} + summary = "; ".join(f"{name}: {step[1]}" for name, step in steps.items()) + return _result( + "metrics_review", + worst[0], + summary, + evidence, + details={name: step[0] for name, step in steps.items()}, + ) + + +# --- Reform responsiveness ------------------------------------------------------- + + +def _fiscal_year(year: int) -> str: + return f"{year}-{(year + 1) % 100:02d}" + + +def reform_responsiveness( + *, + report: Optional[Mapping[str, Any]], + standard_reforms: Sequence[Mapping[str, Any]], + years: Sequence[int], + unavailable_reason: Optional[str] = None, +) -> GateOutcome: + """Every standard reform ran and moved its target as required, or fail. + + The metrics tool costs its standard reforms and, for each, checks that + the weighted total of the reform's target variable moved in the stated + direction by at least the stated minimum in every year + (``responsive:``), and that the reform ran (``reform_runs:``). + This gate holds the candidate's report to that: + + - both checks must be present and ``pass`` for every reform in + ``standard_reforms`` (the tool's own list); a missing check is a + failure, so a run that costed no reforms cannot pass; + - the report must cover exactly ``years``; + - the tool's verdict is recomputed here from the report's + ``reform_targets.`` values, and a disagreement is an error + (differential check of the check). + + Any reform that does not respond fails the gate. There is no review + path: a reform the model silently ignores is a defect, never a small + number. The gate is monotone: shrinking a reform's response toward zero + or flipping its sign never turns a failure into a pass. + """ + + if unavailable_reason or report is None: + return _result( + "reform_responsiveness", + "unavailable", + unavailable_reason or "No candidate metrics report is available.", + {}, + ) + if not standard_reforms: + return _result( + "reform_responsiveness", + "unavailable", + "The metrics tool defines no standard reforms.", + {}, + ) + checks = { + str(row.get("identity_id")): row for row in report.get("identities") or [] + } + values = { + (str(row.get("metric_id")), row.get("year")): row.get("value") + for row in report.get("metrics") or [] + } + problems: list[str] = [] + disagreements: list[str] = [] + table: list[dict[str, Any]] = [] + if report.get("status") not in (None, "ok"): + problems.append(f"the report status is {report.get('status')!r}") + if sorted(report.get("years") or []) != sorted(years): + disagreements.append( + f"the report covers {report.get('years')}, not the horizon {list(years)}" + ) + for reform in standard_reforms: + reform_id = reform["id"] + sign = 1.0 if reform["direction"] == "up" else -1.0 + changes: dict[str, Any] = {} + recomputed = True + for year in years: + value = values.get((f"reform_targets.{reform_id}", year)) + changes[str(year)] = value + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or not sign * value >= reform["minimum"] + ): + recomputed = False + ran = (checks.get(f"reform_runs:{reform_id}") or {}).get("status") + responsive = checks.get(f"responsive:{reform_id}") or {} + row = { + **reform, + "target_changes": changes, + "reform_runs": ran, + "responsive": responsive.get("status"), + "detail": responsive.get("detail"), + } + table.append(row) + label = f"{reform['name']} ({reform_id})" + if ran != "pass": + problems.append(f"{label} did not run") + elif responsive.get("status") != "pass": + problems.append( + f"{label} did not respond: " + + (responsive.get("detail") or "no responsive check in the report") + ) + if ran == "pass" and responsive.get("status") in ("pass", "fail"): + if (responsive["status"] == "pass") != recomputed: + disagreements.append( + f"{label}: the tool's responsive check is " + f"{responsive['status']} but its reform_targets values " + f"{'meet' if recomputed else 'do not meet'} the minimum" + ) + evidence = { + "years": list(years), + "reforms": table, + "candidate_report_sha256": evidence_sha256(report), + } + if problems: + return _result( + "reform_responsiveness", + "fail", + f"{len(problems)} responsiveness failure(s): " + "; ".join(problems[:6]), + evidence, + ) + if disagreements: + return _result( + "reform_responsiveness", "error", "; ".join(disagreements[:6]), evidence + ) + return _result( + "reform_responsiveness", + "pass", + f"All {len(table)} standard reforms ran and moved their targets by at " + f"least their minimums in every year from {_fiscal_year(years[0])} to " + f"{_fiscal_year(years[-1])}.", + evidence, + ) + + +# --- Metrics tool (W3's aggregate-impact report) --------------------------------- + +VENDORED_TOOL_PACKAGE = "pe_uk_aggregate_impact" +PARTITION_PATH_IN_REPO = "policyengine_uk/data/fiscal_partition.yaml" + +#: Self-contained runner, executed in any environment that has a policyengine +#: release installed (the candidate's, or the previous certified pair's +#: throwaway venv). Arguments: vendor root, JSON settings, output path. +METRICS_RUNNER = """ +import importlib, importlib.util, json, sys +from pathlib import Path +vendor, settings, out = sys.argv[1], json.loads(sys.argv[2]), Path(sys.argv[3]) +sys.path.insert(0, vendor) +api = importlib.import_module("pe_uk_aggregate_impact.api") +partition_module = importlib.import_module("pe_uk_aggregate_impact.partition") +installed = partition_module.default_partition_path() +if installed is not None: + partition_path, source = installed, "installed policyengine_uk" +else: + partition_path, source = Path(vendor) / "fiscal_partition.yaml", "vendored tool commit" +dataset = settings.get("dataset_path") +if not dataset: + from policyengine.provenance.dataset_materialization import materialize_dataset + dataset = materialize_dataset("uk", data_dir=Path(settings["data_dir"])).path +from policyengine_uk import Microsimulation +# No ``reforms`` argument: the tool then costs its own standard set and checks +# each reform's responsiveness (passing [] would cost none). +report = api.compute_budget_metrics( + Microsimulation(dataset=str(dataset)), + years=settings["years"], + partition=partition_module.load_partition(partition_path), + run_guard=settings.get("run_guard", True), + label=settings.get("label", "candidate"), +) +report["partition_source"] = source +report["dataset_path"] = str(dataset) +out.write_text(json.dumps(report)) +""" + + +#: Runner for a pair's input coverage and supported years, in its own +#: environment. It mirrors ``budget_inputs`` and ``supported_years``; a +#: differential test checks they agree. Arguments: JSON settings, output path. +INPUTS_RUNNER = """ +import json, sys +from pathlib import Path +import pandas as pd +settings, out = json.loads(sys.argv[1]), Path(sys.argv[2]) +path = settings.get("dataset_path") +if not path: + from policyengine.provenance.dataset_materialization import materialize_dataset + path = str(materialize_dataset("uk", data_dir=Path(settings["data_dir"])).path) +inputs = settings.get("model_input_variables") +if inputs is None: + from policyengine_uk.system import system + inputs = [name for name, variable in system.variables.items() if variable.is_input_variable()] +inputs = set(inputs) +with pd.HDFStore(path, mode="r") as store: + tables = {e: store[e] for e in ("person", "benunit", "household") if f"/{e}" in store} + first_year = int(str(store["time_period"].iloc[0])[:4]) +columns = {} +for entity, table in tables.items(): + for column in table.columns: + columns.setdefault(column, entity) +def degenerate(series): + values = series.dropna() + if len(values) == 0 or values.nunique() <= 1: + return True + if values.dtype.kind in "biuf": + return bool((values.astype(float) == 0).all()) + return False +unusable = [] +for name in settings["critical"]: + if name not in columns: + unusable.append([name, "missing"]) + elif degenerate(tables[columns[name]][name]): + unusable.append([name, "degenerate"]) +years = settings.get("projected_years") +if years is None: + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + from policyengine_uk.system import system + multi_year = extend_single_year_dataset(UKSingleYearDataset(path), system.parameters) + years = getattr(multi_year, "years", None) + years = years if years is not None else list(multi_year.datasets) +years = sorted(int(y) for y in years) +out.write_text(json.dumps({ + "dataset_path": path, + "missing_inputs": sorted(inputs - set(columns)), + "unusable_critical": unusable, + "supported_years": [y for y in years if y >= first_year], +})) +""" + + +def run_inputs_snapshot( + python: str, + *, + critical: Sequence[str], + out: Path, + dataset_path: Optional[Path] = None, + data_dir: Optional[Path] = None, + model_input_variables: Optional[Sequence[str]] = None, + projected_years: Optional[Sequence[int]] = None, + run: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + """Input coverage and supported years of a pair, via ``INPUTS_RUNNER``. + + ``model_input_variables`` and ``projected_years`` default to what the + environment's policyengine-uk reports; tests pass them explicitly. + """ + + import subprocess + + run = run if run is not None else subprocess.run + settings = { + "critical": list(critical), + "dataset_path": str(dataset_path) if dataset_path else None, + "data_dir": str(data_dir) if data_dir else None, + "model_input_variables": ( + list(model_input_variables) if model_input_variables is not None else None + ), + "projected_years": list(projected_years) + if projected_years is not None + else None, + } + run( + [python, "-c", INPUTS_RUNNER, json.dumps(settings), str(out)], + check=True, + cwd=str(out.parent), + ) + snapshot: dict[str, Any] = json.loads(out.read_text()) + return snapshot + + +def vendor_metrics_tool( + tool: Mapping[str, Any], destination: Path, *, get: Callable[..., Any] +) -> Path: + """Copy the metrics tool and its partition from one policyengine-uk commit. + + Returns the directory to put on ``sys.path``; the tool imports as + ``pe_uk_aggregate_impact``. The commit is pinned in the qualification + config so both pairs are measured with the same definitions. + """ + + import tarfile + + repository = tool["repository"] + commit = tool["commit"] + tool_path = tool["path"].rstrip("/") + root = destination / f"metrics-tool-{commit[:12]}" + package = root / VENDORED_TOOL_PACKAGE + if (package / "api.py").exists(): + return root + response = get( + f"https://codeload.github.com/{repository}/tar.gz/{commit}", timeout=120 + ) + response.raise_for_status() + package.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(response.content), mode="r:gz") as archive: + for member in archive.getmembers(): + _, _, relative = member.name.partition("/") + if not member.isfile(): + continue + if relative.startswith(tool_path + "/"): + target = package / relative[len(tool_path) + 1 :] + elif relative == PARTITION_PATH_IN_REPO: + target = root / "fiscal_partition.yaml" + else: + continue + if ".." in Path(relative).parts: + raise ValueError(f"Unsafe path in the tool archive: {member.name}") + target.parent.mkdir(parents=True, exist_ok=True) + extracted = archive.extractfile(member) + assert extracted is not None + target.write_bytes(extracted.read()) + if not (package / "api.py").exists(): + raise ValueError(f"{repository}@{commit} has no {tool_path}/api.py.") + return root + + +def load_standard_reforms(vendor_root: Path, filename: str) -> list[dict[str, Any]]: + """The metrics tool's standard reform set, read from the vendored tool. + + Each entry must carry an ``id`` and a ``responds`` block (``variable``, + ``direction`` ``up`` or ``down``, positive ``minimum``): a standard reform + with no stated response cannot be held to one, so it is refused. + """ + + import yaml # type: ignore[import-untyped] + + path = vendor_root / VENDORED_TOOL_PACKAGE / filename + if not path.exists(): + raise ValueError(f"The metrics tool ships no {filename}.") + document = yaml.safe_load(path.read_text()) or {} + reforms: list[dict[str, Any]] = [] + for entry in document.get("reforms") or []: + responds = entry.get("responds") or {} + minimum = responds.get("minimum") + if ( + not entry.get("id") + or not responds.get("variable") + or responds.get("direction") not in ("up", "down") + or isinstance(minimum, bool) + or not isinstance(minimum, (int, float)) + or not minimum > 0 + ): + raise ValueError( + f"Standard reform {entry.get('id') or entry.get('name')!r} states " + "no id or no expected response." + ) + reforms.append( + { + "id": str(entry["id"]), + "name": str(entry.get("name") or entry["id"]), + "variable": str(responds["variable"]), + "direction": responds["direction"], + "minimum": float(minimum), + } + ) + ids = [reform["id"] for reform in reforms] + if len(set(ids)) != len(ids): + raise ValueError("The standard reform set repeats an id.") + return reforms + + +def run_metrics_report( + python: str, + vendor_root: Path, + *, + years: Sequence[int], + out: Path, + dataset_path: Optional[Path] = None, + data_dir: Optional[Path] = None, + run_guard: bool = True, + label: str = "candidate", + run: Optional[Callable[..., Any]] = None, +) -> dict[str, Any]: + """Compute a metrics report with ``python`` (any environment) via the runner. + + The tool also costs its standard reform set against the same baseline, + each reform as a full run over ``years``. + """ + + import subprocess + + run = run if run is not None else subprocess.run + settings = { + "years": list(years), + "dataset_path": str(dataset_path) if dataset_path else None, + "data_dir": str(data_dir) if data_dir else None, + "run_guard": run_guard, + "label": label, + } + run( + [ + python, + "-c", + METRICS_RUNNER, + str(vendor_root), + json.dumps(settings), + str(out), + ], + check=True, + cwd=str(out.parent), + ) + report: dict[str, Any] = json.loads(out.read_text()) + return report + + +def previous_pair_python( + previous: Mapping[str, Any], + work_dir: Path, + *, + run: Optional[Callable[..., Any]] = None, +) -> str: + """A throwaway venv with the previous certified release installed.""" + + import subprocess + + run = run if run is not None else subprocess.run + venv = work_dir / f"previous-{previous['bundle_version']}" + python = venv / "bin" / "python" + if not python.exists(): + run(["uv", "venv", str(venv), "--python", "3.13"], check=True) + run( + [ + "uv", + "pip", + "install", + "--python", + str(python), + f"policyengine[uk]=={previous['bundle_version']}", + ], + check=True, + ) + return str(python) + + +# --- Orchestration ------------------------------------------------------------- + + +def run_gate(gate_id: str, function: Callable[[], GateOutcome]) -> GateOutcome: + """Run one gate; an unexpected exception becomes an ``error`` result.""" + + try: + return function() + except Exception as exc: + return _result( + gate_id, + "error", + f"The gate raised {type(exc).__name__}: {exc}", + {"traceback": traceback.format_exc()}, + ) + + +def run_gates( + gates: Sequence[tuple[str, Callable[[], GateOutcome]]], +) -> tuple[list[GateResult], dict[str, Evidence]]: + """Run gates in order; always one result per gate, never fewer.""" + + results: list[GateResult] = [] + evidence: dict[str, Evidence] = {} + for gate_id, function in gates: + result, item = run_gate(gate_id, function) + if result.gate_id != gate_id: + result = result.model_copy(update={"gate_id": gate_id}) + # Orchestration may append source/tool metadata to a gate's evidence. + # Bind the result to the complete JSON that will actually be stored. + item = _json_safe(item) + result = result.model_copy(update={"evidence_sha256": evidence_sha256(item)}) + results.append(result) + evidence[gate_id] = item + return results, evidence + + +def _download(url: str, destination: Path, *, get: Callable[..., Any]) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + with get(url, stream=True, timeout=120) as response: + response.raise_for_status() + with destination.open("wb") as stream: + for chunk in response.iter_content(chunk_size=1 << 20): + stream.write(chunk) + return destination + + +def _previous_gate_evidence( + ledger_root: Optional[Path], previous: Optional[Mapping[str, Any]] +) -> dict[str, Any]: + """Verified evidence of the exact certified pair and receipt's attempt. + + Legacy certificates that cannot bind every wheel and data pin by pair ID, + or incomplete/corrupt evidence, fall back to recomputation by the caller. + """ + + if ledger_root is None or not previous: + return {} + pair_id = previous.get("qualification_pair_id") + if not isinstance(pair_id, str) or not re.fullmatch(r"[a-f0-9]{64}", pair_id): + return {} + receipt_path = ledger_root / "uk" / pair_id / "qualification_receipt.json" + try: + receipt = QualificationReceipt.model_validate_json(receipt_path.read_text()) + pair = receipt.pair + if not receipt.promotable or receipt.pair_id != pair_id: + return {} + expected = ( + (pair.model.version, previous.get("model_version")), + (pair.core.version, previous.get("core_version")), + (pair.data.release_version, previous.get("data_release_version")), + (pair.data.dataset_sha256, previous.get("dataset_sha256")), + ) + if any(actual != certified for actual, certified in expected): + return {} + # The pair ID binds all wheels and data pins even for certificates + # whose bundle's package record does not repeat the core wheel hash. + for field, actual in ( + ("model_wheel_sha256", pair.model.wheel_sha256), + ("core_wheel_sha256", pair.core.wheel_sha256), + ): + if previous.get(field) is not None and previous[field] != actual: + return {} + for attempt_receipt in sorted( + (receipt_path.parent / "attempts").glob("*/qualification_receipt.json") + ): + attempt = QualificationReceipt.model_validate_json( + attempt_receipt.read_text() + ) + # Review acceptance can change the latest receipt without changing + # the immutable attempt's underlying qualification. + if attempt.unreviewed_sha256 != receipt.unreviewed_sha256: + continue + harness = json.loads((attempt_receipt.parent / "harness.json").read_text()) + if harness != receipt.harness.canonical_payload(): + continue + by_gate = {gate.gate_id: gate for gate in receipt.gates} + found: dict[str, Any] = {} + for name in ("supported_years", "budget_inputs"): + gate = by_gate.get(name) + evidence = json.loads( + (attempt_receipt.parent / "gates" / f"{name}.json").read_text() + ) + if ( + not gate + or gate.status not in ("pass", "needs_review") + or gate.evidence_sha256 != evidence_sha256(evidence) + ): + return {} + found[name] = evidence + if _valid_previous_snapshot_evidence(found): + return found + return {} + except (OSError, ValueError, TypeError, AttributeError): + # An unavailable cache is not qualification evidence; recompute it. + return {} + return {} + + +def _valid_previous_snapshot_evidence(evidence: Mapping[str, Any]) -> bool: + years = evidence["supported_years"].get("supported_years") + inputs = evidence["budget_inputs"] + missing = inputs.get("missing_inputs") + unusable = inputs.get("unusable_critical") + return ( + isinstance(years, list) + and bool(years) + and all( + isinstance(year, int) and not isinstance(year, bool) and year > 0 + for year in years + ) + and len(years) == len(set(years)) + and isinstance(missing, list) + and all(isinstance(name, str) and bool(name) for name in missing) + and len(missing) == len(set(missing)) + and isinstance(unusable, list) + and all( + isinstance(row, list) + and len(row) == 2 + and all(isinstance(value, str) and bool(value) for value in row) + for row in unusable + ) + ) + + +def run_candidate_gates( + resolved: Any, + harness: QualificationHarness, + *, + config: Mapping[str, Any], + work_dir: Path, + previous_certificate: Optional[Mapping[str, Any]], + ledger_root: Optional[Path], +) -> tuple[list[GateResult], dict[str, Evidence]]: + """Run gates 1–9 on the installed candidate, with real I/O.""" + + import os + + import requests + + from policyengine.provenance.dataset_materialization import materialize_dataset + + pair: CandidatePair = resolved.pair + work_dir.mkdir(parents=True, exist_ok=True) + data_dir = Path(config.get("data_dir") or work_dir / "data") + qualification_dir = Path(__file__).resolve().parents[1] / "data" / "qualification" + previous_evidence = _previous_gate_evidence(ledger_root, previous_certificate) + critical_spec = json.loads( + (qualification_dir / config["budget_inputs"]["critical"]).read_text() + ) + token = os.environ.get("HUGGING_FACE_TOKEN") or os.environ.get("HF_TOKEN") + state: dict[str, Any] = {} + + def dataset() -> Path: + if "dataset" not in state: + source = materialize_dataset("uk", data_dir=data_dir) + state["dataset"] = Path(source.path) + path: Path = state["dataset"] + return path + + def gate_wheels() -> GateOutcome: + paths = {} + for package, wheel in ( + (pair.model, resolved.model_wheel), + (pair.core, resolved.core_wheel), + ): + paths[package.name] = _download( + wheel.url, work_dir / "wheels" / wheel.filename, get=requests.get + ) + return wheel_hashes( + pair, + wheel_paths=paths, + pypi_digests={ + pair.model.name: resolved.model_wheel.sha256, + pair.core.name: resolved.core_wheel.sha256, + }, + ) + + def gate_core() -> GateOutcome: + if "manifest" not in state: + return _result( + "core_compatibility", + "error", + "The release manifest was not read (data_access did not succeed).", + {}, + ) + return core_compatibility( + pair, state["manifest"], model_requires_core=resolved.model_requires_core + ) + + def gate_data_access() -> GateOutcome: + result, evidence, manifest = data_access( + pair, get=requests.get, head=requests.head, token=token + ) + if manifest is not None: + state["manifest"] = manifest + return result, evidence + + def previous_snapshot() -> Optional[dict[str, Any]]: + """The previous pair's inputs and years: from the ledger, else computed.""" + + if "previous_snapshot" in state: + cached: Optional[dict[str, Any]] = state["previous_snapshot"] + return cached + snapshot: Optional[dict[str, Any]] = None + recorded_inputs = previous_evidence.get("budget_inputs") + recorded_years = previous_evidence.get("supported_years") + if recorded_inputs and recorded_years: + snapshot = { + "missing_inputs": recorded_inputs.get("missing_inputs"), + "unusable_critical": recorded_inputs.get("unusable_critical", []), + "supported_years": recorded_years.get("supported_years"), + "source": "ledger", + } + elif previous_certificate: + snapshot = run_inputs_snapshot( + previous_pair_python(previous_certificate, work_dir), + critical=critical_spec["critical"], + out=work_dir / "previous-inputs.json", + data_dir=work_dir / "previous-data", + ) + snapshot["source"] = ( + f"computed with policyengine {previous_certificate['bundle_version']}" + ) + state["previous_snapshot"] = snapshot + return snapshot + + def gate_years() -> GateOutcome: + previous = previous_snapshot() + previous_years = previous.get("supported_years") if previous else None + return supported_years( + pair, + dataset(), + required_years=config["required_years"], + budget_horizon_years=config["budget_horizon_years"], + previous_supported_years=previous_years, + ) + + def gate_inputs() -> GateOutcome: + from policyengine_uk.system import system + + previous = previous_snapshot() + result, evidence = budget_inputs( + pair, + dataset(), + model_input_variables=[ + name + for name, variable in system.variables.items() + if variable.is_input_variable() + ], + critical_inputs=critical_spec["critical"], + accepted_gaps=critical_spec.get("accepted_gaps", {}), + previous_missing_inputs=previous.get("missing_inputs") + if previous + else None, + previous_unusable_critical=( + [item[0] for item in previous.get("unusable_critical", [])] + if previous + else None + ), + ) + evidence["previous_source"] = previous.get("source") if previous else None + return result, evidence + + def gate_contract() -> GateOutcome: + settings = config["reform_contract"] + return reform_contract( + entrypoint=settings.get("entrypoint"), + dataset_path=dataset(), + seed=int(settings.get("seed", 0)), + max_examples=int(settings.get("max_examples", 50)), + expected_versions={ + pair.model.name: pair.model.version, + pair.core.name: pair.core.version, + }, + expected_dataset_sha256=pair.data.dataset_sha256, + pending_decisions=settings.get("pending_decisions", ()), + ) + + def metrics_tool() -> Path: + if "metrics_tool" not in state: + state["metrics_tool"] = vendor_metrics_tool( + config["metrics"]["tool"], work_dir, get=requests.get + ) + tool_root: Path = state["metrics_tool"] + return tool_root + + def standard_reforms() -> list[dict[str, Any]]: + if "standard_reforms" not in state: + state["standard_reforms"] = load_standard_reforms( + metrics_tool(), + config["reform_responsiveness"]["standard_reforms_file"], + ) + reforms: list[dict[str, Any]] = state["standard_reforms"] + return reforms + + def candidate_metrics() -> dict[str, Any]: + """The candidate's report, computed once for both metric gates.""" + + import sys + + if "candidate_metrics" not in state: + state["candidate_metrics"] = run_metrics_report( + sys.executable, + metrics_tool(), + years=config["metrics"]["years"], + out=work_dir / "metrics-candidate.json", + dataset_path=dataset(), + label="candidate", + ) + state.setdefault("metrics_reports", {})["candidate"] = state[ + "candidate_metrics" + ] + report: dict[str, Any] = state["candidate_metrics"] + return report + + def gate_responsiveness() -> GateOutcome: + settings = config.get("reform_responsiveness") + if not settings or not config["metrics"].get("tool"): + return reform_responsiveness( + report=None, + standard_reforms=[], + years=[], + unavailable_reason=( + "No reform responsiveness settings or metrics tool is configured." + ), + ) + result, evidence = reform_responsiveness( + report=candidate_metrics(), + standard_reforms=standard_reforms(), + years=config["metrics"]["years"], + ) + evidence["tool"] = dict(config["metrics"]["tool"]) + return result, evidence + + def gate_metrics() -> GateOutcome: + import sys + + settings = config["metrics"] + tool = settings.get("tool") + if not tool: + return metrics_review( + candidate_report=None, + previous_report=None, + bounds={}, + unavailable_reason="No metrics tool commit is configured (metrics.tool).", + ) + if not previous_certificate: + return metrics_review( + candidate_report=None, + previous_report=None, + bounds={}, + unavailable_reason="No previous certificate to compare against.", + ) + bounds = json.loads((qualification_dir / settings["bounds"]).read_text()) + vendor_root = metrics_tool() + years = settings["years"] + candidate_report = candidate_metrics() + previous_report = run_metrics_report( + previous_pair_python(previous_certificate, work_dir), + vendor_root, + years=years, + out=work_dir / "metrics-previous.json", + data_dir=work_dir / "previous-data", + label="previous", + ) + cross_report = None + if previous_certificate.get("dataset_sha256") != pair.data.dataset_sha256: + cross_report = run_metrics_report( + sys.executable, + vendor_root, + years=years, + out=work_dir / "metrics-candidate-model-previous-data.json", + dataset_path=Path(previous_report["dataset_path"]), + label="candidate model, previous data", + ) + state.setdefault("metrics_reports", {}).update( + previous=previous_report, + candidate_model_previous_data=cross_report, + ) + result, evidence = metrics_review( + candidate_report=candidate_report, + previous_report=previous_report, + bounds=bounds, + cross_report=cross_report, + ) + evidence["tool"] = dict(tool) + evidence["partition_sources"] = { + "candidate": candidate_report.get("partition_source"), + "previous": previous_report.get("partition_source"), + } + return result, evidence + + gates = [ + ("installed_versions", lambda: installed_versions(pair, harness)), + ("wheel_hashes", gate_wheels), + ("data_access", gate_data_access), + ("core_compatibility", gate_core), + ("dataset_bytes", lambda: dataset_bytes(pair, dataset())), + ("supported_years", gate_years), + ("budget_inputs", gate_inputs), + ("reform_contract", gate_contract), + ("metrics_review", gate_metrics), + ("reform_responsiveness", gate_responsiveness), + ] + results, evidence = run_gates(gates) + for name, report in (state.get("metrics_reports") or {}).items(): + if report is not None: + evidence[f"metrics_report_{name}"] = report + return results, evidence + + +def version_sort_key(version: str) -> Version: + return Version(version) diff --git a/src/policyengine/provenance/uk_qualification.py b/src/policyengine/provenance/uk_qualification.py new file mode 100644 index 00000000..7dd5870a --- /dev/null +++ b/src/policyengine/provenance/uk_qualification.py @@ -0,0 +1,1302 @@ +"""Orchestration for qualifying a UK model/data candidate. + +``scripts/check_uk_candidate.py`` is the command-line front end and +``.github/workflows/certify-uk-release.yaml`` runs it. The steps are: + +``resolve`` + Find the candidate pair: the newest (or requested) policyengine-uk wheel + from PyPI, the core it needs, and the newest (or requested) uk-data + release at the data repository's current manifest revision. Decide + whether to qualify it, or whether it is already certified, stale, or a + duplicate of an attempt in the ledger. +``prepare`` + On a checkout of ``main``, certify the pair into the bundle manifest + (exact ``==`` claim backed by the pair id when no publisher claim covers + it), regenerate pins, ``uv.lock``, TROs, the certified-version test + constants and household snapshots together, and commit that as the + candidate commit on ``candidate/uk/-``. +``gates`` + Run the mandatory local gates on the candidate commit + (:mod:`policyengine.provenance.uk_candidate`). +``request-replay`` / ``poll-replay`` + Send the scorecard an immutable replay request naming the candidate + commit, and read back its terminal status and receipt. +``finalize`` + Record the qualification receipt in the ledger. +``promote`` + Pick the newest promotable pair that advances ``main`` and mark its + candidate PR ready, closing superseded candidates. + +Network, git and GitHub access are injected so every decision can be tested +offline. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Optional + +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.version import InvalidVersion, Version + +from policyengine.provenance.manifest import DataReleaseManifest +from policyengine.provenance.qualification import ( + REQUIRED_GATES, + CandidatePair, + GateResult, + IssuedBy, + PinnedData, + PinnedWheel, + PreviousCertificate, + QualificationHarness, + QualificationReceipt, + ReplayQualificationRequest, + ReplayReceipt, + ReplayReference, + ReplayStatus, + ReviewAcceptance, + dominates, + evaluate_replay_receipt, + qualification_verdict, + replay_gate_while_pending, + select_promotion_target, +) +from policyengine.provenance.qualification_ledger import ( + attempt_directory, + pair_directory, + read_json, + write_json, +) +from policyengine.provenance.receipt import certificate_sha256 + +UK_MODEL_PACKAGE = "policyengine-uk" +CORE_PACKAGE = "policyengine-core" +UK_DATA_REPO_ID = "policyengine/policyengine-uk-data-private" +UK_DATA_REPO_TYPE: Literal["model"] = "model" +TOKEN_VARIABLES = ("HF_TOKEN", "HUGGING_FACE_TOKEN", "HUGGINGFACE_HUB_TOKEN") +#: The scorecard's certification recipe (policyengine-scorecard +#: pipeline/uk_replay_variants.py, isolate_certification_environment), which +#: expected_release_bundle must reproduce exactly. policyengine.py 6.x fetches +#: the data release manifest with requests, which ignores HF_HUB_OFFLINE; with +#: these proxies the fetch fails at once for a private or public data repo and +#: certify_data_release_compatibility falls back to the packaged certification. +PROXY_EXCEPTIONS = ("NO_PROXY", "no_proxy") +OFFLINE_VARIABLES = { + "HF_HUB_OFFLINE": "1", + "HF_DATASETS_OFFLINE": "1", + "TRANSFORMERS_OFFLINE": "1", + "HF_HUB_DISABLE_TELEMETRY": "1", + "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1", +} +NO_NETWORK = { + name: "http://127.0.0.1:9" + for name in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ) +} + + +def certification_environment(environ: Mapping[str, str]) -> dict[str, str]: + """``environ`` as the scorecard isolates it before reading release_bundle.""" + + env = { + key: value + for key, value in environ.items() + if key not in TOKEN_VARIABLES and key not in PROXY_EXCEPTIONS + } + env.update(OFFLINE_VARIABLES) + env.update(NO_NETWORK) + return env + + +REPO_ROOT = Path(__file__).resolve().parents[3] + +Action = Literal["qualify", "already_certified", "stale", "duplicate"] + + +# --- Resolution ------------------------------------------------------------ + + +@dataclass(frozen=True) +class WheelFile: + filename: str + url: str + sha256: str + + +@dataclass(frozen=True) +class ResolvedCandidate: + """A candidate pair plus what is needed to fetch and certify it.""" + + pair: CandidatePair + model_wheel: WheelFile + core_wheel: WheelFile + manifest_uri: str + model_requires_core: str + + +def _stable_versions( + releases: Mapping[str, Sequence[Mapping[str, Any]]], +) -> list[Version]: + versions = [] + for text, files in releases.items(): + try: + version = Version(text) + except InvalidVersion: + continue + if version.is_prerelease or version.is_devrelease: + continue + if not files or all(item.get("yanked") for item in files): + continue + versions.append(version) + return sorted(versions) + + +def _wheel(files: Sequence[Mapping[str, Any]], name: str, version: str) -> WheelFile: + wheels = [ + item + for item in files + if item.get("packagetype") == "bdist_wheel" and not item.get("yanked") + ] + if len(wheels) != 1: + raise ValueError( + f"{name} {version} must publish exactly one wheel; found {len(wheels)}." + ) + entry = wheels[0] + return WheelFile( + filename=entry["filename"], url=entry["url"], sha256=entry["digests"]["sha256"] + ) + + +def core_requirement(requires_dist: Iterable[str]) -> str: + """The model's ``policyengine-core`` specifier (unconditional requirements).""" + + for line in requires_dist or (): + requirement = Requirement(line) + if requirement.name.lower().replace("_", "-") != CORE_PACKAGE: + continue + if requirement.marker is not None and not requirement.marker.evaluate(): + continue + return str(requirement.specifier) + raise ValueError("policyengine-uk declares no policyengine-core requirement.") + + +def choose_core_version( + current_core: str, + specifier: str, + available: Sequence[Version], +) -> str: + """Keep the certified core if the model accepts it; else the newest accepted.""" + + spec = SpecifierSet(specifier) + if Version(current_core) in spec: + return current_core + accepted = [version for version in available if version in spec] + if not accepted: + raise ValueError( + f"No published policyengine-core release satisfies {specifier}." + ) + return str(max(accepted)) + + +def latest_data_release(tags: Iterable[str]) -> str: + """The newest release tag that is a plain version (``1.58.0``).""" + + versions = [] + for tag in tags: + if not re.fullmatch(r"\d+(\.\d+)*", tag): + continue + versions.append(Version(tag)) + if not versions: + raise ValueError("The data repository has no version tags.") + return str(max(versions)) + + +def resolve_candidate( + *, + current_core: str, + pypi_project: Callable[[str], Mapping[str, Any]], + hf_revision_sha: Callable[[str, str], str], + hf_tags: Callable[[str], Sequence[str]], + hf_file: Callable[[str, str, str], bytes], + model_version: Optional[str] = None, + data_version: Optional[str] = None, +) -> ResolvedCandidate: + """Resolve the candidate pair from PyPI and Hugging Face.""" + + model_project = pypi_project(UK_MODEL_PACKAGE) + releases = model_project["releases"] + model_version = model_version or str(_stable_versions(releases)[-1]) + if model_version not in releases: + raise ValueError(f"{UK_MODEL_PACKAGE} {model_version} is not on PyPI.") + model_wheel = _wheel(releases[model_version], UK_MODEL_PACKAGE, model_version) + model_release = pypi_project(f"{UK_MODEL_PACKAGE}/{model_version}") + 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"]) + ) + core_wheel = _wheel( + core_project["releases"][core_version], CORE_PACKAGE, core_version + ) + + data_version = data_version or latest_data_release(hf_tags(UK_DATA_REPO_ID)) + manifest_revision = hf_revision_sha(UK_DATA_REPO_ID, "main") + manifest_path = f"releases/{data_version}/release_manifest.json" + manifest_bytes = hf_file(UK_DATA_REPO_ID, manifest_revision, manifest_path) + manifest = DataReleaseManifest.model_validate_json(manifest_bytes) + if manifest.data_package.version != data_version: + raise ValueError( + f"{manifest_path} describes {manifest.data_package.version}, not {data_version}." + ) + dataset = manifest.default_datasets.get("national") + if dataset is None or dataset not in manifest.artifacts: + raise ValueError( + f"uk-data {data_version} declares no national default dataset." + ) + artifact = manifest.artifacts[dataset] + if not artifact.sha256 or not artifact.size_bytes: + raise ValueError(f"uk-data {data_version} {dataset} has no sha256 or size.") + build_id = ( + manifest.build.build_id if manifest.build and manifest.build.build_id else None + ) + if build_id is None: + raise ValueError(f"uk-data {data_version} records no build id.") + + pair = CandidatePair( + country_id="uk", + model=PinnedWheel( + name=UK_MODEL_PACKAGE, + version=model_version, + wheel_sha256=model_wheel.sha256, + ), + core=PinnedWheel( + name=CORE_PACKAGE, version=core_version, wheel_sha256=core_wheel.sha256 + ), + data=PinnedData( + data_package=manifest.data_package.name, + release_version=data_version, + build_id=build_id, + repo_id=UK_DATA_REPO_ID, + repo_type=UK_DATA_REPO_TYPE, + release_manifest_path=manifest_path, + release_manifest_revision=manifest_revision, + release_manifest_sha256=hashlib.sha256(manifest_bytes).hexdigest(), + dataset=dataset, + dataset_path=artifact.path, + dataset_revision=artifact.revision, + dataset_sha256=artifact.sha256, + size_bytes=artifact.size_bytes, + ), + ) + return ResolvedCandidate( + pair=pair, + model_wheel=model_wheel, + core_wheel=core_wheel, + manifest_uri=( + f"hf://{UK_DATA_REPO_TYPE}/{UK_DATA_REPO_ID}@{manifest_revision}/{manifest_path}" + ), + model_requires_core=requires_core, + ) + + +# --- Deciding what to do ------------------------------------------------------ + + +def certified_order_key(bundle: Mapping[str, Any]) -> tuple[str, str, str]: + """(model version, data release, core version) certified on a branch.""" + + record = bundle["data_releases"]["uk"] + return ( + record["model_package"]["version"], + record["data_package"]["version"], + bundle["packages"][CORE_PACKAGE]["version"], + ) + + +def previous_certificate(bundle: Mapping[str, Any]) -> PreviousCertificate: + record = bundle["data_releases"]["uk"] + return PreviousCertificate( + bundle_version=bundle["policyengine_version"], + model_version=record["model_package"]["version"], + core_version=bundle["packages"][CORE_PACKAGE]["version"], + data_release_version=record["data_package"]["version"], + dataset_sha256=record["certified_data_artifact"]["sha256"], + certificate_sha256=certificate_sha256(record), + qualification_pair_id=(record.get("certification") or {}).get( + "qualification_pair_id" + ), + model_wheel_sha256=record["model_package"].get("sha256"), + core_wheel_sha256=bundle["packages"][CORE_PACKAGE].get("sha256"), + ) + + +def candidate_action( + pair: CandidatePair, + bundle: Mapping[str, Any], + ledger_root: Optional[Path], + *, + base_commit: str, + force: bool = False, +) -> tuple[Action, str]: + """Whether to qualify ``pair`` now, and why.""" + + current = certified_order_key(bundle) + record = bundle["data_releases"]["uk"] + if pair.order_key == current and ( + record["certified_data_artifact"]["sha256"] == pair.data.dataset_sha256 + ): + return "already_certified", f"{current} is already certified on main" + if dominates(current, pair.order_key): + return "stale", f"main certifies {current}, newer than {pair.order_key}" + if force or ledger_root is None: + return "qualify", "forced" if force else "no ledger" + pair_dir = pair_directory(ledger_root, "uk", pair.pair_id) + latest = pair_dir / "qualification_receipt.json" + if latest.exists(): + receipt = QualificationReceipt.model_validate(read_json(latest)) + if receipt.verdict != "incomplete": + return "duplicate", f"pair already {receipt.verdict} in the ledger" + attempt = attempt_directory(ledger_root, "uk", pair.pair_id, base_commit[:12]) + if attempt.exists(): + return "duplicate", f"an attempt from base {base_commit[:12]} exists" + return "qualify", "new pair, or an incomplete pair on a new base commit" + + +# --- Preparing the candidate commit ----------------------------------------- + +#: Test files that pin the certified UK pair literally. Promotion regenerates +#: them with the rest of the certification so the suite tests the new pair. +CERTIFIED_CONSTANT_FILES = ( + "tests/test_models.py", + "tests/test_release_manifests.py", + "tests/test_uk_regions.py", +) + + +def certified_constant_replacements( + old_record: Mapping[str, Any], new_record: Mapping[str, Any] +) -> list[tuple[str, str]]: + """Exact quoted-literal replacements from the old to the new certificate.""" + + def values(record: Mapping[str, Any]) -> dict[str, Optional[str]]: + certification = record.get("certification") or {} + data = record.get("data_package") or {} + return { + "model": (record.get("model_package") or {}).get("version"), + "built_with": certification.get("built_with_model_version"), + "data": data.get("version"), + "build_id": certification.get("data_build_id"), + "revision": data.get("release_manifest_revision"), + } + + old, new = values(old_record), values(new_record) + pairs: list[tuple[str, str]] = [] + for key in ("build_id", "revision", "model", "built_with", "data"): + before, after = old[key], new[key] + if before and after and before != after: + pairs.append((before, after)) + return pairs + + +def rewrite_certified_constants( + repo_root: Path, replacements: Sequence[tuple[str, str]] +) -> list[str]: + """Rewrite quoted occurrences of old certified values in the constant files. + + Only string-literal occurrences change: ``"2.125.1"``, ``"@1.58.0"``, + ``"releases/1.58.0/release_manifest.json"`` and URIs ending ``@1.58.0``. + Returns the files changed. + """ + + changed = [] + for relative in CERTIFIED_CONSTANT_FILES: + path = repo_root / relative + text = path.read_text() + updated = text + for old, new in replacements: + pattern = re.compile( + r'(?P
["@/-])' + re.escape(old) + r'(?P["/])'
+            )
+            updated = pattern.sub(
+                lambda m: m.group("pre") + new + m.group("post"), updated
+            )
+        if updated != text:
+            path.write_text(updated)
+            changed.append(relative)
+    return changed
+
+
+def candidate_branch(pair: CandidatePair) -> str:
+    """The pair's own branch.
+
+    The pair id is part of the name: two pairs that share model and data
+    versions (another core, another wheel, re-published bytes) must never
+    share a branch, or one pair's qualification could promote the other's
+    commit.
+    """
+
+    return (
+        f"candidate/uk/{pair.model.version}-{pair.data.release_version}"
+        f"-{pair.pair_id[:12]}"
+    )
+
+
+def attempt_key(
+    pair: CandidatePair,
+    ledger_root: Optional[Path],
+    *,
+    base_commit: str,
+    force: bool = False,
+) -> str:
+    """The ledger key of the attempt a qualification run would stage.
+
+    One attempt per base commit, so concurrent runs for the same pair and
+    base deduplicate. A forced run after such an attempt gets the next free
+    key instead, so it stages a fresh attempt and is not mistaken for the
+    old one.
+    """
+
+    key = base_commit[:12]
+    if not force or ledger_root is None:
+        return key
+    if not attempt_directory(ledger_root, "uk", pair.pair_id, key).exists():
+        return key
+    repeat = 2
+    while attempt_directory(
+        ledger_root, "uk", pair.pair_id, f"{key}-{repeat}"
+    ).exists():
+        repeat += 1
+    return f"{key}-{repeat}"
+
+
+def changelog_fragment(pair: CandidatePair, basis_note: str) -> str:
+    return (
+        f"Certify uk-data {pair.data.release_version} (`{pair.data.dataset}`) with "
+        f"policyengine-uk {pair.model.version} and policyengine-core "
+        f"{pair.core.version}, qualified automatically as pair "
+        f"`{pair.pair_id[:12]}`. {basis_note}\n"
+    )
+
+
+Runner = Callable[..., subprocess.CompletedProcess]
+
+
+def _run(
+    command: Sequence[str], *, cwd: Path, env: Optional[Mapping[str, str]] = None
+) -> subprocess.CompletedProcess:
+    return subprocess.run(
+        list(command), cwd=cwd, env=dict(env) if env is not None else None, check=True
+    )
+
+
+def prepare_candidate_commit(
+    repo_root: Path,
+    resolved: ResolvedCandidate,
+    *,
+    python: str,
+    run: Runner = _run,
+    refresh_snapshots: bool = True,
+) -> dict[str, Any]:
+    """Certify the pair into the working tree and regenerate everything with it.
+
+    The working tree must be a clean checkout of the base commit on the
+    candidate branch. Returns what changed; the caller commits it.
+    """
+
+    pair = resolved.pair
+    bundle_path = (
+        repo_root / "src" / "policyengine" / "data" / "bundle" / "manifest.json"
+    )
+    old_record = json.loads(bundle_path.read_text())["data_releases"]["uk"]
+    run(
+        [
+            python,
+            "scripts/bundle.py",
+            "certify-data",
+            "--country",
+            "uk",
+            "--data-producer",
+            "populace",
+            "--manifest-uri",
+            resolved.manifest_uri,
+            "--model-version",
+            pair.model.version,
+            "--core-version",
+            pair.core.version,
+            "--qualified-pair-id",
+            pair.pair_id,
+            "--no-changelog",
+        ],
+        cwd=repo_root,
+    )
+    new_bundle = json.loads(bundle_path.read_text())
+    new_record = new_bundle["data_releases"]["uk"]
+    if new_record["model_package"].get("sha256") != pair.model.wheel_sha256:
+        raise ValueError("The certified model wheel hash differs from the candidate's.")
+    if new_record["certified_data_artifact"].get("sha256") != pair.data.dataset_sha256:
+        raise ValueError("The certified dataset hash differs from the candidate's.")
+    run(
+        [
+            "uv",
+            "lock",
+            "--no-config",
+            "--no-sources",
+            "--default-index",
+            "https://pypi.org/simple",
+            "--python",
+            python,
+        ],
+        cwd=repo_root,
+    )
+    run([python, "scripts/release_lock.py"], cwd=repo_root)
+    # Prepare the candidate's replacement sidecars before strict verification:
+    # the committed sidecars still authenticate the previous data release.
+    run(
+        [python, "scripts/bundle.py", "generate", "--include-tros"],
+        cwd=repo_root,
+    )
+    constants = rewrite_certified_constants(
+        repo_root, certified_constant_replacements(old_record, new_record)
+    )
+    basis = new_record["certification"]["compatibility_basis"]
+    basis_note = {
+        "built_with_model_package": "The data was built with this model version.",
+        "legacy_compatible_model_package": "The data publisher's compatibility claim covers this model version.",
+        "qualified_exact_pair": "No publisher claim covers this model version, so the certificate is an exact claim backed by the qualification receipt.",
+    }.get(basis, f"Compatibility basis: {basis}.")
+    fragment = (
+        repo_root
+        / "changelog.d"
+        / (f"certify-uk-{pair.model.version}-{pair.data.release_version}.changed.md")
+    )
+    fragment.write_text(changelog_fragment(pair, basis_note))
+    if refresh_snapshots:
+        run(
+            [
+                python,
+                "-m",
+                "pytest",
+                "tests/test_household_calculator_snapshot.py",
+                "-q",
+            ],
+            cwd=repo_root,
+            env={**os.environ, "PE_UPDATE_SNAPSHOTS": "1"},
+        )
+    run(
+        [python, "scripts/bundle.py", "check", "--include-tros", "--strict-tros"],
+        cwd=repo_root,
+    )
+    return {
+        "compatibility_basis": basis,
+        "constant_files": constants,
+        "changelog_fragment": str(fragment.relative_to(repo_root)),
+    }
+
+
+def packaged_manifest_sha256(repo_root: Path) -> str:
+    path = repo_root / "src" / "policyengine" / "data" / "bundle" / "manifest.json"
+    return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+# --- Replay request -------------------------------------------------------
+
+
+def expected_release_bundle(python: str, *, cwd: Path) -> dict[str, Optional[str]]:
+    """``dict(pe.uk.uk_latest.release_bundle)`` under packaged certification.
+
+    Runs in a subprocess in the scorecard's certification environment (no
+    tokens, no network for the release-manifest request), so the bundled
+    certification is what is reported for a private or public data repo.
+    """
+
+    env = certification_environment(os.environ)
+    output = subprocess.run(
+        [
+            python,
+            "-c",
+            # The CLI disables automatic country imports. Load the UK API
+            # explicitly so the flag cannot leave pe.uk unset and unrelated
+            # country models need not be initialized for this UK request.
+            "import json; from policyengine.tax_benefit_models.uk import uk_latest; "
+            "print(json.dumps(dict(uk_latest.release_bundle), sort_keys=True))",
+        ],
+        cwd=cwd,
+        env=env,
+        check=True,
+        capture_output=True,
+        text=True,
+    ).stdout
+    bundle: dict[str, Optional[str]] = json.loads(output.strip().splitlines()[-1])
+    return bundle
+
+
+def build_replay_request(
+    pair: CandidatePair,
+    harness: QualificationHarness,
+    *,
+    release_bundle: Mapping[str, Optional[str]],
+    issued_by: IssuedBy,
+    attempt: int = 1,
+    compare_to: str = "latest_certified",
+) -> ReplayQualificationRequest:
+    """The immutable pre-promotion request for the scorecard."""
+
+    return ReplayQualificationRequest.model_validate(
+        {
+            "schema": "policyengine.uk-replay-qualification-request/v1",
+            "kind": "candidate",
+            "attempt": attempt,
+            "candidate": {
+                "policyengine": {
+                    "version": harness.policyengine_version,
+                    "source": {
+                        "git": {
+                            "repository": "PolicyEngine/policyengine.py",
+                            "commit": harness.git_commit,
+                        }
+                    },
+                    "packaged_bundle_manifest_sha256": harness.packaged_bundle_manifest_sha256,
+                },
+                "policyengine_uk": {
+                    "version": pair.model.version,
+                    "wheel_sha256": pair.model.wheel_sha256,
+                },
+                "policyengine_core": {
+                    "version": pair.core.version,
+                    "wheel_sha256": pair.core.wheel_sha256,
+                },
+                "data": {
+                    "build_id": pair.data.build_id,
+                    "dataset": pair.data.dataset,
+                    "repo_id": pair.data.repo_id,
+                    "repo_type": pair.data.repo_type,
+                    "revision": pair.data.dataset_revision,
+                    "path": pair.data.dataset_path,
+                    "sha256": pair.data.dataset_sha256,
+                    "size_bytes": pair.data.size_bytes,
+                    "release_manifest_sha256": pair.data.release_manifest_sha256,
+                },
+                "expected_release_bundle": dict(release_bundle),
+            },
+            "compare_to": compare_to,
+            "requested_by": {
+                "repository": issued_by.repository,
+                "workflow_run_id": issued_by.workflow_run_id,
+                "run_attempt": issued_by.run_attempt,
+                "commit": harness.git_commit,
+                "pair_id": pair.pair_id,
+            },
+        }
+    )
+
+
+def replay_dispatch_payload(
+    request: ReplayQualificationRequest, *, event_type: str
+) -> dict[str, Any]:
+    return {"event_type": event_type, "client_payload": {"request": request.document()}}
+
+
+def replay_state_paths(request_id: str) -> tuple[str, str]:
+    base = f"requests/{request_id}"
+    return f"{base}/STATUS.json", f"{base}/REPLAY_RECEIPT.json"
+
+
+def read_replay(
+    request: ReplayQualificationRequest,
+    *,
+    fetch: Callable[[str, str], Optional[bytes]],
+    state_ref: str,
+) -> tuple[GateResult, Optional[ReplayStatus], Optional[ReplayReceipt]]:
+    """Read the scorecard's status and, when terminal, its receipt.
+
+    ``fetch(ref, path)`` returns file bytes from the scorecard repository at
+    a ref or commit, or ``None`` if absent.
+    """
+
+    status_path, receipt_path = replay_state_paths(request.request_id)
+    raw_status = fetch(state_ref, status_path)
+    if raw_status is None:
+        return replay_gate_while_pending(None), None, None
+    status = ReplayStatus.model_validate_json(raw_status)
+    if not status.terminal:
+        return replay_gate_while_pending(status), status, None
+    assert status.receipt_commit is not None
+    raw_receipt = fetch(status.receipt_commit, receipt_path)
+    if raw_receipt is None:
+        return (
+            GateResult(
+                gate_id="replay",
+                status="fail",
+                summary=(
+                    f"Status names receipt commit {status.receipt_commit}, which has "
+                    "no REPLAY_RECEIPT.json."
+                ),
+            ),
+            status,
+            None,
+        )
+    receipt = ReplayReceipt.from_document(json.loads(raw_receipt))
+    return evaluate_replay_receipt(request, status, receipt), status, receipt
+
+
+# --- Finalizing ---------------------------------------------------------------
+
+
+def assemble_receipt(
+    pair: CandidatePair,
+    harness: QualificationHarness,
+    gates: Sequence[GateResult],
+    *,
+    issued_by: IssuedBy,
+    completed_at: str,
+    previous: Optional[PreviousCertificate],
+    replay: Optional[ReplayReference],
+) -> QualificationReceipt:
+    """A receipt whose verdict follows from the gates; missing gates count as missing."""
+
+    ordered = sorted(
+        gates,
+        key=lambda gate: (
+            REQUIRED_GATES.index(gate.gate_id)
+            if gate.gate_id in REQUIRED_GATES
+            else len(REQUIRED_GATES),
+            gate.gate_id,
+        ),
+    )
+    return QualificationReceipt(
+        schema_version=1,
+        receipt_type="policyengine.candidate_qualification_receipt",
+        pair_id=pair.pair_id,
+        pair=pair,
+        harness=harness,
+        previous_certificate=previous,
+        replay=replay,
+        gates=list(ordered),
+        verdict=qualification_verdict(ordered),
+        issued_by=issued_by,
+        completed_at=completed_at,
+    )
+
+
+def accept_review(
+    receipt: QualificationReceipt,
+    *,
+    reviewer: str,
+    accepted_receipt_sha256: str,
+    explanation: str,
+    accepted_at: str,
+) -> QualificationReceipt:
+    """Attach a reviewer's acceptance of exactly this needs-review receipt."""
+
+    if receipt.verdict != "needs_review":
+        raise ValueError(
+            f"Only needs-review receipts can be accepted, not {receipt.verdict}."
+        )
+    if accepted_receipt_sha256 != receipt.unreviewed_sha256:
+        raise ValueError(
+            "The acceptance names different receipt bytes "
+            f"({accepted_receipt_sha256}, current {receipt.unreviewed_sha256})."
+        )
+    return receipt.model_copy(
+        update={
+            "review": ReviewAcceptance(
+                reviewer=reviewer,
+                accepted_at=accepted_at,
+                accepted_receipt_sha256=accepted_receipt_sha256,
+                explanation=explanation,
+            )
+        }
+    )
+
+
+# --- Attempt directories -----------------------------------------------------
+#
+# Every step of an attempt reads and writes one directory inside the ledger
+# checkout (``//attempts//``), so a
+# scheduled catch-up can finish an attempt whose replay outlasted its job with
+# exactly the code path the job would have run.
+
+
+def stage_attempt(
+    attempt_dir: Path,
+    candidate_payload: Mapping[str, Any],
+    harness: QualificationHarness,
+) -> None:
+    payloads = {
+        attempt_dir / "candidate.json": dict(candidate_payload),
+        attempt_dir / "harness.json": harness.canonical_payload(),
+    }
+    for path, payload in payloads.items():
+        if path.exists() and read_json(path) != payload:
+            raise ValueError(f"A staged attempt's {path.name} is immutable.")
+    staging_path = attempt_dir / "staging.json"
+    if not staging_path.exists():
+        # Persist staging order in the ledger, never infer it from finalization
+        # time, commit hashes or filesystem mtimes. Legacy attempts precede
+        # newly staged attempts and keep their existing receipt bytes.
+        prior_orders = [
+            int(read_json(path)["sequence"])
+            for path in attempt_dir.parent.glob("*/staging.json")
+        ]
+        write_json(staging_path, {"sequence": max(prior_orders, default=0) + 1})
+    for path, payload in payloads.items():
+        if not path.exists():
+            write_json(path, payload)
+
+
+def write_gate_results(
+    attempt_dir: Path, results: Sequence[GateResult], evidence: Mapping[str, Any]
+) -> None:
+    for result in results:
+        write_json(
+            attempt_dir / "gates" / f"{result.gate_id}.result.json",
+            result.canonical_payload(),
+        )
+    for gate_id, item in evidence.items():
+        write_json(attempt_dir / "gates" / f"{gate_id}.json", item)
+
+
+def load_gate_results(attempt_dir: Path) -> list[GateResult]:
+    results = [
+        GateResult.model_validate(read_json(path))
+        for path in sorted((attempt_dir / "gates").glob("*.result.json"))
+    ]
+    replay = attempt_dir / "replay" / "replay.result.json"
+    if replay.exists():
+        results = [gate for gate in results if gate.gate_id != "replay"]
+        results.append(GateResult.model_validate(read_json(replay)))
+    return results
+
+
+def finalize_attempt(
+    attempt_dir: Path,
+    ledger_root: Path,
+    *,
+    issued_by: IssuedBy,
+    completed_at: str,
+) -> QualificationReceipt:
+    """Assemble and record the receipt for a staged attempt."""
+
+    receipt_path = attempt_dir / "qualification_receipt.json"
+    existing = None
+    if receipt_path.exists():
+        existing = QualificationReceipt.model_validate(read_json(receipt_path))
+        if existing.verdict != "incomplete":
+            # A terminal attempt is immutable, including its completion time.
+            # Retry publication if an earlier run stopped after saving it.
+            _publish_latest_attempt_receipt(attempt_dir, ledger_root, existing)
+            return existing
+    candidate = read_json(attempt_dir / "candidate.json")
+    pair = CandidatePair.model_validate(candidate["pair"])
+    harness = QualificationHarness.model_validate(
+        read_json(attempt_dir / "harness.json")
+    )
+    gates = load_gate_results(attempt_dir)
+    if not any(gate.gate_id == "replay" for gate in gates):
+        gates.append(replay_gate_while_pending(None))
+    previous = (
+        PreviousCertificate.model_validate(candidate["previous_certificate"])
+        if candidate.get("previous_certificate")
+        else None
+    )
+    replay_reference = None
+    request_path = attempt_dir / "replay" / "request.json"
+    if request_path.exists():
+        request = ReplayQualificationRequest.model_validate(read_json(request_path))
+        status_path = attempt_dir / "replay" / "status.json"
+        status = (
+            ReplayStatus.model_validate(read_json(status_path))
+            if status_path.exists()
+            else None
+        )
+        replay_reference = ReplayReference(
+            request_id=request.request_id,
+            receipt_sha256=status.receipt_sha256 if status else None,
+            receipt_commit=status.receipt_commit if status else None,
+        )
+    receipt = assemble_receipt(
+        pair,
+        harness,
+        gates,
+        issued_by=issued_by,
+        completed_at=completed_at,
+        previous=previous,
+        replay=replay_reference,
+    )
+    # Pending receipts may advance to terminal when replay finishes. Preserve
+    # every version under its digest; terminal attempt receipts never change.
+    if existing is not None:
+        snapshot = attempt_dir / "receipts" / f"{existing.receipt_sha256}.json"
+        if not snapshot.exists():
+            write_json(snapshot, existing.canonical_payload())
+    snapshot = attempt_dir / "receipts" / f"{receipt.receipt_sha256}.json"
+    if not snapshot.exists():
+        write_json(snapshot, receipt.canonical_payload())
+    write_json(receipt_path, receipt.canonical_payload())
+    _publish_latest_attempt_receipt(attempt_dir, ledger_root, receipt)
+    return receipt
+
+
+def _publish_latest_attempt_receipt(
+    attempt_dir: Path, ledger_root: Path, receipt: QualificationReceipt
+) -> None:
+    """Publish by staging order, repairing interrupted publication on retry."""
+
+    pair = receipt.pair
+    pair_dir = pair_directory(ledger_root, "uk", pair.pair_id)
+    write_json(pair_dir / "pair.json", pair.canonical_payload())
+    staging_path = attempt_dir / "staging.json"
+    sequence = int(read_json(staging_path)["sequence"]) if staging_path.exists() else 0
+    latest_path = pair_dir / "qualification_receipt.json"
+    latest_attempt_path = pair_dir / "latest_attempt.json"
+    latest_sequence = -1
+    latest_attempt_key = None
+    latest_pointer = (
+        read_json(latest_attempt_path) if latest_attempt_path.exists() else None
+    )
+    if latest_path.exists():
+        # Receipt identity is authoritative: a crash can leave a stale pointer.
+        # Recover staging order from immutable receipts/snapshots, including
+        # old ledgers and receipts with a receipt-bound acceptance.
+        latest = QualificationReceipt.model_validate(read_json(latest_path))
+        latest_sequence = 0
+        previous_paths = [
+            *pair_dir.glob("attempts/*/qualification_receipt.json"),
+            *pair_dir.glob("attempts/*/receipts/*.json"),
+        ]
+        for previous_path in previous_paths:
+            previous = QualificationReceipt.model_validate(read_json(previous_path))
+            if previous.unreviewed_sha256 != latest.unreviewed_sha256:
+                continue
+            previous_attempt = (
+                previous_path.parent.parent
+                if previous_path.parent.name == "receipts"
+                else previous_path.parent
+            )
+            previous_stage = previous_attempt / "staging.json"
+            previous_sequence = (
+                int(read_json(previous_stage)["sequence"])
+                if previous_stage.exists()
+                else 0
+            )
+            if previous_sequence >= latest_sequence:
+                latest_sequence = previous_sequence
+                latest_attempt_key = previous_attempt.name
+    elif latest_pointer is not None:
+        # Preserve the known order if only the publication pointer survived.
+        latest_sequence = int(latest_pointer["sequence"])
+        latest_attempt_key = latest_pointer["attempt_key"]
+    same_attempt = latest_attempt_key == attempt_dir.name
+    update_pending = (
+        same_attempt
+        and latest_path.exists()
+        and QualificationReceipt.model_validate(read_json(latest_path)).verdict
+        == "incomplete"
+    )
+    if (
+        sequence > latest_sequence
+        or update_pending
+        or (same_attempt and not latest_path.exists())
+    ):
+        write_json(latest_path, receipt.canonical_payload())
+        latest_attempt_key = attempt_dir.name
+        latest_sequence = sequence
+    expected_pointer = {"attempt_key": latest_attempt_key, "sequence": latest_sequence}
+    if latest_attempt_key is not None and latest_pointer != expected_pointer:
+        # Repair only the pointer when the published receipt already belongs
+        # to the latest attempt, preserving any receipt-bound review acceptance.
+        write_json(latest_attempt_path, expected_pointer)
+
+
+def attempt_is_waiting_for_replay(attempt_dir: Path) -> bool:
+    """A request was sent and the replay gate has not reached a terminal result."""
+
+    if not (attempt_dir / "replay" / "request.json").exists():
+        return False
+    result = attempt_dir / "replay" / "replay.result.json"
+    if not result.exists():
+        return True
+    return GateResult.model_validate(read_json(result)).status == "unavailable"
+
+
+# --- Promotion ----------------------------------------------------------------
+
+
+@dataclass
+class PromotionPlan:
+    target: Optional[QualificationReceipt]
+    ready_branch: Optional[str]
+    close_branches: list[str] = field(default_factory=list)
+    reason: str = ""
+    #: The qualified commit the ready branch must still be at when promoted.
+    ready_commit: Optional[str] = None
+
+
+def plan_promotion(
+    receipts: Iterable[QualificationReceipt],
+    bundle: Mapping[str, Any],
+    open_candidate_branches: Mapping[str, str],
+) -> PromotionPlan:
+    """What to do with candidate PRs given the ledger and ``main``'s certificate.
+
+    ``open_candidate_branches`` maps each open candidate branch to its head
+    commit. The target's branch is readied only while its head is exactly
+    the commit the target's qualification ran on
+    (``receipt.harness.git_commit``): the replayed and tested bytes are the
+    promoted bytes. A branch that moved since is left alone and reported.
+    """
+
+    receipts = list(receipts)
+    current = certified_order_key(bundle)
+    target = select_promotion_target(receipts, current)
+    branch_pairs = {}
+    for receipt in receipts:
+        branch_pairs[candidate_branch(receipt.pair)] = receipt.pair.order_key
+    close = []
+    for branch in open_candidate_branches:
+        key = branch_pairs.get(branch)
+        if key is None:
+            continue
+        if key == current or dominates(current, key):
+            close.append(branch)
+        elif target is not None and dominates(target.pair.order_key, key):
+            close.append(branch)
+    if target is None:
+        return PromotionPlan(
+            target=None,
+            ready_branch=None,
+            close_branches=sorted(close),
+            reason=f"no promotable pair advances {current}",
+        )
+    branch = candidate_branch(target.pair)
+    qualified_commit = target.harness.git_commit
+    head = open_candidate_branches.get(branch)
+    if head is None:
+        return PromotionPlan(
+            target=target,
+            ready_branch=None,
+            close_branches=sorted(close),
+            reason=(
+                f"{target.pair.order_key} advances {current}, but its branch "
+                f"{branch} has no open pull request"
+            ),
+        )
+    if head != qualified_commit:
+        return PromotionPlan(
+            target=target,
+            ready_branch=None,
+            close_branches=sorted(close),
+            reason=(
+                f"{target.pair.order_key} advances {current}, but {branch} is at "
+                f"{head[:12]}, not the qualified commit {qualified_commit[:12]}; "
+                "nothing is promoted"
+            ),
+        )
+    return PromotionPlan(
+        target=target,
+        ready_branch=branch,
+        ready_commit=qualified_commit,
+        close_branches=sorted(close),
+        reason=f"{target.pair.order_key} advances {current}",
+    )
+
+
+def candidate_pr_body(
+    receipt: Optional[QualificationReceipt],
+    pair: CandidatePair,
+    *,
+    ledger_url: str,
+    prepared: Optional[Mapping[str, Any]] = None,
+) -> str:
+    """Markdown body for a candidate PR: the pair, the gates and the verdict."""
+
+    lines = [
+        f"Automated certification candidate for **policyengine-uk {pair.model.version}**, "
+        f"**policyengine-core {pair.core.version}** and **uk-data {pair.data.release_version}** "
+        f"(`{pair.data.dataset}`, sha256 `{pair.data.dataset_sha256[:16]}…`).",
+        "",
+        f"- Pair id: `{pair.pair_id}`",
+        f"- Ledger: {ledger_url}",
+    ]
+    if prepared:
+        lines.append(f"- Compatibility basis: `{prepared.get('compatibility_basis')}`")
+    lines += ["", "| Gate | Status | Summary |", "|---|---|---|"]
+    gates = {gate.gate_id: gate for gate in (receipt.gates if receipt else [])}
+    for gate_id in REQUIRED_GATES:
+        gate = gates.get(gate_id)
+        if gate is None:
+            lines.append(f"| `{gate_id}` | pending | |")
+        else:
+            summary = gate.summary.replace("|", "\\|").replace("\n", " ")
+            lines.append(f"| `{gate_id}` | {gate.status} | {summary} |")
+    verdict = receipt.verdict if receipt else "pending"
+    lines += [
+        "",
+        f"**Verdict: {verdict}.** Merging this PR promotes the pair. Until cos d1344 "
+        "is ruled, promotion is a reviewed merge; the certification evidence test "
+        "refuses an exact-pair certificate without a promotable ledger receipt.",
+        "",
+        "🤖 Generated with [Claude Code](https://claude.com/claude-code)",
+    ]
+    return "\n".join(lines) + "\n"
+
+
+# --- Release dispatch to consumers ----------------------------------------------
+
+
+def release_bundle_identity(
+    bundle: Mapping[str, Any], country_id: str = "uk"
+) -> dict[str, Any]:
+    """The certified pair a release carries, as consumers verify it."""
+
+    record = bundle["data_releases"][country_id]
+    certification = record.get("certification") or {}
+    model_package = bundle["countries"][country_id]["model_package"]
+    return {
+        "model_version": record["model_package"]["version"],
+        "model_wheel_sha256": record["model_package"].get("sha256"),
+        "core_version": bundle["packages"][CORE_PACKAGE]["version"],
+        "data_release_version": record["data_package"]["version"],
+        "dataset": record["default_dataset"],
+        "dataset_sha256": record["certified_data_artifact"].get("sha256"),
+        "certificate_sha256": certificate_sha256(record),
+        "compatibility_basis": certification.get("compatibility_basis"),
+        "model_package": model_package,
+    }
+
+
+def release_dispatch_payload(
+    bundle: Mapping[str, Any],
+    *,
+    version: str,
+    repository: str = "PolicyEngine/policyengine.py",
+    fetch_receipt: Optional[
+        Callable[[str, str], Optional[QualificationReceipt]]
+    ] = None,
+) -> dict[str, Any]:
+    """``policyengine-release`` repository dispatch for consumers.
+
+    ``client_payload.version`` stays the first field, as the existing
+    sim-api and api updaters read only it.
+    """
+
+    if bundle.get("policyengine_version") != version:
+        raise ValueError(
+            f"The bundle manifest is for {bundle.get('policyengine_version')}, not {version}."
+        )
+    identity = release_bundle_identity(bundle)
+    qualification = None
+    certification = bundle["data_releases"]["uk"].get("certification") or {}
+    pair_id = certification.get("qualification_pair_id")
+    if pair_id:
+        receipt = fetch_receipt("uk", pair_id) if fetch_receipt is not None else None
+        qualification = {
+            "uk": {
+                "pair_id": pair_id,
+                "verdict": receipt.verdict if receipt is not None else None,
+                # needs_review pairs are promotable only with an acceptance of
+                # exactly that receipt; consumers refuse promotable=false.
+                "promotable": receipt.promotable if receipt is not None else None,
+                "receipt_sha256": receipt.receipt_sha256
+                if receipt is not None
+                else None,
+            }
+        }
+    return {
+        "event_type": "policyengine-release",
+        "client_payload": {
+            "version": version,
+            "bundle": {"uk": identity},
+            "qualification": qualification,
+            "release_url": f"https://github.com/{repository}/releases/tag/{version}",
+        },
+    }
+
+
+def released_bundle_request(
+    bundle: Mapping[str, Any],
+    *,
+    version: str,
+    wheel_sha256: str,
+    packaged_manifest_sha256: str,
+    release_bundle: Mapping[str, Optional[str]],
+    hf_file: Callable[[str, str, str], bytes],
+    pypi_project: Callable[[str], Mapping[str, Any]],
+    requested_by: Mapping[str, Any],
+) -> ReplayQualificationRequest:
+    """``released_bundle`` replay request for a published policyengine release."""
+
+    record = bundle["data_releases"]["uk"]
+    data_package = record["data_package"]
+    manifest_bytes = hf_file(
+        data_package["repo_id"],
+        data_package["release_manifest_revision"],
+        data_package["release_manifest_path"],
+    )
+    manifest = DataReleaseManifest.model_validate_json(manifest_bytes)
+    dataset = record["default_dataset"]
+    artifact = manifest.artifacts[dataset]
+    reference = record["datasets"][dataset]
+    if reference.get("sha256") != artifact.sha256:
+        raise ValueError(
+            "The release manifest's dataset hash differs from the bundle's."
+        )
+    core_version = bundle["packages"][CORE_PACKAGE]["version"]
+    core_wheel = _wheel(
+        pypi_project(CORE_PACKAGE)["releases"][core_version], CORE_PACKAGE, core_version
+    )
+    return ReplayQualificationRequest.model_validate(
+        {
+            "schema": "policyengine.uk-replay-qualification-request/v1",
+            "kind": "released_bundle",
+            "attempt": 1,
+            "candidate": {
+                "policyengine": {
+                    "version": version,
+                    "source": {"pypi": {"wheel_sha256": wheel_sha256}},
+                    "packaged_bundle_manifest_sha256": packaged_manifest_sha256,
+                },
+                "policyengine_uk": {
+                    "version": record["model_package"]["version"],
+                    "wheel_sha256": record["model_package"]["sha256"],
+                },
+                "policyengine_core": {
+                    "version": core_version,
+                    "wheel_sha256": core_wheel.sha256,
+                },
+                "data": {
+                    "build_id": record["certified_data_artifact"].get("build_id")
+                    or record["build_id"],
+                    "dataset": dataset,
+                    "repo_id": reference.get("repo_id") or data_package["repo_id"],
+                    "repo_type": reference.get("repo_type")
+                    or data_package.get("repo_type", "model"),
+                    "revision": reference["revision"],
+                    "path": reference["path"],
+                    "sha256": reference["sha256"],
+                    "size_bytes": artifact.size_bytes,
+                    "release_manifest_sha256": hashlib.sha256(
+                        manifest_bytes
+                    ).hexdigest(),
+                },
+                "expected_release_bundle": dict(release_bundle),
+            },
+            "compare_to": "latest_certified",
+            "requested_by": dict(requested_by),
+        }
+    )
diff --git a/src/policyengine/provenance/wheels.py b/src/policyengine/provenance/wheels.py
new file mode 100644
index 00000000..61268f73
--- /dev/null
+++ b/src/policyengine/provenance/wheels.py
@@ -0,0 +1,265 @@
+"""Check that an installed distribution is byte-identical to a wheel.
+
+A wheel's ``*.dist-info/RECORD`` lists every file in the archive with its
+SHA-256. Verification has two halves, and both must hold:
+
+1. **The wheel is what its RECORD says.** Every archive member is listed in
+   the RECORD with the hash of its bytes, and every RECORD entry is an
+   archive member: a one-to-one match. Only the RECORD itself (and a RECORD
+   signature) is listed without a hash. Without this, a zip holding a copied
+   RECORD would "verify" and its SHA-256 would name a wheel the installed
+   files never came from.
+2. **The installed files are the wheel's.** Every hashed file in the wheel's
+   RECORD is installed with the same hash, and the bytes on disk hash to it.
+   Nothing else is installed beyond what an installer adds: ``INSTALLER``,
+   ``REQUESTED``, ``direct_url.json`` and ``uv_cache.json`` in the
+   ``.dist-info`` directory, compiled ``__pycache__/*.pyc`` files, the
+   rewritten ``RECORD``, and the console and GUI script launchers
+   it generates in the scripts directory for the entry points the wheel's
+   ``entry_points.txt`` declares. Launchers are generated per environment (they
+   embed the interpreter path), so they are named, not compared.
+
+A wheel with a ``*.data`` directory installs those files under other paths,
+so it does not verify; none of the UK bundle's distributions has one.
+"""
+
+from __future__ import annotations
+
+import base64
+import configparser
+import csv
+import hashlib
+import io
+import zipfile
+from importlib import metadata as importlib_metadata
+from pathlib import Path, PurePosixPath
+from typing import Any, Optional
+
+from policyengine.utils.hashing import sha256_file
+
+__all__ = [
+    "INSTALLER_FILES",
+    "SCRIPT_DIRECTORIES",
+    "parse_record",
+    "record_digest_to_hex",
+    "verify_installed_against_wheel",
+    "verify_wheel_archive",
+    "wheel_record",
+]
+
+#: Files an installer adds to a distribution that are not in the wheel RECORD.
+INSTALLER_FILES = frozenset(
+    {"INSTALLER", "REQUESTED", "direct_url.json", "uv_cache.json"}
+)
+
+#: Directories installers write entry-point launchers to (POSIX, Windows).
+SCRIPT_DIRECTORIES = frozenset({"bin", "Scripts"})
+
+#: Entry-point groups an installer turns into launchers.
+SCRIPT_GROUPS = ("console_scripts", "gui_scripts")
+
+#: Windows launcher suffixes, longest first.
+_LAUNCHER_SUFFIXES = ("-script.pyw", "-script.py", ".exe")
+
+_UNHASHED_RECORD_FILES = ("RECORD", "RECORD.jws", "RECORD.p7s")
+
+
+def record_digest_to_hex(value: str) -> str:
+    """Convert a RECORD ``sha256=`` digest to hex."""
+
+    algorithm, _, encoded = value.partition("=")
+    if algorithm != "sha256":
+        raise ValueError(f"Unsupported RECORD hash {algorithm!r}.")
+    padded = encoded + "=" * (-len(encoded) % 4)
+    return base64.urlsafe_b64decode(padded).hex()
+
+
+def parse_record(text: str) -> dict[str, Optional[str]]:
+    """``{path: hex sha256 or None}`` from a RECORD file."""
+
+    entries: dict[str, Optional[str]] = {}
+    for row in csv.reader(io.StringIO(text)):
+        if not row:
+            continue
+        path, digest = row[0], (row[1] if len(row) > 1 else "")
+        if path in entries:
+            raise ValueError(f"RECORD lists {path} twice.")
+        entries[path] = record_digest_to_hex(digest) if digest else None
+    return entries
+
+
+def _record_member(names: list[str], wheel_name: str) -> str:
+    records = [
+        name
+        for name in names
+        if name.endswith(".dist-info/RECORD") and name.count("/") == 1
+    ]
+    if len(records) != 1:
+        raise ValueError(f"{wheel_name} has {len(records)} RECORD files.")
+    return records[0]
+
+
+def wheel_record(wheel_path: Path) -> dict[str, Optional[str]]:
+    """The wheel's RECORD as ``{path: hex sha256 or None}`` (not verified)."""
+
+    with zipfile.ZipFile(wheel_path) as archive:
+        name = _record_member(archive.namelist(), wheel_path.name)
+        return parse_record(archive.read(name).decode("utf-8"))
+
+
+def _member_sha256(archive: zipfile.ZipFile, name: str) -> str:
+    digest = hashlib.sha256()
+    with archive.open(name) as stream:
+        for chunk in iter(lambda: stream.read(1 << 20), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def _entry_point_scripts(text: str) -> frozenset[str]:
+    parser = configparser.ConfigParser(delimiters=("=",), interpolation=None)
+    parser.optionxform = str  # type: ignore[assignment,method-assign]
+    parser.read_string(text)
+    return frozenset(
+        name.strip()
+        for group in SCRIPT_GROUPS
+        if parser.has_section(group)
+        for name in parser.options(group)
+    )
+
+
+def verify_wheel_archive(wheel_path: Path) -> dict[str, Any]:
+    """Check a wheel's members against its own RECORD; never raises on mismatch.
+
+    Returns ``record`` (``{path: hex sha256 or None}``), ``scripts`` (entry
+    point names an installer turns into launchers), ``members_verified`` and
+    ``problems`` (empty when members and RECORD match one to one). A wheel
+    without exactly one top-level ``*.dist-info/RECORD`` raises ``ValueError``.
+    """
+
+    problems: list[str] = []
+    with zipfile.ZipFile(wheel_path) as archive:
+        members = [info.filename for info in archive.infolist() if not info.is_dir()]
+        record_name = _record_member(members, wheel_path.name)
+        dist_info = record_name.rsplit("/", 1)[0]
+        seen: set[str] = set()
+        for name in members:
+            if name in seen:
+                problems.append(f"{name}: the wheel holds it twice")
+            seen.add(name)
+        record = parse_record(archive.read(record_name).decode("utf-8"))
+        unhashed = {f"{dist_info}/{name}" for name in _UNHASHED_RECORD_FILES}
+        verified = 0
+        for name in sorted(seen):
+            if name in unhashed:
+                if record.get(name) is not None:
+                    problems.append(f"{name}: RECORD hashes a file it cannot hash")
+                continue
+            if name not in record:
+                problems.append(f"{name}: in the wheel but not in its RECORD")
+            elif record[name] is None:
+                problems.append(f"{name}: RECORD gives no hash")
+            elif _member_sha256(archive, name) != record[name]:
+                problems.append(f"{name}: bytes differ from the wheel's RECORD")
+            else:
+                verified += 1
+        problems.extend(
+            f"{path}: in the wheel's RECORD but not in the wheel"
+            for path in sorted(record)
+            if path not in seen
+        )
+        entry_points = f"{dist_info}/entry_points.txt"
+        scripts: frozenset[str] = frozenset()
+        if entry_points in seen:
+            try:
+                scripts = _entry_point_scripts(
+                    archive.read(entry_points).decode("utf-8")
+                )
+            except (configparser.Error, UnicodeDecodeError) as exc:
+                problems.append(f"{entry_points}: unreadable ({type(exc).__name__})")
+    return {
+        "record": record,
+        "scripts": scripts,
+        "members_verified": verified,
+        "problems": problems,
+    }
+
+
+def _launcher_name(path: str) -> Optional[str]:
+    """The entry point a generated launcher path is for, if it is one."""
+
+    parts = PurePosixPath(path.replace("\\", "/")).parts
+    if len(parts) < 2 or parts[0] != ".." or parts[-2] not in SCRIPT_DIRECTORIES:
+        return None
+    name = parts[-1]
+    for suffix in _LAUNCHER_SUFFIXES:
+        if name.endswith(suffix):
+            return name[: -len(suffix)]
+    return name
+
+
+def _installer_file(path: str) -> bool:
+    """Whether an installed path is one an installer writes itself."""
+
+    location = PurePosixPath(path)
+    if location.parent.name.endswith(".dist-info"):
+        return location.name in INSTALLER_FILES or location.name == "RECORD"
+    return location.suffix == ".pyc" and location.parent.name == "__pycache__"
+
+
+def verify_installed_against_wheel(
+    name: str, wheel_path: Path, *, metadata: Any = importlib_metadata
+) -> dict[str, Any]:
+    """Compare an installed distribution with a wheel; never raises on mismatch.
+
+    Returns evidence: the wheel's SHA-256, how many archive members and
+    installed files were verified, the launchers exempted, any files installed
+    beyond the wheel, and ``problems`` (empty only when the wheel matches its
+    RECORD and the installed files are exactly the wheel's).
+    """
+
+    archive = verify_wheel_archive(wheel_path)
+    expected: dict[str, Optional[str]] = archive["record"]
+    problems: list[str] = list(archive["problems"])
+    distribution = metadata.distribution(name)
+    installed: dict[str, Optional[str]] = {}
+    for file in distribution.files or []:
+        installed[str(file)] = (
+            record_digest_to_hex(f"{file.hash.mode}={file.hash.value}")
+            if file.hash is not None
+            else None
+        )
+    verified = 0
+    for path, digest in sorted(expected.items()):
+        if digest is None:
+            continue
+        if installed.get(path, "absent") != digest:
+            problems.append(f"{path}: installed RECORD differs from the wheel")
+            continue
+        located = Path(distribution.locate_file(path))
+        if not located.is_file():
+            problems.append(f"{path}: missing on disk")
+            continue
+        if sha256_file(located) != digest:
+            problems.append(f"{path}: bytes differ from the wheel")
+            continue
+        verified += 1
+    launchers = sorted(
+        path
+        for path in installed
+        if path not in expected and _launcher_name(path) in archive["scripts"]
+    )
+    extra = sorted(
+        path
+        for path in installed
+        if path not in expected and path not in launchers and not _installer_file(path)
+    )
+    problems.extend(f"{path}: installed but not in the wheel" for path in extra)
+    return {
+        "wheel": wheel_path.name,
+        "wheel_sha256": sha256_file(wheel_path),
+        "archive_members_verified": archive["members_verified"],
+        "files_verified": verified,
+        "generated_launchers": launchers,
+        "unexpected_installed_files": extra,
+        "problems": problems,
+    }
diff --git a/tests/fixtures/uk_contract/README.md b/tests/fixtures/uk_contract/README.md
new file mode 100644
index 00000000..60dcc40c
--- /dev/null
+++ b/tests/fixtures/uk_contract/README.md
@@ -0,0 +1,12 @@
+# UK Budget reform contract reports
+
+Real reports from the UK Budget reform contract runner (workstream W1,
+`policyengine.tax_benefit_models.uk.budget_reform_contract`, PolicyEngine/policyengine.py#577),
+run on 11 October 2026. The qualification gate tests in `tests/test_uk_candidate_gates.py` read them.
+
+- `pinned_uk_2.125.1_defects.json`: every case on policyengine-uk 2.125.1, the pinned model at the time, with `max_examples=2` and no dataset. The 66 failures and 35 errors are the dated-reform defects the contract was written to catch.
+- `full_allpass_uk_2.126.8.json`: the full run the gate expects. Every route and case, seed 0, `max_examples=20`, and population checks on the certified `enhanced_frs_2024_25` (SHA-256 `5ba399ea…`). The model was policyengine-uk 2.126.8 from the policyengine-uk#2305 branch with core 3.33.0, a development combination rather than a certified pair, so treat its `versions` as fixture metadata. Result: 267 pass, 6 pending decision d1344, 67 unsupported with reasons, both properties passing, 13 of 13 population checks; 20.6 minutes, peak RSS 2.7 GB.
+- `full_released_uk_2.126.12.json`: the full run on released policyengine-uk 2.126.12 with core 3.33.0 (12 October 2026, runner at main after #577, contract 1.0.0): every route and case, seed 0, `max_examples=20`, population checks on the certified `enhanced_frs_2024_25`. Result: 673 pass, 6 pending decision d1344, 79 unsupported with reasons, both properties passing, 19 of 19 population rows. A test gates it against the coverage the merged runner module declares.
+- `population_pass_max_examples_0.json`: population checks only, on the same dataset. It has no cases, and with `max_examples=0` its sampled properties did not run, so the gate treats it as an incomplete run.
+
+The only edits: `dataset.path` (a local Hugging Face cache path) was replaced with a placeholder in the two files that name a dataset, and every file was re-serialized with sorted keys.
diff --git a/tests/fixtures/uk_contract/full_allpass_uk_2.126.8.json b/tests/fixtures/uk_contract/full_allpass_uk_2.126.8.json
new file mode 100644
index 00000000..4c3feeb6
--- /dev/null
+++ b/tests/fixtures/uk_contract/full_allpass_uk_2.126.8.json
@@ -0,0 +1,3665 @@
+{
+ "cases": [
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13630.0,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": "d1344",
+   "expected": 12830.0,
+   "holds": false,
+   "oracle": "ITA 2007 s57 from the frozen \u00a312,570 with 2034's September CPI rise",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "decision_id": "d1344",
+   "expected": 38500.0,
+   "holds": false,
+   "oracle": "ITA 2007 s21 from the frozen \u00a337,700 with 2034's September CPI rise",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13630.0,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": "d1344",
+   "expected": 12830.0,
+   "holds": false,
+   "oracle": "ITA 2007 s57 from the frozen \u00a312,570 with 2034's September CPI rise",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pending"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "decision_id": "d1344",
+   "expected": 38500.0,
+   "holds": false,
+   "oracle": "ITA 2007 s21 from the frozen \u00a337,700 with 2034's September CPI rise",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pending"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13630.0,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": "d1344",
+   "expected": 12830.0,
+   "holds": false,
+   "oracle": "ITA 2007 s57 from the frozen \u00a312,570 with 2034's September CPI rise",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "decision_id": "d1344",
+   "expected": 38500.0,
+   "holds": false,
+   "oracle": "ITA 2007 s21 from the frozen \u00a337,700 with 2034's September CPI rise",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_benefit_uprating",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.18868852459016394,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18868852459016394,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18868852459016394,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 13200.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 39600.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 253.85,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13200.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 39600.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 253.85,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13200.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 39600.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 253.85,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "12bcb296368c2b63f30b2040bfa5c84678f0bd52aaf7f85d510a75cd70f995c3",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "baseline_isolation:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "12bcb296368c2b63f30b2040bfa5c84678f0bd52aaf7f85d510a75cd70f995c3",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "909e6875b8d449e87b5d3eb0ad74e584e80241b9f40677343430b41232b20c5f",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "baseline_isolation:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "909e6875b8d449e87b5d3eb0ad74e584e80241b9f40677343430b41232b20c5f",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "9947c105e6b609c8a5f2c13ce4707daad40cfa8529bf2487734ef2a513169e8d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "baseline_isolation:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "9947c105e6b609c8a5f2c13ce4707daad40cfa8529bf2487734ef2a513169e8d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "a695bb7865a505dbbcb9c973b674a5666804e15838252c66bb7e84fa082388f0",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "88900943775d8451bee562422107eca7016ee21042a516b295fe3c00bc9a7da9",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "f0cad18ed901a4062c12dd721fa663915f3329fad55f54bbe407922de196d60d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "f0cad18ed901a4062c12dd721fa663915f3329fad55f54bbe407922de196d60d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "f76cc80028ce85017f3730a4d7db21c8a69f7b68bae0123c0aed45df087b1393",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "6e7e98694b32def0a541b52e0abb79b1cdcfc1e50a70784147d24a17e652ffe0",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "structural_switch",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "ee8e8cffbe252201a18cc7581c191e14f1b0269bfcfaf9d48cde191e9b2aaae9",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "8465ddfc32335f10bb9d23b050f0156a37c48e8a10dc35719d6b7e4f6d1a9238",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "a72b9604c243f77845e1446379e85bd2eca8e0fa7c671956a26e05ecadc3c4a7",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "32c326f8ca94bac9e220000e7f0fcf1a34bccd4947357e3819d419bb6ff193ea",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.budget_2026.new_credit.amount'",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "292d055c4ba7f1a326b9fa798441a0f6d2186c07206ce7d63177d829371ee335",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.budget_2026.new_credit.amount'",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "29a0b52eda9615814b814d0de2932d01d8d59430150dcbf9fbb2fcdf79d1fd8d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "unknown_path",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "fiscal year 2027",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "that year only",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before 1 January 2027",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "from 1 January 2027",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "onwards",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "one day is not 30 April",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "one day only",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  }
+ ],
+ "contract_id": "policyengine-uk/budget-reform-contract",
+ "contract_version": "1.0.0",
+ "dataset": {
+  "path": "",
+  "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6"
+ },
+ "elapsed_seconds": 1234.3,
+ "holds": true,
+ "max_examples": 20,
+ "population": [
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "unreformed",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_query_order",
+   "detail": null,
+   "route": "unreformed",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_routes_agree",
+   "route": "country.reform_dict = country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_routes_agree",
+   "route": "country.reform_dict = country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0
+   },
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0
+   },
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0
+   },
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  }
+ ],
+ "prior_law_option": "projected",
+ "properties": [
+  {
+   "engine": "hypothesis",
+   "examples_run": 20,
+   "property_id": "annual_values_match_the_oracle",
+   "status": "pass"
+  },
+  {
+   "engine": "hypothesis",
+   "examples_run": 20,
+   "property_id": "routes_agree_and_baselines_hold",
+   "status": "pass"
+  }
+ ],
+ "routes": [
+  "country.reform_dict",
+  "country.scenario_parameter_changes",
+  "country.reform_from_dict",
+  "wrapper.household",
+  "wrapper.policy",
+  "api.sim_api"
+ ],
+ "seed": 0,
+ "summary": {
+  "error": 0,
+  "fail": 0,
+  "pass": 267,
+  "pending": 6,
+  "unsupported": 67
+ },
+ "versions": {
+  "policyengine": "6.2.6",
+  "policyengine-core": "3.33.0",
+  "policyengine-uk": "2.126.8"
+ }
+}
diff --git a/tests/fixtures/uk_contract/full_released_uk_2.126.12.json b/tests/fixtures/uk_contract/full_released_uk_2.126.12.json
new file mode 100644
index 00000000..06e3f812
--- /dev/null
+++ b/tests/fixtures/uk_contract/full_released_uk_2.126.12.json
@@ -0,0 +1,8005 @@
+{
+ "cases": [
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "unreformed_after_reform:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13630.0,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": "d1344",
+   "expected": 12830.0,
+   "holds": false,
+   "oracle": "ITA 2007 s57 from the frozen \u00a312,570 with 2034's September CPI rise",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "decision_id": "d1344",
+   "expected": 38500.0,
+   "holds": false,
+   "oracle": "ITA 2007 s21 from the frozen \u00a337,700 with 2034's September CPI rise",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 38500.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "expected": 38500.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 51330.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "expected": 51330.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 246.73,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "expected": 246.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 987.12,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "expected": 987.12,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13090.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "expected": 13090.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 39300.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "expected": 39300.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13090.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "expected": 13090.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 52390.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "expected": 52390.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 251.73,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "expected": 251.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1007.5,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "expected": 1007.5,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13360.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "expected": 13360.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 40100.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "expected": 40100.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13360.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "expected": 13360.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 53460.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "expected": 53460.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 256.92,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "expected": 256.92,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1028.08,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "expected": 1028.08,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "expected": 13630.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "expected": 41000.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13630.0,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": "d1344",
+   "expected": 12830.0,
+   "holds": false,
+   "oracle": "ITA 2007 s57 from the frozen \u00a312,570 with 2034's September CPI rise",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pending"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "decision_id": "d1344",
+   "expected": 38500.0,
+   "holds": false,
+   "oracle": "ITA 2007 s21 from the frozen \u00a337,700 with 2034's September CPI rise",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pending"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 38500.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "expected": 38500.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 51330.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "expected": 51330.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 246.73,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "expected": 246.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 987.12,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "expected": 987.12,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13090.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "expected": 13090.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 39300.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "expected": 39300.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13090.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "expected": 13090.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 52390.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "expected": 52390.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 251.73,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "expected": 251.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1007.5,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "expected": 1007.5,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13360.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "expected": 13360.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 40100.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "expected": 40100.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13360.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "expected": 13360.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 53460.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "expected": 53460.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 256.92,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "expected": 256.92,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1028.08,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "expected": 1028.08,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "expected": 13630.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "expected": 41000.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 50270.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 241.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 966.73,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13630.0,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": "d1344",
+   "expected": 12830.0,
+   "holds": false,
+   "oracle": "ITA 2007 s57 from the frozen \u00a312,570 with 2034's September CPI rise",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "decision_id": "d1344",
+   "expected": 38500.0,
+   "holds": false,
+   "oracle": "ITA 2007 s21 from the frozen \u00a337,700 with 2034's September CPI rise",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pending"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 38500.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "expected": 38500.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 51330.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "expected": 51330.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 246.73,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "expected": 246.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 987.12,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "expected": 987.12,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13090.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "expected": 13090.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 39300.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "expected": 39300.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13090.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "expected": 13090.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 52390.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "expected": 52390.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 251.73,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "expected": 251.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1007.5,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "expected": 1007.5,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13360.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "expected": 13360.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 40100.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "expected": 40100.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13360.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "expected": 13360.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 53460.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "expected": 53460.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 256.92,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "expected": 256.92,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1028.08,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "expected": 1028.08,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13630.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "expected": 13630.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 41000.0,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "expected": 41000.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 52.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 52.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 104.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 104.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 158.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 158.0,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 3424.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2031",
+   "expected": 3424.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3372.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2032",
+   "expected": 3372.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 3318.39990234375,
+   "case_id": "extended_freeze",
+   "check": "unreformed_after_reform:income_tax:2033",
+   "expected": 3318.39990234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "unreformed_after_reform:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_benefit_uprating",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "unreformed_after_reform:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "variable:universal_credit:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "with no income or other elements, the award is the standard allowance: \u00a3500 x 12",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "variable:universal_credit:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "with no income or other elements, the award is the standard allowance: \u00a3500 x 12",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "variable:universal_credit:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "with no income or other elements, the award is the standard allowance: \u00a3500 x 12",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "baseline_isolation:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "variable:universal_credit:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "with no income or other elements, the award is the standard allowance: \u00a3500 x 12",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "variable:universal_credit:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "with no income or other elements, the award is the standard allowance: \u00a3500 x 12",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "change:universal_credit:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2026",
+   "expected": 5098.7998046875,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": "unreformed_after_reform:universal_credit:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "uc_award_follows_the_standard_allowance",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 14560.0,
+   "case_id": "new_state_pension_rate",
+   "check": "variable:new_state_pension:2027",
+   "decision_id": null,
+   "expected": 14560.0,
+   "holds": null,
+   "oracle": "the full weekly rate x 52: \u00a3280 x 52",
+   "reform_digest": "7c9f24257e0db73b283f97e34d0226bb7ef3deeb87e53cb9b6d8ae0813d8ffef",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "new_state_pension_rate",
+   "check": "change:new_state_pension:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "7c9f24257e0db73b283f97e34d0226bb7ef3deeb87e53cb9b6d8ae0813d8ffef",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "baseline_isolation:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "7c9f24257e0db73b283f97e34d0226bb7ef3deeb87e53cb9b6d8ae0813d8ffef",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "baseline_isolation:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "7c9f24257e0db73b283f97e34d0226bb7ef3deeb87e53cb9b6d8ae0813d8ffef",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "7c9f24257e0db73b283f97e34d0226bb7ef3deeb87e53cb9b6d8ae0813d8ffef",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "7c9f24257e0db73b283f97e34d0226bb7ef3deeb87e53cb9b6d8ae0813d8ffef",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 14560.0,
+   "case_id": "new_state_pension_rate",
+   "check": "variable:new_state_pension:2027",
+   "decision_id": null,
+   "expected": 14560.0,
+   "holds": null,
+   "oracle": "the full weekly rate x 52: \u00a3280 x 52",
+   "reform_digest": "c3d7325ace7970b8e9ee72145bbd5355a951fb28634263fe8d45bf5b5bef852b",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "new_state_pension_rate",
+   "check": "change:new_state_pension:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "c3d7325ace7970b8e9ee72145bbd5355a951fb28634263fe8d45bf5b5bef852b",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "baseline_isolation:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c3d7325ace7970b8e9ee72145bbd5355a951fb28634263fe8d45bf5b5bef852b",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "baseline_isolation:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "c3d7325ace7970b8e9ee72145bbd5355a951fb28634263fe8d45bf5b5bef852b",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c3d7325ace7970b8e9ee72145bbd5355a951fb28634263fe8d45bf5b5bef852b",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "c3d7325ace7970b8e9ee72145bbd5355a951fb28634263fe8d45bf5b5bef852b",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 14560.0,
+   "case_id": "new_state_pension_rate",
+   "check": "variable:new_state_pension:2027",
+   "decision_id": null,
+   "expected": 14560.0,
+   "holds": null,
+   "oracle": "the full weekly rate x 52: \u00a3280 x 52",
+   "reform_digest": "2d1c4aca40ef23e4e38ee649a9a1326147605eb994e0a45c159dc41f9bb6916e",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "new_state_pension_rate",
+   "check": "change:new_state_pension:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "2d1c4aca40ef23e4e38ee649a9a1326147605eb994e0a45c159dc41f9bb6916e",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "baseline_isolation:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "2d1c4aca40ef23e4e38ee649a9a1326147605eb994e0a45c159dc41f9bb6916e",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "baseline_isolation:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "2d1c4aca40ef23e4e38ee649a9a1326147605eb994e0a45c159dc41f9bb6916e",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "2d1c4aca40ef23e4e38ee649a9a1326147605eb994e0a45c159dc41f9bb6916e",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "2d1c4aca40ef23e4e38ee649a9a1326147605eb994e0a45c159dc41f9bb6916e",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 14560.0,
+   "case_id": "new_state_pension_rate",
+   "check": "variable:new_state_pension:2027",
+   "decision_id": null,
+   "expected": 14560.0,
+   "holds": null,
+   "oracle": "the full weekly rate x 52: \u00a3280 x 52",
+   "reform_digest": "0b2bf84c45612ec5d6e3801f4f9e65f4c60f4790c0fec009a23e2740fb01a88a",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "new_state_pension_rate",
+   "check": "change:new_state_pension:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "0b2bf84c45612ec5d6e3801f4f9e65f4c60f4790c0fec009a23e2740fb01a88a",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "0b2bf84c45612ec5d6e3801f4f9e65f4c60f4790c0fec009a23e2740fb01a88a",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "0b2bf84c45612ec5d6e3801f4f9e65f4c60f4790c0fec009a23e2740fb01a88a",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 14560.0,
+   "case_id": "new_state_pension_rate",
+   "check": "variable:new_state_pension:2027",
+   "decision_id": null,
+   "expected": 14560.0,
+   "holds": null,
+   "oracle": "the full weekly rate x 52: \u00a3280 x 52",
+   "reform_digest": "52299dd48f65858b74ac05e1fb951f0b751dd0a6ed3430393643797f95ef5fff",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "new_state_pension_rate",
+   "check": "change:new_state_pension:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "52299dd48f65858b74ac05e1fb951f0b751dd0a6ed3430393643797f95ef5fff",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 13036.9736328125,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2027",
+   "expected": 13036.9736328125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "52299dd48f65858b74ac05e1fb951f0b751dd0a6ed3430393643797f95ef5fff",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 12547.599609375,
+   "case_id": "new_state_pension_rate",
+   "check": "unreformed_after_reform:new_state_pension:2026",
+   "expected": 12547.599609375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "52299dd48f65858b74ac05e1fb951f0b751dd0a6ed3430393643797f95ef5fff",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "new_state_pension_rate",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.18868852459016394,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 60.8197021484375,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2027",
+   "decision_id": null,
+   "expected": 60.81967213114759,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; 159 of 2027-28's 366 days",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 140.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2028",
+   "decision_id": null,
+   "expected": 139.99999999999994,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; all of 2028-29",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2027",
+   "expected": 0.18,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2028",
+   "expected": 0.18000000000000002,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2026",
+   "expected": 0.18000000000000002,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18868852459016394,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 60.8197021484375,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2027",
+   "decision_id": null,
+   "expected": 60.81967213114759,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; 159 of 2027-28's 366 days",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 140.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2028",
+   "decision_id": null,
+   "expected": 139.99999999999994,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; all of 2028-29",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2027",
+   "expected": 0.18,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2028",
+   "expected": 0.18000000000000002,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2026",
+   "expected": 0.18000000000000002,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18868852459016394,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 60.8197021484375,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2027",
+   "decision_id": null,
+   "expected": 60.81967213114759,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; 159 of 2027-28's 366 days",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 140.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2028",
+   "decision_id": null,
+   "expected": 139.99999999999994,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; all of 2028-29",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "baseline_isolation:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2027",
+   "expected": 0.18,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2028",
+   "expected": 0.18000000000000002,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:gov.hmrc.cgt.basic_rate:2026",
+   "expected": 0.18000000000000002,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 60.8197021484375,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2027",
+   "decision_id": null,
+   "expected": 60.81967213114759,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; 159 of 2027-28's 366 days",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 140.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2028",
+   "decision_id": null,
+   "expected": 139.99999999999994,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; all of 2028-29",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 60.8197021484375,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2027",
+   "decision_id": null,
+   "expected": 60.81967213114759,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; 159 of 2027-28's 366 days",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 140.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "change:capital_gains_tax:2028",
+   "decision_id": null,
+   "expected": 139.99999999999994,
+   "holds": null,
+   "oracle": "(\u00a310,000 of gains - the annual exempt amount) x (20% - current rate) x the share of the tax year at 20%; the gains sit wholly in the basic rate band, and the exempt amount and current rate are read from current law; all of 2028-29",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2026",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2027",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "mid_year_blended_rate",
+   "check": "unreformed_after_reform:capital_gains_tax:2028",
+   "expected": 1260.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 13200.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 39600.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 253.85,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 38500.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "expected": 38500.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 246.73,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "expected": 246.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13200.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 39600.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 253.85,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 38500.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "expected": 38500.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 246.73,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "expected": 246.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 13200.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 39600.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 253.85,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 12830.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "expected": 12830.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 38500.0,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "expected": 38500.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 246.73,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "unreformed_after_reform:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "expected": 246.73,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "12bcb296368c2b63f30b2040bfa5c84678f0bd52aaf7f85d510a75cd70f995c3",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "baseline_isolation:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "12bcb296368c2b63f30b2040bfa5c84678f0bd52aaf7f85d510a75cd70f995c3",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "unreformed_after_reform:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "12bcb296368c2b63f30b2040bfa5c84678f0bd52aaf7f85d510a75cd70f995c3",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "909e6875b8d449e87b5d3eb0ad74e584e80241b9f40677343430b41232b20c5f",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "baseline_isolation:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "909e6875b8d449e87b5d3eb0ad74e584e80241b9f40677343430b41232b20c5f",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "unreformed_after_reform:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "909e6875b8d449e87b5d3eb0ad74e584e80241b9f40677343430b41232b20c5f",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "9947c105e6b609c8a5f2c13ce4707daad40cfa8529bf2487734ef2a513169e8d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "baseline_isolation:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "9947c105e6b609c8a5f2c13ce4707daad40cfa8529bf2487734ef2a513169e8d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "unreformed_after_reform:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9947c105e6b609c8a5f2c13ce4707daad40cfa8529bf2487734ef2a513169e8d",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "a695bb7865a505dbbcb9c973b674a5666804e15838252c66bb7e84fa082388f0",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "unreformed_after_reform:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "a695bb7865a505dbbcb9c973b674a5666804e15838252c66bb7e84fa082388f0",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "variable:uc_LCWRA_element:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "88900943775d8451bee562422107eca7016ee21042a516b295fe3c00bc9a7da9",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5259.26806640625,
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": "unreformed_after_reform:uc_LCWRA_element:2027",
+   "expected": 5259.26806640625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "88900943775d8451bee562422107eca7016ee21042a516b295fe3c00bc9a7da9",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "unreformed_after_reform:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "f0cad18ed901a4062c12dd721fa663915f3329fad55f54bbe407922de196d60d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "f0cad18ed901a4062c12dd721fa663915f3329fad55f54bbe407922de196d60d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "unreformed_after_reform:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f0cad18ed901a4062c12dd721fa663915f3329fad55f54bbe407922de196d60d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "unreformed_after_reform:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "f76cc80028ce85017f3730a4d7db21c8a69f7b68bae0123c0aed45df087b1393",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "unreformed_after_reform:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "f76cc80028ce85017f3730a4d7db21c8a69f7b68bae0123c0aed45df087b1393",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 1260.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "6e7e98694b32def0a541b52e0abb79b1cdcfc1e50a70784147d24a17e652ffe0",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "unreformed_after_reform:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "6e7e98694b32def0a541b52e0abb79b1cdcfc1e50a70784147d24a17e652ffe0",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "structural_switch",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 526.3154296875,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "change:vat:2026",
+   "decision_id": null,
+   "expected": 526.3157894736839,
+   "holds": null,
+   "oracle": "\u00a310,000 of full-rate consumption x (22% - current rate), over the share of consumption the microdata covers; the current rate and coverage are read from current law",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 526.3154296875,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "change:vat:2026",
+   "decision_id": null,
+   "expected": 526.3157894736839,
+   "holds": null,
+   "oracle": "\u00a310,000 of full-rate consumption x (22% - current rate), over the share of consumption the microdata covers; the current rate and coverage are read from current law",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 526.3154296875,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "change:vat:2026",
+   "decision_id": null,
+   "expected": 526.3157894736839,
+   "holds": null,
+   "oracle": "\u00a310,000 of full-rate consumption x (22% - current rate), over the share of consumption the microdata covers; the current rate and coverage are read from current law",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "ee8e8cffbe252201a18cc7581c191e14f1b0269bfcfaf9d48cde191e9b2aaae9",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 526.3154296875,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "change:vat:2026",
+   "decision_id": null,
+   "expected": 526.3157894736839,
+   "holds": null,
+   "oracle": "\u00a310,000 of full-rate consumption x (22% - current rate), over the share of consumption the microdata covers; the current rate and coverage are read from current law",
+   "reform_digest": "ee8e8cffbe252201a18cc7581c191e14f1b0269bfcfaf9d48cde191e9b2aaae9",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ee8e8cffbe252201a18cc7581c191e14f1b0269bfcfaf9d48cde191e9b2aaae9",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "ee8e8cffbe252201a18cc7581c191e14f1b0269bfcfaf9d48cde191e9b2aaae9",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "reason": "the policy route's output dataset holds the reformed run only",
+   "reform_digest": "228c864948e49f1bafb1020ecd1a6a6aab050d94c43bd4f60144d4d6074d7626",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": 526.3154296875,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "change:vat:2026",
+   "decision_id": null,
+   "expected": 526.3157894736839,
+   "holds": null,
+   "oracle": "\u00a310,000 of full-rate consumption x (22% - current rate), over the share of consumption the microdata covers; the current rate and coverage are read from current law",
+   "reform_digest": "228c864948e49f1bafb1020ecd1a6a6aab050d94c43bd4f60144d4d6074d7626",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "unreformed_after_reform:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "228c864948e49f1bafb1020ecd1a6a6aab050d94c43bd4f60144d4d6074d7626",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "baseline_vat_is_current_law",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "5c5e53ff74dc04bfc360ff580ca1013acb39023cef9edc4ad458d73bebe035d5",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "05701fb7bdc19fc311b70302afe7979c1f8e5b17b9484e76d3906b11d207de83",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "e6aba941ed6d2857584bcb6323f8e4186dc97005a39b1a9f05458858523ba545",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "modifier_then_parameter_changes",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "modifier_then_parameter_changes",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fdd130c561e213b17840d484c320191f464d341cf29bb534f03aa9a846d143d5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "9fd82d1d38614616b8ee42850e07bc6bbd018da3b7007014609714017d39a3d4",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "fcaf08431cabc68b5e0b6983a816afaa3482a7d32ef9f23deaca75753b191249",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0875,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.0875,
+   "holds": null,
+   "oracle": "set by the modifier",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.1075,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "expected": 0.1075,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "8e7554729292ab5cb608f34d2be15d8619eefa8f492ca83e2d7c93b41e2ca3dd",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.dividends[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "parameter_changes_then_modifier",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "actual": -190.0,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": -190.00000000000003,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": -15.7001953125,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": -15.700000000000358,
+   "holds": null,
+   "oracle": "(\u00a310,000 of dividends - the dividend allowance) x (8.75% - current dividend rate), all within the basic rate band, plus (30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2026",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 4507.25,
+   "case_id": "parameter_changes_then_modifier",
+   "check": "unreformed_after_reform:income_tax:2027",
+   "expected": 4507.25,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "43a019779739fe68e8b656937b0ddc64095111ef68da072075d4212c17e66faf",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "8465ddfc32335f10bb9d23b050f0156a37c48e8a10dc35719d6b7e4f6d1a9238",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "a72b9604c243f77845e1446379e85bd2eca8e0fa7c671956a26e05ecadc3c4a7",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "32c326f8ca94bac9e220000e7f0fcf1a34bccd4947357e3819d419bb6ff193ea",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.budget_2026.new_credit.amount'",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "292d055c4ba7f1a326b9fa798441a0f6d2186c07206ce7d63177d829371ee335",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.budget_2026.new_credit.amount'",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "29a0b52eda9615814b814d0de2932d01d8d59430150dcbf9fbb2fcdf79d1fd8d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "unknown_path",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "fiscal year 2027",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "that year only",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before 1 January 2027",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "from 1 January 2027",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "onwards",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "one day is not 30 April",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "one day only",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "unreformed_after_reform:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "expected": 0.2,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "unreformed_after_reform:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": "unreformed_after_reform:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "an unreformed simulation built after the reform equals one built before it",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "actual": "unchanged",
+   "case_id": "module_parameters_unchanged",
+   "check": "every parameter's dated values and metadata in processed, projected, statutory, system",
+   "expected": "policyengine-uk's shared parameters as they were before the run",
+   "oracle": "the shared parameter state's values, read before any reform ran",
+   "reform_digest": null,
+   "route": "all",
+   "status": "pass"
+  }
+ ],
+ "contract_id": "policyengine-uk/budget-reform-contract",
+ "contract_version": "1.0.0",
+ "dataset": {
+  "path": "",
+  "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6"
+ },
+ "holds": true,
+ "max_examples": 20,
+ "population": [
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "unreformed",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_query_order",
+   "detail": null,
+   "route": "unreformed",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_routes_agree",
+   "route": "country.reform_dict = country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_routes_agree",
+   "route": "country.reform_dict = country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0,
+    "2027_total_change": 205718.88776016235
+   },
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0,
+    "2027_total_change": 205718.88776016235
+   },
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0,
+    "2027_total_change": 205718.88776016235
+   },
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_uc_standard_allowance_plus_10",
+   "detail": {
+    "awards_higher": 105,
+    "awards_lower": 0,
+    "standard_allowance_scaled_10_percent": true
+   },
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_uc_standard_allowance_plus_10",
+   "detail": {
+    "awards_higher": 105,
+    "awards_lower": 0,
+    "standard_allowance_scaled_10_percent": true
+   },
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_uc_standard_allowance_plus_10",
+   "detail": {
+    "awards_higher": 105,
+    "awards_lower": 0,
+    "standard_allowance_scaled_10_percent": true
+   },
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_state_pension_plus_10",
+   "detail": {
+    "basic_and_new_scaled_10_percent": true,
+    "pensions_higher": 507,
+    "pensions_lower": 0
+   },
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_state_pension_plus_10",
+   "detail": {
+    "basic_and_new_scaled_10_percent": true,
+    "pensions_higher": 507,
+    "pensions_lower": 0
+   },
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_state_pension_plus_10",
+   "detail": {
+    "basic_and_new_scaled_10_percent": true,
+    "pensions_higher": 507,
+    "pensions_lower": 0
+   },
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  }
+ ],
+ "prior_law_option": "projected",
+ "properties": [
+  {
+   "engine": "hypothesis",
+   "examples_run": 20,
+   "property_id": "annual_values_match_the_oracle",
+   "status": "pass"
+  },
+  {
+   "engine": "hypothesis",
+   "examples_run": 20,
+   "property_id": "routes_agree_and_baselines_hold",
+   "status": "pass"
+  }
+ ],
+ "routes": [
+  "country.reform_dict",
+  "country.scenario_parameter_changes",
+  "country.reform_from_dict",
+  "wrapper.household",
+  "wrapper.policy",
+  "api.sim_api"
+ ],
+ "seed": 0,
+ "summary": {
+  "error": 0,
+  "fail": 0,
+  "pass": 673,
+  "pending": 6,
+  "unsupported": 79
+ },
+ "versions": {
+  "policyengine": "6.2.7",
+  "policyengine-core": "3.33.0",
+  "policyengine-uk": "2.126.12"
+ }
+}
diff --git a/tests/fixtures/uk_contract/pinned_uk_2.125.1_defects.json b/tests/fixtures/uk_contract/pinned_uk_2.125.1_defects.json
new file mode 100644
index 00000000..e7d92fa8
--- /dev/null
+++ b/tests/fixtures/uk_contract/pinned_uk_2.125.1_defects.json
@@ -0,0 +1,2050 @@
+{
+ "cases": [
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "1cf30cfa43f9bcee4bbb7c3d7d42d481aafe57cb674b93709b952a6089e23665",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2027-04-06.2100-12-31'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "c24ddebf09ef159ad38ebd530d2deca18d2ec7165400a6d92ada5c9d6175377e",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "unchanged before 2027-28",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "april_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "held from 6 April 2027",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "april_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2026",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "april_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "eab1e87614e840127bd7b77723df34e4412b2cc05d1dee4326e255c08aae4087",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "f504a06f3382203f96a08fc1c07b86a0f001d4b40c0a2e805f6c0c4d29c50221",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": null,
+   "detail": "KeyError: 'region'",
+   "reform_digest": "cd54b3b3b30cfd71448cbeb374c5d31aedca3d52733ab5c81e542feaba80704c",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "april_rate_change",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "5a0ca24b82794f7c32123733efa38f90aa602676efa5219e4a13d97ff46f69ea",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2027-04-06.2028-04-05'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "5e2b0da064082c7c363dec99c7e5bab2f190ae2f5ff9633454f1e10223293323",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "in force on 30 April 2027",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "finite_rate_change",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "prior law after 5 April 2028",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2027",
+   "decision_id": null,
+   "expected": 174.29999999999967,
+   "holds": null,
+   "oracle": "(30,000 - personal allowance) x (new rate - current rate); the allowance and current rate are read from current law",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 174.300048828125,
+   "case_id": "finite_rate_change",
+   "check": "change:income_tax:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "the change has ended",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2027",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3486.0,
+   "case_id": "finite_rate_change",
+   "check": "baseline_isolation:income_tax:2028",
+   "expected": 3486.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "ed4782036ee619f8c07483cac573d44bf1917fa33d3fa49a8551eebb632852ef",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "97674d142cf5719ae87a2523e758805be566fe02241aeff2a658fee27235c3f8",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": null,
+   "detail": "ValueError: unconverted data remains: .2028-04-05",
+   "reform_digest": "635e34257a38637cabd07234afb9e37355921ce7b680a98d8f85a1e05ecbbb64",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "finite_rate_change",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 12821.408457811869,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 38454.01293702494,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12821.408457811869,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 51275.43382451891,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 246.56476264971067,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 986.0652504709999,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 50.28169156237382,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13077.821120979681,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 52300.8804893913,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 251.49575971156867,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 1005.7853629502536,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 101.599853515625,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 101.56422419593619,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13339.378953034586,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 53346.903736598935,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 256.52570201408514,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 1025.901178621092,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 154.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 153.87579060691715,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13606.152435742164,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "detail": "ValueError('Could not find the parameter gov.economic_assumptions.yoy_growth.september_cpi_uprating (failed at september_cpi_uprating).')",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "error"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "detail": "ValueError('Could not find the parameter gov.economic_assumptions.yoy_growth.september_cpi_uprating (failed at september_cpi_uprating).')",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "error"
+  },
+  {
+   "actual": 3426.0,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3426.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3374.800048828125,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3374.800048828125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3322.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3322.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "234ec5c3931182f8f76930655330ebbdb77a6e62586a0af81f386149be5c5036",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2031-04-06.2034-04-05'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "725339aa214a4609707b6c10f7e32fdfb2491ad2e48da70f39a414dfc1f83aca",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 12821.408457811869,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 38454.01293702494,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12821.408457811869,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2031",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 51275.43382451891,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2031",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 246.56476264971067,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 986.0652504709999,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2031",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2031",
+   "decision_id": null,
+   "expected": 50.28169156237382,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2032",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13077.821120979681,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2032",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 52300.8804893913,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2032",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 251.49575971156867,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2032",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 1005.7853629502536,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2032",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 101.599853515625,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2032",
+   "decision_id": null,
+   "expected": 101.56422419593619,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 37700.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2033",
+   "decision_id": null,
+   "expected": 37700.0,
+   "holds": null,
+   "oracle": "frozen",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 13339.378953034586,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit:2033",
+   "decision_id": null,
+   "expected": 12570.0,
+   "holds": null,
+   "oracle": "aligned with the allowance",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 53346.903736598935,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit:2033",
+   "decision_id": null,
+   "expected": 50270.0,
+   "holds": null,
+   "oracle": "allowance + basic rate limit",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 256.52570201408514,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2033",
+   "decision_id": null,
+   "expected": 241.73,
+   "holds": null,
+   "oracle": "12,570 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 1025.901178621092,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit:2033",
+   "decision_id": null,
+   "expected": 966.73,
+   "holds": null,
+   "oracle": "50,270 / 52, to the penny",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 154.0,
+   "case_id": "extended_freeze",
+   "check": "change:income_tax:2033",
+   "decision_id": null,
+   "expected": 153.87579060691715,
+   "holds": null,
+   "oracle": "basic rate x (current law's allowance - 12,570)",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 12570.0,
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "decision_id": null,
+   "expected": 13606.152435742164,
+   "holds": null,
+   "oracle": "contract 1.0.0: prior law's projected path resumes",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2034",
+   "detail": "ValueError('Could not find the parameter gov.economic_assumptions.yoy_growth.september_cpi_uprating (failed at september_cpi_uprating).')",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "error"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2034",
+   "detail": "ValueError('Could not find the parameter gov.economic_assumptions.yoy_growth.september_cpi_uprating (failed at september_cpi_uprating).')",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "error"
+  },
+  {
+   "actual": 3426.0,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2031",
+   "expected": 3426.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3374.800048828125,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2032",
+   "expected": 3374.800048828125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 3322.39990234375,
+   "case_id": "extended_freeze",
+   "check": "baseline_isolation:income_tax:2033",
+   "expected": 3322.39990234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "423fc235458423a35a13511dffa5b18190c7e1d0e7b3a209d4b923708dd647f0",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "b53bec711f02356d55dbb011544c0e556eba7559496d89d10ae266da9e217a0e",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": null,
+   "detail": "ValueError: unconverted data remains: .2034-04-05",
+   "reform_digest": "f0cebbfddd5f9a74d7ebb7f83247004201148486b8714cd916087872ce2dfaaa",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "extended_freeze",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "e98d41db2ed7d855dda05d65e66e1e4ef4b4167088a2d88bbd92c9b3260402ff",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_benefit_uprating",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2027-04-06.2028-04-05'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "0ab5df46a43311bce2af6c4b9b70155c64df30388c8a0f99f15a2846351f715a",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 6000.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "\u00a3520 x 12",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 6240.0,
+   "case_id": "april_benefit_uprating",
+   "check": "variable:uc_standard_allowance:2029",
+   "decision_id": null,
+   "expected": 6240.0,
+   "holds": null,
+   "oracle": "held from April 2028",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "april_benefit_uprating",
+   "check": "change:uc_standard_allowance:2026",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5426.7763671875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2029",
+   "expected": 5426.7763671875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5098.7998046875,
+   "case_id": "april_benefit_uprating",
+   "check": "baseline_isolation:uc_standard_allowance:2026",
+   "expected": 5098.7998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "33b86ab43be584c8c6e8aafdd55053dcf72078a3a525cd638f38bcd1d645227f",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "april_benefit_uprating",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "dfc339899202506f1bb7300cea49e605d3c128f1c036c0ec5df0c3d326d0c33e",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "april_benefit_uprating",
+   "check": null,
+   "detail": "ValueError: unconverted data remains: .2028-04-05",
+   "reform_digest": "d4388eb374c51a6da454f1325fe7699aca1a9004b95dae95464c3a9b2aea8bd5",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "april_benefit_uprating",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 679.6259765625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d646393894abdc73e4d317eff0c39419cfbf5f69df4dfea57f44db7700f1fd54",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2027-04-06.2028-04-05'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "ba3722100cf3de0524dfd14b8e0b498a4c2caebe66bdf38395ae8d4568589236",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "variable:uc_standard_allowance:2027",
+   "decision_id": null,
+   "expected": 6000.0,
+   "holds": null,
+   "oracle": "\u00a3500 x 12",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 679.6259765625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2028",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "change:uc_standard_allowance:2030",
+   "decision_id": null,
+   "expected": 0.0,
+   "holds": null,
+   "oracle": "prior law's uprated path resumes",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5216.06103515625,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2027",
+   "expected": 5216.06103515625,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5320.3740234375,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2028",
+   "expected": 5320.3740234375,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5535.2998046875,
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": "baseline_isolation:uc_standard_allowance:2030",
+   "expected": 5535.2998046875,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "20b97441158d9b07c0a62a5bfd332aaa4ab9749ac9a9d3b93f046461f54acfc4",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "6df62a9f41db002ef95cadf25f2cd566de071a424141ebd1c479e4398ead4a4e",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": null,
+   "detail": "ValueError: unconverted data remains: .2028-04-05",
+   "reform_digest": "48f71ddba48e9494e868325cf1af191218d7edb5c4846616dbe75500e6db417d",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "bounded_change_on_uprated_benefit",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.18,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "3d8fd4c48241e68e1e1d8479b2b2067b274092a7c4fc2476b7cea829c18aa588",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2027-10-30.2100-12-31'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "576ce816dacf70876bdfd765e57f48d44a9499181cca2b3e178dfaf51cbad0bd",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 0.18,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "decision_id": null,
+   "expected": 0.18868852459016502,
+   "holds": null,
+   "oracle": "day-weighted: 207 days at 18% and 159 at 20% of 366",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "in force all year",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.18000000000000002,
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "decision_id": null,
+   "expected": 0.18,
+   "holds": null,
+   "oracle": "before the change",
+   "reform_digest": "abe5e3d10b5669e954ed794facadad012121eef44cc1444801edb99a6c902081",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "f9f1430783586c75c5ce96f54a3e4ee30af28059391bcfb9d0cb16d4230184e5",
+   "route": "wrapper.household",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2027",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2028",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": "parameter:gov.hmrc.cgt.basic_rate:2026",
+   "reason": "this route exposes outputs, not parameter values",
+   "reform_digest": "772a73501537a9c16f95dadad9ad48d044520ef6f92b0f53345c881b8250b5b7",
+   "route": "wrapper.policy",
+   "status": "unsupported"
+  },
+  {
+   "case_id": "mid_year_blended_rate",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 12821.408457811869,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 38454.01293702494,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 246.56476264971067,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "8bd9476925c59e9d13946308e4604e33976dc8a1d68b63e276628c95135a82d0",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2030-09-01.2031-08-31'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "afe5d61179ae6fd02453405015018bcbdd8047fcc3e69e23c43e5f57dce17c67",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 12821.408457811869,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.allowances.personal_allowance.amount:2031",
+   "decision_id": null,
+   "expected": 13200.0,
+   "holds": null,
+   "oracle": "ITA 2007 s57(3)",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 38454.01293702494,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[1].threshold:2031",
+   "decision_id": null,
+   "expected": 39600.0,
+   "holds": null,
+   "oracle": "ITA 2007 s21(3)",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 246.56476264971067,
+   "case_id": "september_cpi_moves_indexed_thresholds",
+   "check": "parameter:gov.hmrc.national_insurance.class_1.thresholds.primary_threshold:2031",
+   "decision_id": null,
+   "expected": 253.85,
+   "holds": null,
+   "oracle": "aligned with the allowance, weekly",
+   "reform_digest": "fda4228463d1ee4cd7263094d2b28ffb49c78d09b87468542dba7e6821ff3f9d",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "detail": "ValueError: Could not find the parameter gov.dwp.universal_credit.rebalancing.protected_health_element (failed at protected_health_element).",
+   "reform_digest": "12bcb296368c2b63f30b2040bfa5c84678f0bd52aaf7f85d510a75cd70f995c3",
+   "route": "country.reform_dict",
+   "status": "error"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "detail": "ValueError: Could not find the parameter gov.dwp.universal_credit.rebalancing.protected_health_element (failed at protected_health_element).",
+   "reform_digest": "909e6875b8d449e87b5d3eb0ad74e584e80241b9f40677343430b41232b20c5f",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "detail": "ValueError: Could not find the parameter gov.dwp.universal_credit.rebalancing.protected_health_element (failed at protected_health_element).",
+   "reform_digest": "9947c105e6b609c8a5f2c13ce4707daad40cfa8529bf2487734ef2a513169e8d",
+   "route": "country.reform_from_dict",
+   "status": "error"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "detail": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.dwp.universal_credit.rebalancing.protected_health_element' (did you mean 'gov.dwp.universal_credit.rebalancing.new_claimant_health_element'?)",
+   "reform_digest": "a695bb7865a505dbbcb9c973b674a5666804e15838252c66bb7e84fa082388f0",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "detail": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.dwp.universal_credit.rebalancing.protected_health_element' (did you mean 'gov.dwp.universal_credit.rebalancing.new_claimant_health_element'?)",
+   "reform_digest": "88900943775d8451bee562422107eca7016ee21042a516b295fe3c00bc9a7da9",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "uc_rebalancing_reform_after_data_load",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "d837da2e6fabde655a05b130322e0c29d06d5d493ad99183a699198dc5e1d4b2",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "structural_switch",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2025-01-01.2030-12-31'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "f0cad18ed901a4062c12dd721fa663915f3329fad55f54bbe407922de196d60d",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "variable:marriage_allowance:2026",
+   "decision_id": null,
+   "expected": 1260.0,
+   "holds": null,
+   "oracle": "10% of the \u00a312,570 allowance, rounded up to \u00a310 (ITA 2007 s55B)",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "fail"
+  },
+  {
+   "actual": 0.0,
+   "case_id": "structural_switch",
+   "check": "baseline_isolation:marriage_allowance:2026",
+   "expected": 0.0,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "19b28590adbd8d9b80069a5504380f0226f0a1e5b1b2f73f5ba411fc562d7742",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "structural_switch",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "f76cc80028ce85017f3730a4d7db21c8a69f7b68bae0123c0aed45df087b1393",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "structural_switch",
+   "check": null,
+   "detail": "ValueError: unconverted data remains: .2030-12-31",
+   "reform_digest": "6e7e98694b32def0a541b52e0abb79b1cdcfc1e50a70784147d24a17e652ffe0",
+   "route": "wrapper.policy",
+   "status": "error"
+  },
+  {
+   "case_id": "structural_switch",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "062a65e94c93f043b9ff9322b07152c930d1e1cb375237eb2375343a9e470e73",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "baseline_vat_is_current_law",
+   "check": null,
+   "detail": "ValueError: Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2026-01-01.2100-12-31'.\nLearn more about legal period formats in OpenFisca:\n.",
+   "reform_digest": "8a1e1a66ee167c2dc554452d46828576b58159ed3d3276ffa1723f417d5ade0d",
+   "route": "country.scenario_parameter_changes",
+   "status": "error"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_equals:baseline_vat:2026",
+   "decision_id": null,
+   "expected": 5263.158203125,
+   "holds": null,
+   "oracle": "baseline_vat is the unreformed vat",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:baseline_vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 5263.158203125,
+   "case_id": "baseline_vat_is_current_law",
+   "check": "baseline_isolation:vat:2026",
+   "expected": 5263.158203125,
+   "oracle": "the reformed simulation's baseline equals an unreformed simulation",
+   "reform_digest": "8df662d00e257aeda318ef34432e8de4ab0474fcd093ab9afd16e5fd97c4f096",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "baseline_vat_is_current_law",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "ee8e8cffbe252201a18cc7581c191e14f1b0269bfcfaf9d48cde191e9b2aaae9",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "8465ddfc32335f10bb9d23b050f0156a37c48e8a10dc35719d6b7e4f6d1a9238",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "a72b9604c243f77845e1446379e85bd2eca8e0fa7c671956a26e05ecadc3c4a7",
+   "route": "country.scenario_parameter_changes",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Could not find the parameter gov.budget_2026.new_credit.amount (failed at budget_2026).",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "32c326f8ca94bac9e220000e7f0fcf1a34bccd4947357e3819d419bb6ff193ea",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.budget_2026.new_credit.amount'",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "292d055c4ba7f1a326b9fa798441a0f6d2186c07206ce7d63177d829371ee335",
+   "route": "wrapper.household",
+   "status": "pass"
+  },
+  {
+   "actual": "ValueError: Reform contains parameter paths not defined on policyengine-uk 2.125.1:\n  - 'gov.budget_2026.new_credit.amount'",
+   "case_id": "unknown_path",
+   "check": "rejected",
+   "expected": "an error matching 'new_credit'",
+   "reform_digest": "29a0b52eda9615814b814d0de2932d01d8d59430150dcbf9fbb2fcdf79d1fd8d",
+   "route": "wrapper.policy",
+   "status": "pass"
+  },
+  {
+   "case_id": "unknown_path",
+   "check": null,
+   "reason": "Runs in policyengine-sim-api's deploy gate, which imports CASES and routes them through its runtime; it is not callable in-process.",
+   "reform_digest": null,
+   "route": "api.sim_api",
+   "status": "unsupported"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "fiscal year 2027",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "that year only",
+   "reform_digest": "3314f6d0362de9643ce2022904e4fcbbe84297c25d36c37add61a878c7dc3e9c",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2026",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "before 1 January 2027",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "from 1 January 2027",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.21,
+   "case_id": "reading_bare_year_reform_from_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.21,
+   "holds": null,
+   "oracle": "onwards",
+   "reform_digest": "d19ea548203c2417e712543d47546f3fa48c741252ccd5b6bf1664d9a517d6b6",
+   "route": "country.reform_from_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2027",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "one day is not 30 April",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "actual": 0.2,
+   "case_id": "reading_single_day_dict",
+   "check": "parameter:gov.hmrc.income_tax.rates.uk[0].rate:2028",
+   "decision_id": null,
+   "expected": 0.2,
+   "holds": null,
+   "oracle": "one day only",
+   "reform_digest": "dec76eb1ea64e800caff37168afaaa26599bb03b533c169908d691c111cdbc36",
+   "route": "country.reform_dict",
+   "status": "pass"
+  },
+  {
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": null,
+   "detail": "TypeError: unhashable type: 'dict'",
+   "reform_digest": "56c7ad7d1ddf6185703a5f60d31f11a12272e1166c667996b484801f57dbebcd",
+   "route": "wrapper.household",
+   "status": "error"
+  },
+  {
+   "case_id": "reading_effective_dates_reverse_order",
+   "check": null,
+   "detail": "KeyError: 'region'",
+   "reform_digest": "b09577ce022e4cc30b4702add142a1e3aa1ab438a27955d182bba894199cecd5",
+   "route": "wrapper.policy",
+   "status": "error"
+  }
+ ],
+ "contract_id": "policyengine-uk/budget-reform-contract",
+ "contract_version": "1.0.0",
+ "dataset": null,
+ "elapsed_seconds": 280.6,
+ "holds": false,
+ "max_examples": 2,
+ "population": [],
+ "prior_law_option": "projected",
+ "properties": [
+  {
+   "detail": "ValueError(\"Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2025-06-01.2100-12-31'.\\nLearn more about legal period formats in OpenFisca:\\n.\")",
+   "engine": "hypothesis",
+   "examples_run": 2,
+   "property_id": "annual_values_match_the_oracle",
+   "status": "error"
+  },
+  {
+   "detail": "ValueError(\"Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '2025-06-01.2100-12-31'.\\nLearn more about legal period formats in OpenFisca:\\n.\")",
+   "engine": "hypothesis",
+   "examples_run": 2,
+   "property_id": "routes_agree_and_baselines_hold",
+   "status": "error"
+  }
+ ],
+ "routes": [
+  "country.reform_dict",
+  "country.scenario_parameter_changes",
+  "country.reform_from_dict",
+  "wrapper.household",
+  "wrapper.policy",
+  "api.sim_api"
+ ],
+ "seed": 0,
+ "summary": {
+  "error": 35,
+  "fail": 66,
+  "pass": 76,
+  "pending": 0,
+  "unsupported": 15
+ },
+ "versions": {
+  "policyengine": "6.2.6",
+  "policyengine-core": "3.33.0",
+  "policyengine-uk": "2.125.1"
+ }
+}
diff --git a/tests/fixtures/uk_contract/population_pass_max_examples_0.json b/tests/fixtures/uk_contract/population_pass_max_examples_0.json
new file mode 100644
index 00000000..22db1db2
--- /dev/null
+++ b/tests/fixtures/uk_contract/population_pass_max_examples_0.json
@@ -0,0 +1,161 @@
+{
+ "cases": [],
+ "contract_id": "policyengine-uk/budget-reform-contract",
+ "contract_version": "1.0.0",
+ "dataset": {
+  "path": "",
+  "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6"
+ },
+ "elapsed_seconds": 23.6,
+ "holds": true,
+ "max_examples": 0,
+ "population": [
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_baseline_isolation",
+   "detail": null,
+   "route": "unreformed",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_query_order",
+   "detail": null,
+   "route": "unreformed",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_routes_agree",
+   "route": "country.reform_dict = country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_routes_agree",
+   "route": "country.reform_dict = country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0
+   },
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0
+   },
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_april_rate_change",
+   "detail": {
+    "2026_unchanged": true,
+    "2027_people_with_higher_tax": 1093,
+    "2027_people_with_lower_tax": 0
+   },
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.reform_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.scenario_parameter_changes",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  },
+  {
+   "check_id": "population_finite_change_expires",
+   "route": "country.reform_from_dict",
+   "seed": 0,
+   "status": "pass",
+   "subsample_households": 1000
+  }
+ ],
+ "prior_law_option": "projected",
+ "properties": [
+  {
+   "examples_run": 0,
+   "property_id": "annual_values_match_the_oracle",
+   "reason": "max_examples is 0, so no examples were requested",
+   "status": "unsupported"
+  },
+  {
+   "examples_run": 0,
+   "property_id": "routes_agree_and_baselines_hold",
+   "reason": "max_examples is 0, so no examples were requested",
+   "status": "unsupported"
+  }
+ ],
+ "routes": [
+  "country.reform_dict",
+  "country.scenario_parameter_changes",
+  "country.reform_from_dict",
+  "wrapper.household",
+  "wrapper.policy",
+  "api.sim_api"
+ ],
+ "seed": 0,
+ "summary": {
+  "error": 0,
+  "fail": 0,
+  "pass": 13,
+  "pending": 0,
+  "unsupported": 2
+ },
+ "versions": {
+  "policyengine": "6.2.6",
+  "policyengine-core": "3.33.0",
+  "policyengine-uk": "2.126.8"
+ }
+}
diff --git a/tests/fixtures/uk_metrics/README.md b/tests/fixtures/uk_metrics/README.md
new file mode 100644
index 00000000..f438a64c
--- /dev/null
+++ b/tests/fixtures/uk_metrics/README.md
@@ -0,0 +1,24 @@
+# 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.
+
+- `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`.
diff --git a/tests/fixtures/uk_metrics/budget_metrics_report.json b/tests/fixtures/uk_metrics/budget_metrics_report.json
new file mode 100644
index 00000000..6b599fb5
--- /dev/null
+++ b/tests/fixtures/uk_metrics/budget_metrics_report.json
@@ -0,0 +1,16145 @@
+{
+ "schema": "policyengine-uk-aggregate-impact/1/report",
+ "status": "ok",
+ "metrics_version": "policyengine-uk-aggregate-impact/1+3d0c53d5b453bd14+bf81c943e4ebec5a",
+ "partition_version": "779be6fce5878c1a358e1d921e4427cae51b2cb9391dabf923b5c624ae2d1070",
+ "years": [
+  2025,
+  2026,
+  2027,
+  2028,
+  2029,
+  2030,
+  2031
+ ],
+ "metrics": [
+  {
+   "metric_id": "population.person",
+   "year": 2025,
+   "value": 69712844.3129216,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.person",
+   "year": 2026,
+   "value": 69977755.64010108,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.person",
+   "year": 2027,
+   "value": 70236674.50701487,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.person",
+   "year": 2028,
+   "value": 70517617.61980712,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.person",
+   "year": 2029,
+   "value": 70827896.20421527,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.person",
+   "year": 2030,
+   "value": 71146624.04218265,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.person",
+   "year": 2031,
+   "value": 71452690.09232165,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "People"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2025,
+   "value": 35436904.67127848,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2026,
+   "value": 35571566.183018014,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2027,
+   "value": 35703181.570698276,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2028,
+   "value": 35845992.467239186,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2029,
+   "value": 36003715.37718047,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2030,
+   "value": 36165733.258539036,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.benunit",
+   "year": 2031,
+   "value": 36321314.823288456,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Benefit units"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2025,
+   "value": 31245113.389789984,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2026,
+   "value": 31363845.951987505,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2027,
+   "value": 31479892.706091657,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2028,
+   "value": 31605810.66237679,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2029,
+   "value": 31744876.701983795,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2030,
+   "value": 31887729.66803205,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "population.household",
+   "year": 2031,
+   "value": 32024907.664337814,
+   "unit": "count",
+   "kind": "population",
+   "partition": null,
+   "label": "Households"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2025,
+   "value": 300493514624.05597,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2026,
+   "value": 320544520884.17847,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2027,
+   "value": 338021251015.9257,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2028,
+   "value": 353227552195.2491,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2029,
+   "value": 369117767213.9463,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2030,
+   "value": 387232407347.2469,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.income_tax",
+   "year": 2031,
+   "value": 411429752919.1772,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "income_tax",
+   "label": "Income tax"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2025,
+   "value": 53131882407.61014,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2026,
+   "value": 55499684495.07545,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2027,
+   "value": 57316093047.9056,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2028,
+   "value": 59021168416.7411,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2029,
+   "value": 60933744650.36868,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2030,
+   "value": 62865473690.570404,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_individual",
+   "year": 2031,
+   "value": 65321678070.619606,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_individual",
+   "label": "National Insurance (employee and self-employed)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2025,
+   "value": 154541037552.12073,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2026,
+   "value": 161154088343.2087,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2027,
+   "value": 166167835008.01767,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2028,
+   "value": 170808399523.67877,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2029,
+   "value": 177113429874.5147,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2030,
+   "value": 182684601617.84186,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.nics_employer",
+   "year": 2031,
+   "value": 189883460588.0551,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "nics_employer",
+   "label": "National Insurance (employer)"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_capital",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_capital",
+   "label": "Employer NI change borne by capital"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.employer_nics_response_consumer",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "employer_nics_response_consumer",
+   "label": "Employer NI change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2025,
+   "value": 12717206634.11718,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2026,
+   "value": 13157373776.93752,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2027,
+   "value": 13652775596.971504,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2028,
+   "value": 14151592611.504883,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2029,
+   "value": 14653801672.085384,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2030,
+   "value": 15204600241.922672,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.capital_gains_tax",
+   "year": 2031,
+   "value": 15768244652.787172,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "capital_gains_tax",
+   "label": "Capital gains tax"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2025,
+   "value": 7830274125.077207,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2026,
+   "value": 8127270450.824548,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2027,
+   "value": 8353117705.791328,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2028,
+   "value": 8562646845.026996,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2029,
+   "value": 8789529758.204428,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2030,
+   "value": 9040980903.523558,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.student_loan_repayments",
+   "year": 2031,
+   "value": 8059998166.851627,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "student_loan_repayments",
+   "label": "Student loan repayments"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2025,
+   "value": 317136171663.45935,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2026,
+   "value": 325663150441.0445,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2027,
+   "value": 333405472036.0278,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2028,
+   "value": 341433856818.8758,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2029,
+   "value": 349794893731.6442,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2030,
+   "value": 358396364144.5514,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat",
+   "year": 2031,
+   "value": 367137164287.62054,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat",
+   "label": "VAT"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.vat_change",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vat_change",
+   "label": "Change in VAT from its baseline"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.private_school_vat",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "private_school_vat",
+   "label": "VAT on private school fees"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2025,
+   "value": 24041873879.224743,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2026,
+   "value": 24144661501.69051,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2027,
+   "value": 26103457396.62795,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2028,
+   "value": 26158807847.977238,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2029,
+   "value": 25887798204.586216,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2030,
+   "value": 25243234770.62697,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.fuel_duty",
+   "year": 2031,
+   "value": 25824982812.576405,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "fuel_duty",
+   "label": "Fuel duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.alcohol_duty",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "alcohol_duty",
+   "label": "Alcohol duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.tobacco_duty",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tobacco_duty",
+   "label": "Tobacco duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.vehicle_excise_duty",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "vehicle_excise_duty",
+   "label": "Vehicle excise duties"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2025,
+   "value": 4306586174.401852,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2026,
+   "value": 4459282492.68721,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2027,
+   "value": 4476138521.654851,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2028,
+   "value": 4496883532.289182,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2029,
+   "value": 4517653126.548064,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2030,
+   "value": 4538717337.874185,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.tv_licence",
+   "year": 2031,
+   "value": 4613696461.117936,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "tv_licence",
+   "label": "TV licence fee"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2025,
+   "value": 49771708157.89493,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2026,
+   "value": 52672603769.35538,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2027,
+   "value": 56014381640.763115,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2028,
+   "value": 59637347359.69022,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2029,
+   "value": 63246644418.46654,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2030,
+   "value": 67017263920.12569,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.council_tax",
+   "year": 2031,
+   "value": 67305565868.62192,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "council_tax",
+   "label": "Council tax"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2028,
+   "value": 493860763.3252144,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2029,
+   "value": 505953941.79338336,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2030,
+   "value": 518394260.7963829,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.high_value_council_tax_surcharge",
+   "year": 2031,
+   "value": 531037020.5527634,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "high_value_council_tax_surcharge",
+   "label": "High value council tax surcharge"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2025,
+   "value": 584068659.0970445,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2026,
+   "value": 611674416.437277,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2027,
+   "value": 640521131.9170634,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2028,
+   "value": 670928666.9686946,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2029,
+   "value": 703059797.6875113,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2030,
+   "value": 736803086.5428002,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.domestic_rates",
+   "year": 2031,
+   "value": 739972742.2779194,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "domestic_rates",
+   "label": "Northern Ireland domestic rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2025,
+   "value": 31732500424.943848,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2026,
+   "value": 31732499121.74478,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2027,
+   "value": 31732496311.19231,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2028,
+   "value": 31732499335.602257,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2029,
+   "value": 31732495775.940987,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2030,
+   "value": 31732497033.93507,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.business_rates",
+   "year": 2031,
+   "value": 31732498138.642197,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "business_rates",
+   "label": "Business rates"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2025,
+   "value": 19447521756.60174,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2026,
+   "value": 20439906422.593903,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2027,
+   "value": 21414316294.15224,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2028,
+   "value": 22393098228.154316,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2029,
+   "value": 23394928012.007107,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2030,
+   "value": 24493314488.64765,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.sdlt_household",
+   "year": 2031,
+   "value": 25645659350.923958,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "sdlt_household",
+   "label": "Stamp duty land tax (own purchases, and corporate until the"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2025,
+   "value": 2926955.7028617123,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2026,
+   "value": 3159363.4106524643,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2027,
+   "value": 3426934.00067959,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2028,
+   "value": 3700930.558409606,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2029,
+   "value": 3976395.985409789,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2030,
+   "value": 4283730.381307287,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_transaction_tax",
+   "year": 2031,
+   "value": 4617409.568686154,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_transaction_tax",
+   "label": "Land transaction tax (Wales)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2025,
+   "value": 99491389.10783455,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2026,
+   "value": 103957133.89622791,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2027,
+   "value": 109071438.72328326,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2028,
+   "value": 114234820.44540606,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2029,
+   "value": 119415529.40185314,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2030,
+   "value": 125108787.1325237,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.land_and_buildings_transaction_tax",
+   "year": 2031,
+   "value": 130998504.05282259,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_and_buildings_transaction_tax",
+   "label": "Land and buildings transaction tax (Scotland)"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.corporate_incident_tax_change",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "corporate_incident_tax_change",
+   "label": "Unspecified tax change borne by capital"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.consumer_incident_tax_change",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "consumer_incident_tax_change",
+   "label": "Unspecified tax change borne by consumers"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.wealth_tax",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "wealth_tax",
+   "label": "Wealth tax"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.non_primary_residence_wealth_tax",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "non_primary_residence_wealth_tax",
+   "label": "Wealth tax excluding the main residence"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.land_value_tax",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "land_value_tax",
+   "label": "Land value tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "receipts.carbon_tax",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "receipt",
+   "partition": "carbon_tax",
+   "label": "Carbon tax"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2025,
+   "value": 123608843187.93976,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2026,
+   "value": 128313805027.58318,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2027,
+   "value": 130127006024.04692,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2028,
+   "value": 132238843074.43095,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2029,
+   "value": 136141146063.11377,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2030,
+   "value": 140172899465.68817,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.state_pension",
+   "year": 2031,
+   "value": 144295261485.49826,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2025,
+   "value": 11410127.781640545,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2026,
+   "value": 11314830.782796293,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2027,
+   "value": 11062132.340065777,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2028,
+   "value": 10978110.546501547,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2029,
+   "value": 11026414.415012136,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2030,
+   "value": 11076033.608679011,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "caseloads.state_pension",
+   "year": 2031,
+   "value": 11123681.665928766,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "state_pension",
+   "label": "State Pension"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2025,
+   "value": 6693405148.200283,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2026,
+   "value": 7053483709.162087,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2027,
+   "value": 7033310841.277825,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2028,
+   "value": 7106016715.233224,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2029,
+   "value": 7226848094.900362,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2030,
+   "value": 7357686467.835372,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.pension_credit",
+   "year": 2031,
+   "value": 6706505197.30149,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2025,
+   "value": 1482756.0540938377,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2026,
+   "value": 1480853.1148245037,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2027,
+   "value": 1468135.4329868257,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2028,
+   "value": 1447010.371819973,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2029,
+   "value": 1447749.8420774192,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2030,
+   "value": 1444977.4954886883,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "caseloads.pension_credit",
+   "year": 2031,
+   "value": 1071942.0777988583,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pension_credit",
+   "label": "Pension Credit"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2025,
+   "value": 1703858030.6710334,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2026,
+   "value": 1687685652.7937918,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2027,
+   "value": 1616450871.1886823,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2028,
+   "value": 1595491327.4155288,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2029,
+   "value": 1589373766.706106,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2030,
+   "value": 1572193208.341339,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.winter_fuel_payment",
+   "year": 2031,
+   "value": 1561450492.8642156,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2025,
+   "value": 7112673.635766387,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2026,
+   "value": 7034862.558265865,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2027,
+   "value": 6701440.449254692,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2028,
+   "value": 6595675.344529897,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2029,
+   "value": 6562245.612942323,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2030,
+   "value": 6475253.517584786,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "caseloads.winter_fuel_payment",
+   "year": 2031,
+   "value": 6396321.154249236,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "winter_fuel_payment",
+   "label": "Winter Fuel Payment"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2025,
+   "value": 195652682.01216725,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2026,
+   "value": 203249126.6687394,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2027,
+   "value": 207355049.2189332,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2028,
+   "value": 211194062.34368283,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2029,
+   "value": 216365284.1364411,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2030,
+   "value": 221685784.68137968,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.pawhp",
+   "year": 2031,
+   "value": 227092425.0723793,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2025,
+   "value": 829304.9473122358,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2026,
+   "value": 829534.2019373178,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2027,
+   "value": 829233.4100881219,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2028,
+   "value": 827297.3866233826,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2029,
+   "value": 830937.4991832376,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2030,
+   "value": 834676.7390937805,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.pawhp",
+   "year": 2031,
+   "value": 838267.4468256831,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pawhp",
+   "label": "Pension Age Winter Heating Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2025,
+   "value": 79297069929.09415,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2026,
+   "value": 83589492640.30864,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2027,
+   "value": 85214410402.33775,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2028,
+   "value": 86751184502.34996,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2029,
+   "value": 87589733492.11055,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2030,
+   "value": 84522628500.50685,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.universal_credit",
+   "year": 2031,
+   "value": 84959856870.21652,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2025,
+   "value": 6348321.546875536,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2026,
+   "value": 6360591.7880883515,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2027,
+   "value": 6371549.96376504,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2028,
+   "value": 6387956.646527201,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2029,
+   "value": 6368112.714898095,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2030,
+   "value": 6306509.543026373,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "caseloads.universal_credit",
+   "year": 2031,
+   "value": 6290932.269934744,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_credit",
+   "label": "Universal Credit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2025,
+   "value": 8156481113.662994,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2026,
+   "value": 8481531600.187535,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2027,
+   "value": 8730679159.952463,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2028,
+   "value": 8864953021.672188,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2029,
+   "value": 9128777068.594437,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2030,
+   "value": 9364726526.233978,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.housing_benefit",
+   "year": 2031,
+   "value": 7910651616.326078,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2025,
+   "value": 1231862.2140089273,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2026,
+   "value": 1232121.5733534694,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2027,
+   "value": 1233595.1880552769,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2028,
+   "value": 1219421.9009352624,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2029,
+   "value": 1224496.4099243134,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2030,
+   "value": 1225629.5051008165,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "caseloads.housing_benefit",
+   "year": 2031,
+   "value": 1040899.9826000333,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "housing_benefit",
+   "label": "Housing Benefit"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2025,
+   "value": 2113520041.1605535,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2026,
+   "value": 2160301971.6342654,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2027,
+   "value": 2208773724.380773,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2028,
+   "value": 2263288488.6852794,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2029,
+   "value": 2318590052.1342373,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2030,
+   "value": 2383255853.6602764,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.council_tax_reduction",
+   "year": 2031,
+   "value": 2140059095.905221,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2025,
+   "value": 3390071.4158337414,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2026,
+   "value": 3414428.1972658634,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2027,
+   "value": 3432134.60831666,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2028,
+   "value": 3454574.494795531,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2029,
+   "value": 3471370.6449995935,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2030,
+   "value": 3503850.509585738,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "caseloads.council_tax_reduction",
+   "year": 2031,
+   "value": 3284019.218611926,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "council_tax_reduction",
+   "label": "Council Tax Reduction"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2025,
+   "value": 2157918345.897898,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2026,
+   "value": 2215460941.2027354,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2027,
+   "value": 2267178997.307175,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2028,
+   "value": 2318791077.751506,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2029,
+   "value": 2374655132.983756,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2030,
+   "value": 2432303236.337222,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_income",
+   "year": 2031,
+   "value": 2490528072.8603873,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2025,
+   "value": 188801.45329512656,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2026,
+   "value": 189518.90480037034,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2027,
+   "value": 190220.12851785123,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2028,
+   "value": 190792.28100878,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2029,
+   "value": 191631.7696904242,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2030,
+   "value": 192494.11825346947,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "caseloads.esa_income",
+   "year": 2031,
+   "value": 193322.20888361335,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_income",
+   "label": "Employment and Support Allowance (income-related)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2025,
+   "value": 5185465918.024606,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2026,
+   "value": 5324889662.558813,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2027,
+   "value": 5451483839.668319,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2028,
+   "value": 5582755219.611585,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2029,
+   "value": 5719465848.776321,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2030,
+   "value": 5860107436.9060955,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.esa_contrib",
+   "year": 2031,
+   "value": 6003027242.14748,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2025,
+   "value": 716681.2252025008,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2026,
+   "value": 719404.6381566375,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2027,
+   "value": 722066.4522648603,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2028,
+   "value": 724954.6817337871,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2029,
+   "value": 728144.496014744,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2030,
+   "value": 731421.169972077,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.esa_contrib",
+   "year": 2031,
+   "value": 734567.6720936149,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "esa_contrib",
+   "label": "Employment and Support Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2025,
+   "value": 2091746.573322478,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_income",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2025,
+   "value": 428.6262260079384,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_income",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_income",
+   "label": "Jobseeker's Allowance (income-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2025,
+   "value": 297414211.9393128,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2026,
+   "value": 305410902.6767736,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2027,
+   "value": 312671758.9873353,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2028,
+   "value": 320200875.2165054,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2029,
+   "value": 328041963.6404445,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2030,
+   "value": 336108514.2782755,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.jsa_contrib",
+   "year": 2031,
+   "value": 344305739.25844115,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2025,
+   "value": 69247.07556176186,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2026,
+   "value": 69510.21602272987,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2027,
+   "value": 69767.40603733063,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2028,
+   "value": 70046.47099995613,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2029,
+   "value": 70354.67849779129,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2030,
+   "value": 70671.27628540993,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "caseloads.jsa_contrib",
+   "year": 2031,
+   "value": 70975.29594826698,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "jsa_contrib",
+   "label": "Jobseeker's Allowance (contribution-based)"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2025,
+   "value": 168861494.13127527,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.income_support",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2025,
+   "value": 34027.1418364048,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "caseloads.income_support",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "income_support",
+   "label": "Income Support"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.working_tax_credit",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.working_tax_credit",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "working_tax_credit",
+   "label": "Working Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.child_tax_credit",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "caseloads.child_tax_credit",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_tax_credit",
+   "label": "Child Tax Credit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.incapacity_benefit",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "caseloads.incapacity_benefit",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "incapacity_benefit",
+   "label": "Incapacity Benefit"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2025,
+   "value": 26930742886.35225,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2026,
+   "value": 28062341229.807953,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2027,
+   "value": 28813932611.682465,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2028,
+   "value": 29507723865.475834,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2029,
+   "value": 30230279635.690178,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2030,
+   "value": 30973577012.717224,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.pip",
+   "year": 2031,
+   "value": 31728969558.821815,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2025,
+   "value": 3561191.43436794,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2026,
+   "value": 3574724.089687288,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2027,
+   "value": 3587950.6320851147,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2028,
+   "value": 3602302.242634833,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2029,
+   "value": 3618152.4307922274,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2030,
+   "value": 3634434.2309794426,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "caseloads.pip",
+   "year": 2031,
+   "value": 3650069.225597918,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "pip",
+   "label": "Personal Independence Payment"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2025,
+   "value": 7484533549.186283,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2026,
+   "value": 7798317749.580545,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2027,
+   "value": 8007179295.781675,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2028,
+   "value": 8199978749.538992,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2029,
+   "value": 8400772001.314109,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2030,
+   "value": 8607328838.619286,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.dla",
+   "year": 2031,
+   "value": 8817246875.370478,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2025,
+   "value": 1334888.5791397095,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2026,
+   "value": 1339961.2010401338,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2027,
+   "value": 1344919.083814323,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2028,
+   "value": 1350298.687692389,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2029,
+   "value": 1356240.0184779614,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2030,
+   "value": 1362343.138695091,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "caseloads.dla",
+   "year": 2031,
+   "value": 1368203.8070302308,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "dla",
+   "label": "Disability Living Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2025,
+   "value": 8738849002.944973,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2026,
+   "value": 9105426589.661386,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2027,
+   "value": 9349296216.992481,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2028,
+   "value": 9574411518.877106,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2029,
+   "value": 9808860827.118614,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2030,
+   "value": 10050039243.53858,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.attendance_allowance",
+   "year": 2031,
+   "value": 10295142509.771086,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2025,
+   "value": 1716598.859297052,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2026,
+   "value": 1723121.9867911339,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2027,
+   "value": 1729497.5712008923,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2028,
+   "value": 1736415.4675818533,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2029,
+   "value": 1744055.7271453738,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2030,
+   "value": 1751904.025848925,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "caseloads.attendance_allowance",
+   "year": 2031,
+   "value": 1759440.5596084595,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "attendance_allowance",
+   "label": "Attendance Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2025,
+   "value": 4648737205.678978,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2026,
+   "value": 4842862964.368323,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2027,
+   "value": 4972568907.329457,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2028,
+   "value": 5092300033.036156,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2029,
+   "value": 5216995990.439329,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2030,
+   "value": 5345270444.763329,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carers_allowance",
+   "year": 2031,
+   "value": 5475632307.34242,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2025,
+   "value": 1073214.7707251012,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2026,
+   "value": 1077293.0261095166,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2027,
+   "value": 1081279.032247901,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2028,
+   "value": 1085604.089460224,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2029,
+   "value": 1090380.7663949132,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2030,
+   "value": 1095287.5083169043,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "caseloads.carers_allowance",
+   "year": 2031,
+   "value": 1099999.3295924067,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carers_allowance",
+   "label": "Carer's Allowance"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2025,
+   "value": 375132605.30346316,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2026,
+   "value": 390797693.02699834,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2027,
+   "value": 401264392.71339667,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2028,
+   "value": 410926172.48663545,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2029,
+   "value": 420988584.3188077,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2030,
+   "value": 431339766.7553348,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.carer_support_payment",
+   "year": 2031,
+   "value": 441859394.4117867,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2025,
+   "value": 86603.70229154825,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2026,
+   "value": 86932.79830861092,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2027,
+   "value": 87254.451836586,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2028,
+   "value": 87603.46610051394,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2029,
+   "value": 87988.92237108946,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2030,
+   "value": 88384.87467557192,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.carer_support_payment",
+   "year": 2031,
+   "value": 88765.09786373377,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "carer_support_payment",
+   "label": "Carer Support Payment (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2026,
+   "value": 52889916.61334165,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2027,
+   "value": 53085610.62761456,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2028,
+   "value": 53297950.914309174,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2029,
+   "value": 53532462.518737875,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2030,
+   "value": 53773359.910451815,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.scottish_carer_supplement",
+   "year": 2031,
+   "value": 54004687.70741227,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2026,
+   "value": 86932.79830861092,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2027,
+   "value": 87254.451836586,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2028,
+   "value": 87603.46610051394,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2029,
+   "value": 87988.92237108946,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2030,
+   "value": 88384.87467557192,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "caseloads.scottish_carer_supplement",
+   "year": 2031,
+   "value": 88765.09786373377,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_carer_supplement",
+   "label": "Carer's Allowance Supplement (Scotland)"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2025,
+   "value": 22916850.46898842,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2026,
+   "value": 23874085.23946771,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2027,
+   "value": 24513501.268621735,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2028,
+   "value": 25103746.46338744,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2029,
+   "value": 25718463.554498497,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2030,
+   "value": 26350824.69866039,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.sda",
+   "year": 2031,
+   "value": 26993475.082385655,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2025,
+   "value": 3832.2492423057556,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2026,
+   "value": 3846.8119764328003,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2027,
+   "value": 3861.0452857017517,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2028,
+   "value": 3876.4891839027405,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2029,
+   "value": 3893.5458583831787,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2030,
+   "value": 3911.066900253296,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "caseloads.sda",
+   "year": 2031,
+   "value": 3927.8920154571533,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "sda",
+   "label": "Severe Disablement Allowance"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2025,
+   "value": 507122677.50677097,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2026,
+   "value": 520757913.07925874,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2027,
+   "value": 533138408.2492215,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2028,
+   "value": 545976365.926789,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2029,
+   "value": 559346215.9575605,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2030,
+   "value": 573100581.8244832,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.iidb",
+   "year": 2031,
+   "value": 587077716.6684005,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2025,
+   "value": 136309.47050744295,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2026,
+   "value": 136827.45147165656,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2027,
+   "value": 137333.71539336443,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2028,
+   "value": 137883.04356488585,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2029,
+   "value": 138489.7313208878,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2030,
+   "value": 139112.9398522377,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "caseloads.iidb",
+   "year": 2031,
+   "value": 139711.38968542218,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "iidb",
+   "label": "Industrial Injuries Disablement Benefit"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2025,
+   "value": 568151093.7335573,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2026,
+   "value": 583427211.5685618,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2027,
+   "value": 597297627.7972465,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2028,
+   "value": 611680516.7185693,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2029,
+   "value": 626659337.1968452,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2030,
+   "value": 642068929.7703295,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.afcs",
+   "year": 2031,
+   "value": 657728126.7655065,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2025,
+   "value": 87231.42297008634,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2026,
+   "value": 87562.9048769474,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2027,
+   "value": 87886.88816124201,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2028,
+   "value": 88238.43054047227,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2029,
+   "value": 88626.6806499958,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2030,
+   "value": 89025.50376203656,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "caseloads.afcs",
+   "year": 2031,
+   "value": 89408.48312401772,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "afcs",
+   "label": "Armed Forces Compensation Scheme"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2025,
+   "value": 16225320972.639776,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2026,
+   "value": 16896152580.691387,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2027,
+   "value": 17341380924.037914,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2028,
+   "value": 17755008028.657654,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2029,
+   "value": 18184587501.076538,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2030,
+   "value": 18623367766.380272,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.child_benefit",
+   "year": 2031,
+   "value": 19051556819.483387,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2025,
+   "value": 8144503.8247400075,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2026,
+   "value": 8170465.289068207,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2027,
+   "value": 8197312.676083803,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2028,
+   "value": 8228525.797302961,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2029,
+   "value": 8262403.058013514,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2030,
+   "value": 8295971.741483018,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "caseloads.child_benefit",
+   "year": 2031,
+   "value": 8318393.685991436,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "child_benefit",
+   "label": "Child Benefit"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2025,
+   "value": 497726869.8419192,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2026,
+   "value": 519612809.90459955,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2027,
+   "value": 533556568.34392536,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2028,
+   "value": 546831672.8782737,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2029,
+   "value": 557690457.6552503,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2030,
+   "value": 571385467.1006676,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.scottish_child_payment",
+   "year": 2031,
+   "value": 579782864.4065932,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2025,
+   "value": 352547.7069185972,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2026,
+   "value": 354345.8819241524,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2027,
+   "value": 355656.9695440531,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2028,
+   "value": 357079.5769625902,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2029,
+   "value": 356899.05693626404,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2030,
+   "value": 358505.11331129074,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "caseloads.scottish_child_payment",
+   "year": 2031,
+   "value": 356640.845736742,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "scottish_child_payment",
+   "label": "Scottish Child Payment"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.two_child_limit_payment",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "caseloads.two_child_limit_payment",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "two_child_limit_payment",
+   "label": "Two Child Limit Payment (Scotland, counterfactual)"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2025,
+   "value": 53970826.290031455,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2026,
+   "value": 54175917.177720994,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2027,
+   "value": 54376369.043138646,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2028,
+   "value": 54593871.5011113,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2029,
+   "value": 54834085.12924674,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2030,
+   "value": 55080840.175692044,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.healthy_start_vouchers",
+   "year": 2031,
+   "value": 55317793.086847685,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2025,
+   "value": 208602.1595891416,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2026,
+   "value": 209394.85441070795,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2027,
+   "value": 210169.61887291074,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2028,
+   "value": 211010.2855282426,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2029,
+   "value": 211938.73312041163,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2030,
+   "value": 212892.46397477388,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "caseloads.healthy_start_vouchers",
+   "year": 2031,
+   "value": 213808.30855458975,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "healthy_start_vouchers",
+   "label": "Healthy Start vouchers"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.maternity_allowance",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "caseloads.maternity_allowance",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "maternity_allowance",
+   "label": "Maternity Allowance"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2025,
+   "value": 3337974284.302048,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2026,
+   "value": 3427723791.796866,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2027,
+   "value": 3509214573.900618,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2028,
+   "value": 3593716240.856939,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2029,
+   "value": 3681719191.086521,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2030,
+   "value": 3772252863.671072,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_maternity_pay",
+   "year": 2031,
+   "value": 3864252917.3662605,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2025,
+   "value": 321312.8473469913,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2026,
+   "value": 322533.84839725494,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2027,
+   "value": 323727.22941863537,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2028,
+   "value": 325022.1207540333,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2029,
+   "value": 326452.22118473053,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2030,
+   "value": 327921.2663208842,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_maternity_pay",
+   "year": 2031,
+   "value": 329331.95074787736,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_maternity_pay",
+   "label": "Statutory Maternity Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2025,
+   "value": 1179772370.051021,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2026,
+   "value": 1211493383.3111763,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2027,
+   "value": 1240295431.5737867,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2028,
+   "value": 1270161726.4028587,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2029,
+   "value": 1301265465.0001216,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2030,
+   "value": 1333263686.1531434,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.statutory_sick_pay",
+   "year": 2031,
+   "value": 1365780194.8969457,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2025,
+   "value": 185810.46695950627,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2026,
+   "value": 186516.55576607585,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2027,
+   "value": 187206.66695040464,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2028,
+   "value": 187955.48840495944,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2029,
+   "value": 188782.49710434675,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2030,
+   "value": 189632.02460759878,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "caseloads.statutory_sick_pay",
+   "year": 2031,
+   "value": 190447.80656796694,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "statutory_sick_pay",
+   "label": "Statutory Sick Pay"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2025,
+   "value": 14081577.514007688,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2026,
+   "value": 14135088.059857488,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2027,
+   "value": 14187388.068765402,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2028,
+   "value": 14244136.843547225,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2029,
+   "value": 14306811.286181211,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2030,
+   "value": 14371192.14501977,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.ssmg",
+   "year": 2031,
+   "value": 14433015.649303794,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2025,
+   "value": 28163.155028015375,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2026,
+   "value": 28270.176119714975,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2027,
+   "value": 28374.776137530804,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2028,
+   "value": 28488.27368709445,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2029,
+   "value": 28613.622572362423,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2030,
+   "value": 28742.38429003954,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "caseloads.ssmg",
+   "year": 2031,
+   "value": 28866.031298607588,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "ssmg",
+   "label": "Sure Start Maternity Grant"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2025,
+   "value": 654447661.0694321,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2026,
+   "value": 672044090.7987709,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2027,
+   "value": 688021282.8463899,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2028,
+   "value": 704588777.7902418,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2029,
+   "value": 721842764.085712,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2030,
+   "value": 739592896.5915165,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.bsp",
+   "year": 2031,
+   "value": 757630569.6200083,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2025,
+   "value": 92227.57971167564,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2026,
+   "value": 92578.04749596119,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2027,
+   "value": 92920.58816957474,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2028,
+   "value": 93292.26550346613,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2029,
+   "value": 93702.75265604258,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2030,
+   "value": 94124.41794991493,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "caseloads.bsp",
+   "year": 2031,
+   "value": 94529.3324996233,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bsp",
+   "label": "Bereavement Support Payment"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2025,
+   "value": 563525940.8773441,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2026,
+   "value": 568299173.1681643,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2027,
+   "value": 573445937.53699,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2028,
+   "value": 585862809.08509,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2029,
+   "value": 561084451.3935778,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2030,
+   "value": 580251499.0481504,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.tax_free_childcare",
+   "year": 2031,
+   "value": 560322627.8579925,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2025,
+   "value": 974133.2423208505,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2026,
+   "value": 979682.5571617931,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2027,
+   "value": 971293.5103004575,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2028,
+   "value": 976896.6010332257,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2029,
+   "value": 957558.250394702,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2030,
+   "value": 966774.040873915,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "caseloads.tax_free_childcare",
+   "year": 2031,
+   "value": 914534.0746401399,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "tax_free_childcare",
+   "label": "Tax-Free Childcare"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2025,
+   "value": 4769443952.411871,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2026,
+   "value": 5307606110.253133,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2027,
+   "value": 5433878200.598173,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2028,
+   "value": 5575606222.716693,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2029,
+   "value": 5682980459.519588,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2030,
+   "value": 5856928114.420198,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.extended_childcare_entitlement",
+   "year": 2031,
+   "value": 5926506204.517755,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2025,
+   "value": 962176.1845641136,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2026,
+   "value": 957668.9594607353,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2027,
+   "value": 959484.9431653023,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2028,
+   "value": 963973.5987744331,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2029,
+   "value": 955768.6225590706,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2030,
+   "value": 963198.3530750275,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.extended_childcare_entitlement",
+   "year": 2031,
+   "value": 953890.9335918427,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "extended_childcare_entitlement",
+   "label": "Extended childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2025,
+   "value": 1303677004.8374069,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2026,
+   "value": 1372546985.2564154,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2027,
+   "value": 1412467299.31001,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2028,
+   "value": 1447975936.2291465,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2029,
+   "value": 1528607528.5243466,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2030,
+   "value": 1568458954.5961401,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.universal_childcare_entitlement",
+   "year": 2031,
+   "value": 1607433548.773115,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2025,
+   "value": 379484.6813406944,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2026,
+   "value": 386394.35359191895,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2027,
+   "value": 388693.47555303574,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2028,
+   "value": 390652.560110569,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2029,
+   "value": 404320.32845020294,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2030,
+   "value": 406727.4603328705,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.universal_childcare_entitlement",
+   "year": 2031,
+   "value": 408660.8797736168,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "universal_childcare_entitlement",
+   "label": "Universal childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2025,
+   "value": 443196400.7338863,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2026,
+   "value": 460006640.4923033,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2027,
+   "value": 472326964.79148394,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2028,
+   "value": 483699822.07395244,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2029,
+   "value": 495544205.88908577,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2030,
+   "value": 497331070.06473446,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.targeted_childcare_entitlement",
+   "year": 2031,
+   "value": 504378844.1796443,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2025,
+   "value": 91615.18099975586,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2026,
+   "value": 91963.32138204575,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2027,
+   "value": 92303.58706235886,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2028,
+   "value": 92672.79612493515,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2029,
+   "value": 93080.55707216263,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2030,
+   "value": 91584.69786310196,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "caseloads.targeted_childcare_entitlement",
+   "year": 2031,
+   "value": 91061.30740070343,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "targeted_childcare_entitlement",
+   "label": "Targeted childcare entitlement"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.care_to_learn",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "caseloads.care_to_learn",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "care_to_learn",
+   "label": "Care to Learn"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.childcare_grant",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "caseloads.childcare_grant",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "childcare_grant",
+   "label": "Childcare Grant"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2025,
+   "value": 200089205.6251793,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2026,
+   "value": 200849551.41910172,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2027,
+   "value": 201592698.65725708,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2028,
+   "value": 202399059.3290062,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2029,
+   "value": 203289618.08014297,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2030,
+   "value": 167103353.08280182,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.parents_learning_allowance",
+   "year": 2031,
+   "value": 167822216.12641907,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2025,
+   "value": 98858.3031744957,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2026,
+   "value": 99233.96809244156,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2027,
+   "value": 99601.13570022583,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2028,
+   "value": 99999.53524160385,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2029,
+   "value": 100439.53462457657,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2030,
+   "value": 82560.94519901276,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "caseloads.parents_learning_allowance",
+   "year": 2031,
+   "value": 82916.11468696594,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "parents_learning_allowance",
+   "label": "Parents' Learning Allowance"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2025,
+   "value": 37181344.76393461,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2026,
+   "value": 37322635.825378895,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2027,
+   "value": 37460730.62564373,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2028,
+   "value": 37610571.710464954,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2029,
+   "value": 37776058.51188898,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2030,
+   "value": 37946051.452560425,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.adult_dependants_grant",
+   "year": 2031,
+   "value": 38109291.55965805,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2025,
+   "value": 10488.390624523163,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2026,
+   "value": 10528.247059345245,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2027,
+   "value": 10567.201869010925,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2028,
+   "value": 10609.470158100128,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2029,
+   "value": 10656.151907444,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2030,
+   "value": 10704.104782104492,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "caseloads.adult_dependants_grant",
+   "year": 2031,
+   "value": 10750.152767181396,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "adult_dependants_grant",
+   "label": "Adult Dependants' Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.travel_grant",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "caseloads.travel_grant",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "travel_grant",
+   "label": "Travel Grant"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2025,
+   "value": 31398271.13342285,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2026,
+   "value": 31517584.991455078,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2027,
+   "value": 31634201.431274414,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2028,
+   "value": 31760736.053466797,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2029,
+   "value": 31900483.795166016,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2030,
+   "value": 32044037.338256836,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.disabled_students_allowance",
+   "year": 2031,
+   "value": 32181888.01574707,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2025,
+   "value": 1744.3483963012695,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2026,
+   "value": 1750.9769439697266,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2027,
+   "value": 1757.4556350708008,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2028,
+   "value": 1764.485336303711,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2029,
+   "value": 1772.2490997314453,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2030,
+   "value": 1780.2242965698242,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "caseloads.disabled_students_allowance",
+   "year": 2031,
+   "value": 1787.882667541504,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disabled_students_allowance",
+   "label": "Disabled Students' Allowance"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.bursary_fund_16_to_19",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "caseloads.bursary_fund_16_to_19",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "bursary_fund_16_to_19",
+   "label": "16 to 19 Bursary Fund"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2025,
+   "value": 205232332361.1096,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2026,
+   "value": 206012222586.4446,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2027,
+   "value": 206774471228.05975,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2028,
+   "value": 207601558499.668,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2029,
+   "value": 208515008515.17273,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2030,
+   "value": 209453332677.40167,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.nhs_spending",
+   "year": 2031,
+   "value": 210354381149.51398,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "nhs_spending",
+   "label": "NHS spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2025,
+   "value": 33304098.421608984,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2026,
+   "value": 33430655.237596173,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2027,
+   "value": 33554349.3340033,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2028,
+   "value": 33688564.45852884,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2029,
+   "value": 33836794.51014231,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2030,
+   "value": 33989060.95758362,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dfe_education_spending",
+   "year": 2031,
+   "value": 34135278.81945166,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dfe_education_spending",
+   "label": "State education spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2025,
+   "value": 25151126510.800682,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2026,
+   "value": 25246701591.88542,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2027,
+   "value": 25340114855.52831,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2028,
+   "value": 25441474019.590816,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2029,
+   "value": 25553416910.70827,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2030,
+   "value": 25668408066.936726,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.dft_subsidy_spending",
+   "year": 2031,
+   "value": 30944615266.675636,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "dft_subsidy_spending",
+   "label": "Transport subsidy spending"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.cost_of_living_support_payment",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "caseloads.cost_of_living_support_payment",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "cost_of_living_support_payment",
+   "label": "Cost-of-living support payment"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.energy_bills_rebate",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "caseloads.energy_bills_rebate",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "energy_bills_rebate",
+   "label": "Energy Bills Rebate"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.epg_subsidy",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "caseloads.epg_subsidy",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "epg_subsidy",
+   "label": "Energy Price Guarantee subsidy"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.basic_income",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "caseloads.basic_income",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "basic_income",
+   "label": "Basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.disability_basic_income",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "caseloads.disability_basic_income",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "count",
+   "kind": "caseload",
+   "partition": "disability_basic_income",
+   "label": "Disability basic income"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2025,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2026,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2027,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2028,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2029,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2030,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "spending.other_public_spending_budget_change",
+   "year": 2031,
+   "value": 0.0,
+   "unit": "gbp",
+   "kind": "spending",
+   "partition": "other_public_spending_budget_change",
+   "label": "Other public spending budget change"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2025,
+   "value": 3130000070.860857,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2026,
+   "value": 3129999934.869108,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2027,
+   "value": 3129999659.4758515,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2028,
+   "value": 3129999971.286581,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2029,
+   "value": 3129999604.8102255,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2030,
+   "value": 3129999725.6754894,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "double_counts.corporate_sdlt",
+   "year": 2031,
+   "value": 3129999838.862631,
+   "unit": "gbp",
+   "kind": "double_count",
+   "partition": null,
+   "label": "corporate_sdlt, counted again inside expected_sdlt"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2025,
+   "value": 978966763914.5134,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2026,
+   "value": 1021443831661.053,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2027,
+   "value": 1060540354380.0239,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2028,
+   "value": 1096036577748.5464,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2029,
+   "value": 1133645091567.8396,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2030,
+   "value": 1172964045336.6704,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.gov_tax",
+   "year": 2031,
+   "value": 1217259329604.8296,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2025,
+   "value": 507289555585.61816,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2026,
+   "value": 534626593895.75964,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2027,
+   "value": 560967047377.9646,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2028,
+   "value": 583794322841.5969,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2029,
+   "value": 606736767704.2439,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2030,
+   "value": 631883079371.0518,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.household_tax",
+   "year": 2031,
+   "value": 660238702302.3933,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2025,
+   "value": 499459280987.149,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2026,
+   "value": 526499323434.2302,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2027,
+   "value": 552613929618.1678,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2028,
+   "value": 574737815078.9714,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2029,
+   "value": 597441283752.3987,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2030,
+   "value": 622323704519.8606,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.pre_budget_change_household_tax",
+   "year": 2031,
+   "value": 651647666882.8048,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "pre_budget_change_household_tax"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2025,
+   "value": 538033639372.52985,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2026,
+   "value": 552208058548.4539,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2027,
+   "value": 559025643057.2411,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2028,
+   "value": 566053472893.2496,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2029,
+   "value": 574523316911.5459,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2030,
+   "value": 579305085703.9962,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.gov_spending",
+   "year": 2031,
+   "value": 589946933468.4061,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_spending"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2025,
+   "value": 538389684305.1851,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2026,
+   "value": 552578598164.1414,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2027,
+   "value": 559406220932.2922,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2028,
+   "value": 566443704502.9469,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2029,
+   "value": 574919476375.7018,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2030,
+   "value": 579709866219.54,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "model.household_benefits",
+   "year": 2031,
+   "value": 590354941802.2732,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "household_benefits"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2025,
+   "value": 975836764403.4154,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2026,
+   "value": 1018313832613.0851,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2027,
+   "value": 1057410354079.6709,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2028,
+   "value": 1092906577896.0876,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2029,
+   "value": 1130515092103.1807,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2030,
+   "value": 1169834045361.7195,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.receipts",
+   "year": 2031,
+   "value": 1214129326993.4458,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government receipts, each component once"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2025,
+   "value": 538033639676.7738,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2026,
+   "value": 552208059037.354,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2027,
+   "value": 559025643307.1086,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2028,
+   "value": 566053472435.6144,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2029,
+   "value": 574523317043.8452,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2030,
+   "value": 579305085287.3065,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.spending",
+   "year": 2031,
+   "value": 589946932722.4471,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government spending"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2025,
+   "value": 437803124726.64154,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2026,
+   "value": 466105773575.731,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2027,
+   "value": 498384710772.5623,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2028,
+   "value": 526853105460.47296,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2029,
+   "value": 555991775059.3357,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2030,
+   "value": 590528960074.4128,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.balance",
+   "year": 2031,
+   "value": 624182394270.9988,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Government balance, each receipt once"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2025,
+   "value": 551697696.1319505,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2026,
+   "value": 573788727.0823206,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2027,
+   "value": 587932937.3870641,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2028,
+   "value": 601425544.379385,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2029,
+   "value": 612524542.784497,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2030,
+   "value": 626466307.2763597,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "balance.household_only_benefits",
+   "year": 2031,
+   "value": 635100657.4934409,
+   "unit": "gbp",
+   "kind": "balance",
+   "partition": null,
+   "label": "Benefits in household_benefits but not in gov_spending"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2025,
+   "value": 440933124873.35913,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2026,
+   "value": 469235773431.7386,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2027,
+   "value": 501514711424.7265,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2028,
+   "value": 529983106859.2174,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2029,
+   "value": 559121775135.3358,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2030,
+   "value": 593658959431.4342,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "model.gov_balance",
+   "year": 2031,
+   "value": 627312396103.4205,
+   "unit": "gbp",
+   "kind": "model_total",
+   "partition": null,
+   "label": "gov_balance"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2025,
+   "value": 1671225658932.162,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2026,
+   "value": 1733933903918.711,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2027,
+   "value": 1786792811393.0068,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2028,
+   "value": 1837490253867.1548,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2029,
+   "value": 1899443273442.607,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2030,
+   "value": 1958070419692.7725,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_market_income",
+   "year": 2031,
+   "value": 2030307938225.9048,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household market income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2025,
+   "value": 1662845456756.1794,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2026,
+   "value": 1710908123095.9045,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2027,
+   "value": 1743115474011.6213,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2028,
+   "value": 1776966674392.025,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2029,
+   "value": 1812648751541.618,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2030,
+   "value": 1849604189724.0015,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.household_net_income",
+   "year": 2031,
+   "value": 1901814852667.9023,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2025,
+   "value": 1477497599801.4927,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2026,
+   "value": 1524365447451.7742,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2027,
+   "value": 1557424708805.0208,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2028,
+   "value": 1590631579327.68,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2029,
+   "value": 1624770529416.2014,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2030,
+   "value": 1659800503787.3513,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "household.hbai_household_net_income",
+   "year": 2031,
+   "value": 1706265955643.35,
+   "unit": "gbp",
+   "kind": "household_total",
+   "partition": null,
+   "label": "Household net income (HBAI definition)"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2025,
+   "value": 0.15790499791115192,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2026,
+   "value": 0.15015054717546153,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2027,
+   "value": 0.1549582355031067,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2028,
+   "value": 0.15586605016301394,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2029,
+   "value": 0.15679856209322698,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2030,
+   "value": 0.15996963062767566,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.all",
+   "year": 2031,
+   "value": 0.15916276272552643,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2025,
+   "value": 11008006.535612345,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2026,
+   "value": 10507198.299471915,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2027,
+   "value": 10883751.14921306,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2028,
+   "value": 10991302.525305092,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2029,
+   "value": 11105712.280909285,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2030,
+   "value": 11381299.168434069,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.all",
+   "year": 2031,
+   "value": 11372607.559264764,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2025,
+   "value": 0.23024237894583952,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2026,
+   "value": 0.20587063573611564,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2027,
+   "value": 0.21312205523439626,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2028,
+   "value": 0.21148974274821794,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2029,
+   "value": 0.2129381736428246,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2030,
+   "value": 0.2171366623415043,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.children",
+   "year": 2031,
+   "value": 0.2168645562087503,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2025,
+   "value": 3458586.670649603,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2026,
+   "value": 3104238.0042080134,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2027,
+   "value": 3225469.4537050426,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2028,
+   "value": 3213568.3187645674,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2029,
+   "value": 3249813.6879644096,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2030,
+   "value": 3328802.6769966334,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.children",
+   "year": 2031,
+   "value": 3338933.418882489,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2025,
+   "value": 0.13730515589263637,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2026,
+   "value": 0.13444889083444914,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2027,
+   "value": 0.13964335698946462,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2028,
+   "value": 0.14095635404241666,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2029,
+   "value": 0.14196947141222863,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2030,
+   "value": 0.1453637344339517,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.working_age_adults",
+   "year": 2031,
+   "value": 0.1424988521749652,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2025,
+   "value": 5861346.487035349,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2026,
+   "value": 5780330.343414247,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2027,
+   "value": 6067671.953066379,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2028,
+   "value": 6169748.167899072,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2029,
+   "value": 6241435.053964257,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2030,
+   "value": 6419415.955847114,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.working_age_adults",
+   "year": 2031,
+   "value": 6319971.187485427,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2025,
+   "value": 0.14063908804109557,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2026,
+   "value": 0.13628217188144012,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2027,
+   "value": 0.1365202033742876,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2028,
+   "value": 0.1391945511558675,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2029,
+   "value": 0.1391430413120495,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2030,
+   "value": 0.14011702215368302,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_bhc.pensioners",
+   "year": 2031,
+   "value": 0.14640454331430913,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2025,
+   "value": 1688073.3779273927,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2026,
+   "value": 1622629.9518496543,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2027,
+   "value": 1590609.7424416393,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2028,
+   "value": 1607986.0386414528,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2029,
+   "value": 1614463.5389806181,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2030,
+   "value": 1633080.5355903208,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_bhc.pensioners",
+   "year": 2031,
+   "value": 1713702.9528968483,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2025,
+   "value": 0.2084922594723321,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2026,
+   "value": 0.2033983080431449,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2027,
+   "value": 0.2085258023862174,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2028,
+   "value": 0.21083623822359046,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2029,
+   "value": 0.21373904608988534,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2030,
+   "value": 0.2213187707909106,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.all",
+   "year": 2031,
+   "value": 0.2195671141063277,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2025,
+   "value": 14534588.42504394,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2026,
+   "value": 14233357.097853199,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2027,
+   "value": 14646158.908514857,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2028,
+   "value": 14867669.227449715,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2029,
+   "value": 15138686.971242383,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2030,
+   "value": 15746083.378938913,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.all",
+   "year": 2031,
+   "value": 15688660.958704859,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2025,
+   "value": 0.31720685844300495,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2026,
+   "value": 0.30038158814530413,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2027,
+   "value": 0.3065600330656107,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2028,
+   "value": 0.3089024392477794,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2029,
+   "value": 0.3131465359163617,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2030,
+   "value": 0.32764715169339104,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.children",
+   "year": 2031,
+   "value": 0.32587820140005935,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2025,
+   "value": 4764923.892259136,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2026,
+   "value": 4529329.490584716,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2027,
+   "value": 4639595.002462,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2028,
+   "value": 4693745.802781373,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2029,
+   "value": 4779170.786289513,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2030,
+   "value": 5022978.173772946,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.children",
+   "year": 2031,
+   "value": 5017351.0884489715,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2025,
+   "value": 0.19375179335562778,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2026,
+   "value": 0.1915301826384273,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2027,
+   "value": 0.19779351466886932,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2028,
+   "value": 0.19985745387449372,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2029,
+   "value": 0.20299186364561206,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2030,
+   "value": 0.21009860237041597,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.working_age_adults",
+   "year": 2031,
+   "value": 0.205233615867211,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2025,
+   "value": 8270966.854513526,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2026,
+   "value": 8234413.236980796,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2027,
+   "value": 8594366.300899416,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2028,
+   "value": 8747886.310340255,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2029,
+   "value": 8924175.886719018,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2030,
+   "value": 9278176.056839228,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.working_age_adults",
+   "year": 2031,
+   "value": 9102322.714793772,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2025,
+   "value": 0.1248615596202156,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2026,
+   "value": 0.12343063061462466,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2027,
+   "value": 0.12120729498631465,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2028,
+   "value": 0.12344422855077232,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2029,
+   "value": 0.12370524920005636,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2030,
+   "value": 0.12397378149719714,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.absolute_ahc.pensioners",
+   "year": 2031,
+   "value": 0.13404122784123754,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2025,
+   "value": 1498697.6782712787,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2026,
+   "value": 1469614.3702876866,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2027,
+   "value": 1412197.6051534414,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2028,
+   "value": 1426037.1143280864,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2029,
+   "value": 1435340.2982338518,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2030,
+   "value": 1444929.1483267397,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.absolute_ahc.pensioners",
+   "year": 2031,
+   "value": 1568987.155462116,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Absolute poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2025,
+   "value": 0.17062007668867718,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2026,
+   "value": 0.1647051950949729,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2027,
+   "value": 0.1644876206126917,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2028,
+   "value": 0.16684150829295824,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2029,
+   "value": 0.1684367435335271,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2030,
+   "value": 0.1631533914075491,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.all",
+   "year": 2031,
+   "value": 0.169943728112801,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2025,
+   "value": 11894410.842856497,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2026,
+   "value": 11525699.895011187,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2027,
+   "value": 11553063.469406977,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2028,
+   "value": 11765265.684914708,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2029,
+   "value": 11930020.187968686,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2030,
+   "value": 11607812.999679968,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.all",
+   "year": 2031,
+   "value": 12142936.53797774,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2025,
+   "value": 0.25579913589553366,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2026,
+   "value": 0.23345621726435167,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2027,
+   "value": 0.22912843225646004,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2028,
+   "value": 0.23084457664090657,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2029,
+   "value": 0.23197251050977175,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2030,
+   "value": 0.2220824501863291,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.children",
+   "year": 2031,
+   "value": 0.23445025294767866,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2025,
+   "value": 3842487.581228867,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2026,
+   "value": 3520189.556705728,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2027,
+   "value": 3467715.9921610057,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2028,
+   "value": 3507663.3429688513,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2029,
+   "value": 3540311.3823575675,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2030,
+   "value": 3404623.8287089914,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.children",
+   "year": 2031,
+   "value": 3609689.837369889,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2025,
+   "value": 0.14589881032453236,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2026,
+   "value": 0.14416151943670144,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2027,
+   "value": 0.1465298878616869,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2028,
+   "value": 0.14949165839067746,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2029,
+   "value": 0.15186122947304564,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2030,
+   "value": 0.14784727703111472,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.working_age_adults",
+   "year": 2031,
+   "value": 0.15069757220037083,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2025,
+   "value": 6228196.412573293,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2026,
+   "value": 6197903.1584480405,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2027,
+   "value": 6366900.008937746,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2028,
+   "value": 6543343.801262975,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2029,
+   "value": 6676308.586224228,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2030,
+   "value": 6529091.818519026,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.working_age_adults",
+   "year": 2031,
+   "value": 6683592.883688301,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2025,
+   "value": 0.15194083636457575,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2026,
+   "value": 0.15181812223954255,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2027,
+   "value": 0.1474923682419708,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2028,
+   "value": 0.14839398004790294,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2029,
+   "value": 0.1476699298274378,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2030,
+   "value": 0.14363623269573814,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_bhc.pensioners",
+   "year": 2031,
+   "value": 0.1580190556933559,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2025,
+   "value": 1823726.8490543365,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2026,
+   "value": 1807607.179857418,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2027,
+   "value": 1718447.4683082253,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2028,
+   "value": 1714258.540682882,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2029,
+   "value": 1713400.2193868905,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2030,
+   "value": 1674097.3524519503,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_bhc.pensioners",
+   "year": 2031,
+   "value": 1849653.8169195503,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, before housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2025,
+   "value": 0.21330212332422502,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2026,
+   "value": 0.2105464759711648,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2027,
+   "value": 0.21119415618195903,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2028,
+   "value": 0.2124423499774284,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2029,
+   "value": 0.2140000786845929,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2030,
+   "value": 0.2204001169903213,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.all",
+   "year": 2031,
+   "value": 0.2194456696036841,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2025,
+   "value": 14869897.714917302,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2026,
+   "value": 14733569.846394584,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2027,
+   "value": 14833575.205535918,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2028,
+   "value": 14980928.401961535,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2029,
+   "value": 15157175.360766247,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2030,
+   "value": 15680724.262363464,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.all",
+   "year": 2031,
+   "value": 15679983.42229405,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: everyone"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2025,
+   "value": 0.3248406624741081,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2026,
+   "value": 0.3131278902007049,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2027,
+   "value": 0.30979319184398263,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2028,
+   "value": 0.3102686929501225,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2029,
+   "value": 0.3134903767691043,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2030,
+   "value": 0.3252235647703323,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.children",
+   "year": 2031,
+   "value": 0.3257920669222945,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2025,
+   "value": 4879595.105218306,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2026,
+   "value": 4721525.697256014,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2027,
+   "value": 4688526.845143169,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2028,
+   "value": 4714505.909423858,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2029,
+   "value": 4784418.406716645,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2030,
+   "value": 4985823.496389523,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.children",
+   "year": 2031,
+   "value": 5016024.927589148,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: children"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2025,
+   "value": 0.19713460206621386,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2026,
+   "value": 0.19751408902821108,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2027,
+   "value": 0.19980334977328973,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2028,
+   "value": 0.20092494834158509,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2029,
+   "value": 0.20323204364702235,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2030,
+   "value": 0.2095568415310035,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.working_age_adults",
+   "year": 2031,
+   "value": 0.2051403722499053,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2025,
+   "value": 8415373.769339144,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2026,
+   "value": 8491677.952683121,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2027,
+   "value": 8681696.055470556,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2028,
+   "value": 8794611.213785172,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2029,
+   "value": 8934734.972874314,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2030,
+   "value": 9254251.326298147,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.working_age_adults",
+   "year": 2031,
+   "value": 9098187.264116138,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: working-age adults"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2025,
+   "value": 0.1312126349091424,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2026,
+   "value": 0.12769319774473814,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2027,
+   "value": 0.12559784398751747,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2028,
+   "value": 0.12740664745148308,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2029,
+   "value": 0.12393637087688392,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2030,
+   "value": 0.1236065858698001,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty.relative_ahc.pensioners",
+   "year": 2031,
+   "value": 0.13376648593709345,
+   "unit": "rate",
+   "kind": "poverty",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2025,
+   "value": 1574928.8403598517,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2026,
+   "value": 1520366.1964554489,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2027,
+   "value": 1463352.3049221933,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2028,
+   "value": 1471811.2787525058,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2029,
+   "value": 1438021.9811752886,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2030,
+   "value": 1440649.439675793,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "poverty_count.relative_ahc.pensioners",
+   "year": 2031,
+   "value": 1565771.230588764,
+   "unit": "count",
+   "kind": "poverty_count",
+   "partition": null,
+   "label": "Relative poverty, after housing costs: pensioners"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2025,
+   "value": 0.31169660677586286,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2026,
+   "value": 0.3094015441232131,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2027,
+   "value": 0.3105687571293162,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2028,
+   "value": 0.3112819865588672,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2029,
+   "value": 0.31188628846609523,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2030,
+   "value": 0.3137188460113969,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "inequality.gini_equiv_hbai_bhc",
+   "year": 2031,
+   "value": 0.3126616094414172,
+   "unit": "index",
+   "kind": "inequality",
+   "partition": null,
+   "label": "Gini coefficient, equivalised HBAI income before housing costs"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2025,
+   "value": 18262.585926938675,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2026,
+   "value": 18565.573452097553,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2027,
+   "value": 18587.9409768735,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2028,
+   "value": 18712.32311277851,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2029,
+   "value": 19079.33376464379,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2030,
+   "value": 19168.884477875916,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.1",
+   "year": 2031,
+   "value": 19487.832363597438,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 1: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2025,
+   "value": 28212.59715391376,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2026,
+   "value": 29005.91280891695,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2027,
+   "value": 28622.542707465953,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2028,
+   "value": 28878.39944749893,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2029,
+   "value": 29211.074914268487,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2030,
+   "value": 29528.185297717675,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.2",
+   "year": 2031,
+   "value": 30103.165466505827,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 2: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2025,
+   "value": 34413.19157264763,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2026,
+   "value": 35673.03127903994,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2027,
+   "value": 36287.079062372846,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2028,
+   "value": 36891.915260352755,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2029,
+   "value": 37204.02706843331,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2030,
+   "value": 37191.52316634799,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.3",
+   "year": 2031,
+   "value": 37443.87462441918,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 3: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2025,
+   "value": 37642.82250138588,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2026,
+   "value": 39339.82023962752,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2027,
+   "value": 40283.78733924495,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2028,
+   "value": 41009.69588636075,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2029,
+   "value": 41559.21709716546,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2030,
+   "value": 42169.73199175639,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.4",
+   "year": 2031,
+   "value": 43752.63830672907,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 4: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2025,
+   "value": 45171.99281201596,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2026,
+   "value": 46058.866814268324,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2027,
+   "value": 46923.63741254213,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2028,
+   "value": 47337.90189430204,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2029,
+   "value": 48018.33585686029,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2030,
+   "value": 48015.10342326575,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.5",
+   "year": 2031,
+   "value": 49372.799605199805,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 5: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2025,
+   "value": 48820.920957344184,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2026,
+   "value": 50247.0931869786,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2027,
+   "value": 51421.09655308313,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2028,
+   "value": 52276.76979311605,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2029,
+   "value": 52861.797979942385,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2030,
+   "value": 54632.18051219701,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.6",
+   "year": 2031,
+   "value": 56251.724813561,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 6: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2025,
+   "value": 55387.77506364148,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2026,
+   "value": 56756.00462516803,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2027,
+   "value": 57200.40880691023,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2028,
+   "value": 58134.97463181802,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2029,
+   "value": 58986.25591531483,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2030,
+   "value": 60251.89005484455,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.7",
+   "year": 2031,
+   "value": 62408.99721436145,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 7: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2025,
+   "value": 70806.00916651895,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2026,
+   "value": 72028.31133150091,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2027,
+   "value": 73225.66554327721,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2028,
+   "value": 74341.65681045009,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2029,
+   "value": 75651.30440928646,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2030,
+   "value": 76728.81669615183,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.8",
+   "year": 2031,
+   "value": 77557.30506779443,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 8: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2025,
+   "value": 78613.5574256512,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2026,
+   "value": 80276.59014426098,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2027,
+   "value": 81556.99374054834,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2028,
+   "value": 83394.03807203949,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2029,
+   "value": 84834.09750237051,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2030,
+   "value": 86472.88055967825,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.9",
+   "year": 2031,
+   "value": 90591.67357463166,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 9: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2025,
+   "value": 121628.70831615487,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2026,
+   "value": 124668.9979412319,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2027,
+   "value": 125904.42260266874,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2028,
+   "value": 128224.60518010141,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2029,
+   "value": 130772.6528624675,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2030,
+   "value": 133647.48170630506,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "deciles.mean_income.10",
+   "year": 2031,
+   "value": 135669.54991935962,
+   "unit": "gbp_household",
+   "kind": "decile",
+   "partition": null,
+   "label": "Decile 10: mean household_net_income per household"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2025,
+   "value": 6231978976.779907,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2026,
+   "value": 6514784443.9122925,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2027,
+   "value": 6724056114.445618,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2028,
+   "value": 6921661403.251404,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2029,
+   "value": 7132082771.443726,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2030,
+   "value": 7370366483.297607,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.basic_rate_plus_1pp",
+   "year": 2031,
+   "value": 7650165153.850464,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise basic rate by 1pp"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2025,
+   "value": 6375058856.060486,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2026,
+   "value": 6667124628.2039795,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2027,
+   "value": 6885617422.428345,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2028,
+   "value": 7091326778.457153,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2029,
+   "value": 7306154419.062439,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2030,
+   "value": 7546544692.554932,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.basic_rate_plus_1pp",
+   "year": 2031,
+   "value": 7822055582.338684,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise basic rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2025,
+   "value": 3666986552.897339,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2026,
+   "value": 3977030845.7789307,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2027,
+   "value": 4223083637.6138306,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2028,
+   "value": 4369415244.928589,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2029,
+   "value": 4609416868.0217285,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2030,
+   "value": 4888880522.356812,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reforms.higher_rate_plus_1pp",
+   "year": 2031,
+   "value": 5111936398.052734,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise higher rate by 1pp"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2025,
+   "value": 3671386650.1765137,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2026,
+   "value": 3982920392.3969727,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2027,
+   "value": 4229920079.0026245,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2028,
+   "value": 4377131336.593445,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2029,
+   "value": 4618077924.957153,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2030,
+   "value": 4898722761.492981,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.higher_rate_plus_1pp",
+   "year": 2031,
+   "value": 5122219947.487732,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise higher rate by 1pp: change in income_tax"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2025,
+   "value": -3823707824.9178467,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2026,
+   "value": -3938856676.534912,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2027,
+   "value": -4026654105.0057983,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2028,
+   "value": -4071413798.9171143,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2029,
+   "value": -4134957766.1256104,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2030,
+   "value": -4229688107.533081,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reforms.personal_allowance_13000",
+   "year": 2031,
+   "value": -1711397144.4260254,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2025,
+   "value": -3955109908.810852,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2026,
+   "value": -4086527957.4680176,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2027,
+   "value": -4183341947.9663086,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2028,
+   "value": -4230614997.3900757,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2029,
+   "value": -4295011805.698914,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2030,
+   "value": -4392504289.538818,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.personal_allowance_13000",
+   "year": 2031,
+   "value": -1765597207.2024536,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise personal allowance by ~800GBP/year: change in income_tax"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2025,
+   "value": -1977222467.3527832,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2026,
+   "value": -1744958365.3483887,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2027,
+   "value": -1629814465.277588,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2028,
+   "value": -1534763674.378479,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2029,
+   "value": -1428204556.7736816,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2030,
+   "value": -1322075255.131958,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reforms.child_benefit_additional_25",
+   "year": 2031,
+   "value": -1215395543.2149658,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2025,
+   "value": 2332987565.3313274,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2026,
+   "value": 2143319353.6422234,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2027,
+   "value": 2025631616.9500465,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2028,
+   "value": 1921833405.853962,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2029,
+   "value": 1815708316.6814766,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2030,
+   "value": 1706257920.2213326,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reform_targets.child_benefit_additional_25",
+   "year": 2031,
+   "value": 1592582974.3537292,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise child benefit by 25GBP/week per additional child: change in child_benefit"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2025,
+   "value": -19987138619.665894,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2026,
+   "value": -20585697292.380737,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2027,
+   "value": -20867244208.72113,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2028,
+   "value": -21212204041.55127,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2029,
+   "value": -21266028636.272034,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2030,
+   "value": -21323353884.143677,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reforms.uc_taper_20pc",
+   "year": 2031,
+   "value": -21417216822.362915,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2025,
+   "value": 20026032932.944794,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2026,
+   "value": 20623599493.57924,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2027,
+   "value": 20905978230.628372,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2028,
+   "value": 21252138646.154373,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2029,
+   "value": 21307648843.63382,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2030,
+   "value": 21364495790.550797,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_taper_20pc",
+   "year": 2031,
+   "value": 21460406654.06598,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Reduce Universal Credit taper rate to 20%: change in universal_credit"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2025,
+   "value": 11191545342.31836,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2026,
+   "value": 11645153494.695984,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2027,
+   "value": 11972824309.717712,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2028,
+   "value": 12257716047.155273,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2029,
+   "value": 12566231791.670166,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2030,
+   "value": 12912911611.737793,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reforms.employee_nics_main_10pc",
+   "year": 2031,
+   "value": 13392154732.762207,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2025,
+   "value": 11458770099.1045,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2026,
+   "value": 11919342691.629387,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2027,
+   "value": 12255153227.885277,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2028,
+   "value": 12566120848.304436,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2029,
+   "value": 12878237799.178757,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2030,
+   "value": 13226533370.28511,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reform_targets.employee_nics_main_10pc",
+   "year": 2031,
+   "value": 13713984420.560326,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise Class 1 main employee NICs rate to 10%: change in national_insurance"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2025,
+   "value": 31259132637.84729,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2026,
+   "value": 32099610265.377075,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2027,
+   "value": 32862745005.17981,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2028,
+   "value": 33654079646.918335,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2029,
+   "value": 34478200723.7262,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2030,
+   "value": 35326019624.22656,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reforms.vat_standard_plus_2pp",
+   "year": 2031,
+   "value": 36187574723.726685,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2025,
+   "value": 31259131903.187378,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2026,
+   "value": 32099610338.78839,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2027,
+   "value": 32862746112.62982,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2028,
+   "value": 33654077425.53827,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2029,
+   "value": 34478201429.00751,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2030,
+   "value": 35326020466.349304,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reform_targets.vat_standard_plus_2pp",
+   "year": 2031,
+   "value": 36187575185.13623,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise VAT standard rate by 2pp: change in vat"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2025,
+   "value": 2825467413.079834,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2026,
+   "value": 3049458999.274414,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2027,
+   "value": 3238599613.133667,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2028,
+   "value": 3549931915.0806885,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2029,
+   "value": 3756212324.8634033,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2030,
+   "value": 3997750150.373169,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reforms.additional_rate_plus_3pp",
+   "year": 2031,
+   "value": 4332961887.065674,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise additional rate by 3pp"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2025,
+   "value": 2825466843.0894775,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2026,
+   "value": 3049458483.8516235,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2027,
+   "value": 3238600146.871582,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2028,
+   "value": 3549931957.5039062,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2029,
+   "value": 3756211982.0652466,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2030,
+   "value": 3997750696.064453,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reform_targets.additional_rate_plus_3pp",
+   "year": 2031,
+   "value": 4332963129.139282,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise additional rate by 3pp: change in income_tax"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2025,
+   "value": -3024426671.0059814,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2026,
+   "value": -3159166047.906311,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2027,
+   "value": -3213933471.489624,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2028,
+   "value": -3272574045.103821,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2029,
+   "value": -3304278267.8723145,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2030,
+   "value": -3329443110.629883,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reforms.uc_standard_allowance_plus_10pc",
+   "year": 2031,
+   "value": -3364831042.0513916,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2025,
+   "value": 3028001830.553894,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2026,
+   "value": 3162547160.014694,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2027,
+   "value": 3218303161.6876984,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2028,
+   "value": 3277244745.4717865,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2029,
+   "value": 3309693715.520218,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2030,
+   "value": 3335295107.935898,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reform_targets.uc_standard_allowance_plus_10pc",
+   "year": 2031,
+   "value": 3368860316.26828,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the Universal Credit standard allowance by 10%: change in universal_credit"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2025,
+   "value": -9035141432.653503,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2026,
+   "value": -9262484295.463745,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2027,
+   "value": -9298804936.553955,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2028,
+   "value": -9371209948.825623,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2029,
+   "value": -9561709368.57013,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2030,
+   "value": -9807065305.57251,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reforms.state_pension_plus_10pc",
+   "year": 2031,
+   "value": -10306378596.512695,
+   "unit": "gbp",
+   "kind": "reform",
+   "partition": null,
+   "label": "Raise the State Pension by 10%"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2025,
+   "value": 12360890321.875732,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2026,
+   "value": 12831378854.052383,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2027,
+   "value": 13012698482.677444,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2028,
+   "value": 13223877715.22789,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2029,
+   "value": 13614122674.000916,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2030,
+   "value": 14017293680.398499,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  },
+  {
+   "metric_id": "reform_targets.state_pension_plus_10pc",
+   "year": 2031,
+   "value": 14429529140.882904,
+   "unit": "gbp",
+   "kind": "reform_target",
+   "partition": null,
+   "label": "Raise the State Pension by 10%: change in state_pension"
+  }
+ ],
+ "identities": [
+  {
+   "identity_id": "closure",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "weights:person",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "weights:benunit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "weights:household",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:income_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:income_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:income_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:nics_individual",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:nics_individual",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:nics_individual",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:nics_employer",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:nics_employer",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:nics_employer",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:employer_nics_response_capital",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:employer_nics_response_consumer",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:capital_gains_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:capital_gains_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:capital_gains_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:student_loan_repayments",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:student_loan_repayments",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:student_loan_repayments",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:vat",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:vat",
+   "year": null,
+   "status": "fail",
+   "severity": "fail",
+   "failing_scopes": [
+    "2025",
+    "2026",
+    "2027",
+    "2028",
+    "2029",
+    "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"
+  },
+  {
+   "identity_id": "inert:vat",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:vat_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:private_school_vat",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:private_school_vat",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:fuel_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:fuel_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:fuel_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:alcohol_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:alcohol_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:alcohol_duty",
+   "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:tobacco_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:tobacco_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:tobacco_duty",
+   "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:vehicle_excise_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:vehicle_excise_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:vehicle_excise_duty",
+   "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:tv_licence",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:tv_licence",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:tv_licence",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:council_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:council_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:council_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:high_value_council_tax_surcharge",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:high_value_council_tax_surcharge",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:high_value_council_tax_surcharge",
+   "year": null,
+   "status": "warn",
+   "severity": "warn",
+   "failing_scopes": [
+    "2025",
+    "2026",
+    "2027"
+   ],
+   "detail": "2025-26: zero for every unit; 2026-27: zero for every unit; 2027-28: zero for every unit"
+  },
+  {
+   "identity_id": "finite:domestic_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:domestic_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:domestic_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:business_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:business_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:business_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:sdlt_household",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:sdlt_household",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:sdlt_household",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:land_transaction_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:land_transaction_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:land_transaction_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:land_and_buildings_transaction_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:land_and_buildings_transaction_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:land_and_buildings_transaction_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:corporate_incident_tax_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:consumer_incident_tax_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:wealth_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:wealth_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:non_primary_residence_wealth_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:non_primary_residence_wealth_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:land_value_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:land_value_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:carbon_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:carbon_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:state_pension",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:state_pension",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:state_pension",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:pension_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:pension_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:pension_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:winter_fuel_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:winter_fuel_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:winter_fuel_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:pawhp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:pawhp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:pawhp",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:universal_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:universal_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:universal_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:housing_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:housing_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:housing_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:council_tax_reduction",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:council_tax_reduction",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:council_tax_reduction",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:esa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:esa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:esa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:esa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:esa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:esa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:jsa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:jsa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:jsa_income",
+   "year": null,
+   "status": "warn",
+   "severity": "warn",
+   "failing_scopes": [
+    "2026",
+    "2027",
+    "2028",
+    "2029",
+    "2030",
+    "2031"
+   ],
+   "detail": "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:jsa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:jsa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:jsa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:income_support",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:income_support",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:income_support",
+   "year": null,
+   "status": "warn",
+   "severity": "warn",
+   "failing_scopes": [
+    "2026",
+    "2027",
+    "2028",
+    "2029",
+    "2030",
+    "2031"
+   ],
+   "detail": "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:working_tax_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:working_tax_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:working_tax_credit",
+   "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:child_tax_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:child_tax_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:child_tax_credit",
+   "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:incapacity_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:incapacity_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:incapacity_benefit",
+   "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:pip",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:pip",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:pip",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:dla",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:dla",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:dla",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:attendance_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:attendance_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:attendance_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:carers_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:carers_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:carers_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:carer_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:carer_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:carer_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:scottish_carer_supplement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:scottish_carer_supplement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:scottish_carer_supplement",
+   "year": null,
+   "status": "warn",
+   "severity": "warn",
+   "failing_scopes": [
+    "2025"
+   ],
+   "detail": "2025-26: zero for every unit"
+  },
+  {
+   "identity_id": "finite:sda",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:sda",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:sda",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:iidb",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:iidb",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:iidb",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:afcs",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:afcs",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:afcs",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:child_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:child_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:child_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:scottish_child_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:scottish_child_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:scottish_child_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:two_child_limit_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:two_child_limit_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:healthy_start_vouchers",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:healthy_start_vouchers",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:healthy_start_vouchers",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:maternity_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:maternity_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:maternity_allowance",
+   "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_maternity_pay",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:statutory_maternity_pay",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:statutory_maternity_pay",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:statutory_sick_pay",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:statutory_sick_pay",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:statutory_sick_pay",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:ssmg",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:ssmg",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:ssmg",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:bsp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:bsp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:bsp",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:tax_free_childcare",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:tax_free_childcare",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:tax_free_childcare",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:extended_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:extended_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:extended_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:universal_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:universal_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:universal_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:targeted_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:targeted_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:targeted_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:care_to_learn",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:care_to_learn",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:care_to_learn",
+   "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:childcare_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:childcare_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:childcare_grant",
+   "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:parents_learning_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:parents_learning_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:parents_learning_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:adult_dependants_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:adult_dependants_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:adult_dependants_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:travel_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:travel_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:travel_grant",
+   "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:disabled_students_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:disabled_students_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:disabled_students_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:bursary_fund_16_to_19",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:bursary_fund_16_to_19",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:bursary_fund_16_to_19",
+   "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:nhs_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:nhs_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:nhs_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:dfe_education_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:dfe_education_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:dfe_education_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:dft_subsidy_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:dft_subsidy_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "inert:dft_subsidy_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:cost_of_living_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:cost_of_living_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:energy_bills_rebate",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:energy_bills_rebate",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:epg_subsidy",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "sign:epg_subsidy",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:basic_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:disability_basic_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:other_public_spending_budget_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "overlap_guard:property_transaction_tax_geography",
+   "year": null,
+   "status": "fail",
+   "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"
+  },
+  {
+   "identity_id": "overlap_guard:local_tax_geography",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:gov_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:household_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:pre_budget_change_household_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:gov_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:household_benefits",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:gov_balance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "identity:household_net_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:absolute_bhc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "bounds:poverty_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:children",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:working_age_adults",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:pensioners",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:absolute_ahc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:relative_bhc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "available:relative_ahc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:income_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:national_insurance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:ni_employer",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:employer_ni_response_capital_incidence",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:employer_ni_response_consumer_incidence",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:capital_gains_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:student_loan_repayments",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:vat",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:vat_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:private_school_vat",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:fuel_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:alcohol_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:tobacco_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:vehicle_excise_duty",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:tv_licence",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:council_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:high_value_council_tax_surcharge",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:domestic_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:business_rates",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:expected_sdlt",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:expected_ltt",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:expected_lbtt",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:corporate_incident_tax_revenue_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:consumer_incident_tax_revenue_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:wealth_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:non_primary_residence_wealth_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:LVT",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:carbon_tax",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:state_pension",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:pension_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:winter_fuel_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:pawhp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:universal_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:housing_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:council_tax_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:esa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:esa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:jsa_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:jsa_contrib",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:income_support",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:working_tax_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:child_tax_credit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:incapacity_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:pip",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:dla",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:attendance_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:carers_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:carer_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:scottish_carer_supplement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:sda",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:iidb",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:afcs",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:child_benefit",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:scottish_child_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:two_child_limit_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:healthy_start_vouchers",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:maternity_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:statutory_maternity_pay",
+   "year": null,
+   "status": "fail",
+   "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"
+  },
+  {
+   "identity_id": "counted_once:statutory_sick_pay",
+   "year": null,
+   "status": "fail",
+   "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"
+  },
+  {
+   "identity_id": "counted_once:ssmg",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:bsp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:tax_free_childcare",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:extended_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:universal_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:targeted_childcare_entitlement",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:care_to_learn",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:childcare_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:parents_learning_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:adult_dependants_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:travel_grant",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:disabled_students_allowance",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:bursary_fund_16_to_19",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:nhs_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:dfe_education_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:dft_subsidy_spending",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:cost_of_living_support_payment",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:energy_bills_rebate",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:epg_subsidy",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:basic_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:disability_basic_income",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:other_public_spending_budget_change",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:corporate_sdlt",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "double_count_present:corporate_sdlt",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:reset_reproduces_baseline",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "counted_once:ran",
+   "year": null,
+   "status": "pass",
+   "severity": "warn",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "determinism:rebuilt_baseline",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:basic_rate_plus_1pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:higher_rate_plus_1pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:personal_allowance_13000",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:child_benefit_additional_25",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:uc_taper_20pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:employee_nics_main_10pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:vat_standard_plus_2pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:additional_rate_plus_3pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:uc_standard_allowance_plus_10pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "responsive:state_pension_plus_10pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:basic_rate_plus_1pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:higher_rate_plus_1pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:personal_allowance_13000",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:child_benefit_additional_25",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:uc_taper_20pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:employee_nics_main_10pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:vat_standard_plus_2pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:additional_rate_plus_3pp",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:uc_standard_allowance_plus_10pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "reform_runs:state_pension_plus_10pc",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  },
+  {
+   "identity_id": "finite:metrics",
+   "year": null,
+   "status": "pass",
+   "severity": "fail",
+   "failing_scopes": [],
+   "detail": ""
+  }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/uk_metrics/standard_reforms.yaml b/tests/fixtures/uk_metrics/standard_reforms.yaml
new file mode 100644
index 00000000..c2447136
--- /dev/null
+++ b/tests/fixtures/uk_metrics/standard_reforms.yaml
@@ -0,0 +1,106 @@
+# The standard reforms the aggregate-impact report costs on each side.
+#
+# Each reform is either absolute (`parameters`: values applied from 2023, as
+# the reform-cost fixtures in tests/microsimulation/reforms_config.yaml do) or
+# relative (`scale`: each listed parameter is multiplied by the factor in
+# every year of the horizon, starting from that year's baseline value).
+#
+# `responds` states what the reform must do: the model variable whose
+# weighted total it must move, the direction, and the least it must move it
+# in every year of the horizon. A reform that moves its target by less, by
+# nothing or the wrong way fails the `responsive:` check, so a reform the
+# model silently ignores cannot pass as a small number. Each minimum is well
+# below the reform's smallest yearly effect on uk-data 1.58.0 in October 2026
+# (about a fifth of it for most), and is a proposal awaiting the
+# numerical-acceptance ruling (cos d1344). An absolute reform shrinks as the
+# baseline is uprated towards it: the 13,000 personal allowance is worth £4.4bn
+# in 2030-31 but £1.8bn in 2031-32, when the baseline allowance rises to
+# 12,830. From 2032-33 the baseline (13,090) exceeds it, so before the horizon
+# reaches that year, make that reform relative.
+schema_version: 1
+
+reforms:
+  - id: basic_rate_plus_1pp
+    name: Raise basic rate by 1pp
+    parameters:
+      gov.hmrc.income_tax.rates.uk[0].rate: 0.21
+    responds:
+      variable: income_tax
+      direction: up
+      minimum: 1500000000
+  - id: higher_rate_plus_1pp
+    name: Raise higher rate by 1pp
+    parameters:
+      gov.hmrc.income_tax.rates.uk[1].rate: 0.42
+    responds:
+      variable: income_tax
+      direction: up
+      minimum: 1000000000
+  - id: personal_allowance_13000
+    name: Raise personal allowance by ~800GBP/year
+    parameters:
+      gov.hmrc.income_tax.allowances.personal_allowance.amount: 13000
+    responds:
+      variable: income_tax
+      direction: down
+      minimum: 1000000000
+  - id: child_benefit_additional_25
+    name: Raise child benefit by 25GBP/week per additional child
+    parameters:
+      gov.hmrc.child_benefit.amount.additional: 25
+    responds:
+      variable: child_benefit
+      direction: up
+      minimum: 300000000
+  - id: uc_taper_20pc
+    name: Reduce Universal Credit taper rate to 20%
+    parameters:
+      gov.dwp.universal_credit.means_test.reduction_rate: 0.2
+    responds:
+      variable: universal_credit
+      direction: up
+      minimum: 4000000000
+  - id: employee_nics_main_10pc
+    name: Raise Class 1 main employee NICs rate to 10%
+    parameters:
+      gov.hmrc.national_insurance.class_1.rates.employee.main: 0.1
+    responds:
+      variable: national_insurance
+      direction: up
+      minimum: 2500000000
+  - id: vat_standard_plus_2pp
+    name: Raise VAT standard rate by 2pp
+    parameters:
+      gov.hmrc.vat.standard_rate: 0.22
+    responds:
+      variable: vat
+      direction: up
+      minimum: 4000000000
+  - id: additional_rate_plus_3pp
+    name: Raise additional rate by 3pp
+    parameters:
+      gov.hmrc.income_tax.rates.uk[2].rate: 0.48
+    responds:
+      variable: income_tax
+      direction: up
+      minimum: 750000000
+  - id: uc_standard_allowance_plus_10pc
+    name: Raise the Universal Credit standard allowance by 10%
+    scale:
+      gov.dwp.universal_credit.standard_allowance.amount.SINGLE_YOUNG: 1.1
+      gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD: 1.1
+      gov.dwp.universal_credit.standard_allowance.amount.COUPLE_YOUNG: 1.1
+      gov.dwp.universal_credit.standard_allowance.amount.COUPLE_OLD: 1.1
+    responds:
+      variable: universal_credit
+      direction: up
+      minimum: 600000000
+  - id: state_pension_plus_10pc
+    name: Raise the State Pension by 10%
+    scale:
+      gov.dwp.state_pension.new_state_pension.amount: 1.1
+      gov.dwp.state_pension.basic_state_pension.amount: 1.1
+    responds:
+      variable: state_pension
+      direction: up
+      minimum: 2500000000
diff --git a/tests/fixtures/uk_replay/DIGESTS.json b/tests/fixtures/uk_replay/DIGESTS.json
new file mode 100644
index 00000000..c7f000d9
--- /dev/null
+++ b/tests/fixtures/uk_replay/DIGESTS.json
@@ -0,0 +1,8 @@
+{
+ "qualification_request_golden.json": {
+  "request_id": "2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf"
+ },
+ "replay_receipt_golden.json": {
+  "receipt_sha256": "5cbc44c1b79506a6410c12b437a7451a0e1387ab6a133b9cb8dc0bf71b396650"
+ }
+}
diff --git a/tests/fixtures/uk_replay/README.md b/tests/fixtures/uk_replay/README.md
new file mode 100644
index 00000000..398abb5c
--- /dev/null
+++ b/tests/fixtures/uk_replay/README.md
@@ -0,0 +1,10 @@
+# UK replay interface golden fixtures
+
+Vendored unchanged from PolicyEngine/policyengine-scorecard at commit
+`dc040de` (`tests/fixtures/uk_replay/`), the W6 side of the pre-promotion
+replay interface. The scorecard computes digests with `rfc8785==0.1.4`;
+`tests/test_canonical_json.py` checks that
+`policyengine.provenance.canonical` produces the same bytes and digests.
+
+Refresh by copying the files from a newer scorecard commit and updating the
+commit above; never edit them by hand.
diff --git a/tests/fixtures/uk_replay/qualification_request_golden.jcs b/tests/fixtures/uk_replay/qualification_request_golden.jcs
new file mode 100644
index 00000000..0e008c93
--- /dev/null
+++ b/tests/fixtures/uk_replay/qualification_request_golden.jcs
@@ -0,0 +1 @@
+{"attempt":1,"candidate":{"data":{"build_id":"policyengine-uk-data-1.58.0","dataset":"enhanced_frs_2024_25","path":"enhanced_frs_2024_25.h5","release_manifest_sha256":"ddd63e1f7755ee358c4ede9050bfdde09f3a80f48a5c0d8ee9d1fb531e4a1a07","repo_id":"policyengine/policyengine-uk-data-private","repo_type":"model","revision":"1.58.0","sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","size_bytes":128369110},"expected_release_bundle":{"bundle_id":"uk-6.2.6","certified_by":"policyengine.py bundle certification","certified_data_artifact_sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","certified_data_build_id":"policyengine-uk-data-1.58.0","compatibility_basis":"legacy_compatible_model_package","country_id":"uk","data_build_fingerprint":"sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8","data_build_model_git_sha":"4cbedbecca352f52dffe47e849553c2759a00711","data_build_model_version":"2.122.2","data_package":"policyengine-uk-data","data_version":"1.58.0","default_dataset":"enhanced_frs_2024_25","default_dataset_uri":"hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0","model_package":"policyengine-uk","model_version":"2.125.1","policyengine_version":"6.2.6"},"policyengine":{"packaged_bundle_manifest_sha256":"8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54","source":{"git":{"commit":"481940eca44fc75e41fbf9de4ff454e0a0d7c0e0","repository":"PolicyEngine/policyengine.py"}},"version":"6.2.6"},"policyengine_core":{"version":"3.33.0","wheel_sha256":"2e32d02a8910ab683a2b7600987904b351d058fc2bd5910a8ea6641438bcd789"},"policyengine_uk":{"version":"2.125.1","wheel_sha256":"a0983b4cef868467578a4fd0d163a977a0da8f710dd5194ec01c75f66d512c5a"}},"compare_to":"latest_certified","kind":"candidate","requested_by":{"commit":"481940eca44fc75e41fbf9de4ff454e0a0d7c0e0","jcs_probe":[1e-7,15000000000000000,0.30000000000000004,0,1e+21,5e-324,123456789012345680000,9007199254740991],"note":"Golden fixture: JCS probes for non-ASCII (Budget £, café, 予算) and doubles.","repository":"PolicyEngine/policyengine.py","run_attempt":1,"workflow_run_id":1},"schema":"policyengine.uk-replay-qualification-request/v1"}
\ No newline at end of file
diff --git a/tests/fixtures/uk_replay/qualification_request_golden.json b/tests/fixtures/uk_replay/qualification_request_golden.json
new file mode 100644
index 00000000..1f6d69c0
--- /dev/null
+++ b/tests/fixtures/uk_replay/qualification_request_golden.json
@@ -0,0 +1,72 @@
+{
+ "schema": "policyengine.uk-replay-qualification-request/v1",
+ "kind": "candidate",
+ "attempt": 1,
+ "candidate": {
+  "policyengine": {
+   "version": "6.2.6",
+   "source": {
+    "git": {
+     "repository": "PolicyEngine/policyengine.py",
+     "commit": "481940eca44fc75e41fbf9de4ff454e0a0d7c0e0"
+    }
+   },
+   "packaged_bundle_manifest_sha256": "8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54"
+  },
+  "policyengine_uk": {
+   "version": "2.125.1",
+   "wheel_sha256": "a0983b4cef868467578a4fd0d163a977a0da8f710dd5194ec01c75f66d512c5a"
+  },
+  "policyengine_core": {
+   "version": "3.33.0",
+   "wheel_sha256": "2e32d02a8910ab683a2b7600987904b351d058fc2bd5910a8ea6641438bcd789"
+  },
+  "data": {
+   "build_id": "policyengine-uk-data-1.58.0",
+   "dataset": "enhanced_frs_2024_25",
+   "repo_id": "policyengine/policyengine-uk-data-private",
+   "repo_type": "model",
+   "revision": "1.58.0",
+   "path": "enhanced_frs_2024_25.h5",
+   "sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6",
+   "size_bytes": 128369110,
+   "release_manifest_sha256": "ddd63e1f7755ee358c4ede9050bfdde09f3a80f48a5c0d8ee9d1fb531e4a1a07"
+  },
+  "expected_release_bundle": {
+   "bundle_id": "uk-6.2.6",
+   "country_id": "uk",
+   "policyengine_version": "6.2.6",
+   "model_package": "policyengine-uk",
+   "model_version": "2.125.1",
+   "data_package": "policyengine-uk-data",
+   "data_version": "1.58.0",
+   "default_dataset": "enhanced_frs_2024_25",
+   "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0",
+   "certified_data_build_id": "policyengine-uk-data-1.58.0",
+   "certified_data_artifact_sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6",
+   "data_build_model_version": "2.122.2",
+   "data_build_model_git_sha": "4cbedbecca352f52dffe47e849553c2759a00711",
+   "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8",
+   "compatibility_basis": "legacy_compatible_model_package",
+   "certified_by": "policyengine.py bundle certification"
+  }
+ },
+ "compare_to": "latest_certified",
+ "requested_by": {
+  "repository": "PolicyEngine/policyengine.py",
+  "workflow_run_id": 1,
+  "run_attempt": 1,
+  "commit": "481940eca44fc75e41fbf9de4ff454e0a0d7c0e0",
+  "note": "Golden fixture: JCS probes for non-ASCII (Budget £, café, 予算) and doubles.",
+  "jcs_probe": [
+   1e-07,
+   1.5e+16,
+   0.30000000000000004,
+   -0.0,
+   1e+21,
+   5e-324,
+   1.2345678901234568e+20,
+   9007199254740991
+  ]
+ }
+}
diff --git a/tests/fixtures/uk_replay/replay_receipt_golden.jcs b/tests/fixtures/uk_replay/replay_receipt_golden.jcs
new file mode 100644
index 00000000..d542e5bd
--- /dev/null
+++ b/tests/fixtures/uk_replay/replay_receipt_golden.jcs
@@ -0,0 +1 @@
+{"corpus":{"corpus_version":"uk-fiscal-event-replay/1","events":{"spring_budget_2023":{"artifacts":8,"comparison_provenance_sha256":"8888888888888888888888888888888888888888888888888888888888888888","full_supported_grid_complete":true,"inert_measure_years":[],"registry_sha256":"5555555555555555555555555555555555555555555555555555555555555555","run_manifest_sha256":"6666666666666666666666666666666666666666666666666666666666666666","staging_manifest_sha256":"7777777777777777777777777777777777777777777777777777777777777777","supported_years":[2024,2025,2026,2027],"unsupported_years":[{"reason":"before_data_year","year":2023}]}}},"diff":{"base_variant_id":"pe5.0.2_uk2.89.2_populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z","diff_sha256":"9999999999999999999999999999999999999999999999999999999999999999","flag_counts":{"construction":0,"coverage":0,"inertness":0,"mapping":0,"numerical":2,"structural":1,"support":1},"flagged_rows":[{"base_gbp":-15000000000000000,"event":"spring_budget_2023","flags":["numerical"],"head_gbp":0.30000000000000004,"key":"universal_credit","level":"head","measure_key":"spring_budget_2023__uc_childcare_cap_increase","year":2024},{"base_gbp":1e-7,"event":"spring_budget_2023","flags":["numerical","structural"],"head_gbp":-123456789.125,"key":"total","level":"measure_year","measure_key":"spring_budget_2023__uc_childcare_cap_increase","year":2025},{"base_gbp":-2500000,"event":"spring_budget_2023","flags":["support"],"head_gbp":null,"key":"obr-pmd-row-123","level":"source_row","measure_key":"spring_budget_2023__uc_childcare_cap_increase","year":2023}]},"execution":{"dataset_sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","packaged_bundle_manifest_sha256":"8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54","packages":{"policyengine":{"record_sha256":"1111111111111111111111111111111111111111111111111111111111111111","version":"6.2.6"},"policyengine-core":{"record_sha256":"3333333333333333333333333333333333333333333333333333333333333333","version":"3.33.0"},"policyengine-uk":{"record_sha256":"2222222222222222222222222222222222222222222222222222222222222222","version":"2.125.1"}},"platform":"Golden fixture (synthetic values; café £)","python":"3.12.14","release_bundle":{"bundle_id":"uk-6.2.6","certified_by":"policyengine.py bundle certification","certified_data_artifact_sha256":"5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6","certified_data_build_id":"policyengine-uk-data-1.58.0","compatibility_basis":"legacy_compatible_model_package","country_id":"uk","data_build_fingerprint":"sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8","data_build_model_git_sha":"4cbedbecca352f52dffe47e849553c2759a00711","data_build_model_version":"2.122.2","data_package":"policyengine-uk-data","data_version":"1.58.0","default_dataset":"enhanced_frs_2024_25","default_dataset_uri":"hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0","model_package":"policyengine-uk","model_version":"2.125.1","policyengine_version":"6.2.6"},"requirements_sha256":"4444444444444444444444444444444444444444444444444444444444444444","runner":"github-actions ubuntu-latest"},"integrity":{"failures":[],"status":"complete"},"rejection":null,"request_id":"2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf","results":{"commit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","manifest_sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","path":"requests/2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf/results/","ref":"replay-state","repository":"PolicyEngine/policyengine-scorecard"},"schema":"policyengine.uk-replay-receipt/v1","status":"complete","variant_id":"pe6.2.6_uk2.125.1_policyengine-uk-data-1.58.0","variant_identity_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}
\ No newline at end of file
diff --git a/tests/fixtures/uk_replay/replay_receipt_golden.json b/tests/fixtures/uk_replay/replay_receipt_golden.json
new file mode 100644
index 00000000..87898936
--- /dev/null
+++ b/tests/fixtures/uk_replay/replay_receipt_golden.json
@@ -0,0 +1,138 @@
+{
+ "schema": "policyengine.uk-replay-receipt/v1",
+ "request_id": "2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf",
+ "status": "complete",
+ "rejection": null,
+ "variant_id": "pe6.2.6_uk2.125.1_policyengine-uk-data-1.58.0",
+ "variant_identity_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
+ "execution": {
+  "packages": {
+   "policyengine": {
+    "version": "6.2.6",
+    "record_sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+   },
+   "policyengine-uk": {
+    "version": "2.125.1",
+    "record_sha256": "2222222222222222222222222222222222222222222222222222222222222222"
+   },
+   "policyengine-core": {
+    "version": "3.33.0",
+    "record_sha256": "3333333333333333333333333333333333333333333333333333333333333333"
+   }
+  },
+  "packaged_bundle_manifest_sha256": "8633ad814c9c85e53c7c513f69c4c7b1500a03cd2f6ef133d6e2ea8346b2db54",
+  "release_bundle": {
+   "bundle_id": "uk-6.2.6",
+   "country_id": "uk",
+   "policyengine_version": "6.2.6",
+   "model_package": "policyengine-uk",
+   "model_version": "2.125.1",
+   "data_package": "policyengine-uk-data",
+   "data_version": "1.58.0",
+   "default_dataset": "enhanced_frs_2024_25",
+   "default_dataset_uri": "hf://policyengine/policyengine-uk-data-private/enhanced_frs_2024_25.h5@1.58.0",
+   "certified_data_build_id": "policyengine-uk-data-1.58.0",
+   "certified_data_artifact_sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6",
+   "data_build_model_version": "2.122.2",
+   "data_build_model_git_sha": "4cbedbecca352f52dffe47e849553c2759a00711",
+   "data_build_fingerprint": "sha256:c094d7006fdd81df220090284212f16bbffb15624643bc442c4bb3e1b1e5dac8",
+   "compatibility_basis": "legacy_compatible_model_package",
+   "certified_by": "policyengine.py bundle certification"
+  },
+  "dataset_sha256": "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6",
+  "requirements_sha256": "4444444444444444444444444444444444444444444444444444444444444444",
+  "python": "3.12.14",
+  "platform": "Golden fixture (synthetic values; café £)",
+  "runner": "github-actions ubuntu-latest"
+ },
+ "corpus": {
+  "corpus_version": "uk-fiscal-event-replay/1",
+  "events": {
+   "spring_budget_2023": {
+    "registry_sha256": "5555555555555555555555555555555555555555555555555555555555555555",
+    "supported_years": [
+     2024,
+     2025,
+     2026,
+     2027
+    ],
+    "unsupported_years": [
+     {
+      "year": 2023,
+      "reason": "before_data_year"
+     }
+    ],
+    "run_manifest_sha256": "6666666666666666666666666666666666666666666666666666666666666666",
+    "staging_manifest_sha256": "7777777777777777777777777777777777777777777777777777777777777777",
+    "comparison_provenance_sha256": "8888888888888888888888888888888888888888888888888888888888888888",
+    "artifacts": 8,
+    "full_supported_grid_complete": true,
+    "inert_measure_years": []
+   }
+  }
+ },
+ "integrity": {
+  "status": "complete",
+  "failures": []
+ },
+ "diff": {
+  "base_variant_id": "pe5.0.2_uk2.89.2_populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z",
+  "diff_sha256": "9999999999999999999999999999999999999999999999999999999999999999",
+  "flag_counts": {
+   "numerical": 2,
+   "structural": 1,
+   "coverage": 0,
+   "mapping": 0,
+   "support": 1,
+   "inertness": 0,
+   "construction": 0
+  },
+  "flagged_rows": [
+   {
+    "event": "spring_budget_2023",
+    "measure_key": "spring_budget_2023__uc_childcare_cap_increase",
+    "year": 2024,
+    "level": "head",
+    "key": "universal_credit",
+    "flags": [
+     "numerical"
+    ],
+    "base_gbp": -1.5e+16,
+    "head_gbp": 0.30000000000000004
+   },
+   {
+    "event": "spring_budget_2023",
+    "measure_key": "spring_budget_2023__uc_childcare_cap_increase",
+    "year": 2025,
+    "level": "measure_year",
+    "key": "total",
+    "flags": [
+     "numerical",
+     "structural"
+    ],
+    "base_gbp": 1e-07,
+    "head_gbp": -123456789.125
+   },
+   {
+    "event": "spring_budget_2023",
+    "measure_key": "spring_budget_2023__uc_childcare_cap_increase",
+    "year": 2023,
+    "level": "source_row",
+    "key": "obr-pmd-row-123",
+    "flags": [
+     "support"
+    ],
+    "base_gbp": -2500000.0,
+    "head_gbp": null
+   }
+  ]
+ },
+ "results": {
+  "repository": "PolicyEngine/policyengine-scorecard",
+  "ref": "replay-state",
+  "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+  "path": "requests/2e4dd8350bfdc5a39780e002c14ab49c02cbf28b77b222af420e38c68bb945cf/results/",
+  "manifest_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ },
+ "receipt_sha256": "5cbc44c1b79506a6410c12b437a7451a0e1387ab6a133b9cb8dc0bf71b396650"
+}
diff --git a/tests/qualification_fixtures.py b/tests/qualification_fixtures.py
new file mode 100644
index 00000000..5f27153b
--- /dev/null
+++ b/tests/qualification_fixtures.py
@@ -0,0 +1,102 @@
+"""Shared builders for qualification tests (no network, no credentials)."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from policyengine.provenance.qualification import (
+    REQUIRED_GATES,
+    CandidatePair,
+    GateResult,
+    IssuedBy,
+    PinnedData,
+    PinnedWheel,
+    QualificationHarness,
+    QualificationReceipt,
+    qualification_verdict,
+)
+
+FIXTURES = Path(__file__).parent / "fixtures" / "uk_replay"
+VENDORED_SCHEMAS = (
+    Path(__file__).resolve().parents[1]
+    / "src"
+    / "policyengine"
+    / "data"
+    / "schemas"
+    / "vendor"
+    / "policyengine-scorecard"
+)
+
+
+def sha(char: str) -> str:
+    return char * 64
+
+
+def make_pair(
+    model="2.126.8", data="1.58.0", core="3.33.0", dataset_sha=None
+) -> CandidatePair:
+    return CandidatePair(
+        country_id="uk",
+        model=PinnedWheel(name="policyengine-uk", version=model, wheel_sha256=sha("a")),
+        core=PinnedWheel(name="policyengine-core", version=core, wheel_sha256=sha("b")),
+        data=PinnedData(
+            data_package="policyengine-uk-data",
+            release_version=data,
+            build_id=f"policyengine-uk-data-{data}",
+            repo_id="policyengine/policyengine-uk-data-private",
+            repo_type="model",
+            release_manifest_path=f"releases/{data}/release_manifest.json",
+            release_manifest_revision="f" * 40,
+            release_manifest_sha256=sha("c"),
+            dataset="enhanced_frs_2024_25",
+            dataset_path="enhanced_frs_2024_25.h5",
+            dataset_revision=data,
+            dataset_sha256=dataset_sha or sha("d"),
+            size_bytes=128369110,
+        ),
+    )
+
+
+HARNESS = QualificationHarness(
+    policyengine_version="6.2.6",
+    git_commit="e" * 40,
+    packaged_bundle_manifest_sha256=sha("9"),
+)
+ISSUED = IssuedBy(
+    repository="PolicyEngine/policyengine.py",
+    workflow_run_id=1,
+    run_url="https://github.com/PolicyEngine/policyengine.py/actions/runs/1",
+)
+
+
+def gates(status="pass", **overrides) -> list[GateResult]:
+    return [
+        GateResult(
+            gate_id=gate_id, status=overrides.get(gate_id, status), summary=gate_id
+        )
+        for gate_id in REQUIRED_GATES
+    ]
+
+
+def make_receipt(
+    pair=None, gate_results=None, completed_at="2026-10-15T09:00:00Z", review=None
+) -> QualificationReceipt:
+    pair = pair or make_pair()
+    gate_results = gate_results if gate_results is not None else gates()
+    return QualificationReceipt(
+        schema_version=1,
+        receipt_type="policyengine.candidate_qualification_receipt",
+        pair_id=pair.pair_id,
+        pair=pair,
+        harness=HARNESS,
+        gates=gate_results,
+        verdict=qualification_verdict(gate_results),
+        review=review,
+        issued_by=ISSUED,
+        completed_at=completed_at,
+    )
+
+
+def golden(name: str) -> dict:
+    return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8"))
diff --git a/tests/receipt_modifiers.py b/tests/receipt_modifiers.py
new file mode 100644
index 00000000..b2ef6add
--- /dev/null
+++ b/tests/receipt_modifiers.py
@@ -0,0 +1,40 @@
+"""Module-level simulation modifiers for receipt binding tests."""
+
+
+def raise_basic_rate(simulation):
+    simulation.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.uk[
+        0
+    ].rate.update(period="year:2027:10", value=0.21)
+
+
+def raise_higher_rate(simulation):
+    simulation.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.uk[
+        1
+    ].rate.update(period="year:2027:10", value=0.41)
+
+
+class Measure:
+    def __init__(self, rate):
+        self.rate = rate
+
+    def apply(self, simulation):
+        simulation.rate = self.rate
+
+
+def make_modifier(rate):
+    def modifier(simulation):
+        simulation.rate = rate
+
+    return modifier
+
+
+def raise_basic_rate_to(simulation, rate=0.21):
+    simulation.rate = rate
+
+
+def raise_basic_rate_from(simulation, *, start=2027):
+    simulation.start = start
+
+
+def raise_with_object_default(simulation, measure=Measure(0.21)):
+    simulation.rate = measure.rate
diff --git a/tests/test_canonical_json.py b/tests/test_canonical_json.py
new file mode 100644
index 00000000..4c0cf4d5
--- /dev/null
+++ b/tests/test_canonical_json.py
@@ -0,0 +1,231 @@
+"""RFC 8785 canonical JSON used for receipt digests.
+
+``policyengine.provenance.canonical`` is checked against the independent
+``rfc8785`` implementation (a differential test), against the RFC's own
+examples, and for the properties every canonical form needs.
+"""
+
+import json
+import math
+from pathlib import Path
+
+import pytest
+import rfc8785
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from policyengine.provenance.canonical import (
+    MAX_SAFE_INTEGER,
+    CanonicalJSONError,
+    canonical_json,
+    canonical_sha256,
+    drop_null_members,
+)
+
+# Strings without unpaired surrogates: those have no UTF-8 form.
+TEXT = st.text(alphabet=st.characters(blacklist_categories=("Cs",)), max_size=12)
+FINITE = st.floats(allow_nan=False, allow_infinity=False)
+SAFE_INT = st.integers(min_value=-MAX_SAFE_INTEGER, max_value=MAX_SAFE_INTEGER)
+JSON_VALUES = st.recursive(
+    st.none() | st.booleans() | SAFE_INT | FINITE | TEXT,
+    lambda children: (
+        st.lists(children, max_size=4) | st.dictionaries(TEXT, children, max_size=4)
+    ),
+    max_leaves=20,
+)
+
+
+@settings(max_examples=500, deadline=None)
+@given(JSON_VALUES)
+def test_matches_the_rfc8785_reference_implementation(value):
+    assert canonical_json(value) == rfc8785.dumps(value)
+
+
+@settings(max_examples=2000, deadline=None)
+@given(FINITE)
+def test_numbers_match_the_reference_implementation(number):
+    assert canonical_json(number) == rfc8785.dumps(number)
+
+
+@pytest.mark.parametrize(
+    ("number", "expected"),
+    [
+        # RFC 8785 section 3.2.2.3 and appendix B examples.
+        (333333333.33333329, b"333333333.3333333"),
+        (1e30, b"1e+30"),
+        (4.50, b"4.5"),
+        (2e-3, b"0.002"),
+        (0.000000000000000000000000001, b"1e-27"),
+        (-0.0, b"0"),
+        (1e21, b"1e+21"),
+        (1e20, b"100000000000000000000"),
+        (1e-7, b"1e-7"),
+        (1.5e16, b"15000000000000000"),
+        (5e-324, b"5e-324"),
+        (1.7976931348623157e308, b"1.7976931348623157e+308"),
+    ],
+)
+def test_rfc8785_number_examples(number, expected):
+    assert canonical_json(number) == expected
+
+
+def test_rfc8785_member_ordering_example():
+    value = {
+        "€": "Euro Sign",
+        "\r": "Carriage Return",
+        "דּ": "Hebrew Letter Dalet With Dagesh",
+        "1": "One",
+        "\U0001f600": "Emoji: Grinning Face",
+        "\u0080": "Control",
+        "ö": "Latin Small Letter O With Diaeresis",
+    }
+    keys = list(json.loads(canonical_json(value)))
+    assert keys == ["\r", "1", "\u0080", "ö", "€", "\U0001f600", "דּ"]
+
+
+@settings(max_examples=300, deadline=None)
+@given(JSON_VALUES)
+def test_canonical_form_is_a_fixed_point(value):
+    once = canonical_json(value)
+    assert canonical_json(_read_as_jcs_consumer(once)) == once
+
+
+@settings(max_examples=300, deadline=None)
+@given(st.dictionaries(TEXT, JSON_VALUES, max_size=6), st.randoms())
+def test_member_order_does_not_change_bytes(mapping, random):
+    items = list(mapping.items())
+    random.shuffle(items)
+    assert canonical_json(dict(items)) == canonical_json(mapping)
+
+
+@settings(max_examples=300, deadline=None)
+@given(FINITE)
+def test_numbers_round_trip_exactly(number):
+    # RFC 8785 numbers are doubles; read them back as doubles.
+    parsed = json.loads(canonical_json(number), parse_int=float)
+    assert parsed == number
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        math.nan,
+        math.inf,
+        -math.inf,
+        2**53,
+        -(2**53),
+        10**400,
+        "\ud800",
+        {1: "non-string key"},
+        b"bytes",
+        object(),
+    ],
+)
+def test_refuses_values_without_one_canonical_form(value):
+    with pytest.raises(CanonicalJSONError):
+        canonical_json(value)
+
+
+def test_large_integer_valued_doubles_are_written_as_doubles():
+    assert canonical_json(float(2**53)) == b"9007199254740992"
+    assert canonical_json(1e22) == b"1e+22"
+
+
+def _read_as_jcs_consumer(text: bytes):
+    """Parse as a double-based consumer would: unsafe integers are doubles."""
+
+    def parse_int(literal: str):
+        number = int(literal)
+        return number if abs(number) <= MAX_SAFE_INTEGER else float(literal)
+
+    return json.loads(text, parse_int=parse_int)
+
+
+def test_digest_is_sha256_of_canonical_bytes():
+    import hashlib
+
+    value = {"b": [1, 2.5, None], "a": "é"}
+    assert canonical_sha256(value) == hashlib.sha256(canonical_json(value)).hexdigest()
+
+
+@settings(max_examples=300, deadline=None)
+@given(JSON_VALUES)
+def test_dropping_null_members_leaves_no_null_member(value):
+    def has_null_member(item):
+        if isinstance(item, dict):
+            return any(v is None or has_null_member(v) for v in item.values())
+        if isinstance(item, list):
+            return any(has_null_member(v) for v in item)
+        return False
+
+    stripped = drop_null_members(value)
+    assert not has_null_member(stripped)
+    assert drop_null_members(stripped) == stripped
+
+
+FIXTURES = Path(__file__).parent / "fixtures" / "uk_replay"
+
+
+@pytest.mark.parametrize(
+    ("name", "digest_key", "drop"),
+    [
+        ("qualification_request_golden", "request_id", None),
+        ("replay_receipt_golden", "receipt_sha256", "receipt_sha256"),
+    ],
+)
+def test_reproduces_the_scorecard_golden_bytes(name, digest_key, drop):
+    """Cross-implementation check against the replay corpus owner's fixtures."""
+
+    import hashlib
+
+    value = json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8"))
+    if drop:
+        value.pop(drop)
+    expected = (FIXTURES / f"{name}.jcs").read_bytes()
+    assert canonical_json(value) == expected
+    digests = json.loads((FIXTURES / "DIGESTS.json").read_text())
+    assert canonical_sha256(value) == digests[f"{name}.json"][digest_key]
+    assert hashlib.sha256(expected).hexdigest() == digests[f"{name}.json"][digest_key]
+
+
+def test_line_separators_are_written_literally():
+    # RFC 8785 escapes only the JSON-mandatory characters; ECMAScript writes
+    # U+2028 and U+2029 literally.
+    assert canonical_json("

") == '"

"'.encode()
+    assert canonical_json("\x7f") == b'"\x7f"'
+
+
+def test_dropping_nulls_keeps_array_elements():
+    assert drop_null_members([None, {"a": None, "b": [None]}]) == [None, {"b": [None]}]
+
+
+def test_numeric_subclasses_are_written_as_their_values():
+    import enum
+
+    import numpy as np
+
+    class Rate(enum.IntEnum):
+        BASIC = 20
+
+    class Loud(int):
+        def __str__(self):
+            return "twenty"
+
+        __repr__ = __str__
+
+    value = {
+        "a": np.float64(6.1e9),
+        "b": np.float64(1e22),
+        "c": Rate.BASIC,
+        "d": Loud(20),
+    }
+    plain = {"a": 6.1e9, "b": 1e22, "c": 20, "d": 20}
+    assert canonical_json(value) == canonical_json(plain) == rfc8785.dumps(plain)
+
+
+def test_deep_nesting_raises_a_canonical_error():
+    value: list = []
+    for _ in range(5000):
+        value = [value]
+    with pytest.raises(CanonicalJSONError):
+        canonical_json(value)
diff --git a/tests/test_execution_receipt.py b/tests/test_execution_receipt.py
new file mode 100644
index 00000000..209168cb
--- /dev/null
+++ b/tests/test_execution_receipt.py
@@ -0,0 +1,1179 @@
+"""Execution receipt schema v1 (policyengine.provenance.receipt).
+
+Invariants tested here:
+
+- a receipt claims certification only when the certificate covers the
+  installed model and core versions, the simulated dataset bytes and name,
+  and the running wrapper version;
+- verify_certificate accepts exactly the receipts the bundle certifies: a
+  receipt built from a bundle verifies against it, and changing any certified
+  member of the certificate or data, however consistently, is reported
+  (differential with the builder);
+- a wheel hash is recorded only when the builder verified it;
+- the execution identity depends only on runtime, data and certificate
+  content, never on how the builder was called; the receipt digest adds
+  reform, baseline, years and outputs, never runtime context or labels;
+- digests are over strict JSON: nothing is silently stringified, member
+  order and null-versus-omitted do not matter, and colliding keys are refused;
+- a reform digest binds content, or a module-level modifier's source,
+  module source and defaults plus a revision, independent of where the code
+  is checked out; code edited after import, closures, lambdas, partials,
+  methods and non-Scenario objects are refused;
+- the Pydantic model and the published JSON Schema accept the same valid
+  receipts and reject the same malformed ones (differential test).
+"""
+
+from __future__ import annotations
+
+import copy
+import datetime
+import decimal
+import enum
+import functools
+import hashlib
+import importlib.util
+import json
+import linecache
+import sys
+from importlib import metadata
+from pathlib import Path
+from types import SimpleNamespace
+
+import jsonschema
+import numpy as np
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+from pydantic import ValidationError
+
+from policyengine.provenance import receipt as receipt_module
+from policyengine.provenance.canonical import canonical_sha256
+from policyengine.provenance.receipt import (
+    ExecutionReceipt,
+    FiscalYearConvention,
+    ReceiptError,
+    ReformIdentity,
+    build_execution_receipt,
+    build_execution_receipt_for_microsimulation,
+    certificate_sha256,
+    content_sha256,
+    fiscal_year_label,
+    installed_package_identity,
+    reform_identity,
+    scenario_reform_identity,
+    strict_json_value,
+    uk_fiscal_year_convention,
+    verify_certificate,
+)
+from tests import receipt_modifiers
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BUNDLE_DIR = REPO_ROOT / "src" / "policyengine" / "data" / "bundle"
+BUNDLE_PATH = BUNDLE_DIR / "manifest.json"
+SCHEMA_PATH = (
+    REPO_ROOT
+    / "src"
+    / "policyengine"
+    / "data"
+    / "schemas"
+    / "execution_receipt.v1.schema.json"
+)
+SHA = st.text(alphabet="0123456789abcdef", min_size=64, max_size=64)
+INSTALLED_UK = metadata.version("policyengine-uk")
+INSTALLED_CORE = metadata.version("policyengine-core")
+INSTALLED_WRAPPER = metadata.version("policyengine")
+CURRENT_LAW = ReformIdentity(kind="current_law")
+REVISION = {
+    "repository": "PolicyEngine/uk-autumn-budget-dashboard-2026",
+    "git_sha": "a" * 40,
+}
+
+
+def _bundle_for(
+    dataset_bytes: bytes, *, model_version=INSTALLED_UK, core_version=INSTALLED_CORE
+) -> dict:
+    """The committed bundle with its UK certificate pointed at these bytes."""
+
+    bundle = json.loads(BUNDLE_PATH.read_text())
+    record = bundle["data_releases"]["uk"]
+    digest = hashlib.sha256(dataset_bytes).hexdigest()
+    record["certified_data_artifact"]["sha256"] = digest
+    record["datasets"][record["default_dataset"]]["sha256"] = digest
+    record["certification"]["certified_for_model_version"] = model_version
+    bundle["packages"]["policyengine-core"]["version"] = core_version
+    bundle["policyengine_version"] = INSTALLED_WRAPPER
+    record["policyengine_version"] = INSTALLED_WRAPPER
+    return bundle
+
+
+def _build(dataset_file, bundle=None, **kwargs):
+    kwargs.setdefault("years", [2026])
+    kwargs.setdefault("reform", CURRENT_LAW)
+    kwargs.setdefault("baseline", CURRENT_LAW)
+    return build_execution_receipt(
+        country_id="uk",
+        dataset_path=dataset_file,
+        bundle=bundle if bundle is not None else _bundle_for(dataset_file.read_bytes()),
+        **kwargs,
+    )
+
+
+@pytest.fixture
+def dataset_file(tmp_path):
+    path = tmp_path / "enhanced_frs_2024_25.h5"
+    path.write_bytes(b"certified dataset bytes")
+    return path
+
+
+@pytest.fixture
+def certified_receipt(dataset_file):
+    return _build(dataset_file, years=[2027, 2026, 2027])
+
+
+# --- Certification -----------------------------------------------------------
+
+
+def test_certified_when_certificate_covers_what_ran(certified_receipt, dataset_file):
+    receipt = certified_receipt
+    assert receipt.certification_status == "certified"
+    assert (
+        receipt.model.version
+        == receipt.certificate.certified_for_model_version
+        == INSTALLED_UK
+    )
+    assert (
+        receipt.core.version
+        == receipt.certificate.certified_for_core_version
+        == INSTALLED_CORE
+    )
+    assert (
+        receipt.data.dataset_sha256
+        == hashlib.sha256(dataset_file.read_bytes()).hexdigest()
+    )
+    assert receipt.years == [2026, 2027]
+    assert receipt.certificate.certificate_source == "caller_supplied"
+    record = _bundle_for(dataset_file.read_bytes())["data_releases"]["uk"]
+    assert receipt.certificate.certificate_sha256 == certificate_sha256(record)
+
+
+@pytest.mark.parametrize(
+    "change",
+    [
+        "model version",
+        "core version",
+        "wrapper version in bundle",
+        "wrapper version in record",
+        "dataset bytes",
+        "dataset name",
+    ],
+)
+def test_any_uncovered_component_makes_the_receipt_uncertified(
+    dataset_file, tmp_path, change
+):
+    data = dataset_file.read_bytes()
+    bundle = _bundle_for(data)
+    path = dataset_file
+    kwargs = {}
+    if change == "model version":
+        bundle = _bundle_for(data, model_version="0.0.1")
+    elif change == "core version":
+        bundle = _bundle_for(data, core_version="0.0.1")
+    elif change == "wrapper version in bundle":
+        bundle["policyengine_version"] = "0.0.0"
+    elif change == "wrapper version in record":
+        bundle["data_releases"]["uk"]["policyengine_version"] = "0.0.0"
+    elif change == "dataset bytes":
+        path = tmp_path / "other.h5"
+        path.write_bytes(b"different bytes")
+    elif change == "dataset name":
+        # Same bytes registered under another certified dataset name.
+        record = bundle["data_releases"]["uk"]
+        record["datasets"]["frs_2024_25"]["sha256"] = record["certified_data_artifact"][
+            "sha256"
+        ]
+        kwargs["dataset"] = "frs_2024_25"
+    receipt = _build(path, bundle, **kwargs)
+    assert receipt.certification_status == "uncertified"
+    assert receipt.certificate is None
+    assert receipt.model.version == INSTALLED_UK  # installed, never the manifest's
+
+
+def test_unknown_dataset_and_incomplete_bundles_are_refused(dataset_file):
+    with pytest.raises(ReceiptError):
+        _build(dataset_file, dataset="not_a_bundle_dataset")
+    bundle = _bundle_for(dataset_file.read_bytes())
+    del bundle["data_releases"]["uk"]["data_package"]["name"]
+    with pytest.raises(ReceiptError):
+        _build(dataset_file, bundle)
+    with pytest.raises(ReceiptError):
+        build_execution_receipt(
+            country_id="us",
+            dataset_path=dataset_file,
+            years=[2026],
+            reform=CURRENT_LAW,
+            baseline=CURRENT_LAW,
+        )
+
+
+def test_overlay_datasets_receive_uncertified_receipts(dataset_file):
+    bundle = _bundle_for(dataset_file.read_bytes())
+    overlay_name = next(iter(bundle["dataset_overlays"]["uk"]))
+    receipt = _build(dataset_file, bundle, dataset=overlay_name)
+    assert receipt.certification_status == "uncertified"
+    assert receipt.data.dataset == overlay_name
+    assert receipt.data.build_id is None
+
+
+def test_packaged_bundle_certificate_records_its_source_and_tro(tmp_path, monkeypatch):
+    """With the installed bundle, the certificate says packaged and names the TRO."""
+
+    from policyengine.provenance import certification
+    from policyengine.provenance.trace import extract_bundle_tro_reference
+
+    dataset = tmp_path / "data.h5"
+    dataset.write_bytes(b"packaged certified bytes")
+    packaged = tmp_path / "bundle"
+    packaged.mkdir()
+    (packaged / "manifest.json").write_text(
+        json.dumps(_bundle_for(dataset.read_bytes()))
+    )
+    (packaged / "uk.trace.tro.jsonld").write_bytes(
+        (BUNDLE_DIR / "uk.trace.tro.jsonld").read_bytes()
+    )
+    monkeypatch.setattr(
+        certification, "default_bundle_source_path", lambda: packaged / "manifest.json"
+    )
+    receipt = build_execution_receipt(
+        country_id="uk",
+        dataset_path=dataset,
+        years=[2026],
+        reform=CURRENT_LAW,
+        baseline=CURRENT_LAW,
+    )
+    assert receipt.certification_status == "certified"
+    assert receipt.certificate.certificate_source == "packaged"
+    expected = extract_bundle_tro_reference(
+        json.loads((BUNDLE_DIR / "uk.trace.tro.jsonld").read_text())
+    )
+    assert receipt.certificate.tro_composition_fingerprint == expected["fingerprint"]
+
+
+@pytest.mark.parametrize(
+    ("path", "value"),
+    [
+        (("certificate", "certified_for_model_version"), "0.0.1"),
+        (("certificate", "certified_for_core_version"), "0.0.1"),
+        (("certificate", "certified_dataset_sha256"), "0" * 64),
+        (("certificate", "bundle_version"), "0.0.0"),
+        (("certification_status",), "uncertified"),
+        (("years",), [2027, 2026]),
+        (("years",), [2026, 2026]),
+        (("model", "name"), "policyengine-us"),
+        (("core", "name"), "numpy"),
+        (("wrapper", "name"), "not-policyengine"),
+    ],
+)
+def test_receipts_cannot_claim_what_did_not_run(certified_receipt, path, value):
+    payload = certified_receipt.model_dump(mode="json")
+    target = payload
+    for key in path[:-1]:
+        target = target[key]
+    target[path[-1]] = value
+    with pytest.raises(ValidationError):
+        ExecutionReceipt.model_validate(payload)
+
+
+def test_strict_parsing_never_coerces(certified_receipt):
+    payload = certified_receipt.model_dump(mode="json")
+    for mutate in (
+        lambda p: p.update(years=["2026"]),
+        lambda p: p.update(years=[True]),
+        lambda p: p["model"].update(name="Policyengine_UK"),
+        lambda p: p["fiscal_year"].update(period_start="01-01"),
+    ):
+        changed = copy.deepcopy(payload)
+        mutate(changed)
+        with pytest.raises(ValidationError):
+            ExecutionReceipt.model_validate(changed)
+
+
+def test_verify_certificate_against_a_published_bundle(certified_receipt, dataset_file):
+    bundle = _bundle_for(dataset_file.read_bytes())
+    assert verify_certificate(certified_receipt, bundle) == []
+    tampered = copy.deepcopy(bundle)
+    tampered["data_releases"]["uk"]["certification"]["compatibility_basis"] = "trust me"
+    assert verify_certificate(certified_receipt, tampered)
+    assert verify_certificate(_build(dataset_file, _bundle_for(b"x")), bundle) == [
+        "the receipt is uncertified"
+    ]
+    assert verify_certificate(certified_receipt, {"data_releases": {}}) == [
+        "the bundle has no uk data release"
+    ]
+
+
+def _forge(receipt: ExecutionReceipt, **changes) -> ExecutionReceipt:
+    """A receipt edited consistently, so the model's own rules still accept it."""
+
+    payload = copy.deepcopy(receipt.model_dump(mode="json"))
+    for path, value in changes.items():
+        target = payload
+        *parents, last = path.split("__")
+        for key in parents:
+            target = target[key]
+        target[last] = value
+    return ExecutionReceipt.model_validate(payload)
+
+
+def test_a_forged_receipt_with_the_genuine_record_digest_does_not_verify(
+    certified_receipt, dataset_file
+):
+    """Round-2 review N1: the forgery kept certificate_sha256 and changed the rest."""
+
+    bundle = _bundle_for(dataset_file.read_bytes())
+    forged = _forge(
+        certified_receipt,
+        model__version="2.199.0",
+        certificate__certified_for_model_version="2.199.0",
+        data__dataset_sha256="e" * 64,
+        certificate__certified_dataset_sha256="e" * 64,
+        certificate__compatibility_basis="trust me",
+        certificate__certificate_source="packaged",
+    )
+    assert forged.certification_status == "certified"
+    assert forged.certificate.certificate_sha256 == (
+        certified_receipt.certificate.certificate_sha256
+    )
+    problems = verify_certificate(forged, bundle)
+    assert any("certified_for_model_version" in p for p in problems)
+    assert any("certified_dataset_sha256" in p for p in problems)
+    assert any("compatibility_basis" in p for p in problems)
+    assert any("data.dataset_sha256" in p for p in problems)
+
+
+#: Consistent edits a forger could make, each of which the model accepts.
+FORGERIES = {
+    "bundle version": {
+        "wrapper__version": "9.9.9",
+        "certificate__bundle_version": "9.9.9",
+    },
+    "model version": {
+        "model__version": "9.9.9",
+        "certificate__certified_for_model_version": "9.9.9",
+    },
+    "core version": {
+        "core__version": "9.9.9",
+        "certificate__certified_for_core_version": "9.9.9",
+    },
+    "dataset bytes": {
+        "data__dataset_sha256": "e" * 64,
+        "certificate__certified_dataset_sha256": "e" * 64,
+    },
+    "record digest": {"certificate__certificate_sha256": "e" * 64},
+    "basis": {"certificate__compatibility_basis": "built_with_model_package"},
+    "certifier": {"certificate__certified_by": "someone else"},
+    "dataset name": {"data__dataset": "frs_2024_25"},
+    "data release": {"data__release_version": "9.9.9"},
+    "data package": {"data__data_package": "policyengine-uk-data-public"},
+    "data build": {"data__build_id": "policyengine-uk-data-9.9.9"},
+    "build fingerprint": {"data__build_fingerprint": "sha256:" + "e" * 64},
+    "build model": {"data__build_model_version": "9.9.9"},
+    "manifest revision": {"data__release_manifest_revision": "e" * 40},
+    "dataset uri": {"data__dataset_uri": "hf://someone/else.h5@1.58.0"},
+    "dataset revision": {"data__dataset_revision": "main"},
+}
+
+
+@settings(max_examples=40, deadline=None)
+@given(
+    names=st.lists(
+        st.sampled_from(sorted(FORGERIES)), min_size=1, max_size=4, unique=True
+    )
+)
+def test_every_consistent_forgery_is_reported(tmp_path_factory, names):
+    dataset = tmp_path_factory.mktemp("forgery") / "data.h5"
+    dataset.write_bytes(b"forgery bytes")
+    bundle = _bundle_for(dataset.read_bytes())
+    genuine = _build(dataset, bundle)
+    assert verify_certificate(genuine, bundle) == []
+    changes = {path: value for name in names for path, value in FORGERIES[name].items()}
+    assert verify_certificate(_forge(genuine, **changes), bundle)
+
+
+@pytest.mark.parametrize(
+    "edit",
+    [
+        lambda b: b["data_releases"]["uk"]["certification"].update(
+            certified_by="someone else"
+        ),
+        lambda b: b["data_releases"]["uk"]["data_package"].update(version="9.9.9"),
+        lambda b: b["data_releases"]["uk"]["certified_data_artifact"].update(
+            dataset="frs_2024_25"
+        ),
+        lambda b: b["data_releases"]["uk"].update(policyengine_version="0.0.0"),
+        lambda b: b.update(policyengine_version="0.0.0"),
+        lambda b: b["packages"]["policyengine-core"].update(version="0.0.1"),
+    ],
+    ids=[
+        "certifier",
+        "data version",
+        "certified dataset",
+        "record version",
+        "bundle version",
+        "core",
+    ],
+)
+def test_verification_and_building_agree_on_each_bundle(dataset_file, edit):
+    """Differential: a receipt verifies against a bundle iff that bundle built it."""
+
+    bundle = _bundle_for(dataset_file.read_bytes())
+    other = copy.deepcopy(bundle)
+    edit(other)
+    receipt = _build(dataset_file, bundle)
+    assert verify_certificate(receipt, bundle) == []
+    assert verify_certificate(receipt, other)
+    rebuilt = _build(dataset_file, other)
+    if rebuilt.certification_status == "certified":
+        assert verify_certificate(rebuilt, other) == []
+        assert verify_certificate(rebuilt, bundle)
+
+
+# --- Wheel hashes ----------------------------------------------------------------
+
+
+def test_wheel_hashes_need_the_builder_to_verify_them(dataset_file, monkeypatch):
+    seen = {}
+
+    def verify(name, wheel_path, metadata):
+        seen[name] = wheel_path
+        return {"wheel_sha256": "a" * 64, "problems": []}
+
+    monkeypatch.setattr(
+        "policyengine.provenance.wheels.verify_installed_against_wheel", verify
+    )
+    receipt = _build(dataset_file, wheel_paths={"policyengine_uk": Path("uk.whl")})
+    assert receipt.model.wheel_sha256 == "a" * 64
+    assert receipt.model.wheel_sha256_evidence == "installed_record_verified"
+    assert seen == {"policyengine-uk": Path("uk.whl")}
+    monkeypatch.setattr(
+        "policyengine.provenance.wheels.verify_installed_against_wheel",
+        lambda name, wheel_path, metadata: {
+            "wheel_sha256": "a" * 64,
+            "problems": ["x.py: bytes differ"],
+        },
+    )
+    with pytest.raises(ReceiptError, match="differs"):
+        _build(dataset_file, wheel_paths={"policyengine-uk": Path("uk.whl")})
+
+
+def test_installer_archive_hash_is_read_from_direct_url(monkeypatch):
+    class Distribution:
+        version = INSTALLED_UK
+
+        def read_text(self, name):
+            assert name == "direct_url.json"
+            return json.dumps(
+                {
+                    "url": "file:///w.whl",
+                    "archive_info": {"hashes": {"sha256": "b" * 64}},
+                }
+            )
+
+    fake = SimpleNamespace(
+        version=lambda name: INSTALLED_UK, distribution=lambda name: Distribution()
+    )
+    monkeypatch.setattr(receipt_module, "metadata", fake)
+    identity = installed_package_identity("policyengine-uk")
+    assert (identity.wheel_sha256, identity.wheel_sha256_evidence) == (
+        "b" * 64,
+        "installer_archive_hash",
+    )
+
+
+def test_callers_cannot_supply_the_versions_a_receipt_records(dataset_file):
+    """Round-2 review N5: there is no public way to inject package metadata."""
+
+    import inspect
+
+    for function in (build_execution_receipt, installed_package_identity):
+        assert "metadata_module" not in inspect.signature(function).parameters
+    with pytest.raises(TypeError):
+        _build(dataset_file, metadata_module=SimpleNamespace())
+
+
+def test_hash_without_evidence_is_refused():
+    with pytest.raises(ValidationError):
+        receipt_module.PackageIdentity(name="x", version="1", wheel_sha256="c" * 64)
+
+
+# --- Digests ----------------------------------------------------------------------
+
+
+def test_json_round_trip_preserves_receipt_and_digests(certified_receipt):
+    for text in (
+        certified_receipt.model_dump_json(),
+        certified_receipt.to_canonical_json(),
+    ):
+        parsed = ExecutionReceipt.model_validate_json(text)
+        assert parsed == certified_receipt
+        assert parsed.receipt_sha256 == certified_receipt.receipt_sha256
+        assert (
+            parsed.execution_identity_sha256
+            == certified_receipt.execution_identity_sha256
+        )
+
+
+def test_null_and_omitted_fields_digest_identically(certified_receipt):
+    with_nulls = certified_receipt.model_dump(mode="json")
+    assert with_nulls["outputs_sha256"] is None
+    assert (
+        ExecutionReceipt.model_validate(with_nulls).receipt_sha256
+        == ExecutionReceipt.model_validate(
+            certified_receipt.canonical_payload()
+        ).receipt_sha256
+    )
+    assert content_sha256({"a": 1, "b": None}) == content_sha256({"a": 1})
+    assert content_sha256([None, {"a": None}]) == canonical_sha256([None, {}])
+
+
+def test_execution_identity_ignores_how_the_builder_was_called(tmp_path, monkeypatch):
+    from policyengine.provenance import certification
+
+    dataset = tmp_path / "data.h5"
+    dataset.write_bytes(b"identity bytes")
+    bundle = _bundle_for(dataset.read_bytes())
+    packaged = tmp_path / "bundle"
+    packaged.mkdir()
+    (packaged / "manifest.json").write_text(json.dumps(bundle))
+    (packaged / "uk.trace.tro.jsonld").write_bytes(
+        (BUNDLE_DIR / "uk.trace.tro.jsonld").read_bytes()
+    )
+    monkeypatch.setattr(
+        certification, "default_bundle_source_path", lambda: packaged / "manifest.json"
+    )
+    packaged_receipt = build_execution_receipt(
+        country_id="uk",
+        dataset_path=dataset,
+        years=[2026],
+        reform=CURRENT_LAW,
+        baseline=CURRENT_LAW,
+    )
+    supplied = _build(dataset, bundle, qualification_receipt_sha256="9" * 64)
+    assert packaged_receipt.certificate.tro_composition_fingerprint is not None
+    assert supplied.certificate.tro_composition_fingerprint is None
+    assert (
+        packaged_receipt.execution_identity_sha256 == supplied.execution_identity_sha256
+    )
+
+
+REFORM_PAYLOADS = st.dictionaries(
+    st.sampled_from(
+        [
+            "gov.hmrc.income_tax.rates.uk[0].rate",
+            "gov.hmrc.income_tax.allowances.personal_allowance.amount",
+            "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD",
+        ]
+    ),
+    st.dictionaries(
+        st.sampled_from(["2027", "2027-04-06", "2027-04-06.2028-04-05"]),
+        st.floats(min_value=0, max_value=1e6, allow_nan=False),
+        min_size=1,
+        max_size=3,
+    ),
+    min_size=1,
+    max_size=3,
+)
+
+
+@settings(max_examples=60, deadline=None)
+@given(
+    payload=REFORM_PAYLOADS,
+    years=st.lists(st.integers(2024, 2035), min_size=1, max_size=6),
+    outputs=st.dictionaries(
+        st.sampled_from(["budgetary_impact", "poverty_rate", "gini"]),
+        st.floats(allow_nan=False, allow_infinity=False),
+        max_size=3,
+    ),
+    label=st.text(max_size=10),
+)
+def test_digest_separation(tmp_path_factory, payload, years, outputs, label):
+    directory = tmp_path_factory.mktemp("receipt")
+    dataset = directory / "data.h5"
+    dataset.write_bytes(b"same bytes")
+    base = _build(dataset)
+    varied = _build(
+        dataset,
+        years=years,
+        reform=reform_identity(
+            payload, "policyengine.reform_dict/v1", name=label or None
+        ),
+        outputs=outputs or None,
+        ci_run_url="https://github.com/o/r/actions/runs/1",
+    )
+    assert varied.execution_identity_sha256 == base.execution_identity_sha256
+    assert (
+        varied.model_copy(update={"runtime": None}).receipt_sha256
+        == varied.receipt_sha256
+    )
+    renamed = varied.model_copy(
+        update={"reform": varied.reform.model_copy(update={"name": "other label"})}
+    )
+    assert renamed.receipt_sha256 == varied.receipt_sha256
+    assert varied.receipt_sha256 != base.receipt_sha256
+
+
+@settings(max_examples=60, deadline=None)
+@given(payload=REFORM_PAYLOADS, random=st.randoms())
+def test_reform_digest_ignores_member_order_but_not_representation(payload, random):
+    items = list(payload.items())
+    random.shuffle(items)
+    shuffled = {key: dict(reversed(list(value.items()))) for key, value in items}
+    a = reform_identity(payload, "policyengine.reform_dict/v1")
+    assert (
+        a.reform_sha256
+        == reform_identity(shuffled, "policyengine.reform_dict/v1").reform_sha256
+    )
+    assert (
+        a.reform_sha256
+        != reform_identity(payload, "policyengine-uk.reform_dict/v1").reform_sha256
+    )
+
+
+class Rate(enum.IntEnum):
+    BASIC = 20
+
+
+@pytest.mark.parametrize(
+    ("value", "expected"),
+    [
+        (np.int64(1234), 1234),
+        (np.float64(6.1e9), 6.1e9),
+        (np.bool_(True), True),
+        (Rate.BASIC, 20),
+        (datetime.date(2027, 4, 6), "2027-04-06"),
+        ({2027: 0.21}, {"2027": 0.21}),
+        ((1, 2), [1, 2]),
+    ],
+)
+def test_strict_json_converts_exact_values(value, expected):
+    converted = strict_json_value(value)
+    assert converted == expected and type(converted) is type(expected)
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        {1, 2},
+        decimal.Decimal("1.5"),
+        b"bytes",
+        np.arange(2000),
+        float("nan"),
+        np.float64("inf"),
+        {2027: 0.21, "2027": 0.25},
+        {(1, 2): 3},
+        datetime.datetime(2027, 4, 6, 12, 0),
+        object(),
+    ],
+)
+def test_strict_json_refuses_values_without_one_json_form(value):
+    with pytest.raises(ReceiptError):
+        strict_json_value(value)
+
+
+def test_strict_json_refuses_rather_than_overflowing_the_stack():
+    """Round-2 review N8: longdouble and deep nesting raise ReceiptError."""
+
+    longdouble = np.longdouble("0.1")
+    if isinstance(longdouble.item(), float):  # pragma: no cover - platform
+        assert strict_json_value(longdouble) == longdouble.item()
+    else:
+        with pytest.raises(ReceiptError):
+            strict_json_value(longdouble)
+    nested: list = []
+    for _ in range(sys.getrecursionlimit() + 100):
+        nested = [nested]
+    with pytest.raises(ReceiptError):
+        strict_json_value(nested)
+    with pytest.raises(ReceiptError):
+        content_sha256(nested)
+    with pytest.raises(ReceiptError):
+        content_sha256({"total": 2**53})
+
+
+def test_numpy_scalars_digest_like_the_published_json():
+    assert content_sha256({"budgetary_impact": np.int64(1234)}) == content_sha256(
+        {"budgetary_impact": 1234}
+    )
+    assert content_sha256({"budgetary_impact": np.int64(1234)}) != content_sha256(
+        {"budgetary_impact": "1234"}
+    )
+
+
+def test_reform_identity_accepts_only_json_payloads():
+    assert reform_identity(None).kind == "current_law"
+    assert reform_identity({}).kind == "current_law"
+    with pytest.raises(ReceiptError):
+        reform_identity({"a": {"2027": 1}})
+    for bad in ("gov.x", object(), SimpleNamespace(parameter_changes={"a": 1})):
+        with pytest.raises(ReceiptError):
+            reform_identity(bad, "policyengine.reform_dict/v1")
+    with pytest.raises(ReceiptError, match="scenario_reform_identity"):
+        reform_identity({"a": 1}, "policyengine-uk.scenario/v1")
+    with pytest.raises(ValidationError):
+        ReformIdentity(kind="current_law", reform_sha256="0" * 64)
+    with pytest.raises(ValidationError):
+        ReformIdentity(kind="reform", representation="policyengine.reform_dict/v1")
+    with pytest.raises(ValidationError):
+        ReformIdentity(kind="current_law", binding="code_revision")
+
+
+# --- Scenario binding -------------------------------------------------------------
+
+
+def _scenario(parameter_changes=None, simulation_modifier=None, early=False):
+    return SimpleNamespace(
+        parameter_changes=parameter_changes,
+        simulation_modifier=simulation_modifier,
+        applied_before_data_load=early,
+    )
+
+
+class _ReformClass:
+    """Stands in for a policyengine-core Reform subclass."""
+
+    parameter_changes = None
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.21}},
+        [{"parameter": "gov.x", "value": 1}],
+        "gov.hmrc.income_tax.rates.uk[0].rate",
+        _ReformClass,
+        SimpleNamespace(parameter_values=[], name="policyengine.py Policy"),
+        SimpleNamespace(parameter_changes={"a": {"2027": 1}}),
+        _scenario(early="yes"),
+        _scenario(parameter_changes=[("a", 1)]),
+        _scenario(simulation_modifier="raise_basic_rate"),
+    ],
+    ids=[
+        "reform dict",
+        "list",
+        "string",
+        "reform class",
+        "policy",
+        "partial scenario",
+        "non-bool timing",
+        "non-mapping changes",
+        "non-callable modifier",
+    ],
+)
+def test_objects_that_are_not_scenarios_are_refused_not_read_as_current_law(value):
+    """Round-2 review N6."""
+
+    with pytest.raises(ReceiptError):
+        scenario_reform_identity(value, code_revision=REVISION)
+
+
+def test_real_scenarios_are_accepted():
+    from policyengine_uk.utils.scenario import Scenario
+
+    changes = {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}}
+    assert scenario_reform_identity(Scenario()).kind == "current_law"
+    assert scenario_reform_identity(
+        Scenario(parameter_changes=changes)
+    ).reform_sha256 == (scenario_reform_identity(_scenario(changes)).reform_sha256)
+    bound = scenario_reform_identity(
+        Scenario(simulation_modifier=receipt_modifiers.raise_basic_rate),
+        code_revision=REVISION,
+    )
+    assert bound.binding == "code_revision"
+
+
+def test_parameter_scenarios_are_bound_by_content():
+    changes = {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027-04-06": 0.21}}
+    identity = scenario_reform_identity(_scenario(changes))
+    assert identity.binding == "content"
+    assert identity.reform_sha256 == content_sha256(
+        {
+            "representation": "policyengine-uk.scenario/v1",
+            "payload": {
+                "applied_before_data_load": False,
+                "parameter_changes": changes,
+                "simulation_modifier": None,
+            },
+        }
+    )
+    assert (
+        scenario_reform_identity(_scenario(changes, early=True)).reform_sha256
+        != identity.reform_sha256
+    )
+    assert scenario_reform_identity(_scenario()).kind == "current_law"
+
+
+def test_module_level_modifiers_are_bound_by_code_and_revision():
+    with pytest.raises(ReceiptError):
+        scenario_reform_identity(
+            _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate)
+        )
+    basic = scenario_reform_identity(
+        _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate),
+        code_revision=REVISION,
+    )
+    higher = scenario_reform_identity(
+        _scenario(simulation_modifier=receipt_modifiers.raise_higher_rate),
+        code_revision=REVISION,
+    )
+    moved = scenario_reform_identity(
+        _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate),
+        code_revision={**REVISION, "git_sha": "b" * 40},
+    )
+    assert basic.binding == "code_revision"
+    assert len({basic.reform_sha256, higher.reform_sha256, moved.reform_sha256}) == 3
+
+
+@pytest.mark.parametrize(
+    "modifier",
+    [
+        receipt_modifiers.make_modifier(0.21),
+        lambda simulation: None,
+        functools.partial(receipt_modifiers.raise_basic_rate),
+        receipt_modifiers.Measure(0.21).apply,
+        print,
+    ],
+    ids=["closure", "lambda", "partial", "bound method", "builtin"],
+)
+def test_modifiers_whose_behaviour_the_digest_cannot_bind_are_refused(modifier):
+    with pytest.raises(ReceiptError):
+        scenario_reform_identity(
+            _scenario(simulation_modifier=modifier), code_revision=REVISION
+        )
+
+
+@pytest.mark.parametrize(
+    "revision",
+    [
+        None,
+        {},
+        {"repository": "PolicyEngine/x"},
+        {"repository": "", "git_sha": "a" * 40},
+        {"repository": "PolicyEngine/x", "git_sha": "main"},
+        {"repository": "PolicyEngine/x", "git_sha": "A" * 40},
+    ],
+)
+def test_modifiers_need_a_full_code_revision(revision):
+    with pytest.raises(ReceiptError):
+        scenario_reform_identity(
+            _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate),
+            code_revision=revision,
+        )
+
+
+MEASURE_SOURCE = """
+RATE = {rate}
+
+
+def raise_rate(simulation):
+    simulation.rate = RATE
+"""
+
+
+def _import_measures(directory: Path, name: str, rate: float = 0.21):
+    """Import a measures module from ``directory`` as top-level ``name``."""
+
+    directory.mkdir(parents=True, exist_ok=True)
+    path = directory / f"{name}.py"
+    path.write_text(MEASURE_SOURCE.format(rate=rate))
+    linecache.checkcache(str(path))
+    spec = importlib.util.spec_from_file_location(name, path)
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[name] = module
+    spec.loader.exec_module(module)
+    return module, path
+
+
+def _measure_identity(module):
+    return scenario_reform_identity(
+        _scenario(simulation_modifier=module.raise_rate), code_revision=REVISION
+    )
+
+
+def test_modifier_digest_does_not_depend_on_the_checkout_location(tmp_path):
+    """Round-2 review N2: the same code and revision give the same digest anywhere."""
+
+    name = "w4_receipt_measures_location"
+    try:
+        first, _ = _import_measures(tmp_path / "checkout_a", name)
+        a = _measure_identity(first)
+        del sys.modules[name]
+        second, _ = _import_measures(tmp_path / "elsewhere" / "checkout_b", name)
+        b = _measure_identity(second)
+        assert first.raise_rate.__code__.co_filename != (
+            second.raise_rate.__code__.co_filename
+        )
+        assert a.reform_sha256 == b.reform_sha256
+        del sys.modules[name]
+        third, _ = _import_measures(tmp_path / "checkout_c", name, rate=0.99)
+        # The function is unchanged, but its module's source is not.
+        assert _measure_identity(third).reform_sha256 != a.reform_sha256
+    finally:
+        sys.modules.pop(name, None)
+
+
+def test_a_modifier_edited_after_import_is_refused(tmp_path):
+    name = "w4_receipt_measures_edited"
+    try:
+        module, path = _import_measures(tmp_path, name)
+        _measure_identity(module)
+        path.write_text(
+            path.read_text().replace(
+                "simulation.rate = RATE", "simulation.rate = RATE * 2  # edited"
+            )
+        )
+        with pytest.raises(ReceiptError, match="no longer matches"):
+            _measure_identity(module)
+    finally:
+        sys.modules.pop(name, None)
+
+
+def test_modifier_defaults_are_bound():
+    """Round-2 review N7: defaults change behaviour, so they change the digest."""
+
+    def identity(modifier):
+        return scenario_reform_identity(
+            _scenario(simulation_modifier=modifier), code_revision=REVISION
+        ).reform_sha256
+
+    to_rate = receipt_modifiers.raise_basic_rate_to
+    from_year = receipt_modifiers.raise_basic_rate_from
+    original = identity(to_rate), identity(from_year)
+    try:
+        to_rate.__defaults__ = (0.99,)
+        from_year.__kwdefaults__ = {"start": 2030}
+        assert identity(to_rate) != original[0]
+        assert identity(from_year) != original[1]
+    finally:
+        to_rate.__defaults__ = (0.21,)
+        from_year.__kwdefaults__ = {"start": 2027}
+    assert (identity(to_rate), identity(from_year)) == original
+    with pytest.raises(ReceiptError, match="default arguments"):
+        identity(receipt_modifiers.raise_with_object_default)
+
+
+def test_from_reform_scenarios_are_refused_in_favour_of_their_dictionary():
+    from policyengine_uk.utils.scenario import Scenario
+
+    basic = Scenario.from_reform(
+        {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.21}}
+    )
+    with pytest.raises(ReceiptError, match="reform dictionary"):
+        scenario_reform_identity(basic, code_revision=REVISION)
+    a = reform_identity(
+        {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.21}},
+        "policyengine-uk.reform_dict/v1",
+    )
+    b = reform_identity(
+        {"gov.hmrc.income_tax.rates.uk[0].rate": {"2027": 0.25}},
+        "policyengine-uk.reform_dict/v1",
+    )
+    assert a.reform_sha256 != b.reform_sha256
+
+
+def test_microsimulation_receipt_needs_its_reform_and_reads_the_loaded_dataset(
+    dataset_file,
+):
+    simulation = SimpleNamespace(
+        policyengine_bundle={
+            "country_id": "uk",
+            "runtime_dataset": "enhanced_frs_2024_25",
+            "runtime_dataset_source": str(dataset_file),
+        }
+    )
+    bundle = _bundle_for(dataset_file.read_bytes())
+    with pytest.raises(TypeError):
+        build_execution_receipt_for_microsimulation(
+            simulation, years=[2026], bundle=bundle
+        )
+    receipt = build_execution_receipt_for_microsimulation(
+        simulation,
+        years=[2026],
+        reform=CURRENT_LAW,
+        baseline=CURRENT_LAW,
+        bundle=bundle,
+    )
+    assert receipt.certification_status == "certified"
+    assert (
+        receipt.data.build_model_version
+        == bundle["data_releases"]["uk"]["certification"]["built_with_model_version"]
+    )
+    with pytest.raises(ReceiptError):
+        build_execution_receipt_for_microsimulation(
+            object(), years=[2026], reform=CURRENT_LAW, baseline=CURRENT_LAW
+        )
+
+
+# --- Fiscal years --------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    ("year", "label"), [(2027, "2027-28"), (1999, "1999-00"), (2009, "2009-10")]
+)
+def test_uk_fiscal_year_labels(year, label):
+    assert fiscal_year_label(year, uk_fiscal_year_convention()) == label
+
+
+def test_fiscal_year_convention_start_dates_are_fixed():
+    with pytest.raises(ValidationError):
+        FiscalYearConvention(
+            convention="uk_fiscal_year",
+            period_start="01-01",
+            parameter_annualisation="x",
+        )
+
+
+# --- The published JSON Schema agrees with the Pydantic model ------------------------
+
+
+@pytest.fixture(scope="module")
+def schema_validator():
+    schema = json.loads(SCHEMA_PATH.read_text())
+    jsonschema.Draft202012Validator.check_schema(schema)
+    return jsonschema.Draft202012Validator(schema)
+
+
+def test_published_schema_is_current():
+    assert json.loads(SCHEMA_PATH.read_text()) == ExecutionReceipt.json_schema()
+
+
+@settings(max_examples=40, deadline=None)
+@given(
+    years=st.lists(st.integers(2020, 2040), min_size=1, max_size=5),
+    payload=st.none() | REFORM_PAYLOADS,
+    certified=st.booleans(),
+    modifier=st.booleans(),
+    git_sha=st.none() | st.just("c" * 40),
+    qualification=st.none() | SHA,
+)
+def test_schema_accepts_every_valid_receipt(
+    tmp_path_factory,
+    schema_validator,
+    years,
+    payload,
+    certified,
+    modifier,
+    git_sha,
+    qualification,
+):
+    directory = tmp_path_factory.mktemp("schema")
+    dataset = directory / "data.h5"
+    dataset.write_bytes(b"bytes")
+    reform = (
+        scenario_reform_identity(
+            _scenario(simulation_modifier=receipt_modifiers.raise_basic_rate),
+            code_revision=REVISION,
+        )
+        if modifier
+        else reform_identity(payload, "policyengine.reform_dict/v1")
+    )
+    receipt = _build(
+        dataset,
+        _bundle_for(b"bytes" if certified else b"other"),
+        years=years,
+        reform=reform,
+        wrapper_git_sha=git_sha,
+        qualification_receipt_sha256=qualification,
+        outputs={"budgetary_impact": 1.5e9},
+    )
+    schema_validator.validate(json.loads(receipt.model_dump_json()))
+    schema_validator.validate(json.loads(receipt.to_canonical_json()))
+
+
+MUTATIONS = [
+    ("extra top-level member", lambda r: r.update(unexpected=1)),
+    ("bad schema version", lambda r: r.update(schema_version=2)),
+    ("bad receipt type", lambda r: r.update(receipt_type="other")),
+    ("US receipt", lambda r: r.update(country_id="us")),
+    ("missing data", lambda r: r.pop("data")),
+    ("bad dataset digest", lambda r: r["data"].update(dataset_sha256="XYZ")),
+    ("short dataset digest", lambda r: r["data"].update(dataset_sha256="a" * 63)),
+    ("extra data member", lambda r: r["data"].update(rows=10)),
+    ("empty years", lambda r: r.update(years=[])),
+    ("string year", lambda r: r.update(years=["2026"])),
+    ("bad period start", lambda r: r["fiscal_year"].update(period_start="April")),
+    ("calendar period start", lambda r: r["fiscal_year"].update(period_start="01-01")),
+    (
+        "calendar convention",
+        lambda r: r["fiscal_year"].update(
+            convention="calendar_year", period_start="01-01"
+        ),
+    ),
+    ("US model", lambda r: r["model"].update(name="policyengine-us")),
+    ("wrong core", lambda r: r["core"].update(name="numpy")),
+    ("wrong wrapper", lambda r: r["wrapper"].update(name="policyengine-uk")),
+    ("manifest digest", lambda r: r["data"].update(release_manifest_sha256="a" * 64)),
+    ("bad wheel digest", lambda r: r["model"].update(wheel_sha256="1234")),
+    ("unnormalized name", lambda r: r["model"].update(name="Policyengine_UK")),
+    ("bad git sha", lambda r: r["wrapper"].update(git_sha="abc")),
+    (
+        "missing certificate source",
+        lambda r: r["certificate"].pop("certificate_source"),
+    ),
+    (
+        "missing certified core",
+        lambda r: r["certificate"].pop("certified_for_core_version"),
+    ),
+    (
+        "unknown representation",
+        lambda r: r["reform"].update(
+            kind="reform", representation="pickle", reform_sha256="a" * 64
+        ),
+    ),
+]
+
+
+@pytest.mark.parametrize(("label", "mutate"), MUTATIONS, ids=[m[0] for m in MUTATIONS])
+def test_schema_and_model_reject_the_same_malformed_receipts(
+    certified_receipt, schema_validator, label, mutate
+):
+    payload = copy.deepcopy(certified_receipt.model_dump(mode="json"))
+    mutate(payload)
+    assert not schema_validator.is_valid(payload), label
+    with pytest.raises(ValidationError):
+        ExecutionReceipt.model_validate(payload)
+
+
+def test_schema_publishes_the_rules_it_cannot_express(schema_validator):
+    rules = schema_validator.schema["x-policyengine-rules"]
+    assert "certificate.certified_for_core_version equals core.version." in rules
+    assert any("verify_certificate()" in rule for rule in rules)
+    assert any(
+        "certificate_source is the producer's statement" in rule for rule in rules
+    )
+    assert not any("requires certificate_source packaged" in rule for rule in rules)
+    properties = schema_validator.schema["$defs"]
+    assert properties["ModelIdentity"]["properties"]["name"]["const"] == (
+        "policyengine-uk"
+    )
+    assert properties["FiscalYearConvention"]["properties"]["period_start"][
+        "const"
+    ] == ("04-06")
+    assert (
+        "RFC 8785"
+        in schema_validator.schema["x-policyengine-digests"]["canonicalization"]
+    )
+
+
+def test_execution_identity_preimage(certified_receipt):
+    identity = certified_receipt.execution_identity()
+    assert set(identity) == {
+        "schema_version",
+        "country_id",
+        "wrapper",
+        "model",
+        "core",
+        "data",
+        "certification_status",
+        "certificate",
+    }
+    assert "certificate_source" not in identity["certificate"]
+    assert certified_receipt.execution_identity_sha256 == canonical_sha256(identity)
diff --git a/tests/test_qualification_contracts.py b/tests/test_qualification_contracts.py
new file mode 100644
index 00000000..642d6747
--- /dev/null
+++ b/tests/test_qualification_contracts.py
@@ -0,0 +1,717 @@
+"""Candidate qualification contracts, promotion order and the replay interface.
+
+Invariants:
+
+- the verdict is a pure, monotone function of the gate results: making any
+  gate worse never makes the verdict better, and a missing gate is never
+  qualified;
+- a review acceptance is bound to the exact receipt it accepts;
+- promotion picks the newest promotable pair that strictly advances the
+  certified pair, whatever order receipts arrive in, and never moves
+  backwards;
+- 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.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+
+import jsonschema
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+from pydantic import ValidationError
+
+from policyengine.provenance.canonical import canonical_sha256
+from policyengine.provenance.qualification import (
+    REQUIRED_GATES,
+    GateResult,
+    ReplayQualificationRequest,
+    ReplayReceipt,
+    ReplayStatus,
+    ReviewAcceptance,
+    dominates,
+    evaluate_replay_receipt,
+    qualification_verdict,
+    select_promotion_target,
+)
+from tests.qualification_fixtures import (
+    FIXTURES,
+    VENDORED_SCHEMAS,
+    gates,
+    golden,
+    make_pair,
+    make_receipt,
+    sha,
+)
+
+STATUSES = ["pass", "needs_review", "unavailable", "fail", "error"]
+SEVERITY = {"pass": 0, "needs_review": 1, "unavailable": 2, "fail": 3, "error": 3}
+VERDICT_RANK = {"qualified": 0, "needs_review": 1, "incomplete": 2, "rejected": 3}
+
+
+# --- Verdict ------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    ("overrides", "verdict"),
+    [
+        ({}, "qualified"),
+        ({"replay": "needs_review"}, "needs_review"),
+        ({"reform_contract": "unavailable"}, "incomplete"),
+        ({"metrics_review": "unavailable", "replay": "needs_review"}, "incomplete"),
+        ({"wheel_hashes": "fail", "replay": "unavailable"}, "rejected"),
+        ({"budget_inputs": "error"}, "rejected"),
+    ],
+)
+def test_verdict_examples(overrides, verdict):
+    assert qualification_verdict(gates(**overrides)) == verdict
+
+
+def test_a_missing_required_gate_is_never_qualified():
+    for gate_id in REQUIRED_GATES:
+        partial = [gate for gate in gates() if gate.gate_id != gate_id]
+        assert qualification_verdict(partial) == "incomplete"
+
+
+@settings(max_examples=300, deadline=None)
+@given(
+    statuses=st.lists(
+        st.sampled_from(STATUSES),
+        min_size=len(REQUIRED_GATES),
+        max_size=len(REQUIRED_GATES),
+    ),
+    index=st.integers(0, len(REQUIRED_GATES) - 1),
+    worse=st.sampled_from(STATUSES),
+)
+def test_worsening_a_gate_never_improves_the_verdict(statuses, index, worse):
+    before = [
+        GateResult(gate_id=gate_id, status=status, summary="x")
+        for gate_id, status in zip(REQUIRED_GATES, statuses)
+    ]
+    if SEVERITY[worse] < SEVERITY[statuses[index]]:
+        return
+    after = list(before)
+    after[index] = GateResult(gate_id=REQUIRED_GATES[index], status=worse, summary="x")
+    assert (
+        VERDICT_RANK[qualification_verdict(after)]
+        >= VERDICT_RANK[qualification_verdict(before)]
+    )
+
+
+def test_receipt_verdict_must_follow_from_gates():
+    receipt = make_receipt(gate_results=gates(replay="needs_review"))
+    payload = receipt.model_dump(mode="json")
+    payload["verdict"] = "qualified"
+    with pytest.raises(ValidationError):
+        type(receipt).model_validate(payload)
+
+
+def test_pair_id_must_be_the_pair_digest():
+    payload = make_receipt().model_dump(mode="json")
+    payload["pair_id"] = sha("0")
+    with pytest.raises(ValidationError):
+        type(make_receipt()).model_validate(payload)
+
+
+# --- Review acceptance -----------------------------------------------------------
+
+
+def _accepted(receipt, digest=None):
+    return receipt.model_copy(
+        update={
+            "review": ReviewAcceptance(
+                reviewer="vahid",
+                accepted_at="2026-10-16T10:00:00Z",
+                accepted_receipt_sha256=digest or receipt.unreviewed_sha256,
+                explanation="UC moves follow the documented #2242 uplift.",
+            )
+        }
+    )
+
+
+def test_only_the_accepted_receipt_is_promotable():
+    receipt = make_receipt(gate_results=gates(replay="needs_review"))
+    assert not receipt.promotable
+    assert _accepted(receipt).promotable
+    assert not _accepted(receipt, digest=sha("1")).promotable
+    changed = receipt.model_copy(update={"completed_at": "2026-10-17T00:00:00Z"})
+    stale_acceptance = _accepted(changed, digest=receipt.unreviewed_sha256)
+    assert not stale_acceptance.promotable
+
+
+def test_review_cannot_attach_to_a_rejected_receipt():
+    receipt = make_receipt(gate_results=gates(wheel_hashes="fail"))
+    with pytest.raises(ValidationError):
+        type(receipt).model_validate(
+            {
+                **receipt.model_dump(mode="json"),
+                "review": _accepted(
+                    make_receipt(gate_results=gates(replay="needs_review"))
+                ).review.model_dump(),
+            }
+        )
+
+
+# --- Promotion order --------------------------------------------------------------
+
+VERSIONS = st.tuples(
+    st.sampled_from(["2.125.1", "2.126.6", "2.126.8", "2.127.0"]),
+    st.sampled_from(["1.58.0", "1.59.0", "1.60.0"]),
+    st.sampled_from(["3.33.0", "3.33.1"]),
+)
+
+
+@settings(max_examples=200, deadline=None)
+@given(a=VERSIONS, b=VERSIONS, c=VERSIONS)
+def test_dominance_is_a_strict_partial_order(a, b, c):
+    assert not dominates(a, a)
+    assert not (dominates(a, b) and dominates(b, a))
+    if dominates(a, b) and dominates(b, c):
+        assert dominates(a, c)
+
+
+RECEIPT_SPECS = st.lists(
+    st.tuples(
+        VERSIONS, st.sampled_from(["pass", "needs_review", "fail", "unavailable"])
+    ),
+    min_size=0,
+    max_size=6,
+)
+
+
+def _receipts(specs):
+    receipts = []
+    for index, ((model, data, core), status) in enumerate(specs):
+        receipts.append(
+            make_receipt(
+                pair=make_pair(model=model, data=data, core=core),
+                gate_results=gates(replay=status),
+                completed_at=f"2026-10-15T09:00:{index:02d}Z",
+            )
+        )
+    return receipts
+
+
+@settings(max_examples=200, deadline=None)
+@given(specs=RECEIPT_SPECS, current=VERSIONS, data=st.data())
+def test_promotion_target_is_order_independent_and_never_backwards(
+    specs, current, data
+):
+    receipts = _receipts(specs)
+    shuffled = data.draw(st.permutations(receipts))
+    target = select_promotion_target(receipts, current)
+    assert select_promotion_target(shuffled, current) == target
+    if target is None:
+        return
+    assert target.promotable
+    assert dominates(target.pair.order_key, current)
+    for other in receipts:
+        if other.promotable and dominates(other.pair.order_key, current):
+            key = other.pair.order_key
+            assert key == target.pair.order_key or dominates(target.pair.order_key, key)
+
+
+def test_a_stale_completion_cannot_displace_a_newer_accepted_candidate():
+    newer = make_receipt(
+        pair=make_pair(model="2.126.8"), completed_at="2026-10-15T09:00:00Z"
+    )
+    stale = make_receipt(
+        pair=make_pair(model="2.126.6"), completed_at="2026-10-15T12:00:00Z"
+    )
+    current = ("2.125.1", "1.58.0", "3.33.0")
+    assert select_promotion_target([newer, stale], current) == newer
+    assert select_promotion_target([stale, newer], current) == newer
+
+
+def test_incomparable_newest_pairs_promote_nothing_automatically():
+    newer_model = make_receipt(pair=make_pair(model="2.127.0", data="1.58.0"))
+    newer_data = make_receipt(pair=make_pair(model="2.126.8", data="1.59.0"))
+    assert (
+        select_promotion_target(
+            [newer_model, newer_data], ("2.125.1", "1.58.0", "3.33.0")
+        )
+        is None
+    )
+
+
+# --- Replay interface: mirrors agree with the vendored schemas ---------------
+
+
+def _validator(name):
+    schema = json.loads((VENDORED_SCHEMAS / name).read_text())
+    return jsonschema.Draft202012Validator(
+        schema, format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER
+    )
+
+
+def test_golden_request_round_trips_with_its_digest():
+    raw = golden("qualification_request_golden")
+    request = ReplayQualificationRequest.model_validate(raw)
+    assert request.document() == raw
+    digests = json.loads((FIXTURES / "DIGESTS.json").read_text())
+    assert request.request_id == canonical_sha256(raw)
+    assert (
+        request.request_id == digests["qualification_request_golden.json"]["request_id"]
+    )
+    _validator("qualification_request.v1.json").validate(request.document())
+
+
+def test_golden_receipt_digest_matches_received_bytes():
+    raw = golden("replay_receipt_golden")
+    receipt = ReplayReceipt.from_document(raw)
+    assert receipt.computed_receipt_sha256 == raw["receipt_sha256"]
+    _validator("replay_receipt.v1.json").validate(raw)
+
+
+REQUEST_MUTATIONS = [
+    ("extra member", lambda r: r.update(unexpected=True)),
+    ("bad kind", lambda r: r.update(kind="other")),
+    ("attempt zero", lambda r: r.update(attempt=0)),
+    ("bad dataset digest", lambda r: r["candidate"]["data"].update(sha256="xyz")),
+    ("bad repo type", lambda r: r["candidate"]["data"].update(repo_type="space")),
+    (
+        "wrapper with both sources",
+        lambda r: r["candidate"]["policyengine"]["source"].update(
+            pypi={"wheel_sha256": "a" * 64}
+        ),
+    ),
+    ("released bundle from git", lambda r: r.update(kind="released_bundle")),
+    (
+        "missing bundle field",
+        lambda r: r["candidate"]["expected_release_bundle"].pop("model_version"),
+    ),
+    ("bad commit", lambda r: r["requested_by"].update(commit="abc")),
+    ("missing commit", lambda r: r["requested_by"].pop("commit")),
+]
+
+
+@pytest.mark.parametrize(
+    ("label", "mutate"), REQUEST_MUTATIONS, ids=[m[0] for m in REQUEST_MUTATIONS]
+)
+def test_request_mirror_and_schema_reject_the_same_requests(label, mutate):
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    mutate(raw)
+    assert not _validator("qualification_request.v1.json").is_valid(raw), label
+    with pytest.raises(ValidationError):
+        ReplayQualificationRequest.model_validate(raw)
+
+
+REQUEST_MUTATIONS_V1_TIGHTENED = [
+    ("parent segment", "../enhanced_frs_2024_25.h5"),
+    ("nested parent", "a/../b.h5"),
+    ("trailing parent", "a/.."),
+]
+
+
+@pytest.mark.parametrize(
+    ("label", "path"),
+    REQUEST_MUTATIONS_V1_TIGHTENED,
+    ids=[m[0] for m in REQUEST_MUTATIONS_V1_TIGHTENED],
+)
+def test_request_mirror_and_schema_refuse_parent_segments(label, path):
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    raw["candidate"]["data"]["path"] = path
+    assert not _validator("qualification_request.v1.json").is_valid(raw), label
+    with pytest.raises(ValidationError):
+        ReplayQualificationRequest.model_validate(raw)
+
+
+def test_request_mirror_and_schema_accept_dotted_names():
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    raw["candidate"]["data"]["path"] = "releases/1.58.0/..hidden.h5"
+    _validator("qualification_request.v1.json").validate(raw)
+    ReplayQualificationRequest.model_validate(raw)
+
+
+def _rejected_receipt():
+    doc = copy.deepcopy(golden("replay_receipt_golden"))
+    doc.update(
+        status="rejected",
+        rejection={"code": "artifact_hash_mismatch", "detail": "dataset bytes differ"},
+        corpus=None,
+        diff=None,
+        variant_id=None,
+        variant_identity_sha256=None,
+        execution=None,
+    )
+    doc["integrity"] = {"status": "not_run", "failures": []}
+    return _sealed(doc)
+
+
+RECEIPT_MUTATIONS = [
+    (
+        "complete without manifest",
+        "complete",
+        lambda r: r["results"].pop("manifest_sha256"),
+    ),
+    (
+        "complete without identity",
+        "complete",
+        lambda r: r.update(variant_identity_sha256=None),
+    ),
+    (
+        "unanchored python",
+        "complete",
+        lambda r: r["execution"].update(python="3.12.14+local"),
+    ),
+    ("rejected with variant", "rejected", lambda r: r.update(variant_id="pe6.2.6")),
+    (
+        "rejected with identity",
+        "rejected",
+        lambda r: r.update(variant_identity_sha256="0" * 64),
+    ),
+    (
+        "rejected with integrity",
+        "rejected",
+        lambda r: r.update(integrity={"status": "failed", "failures": []}),
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    ("label", "base", "mutate"),
+    RECEIPT_MUTATIONS,
+    ids=[m[0] for m in RECEIPT_MUTATIONS],
+)
+def test_receipt_mirror_and_schema_reject_the_same_receipts(label, base, mutate):
+    """Differential against W6's tightened replay_receipt.v1.json (dc040de)."""
+
+    raw = (
+        copy.deepcopy(golden("replay_receipt_golden"))
+        if base == "complete"
+        else _rejected_receipt()
+    )
+    _validator("replay_receipt.v1.json").validate(raw)
+    ReplayReceipt.from_document(raw)
+    mutate(raw)
+    raw = _sealed(raw)
+    assert not _validator("replay_receipt.v1.json").is_valid(raw), label
+    with pytest.raises(ValidationError):
+        ReplayReceipt.from_document(raw)
+
+
+def test_requested_by_is_open_as_in_the_schema():
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    raw["requested_by"]["pair_id"] = sha("a")
+    request = ReplayQualificationRequest.model_validate(raw)
+    assert request.document()["requested_by"]["pair_id"] == sha("a")
+    _validator("qualification_request.v1.json").validate(request.document())
+
+
+# --- Replay gate ------------------------------------------------------------------
+
+
+def _sealed(receipt_doc):
+    doc = {key: value for key, value in receipt_doc.items() if key != "receipt_sha256"}
+    doc["receipt_sha256"] = canonical_sha256(doc)
+    return doc
+
+
+def _status(request_id, receipt_doc, status=None):
+    return ReplayStatus.model_validate(
+        {
+            "schema": "policyengine.uk-replay-status/v1",
+            "request_id": request_id,
+            "status": status or receipt_doc["status"],
+            "history": [
+                {
+                    "status": status or receipt_doc["status"],
+                    "at": "2026-10-15T10:00:00Z",
+                    "run_url": "https://github.com/PolicyEngine/policyengine-scorecard/actions/runs/7",
+                }
+            ],
+            "receipt_sha256": receipt_doc["receipt_sha256"],
+            "receipt_commit": "c" * 40,
+        }
+    )
+
+
+def _gate(receipt_doc, request=None, status=None):
+    request = request or ReplayQualificationRequest.model_validate(
+        golden("qualification_request_golden")
+    )
+    receipt = ReplayReceipt.from_document(receipt_doc)
+    return evaluate_replay_receipt(
+        request, status or _status(request.request_id, receipt_doc), receipt
+    )
+
+
+def _with_requested_wheels(doc):
+    """Add artifact evidence omitted by the scorecard's synthetic golden fixture."""
+
+    requested = golden("qualification_request_golden")["candidate"]
+    for package, request_key in (
+        ("policyengine-uk", "policyengine_uk"),
+        ("policyengine-core", "policyengine_core"),
+    ):
+        doc["execution"]["packages"][package]["wheel_sha256"] = requested[request_key][
+            "wheel_sha256"
+        ]
+    return doc
+
+
+def _with_counts(sealed=True, **counts):
+    """The golden receipt with these flag counts and one flagged row per flag."""
+
+    doc = _with_requested_wheels(copy.deepcopy(golden("replay_receipt_golden")))
+    base = {key: 0 for key in doc["diff"]["flag_counts"]}
+    base.update(counts)
+    doc["diff"]["flag_counts"] = base
+    template = doc["diff"]["flagged_rows"][0]
+    doc["diff"]["flagged_rows"] = [
+        {**template, "flags": [kind], "year": 2024 + index}
+        for kind, count in counts.items()
+        for index in range(count)
+    ]
+    return _sealed(doc) if sealed else doc
+
+
+def test_golden_receipt_fails_on_its_structural_flag():
+    doc = _with_requested_wheels(copy.deepcopy(golden("replay_receipt_golden")))
+    gate = _gate(_sealed(doc))
+    assert gate.status == "fail"
+    assert "structural" in gate.summary
+
+
+@pytest.mark.parametrize(
+    ("counts", "status"),
+    [
+        ({}, "pass"),
+        ({"numerical": 2}, "needs_review"),
+        ({"support": 1}, "needs_review"),
+        ({"numerical": 2, "coverage": 1}, "fail"),
+        ({"construction": 1}, "fail"),
+        ({"inertness": 1}, "fail"),
+        ({"mapping": 3}, "fail"),
+    ],
+)
+def test_flag_policy(counts, status):
+    assert _gate(_with_counts(**counts)).status == status
+
+
+def _clean_receipt():
+    return _with_counts(sealed=False)
+
+
+def _edit(receipt, edit):
+    edit(receipt)
+    return _sealed(receipt)
+
+
+EVENT = "spring_budget_2023"
+
+CONTRADICTIONS = {
+    "a structural row with zero counts": lambda r: r["diff"].update(
+        flagged_rows=[{**golden("replay_receipt_golden")["diff"]["flagged_rows"][1]}]
+    ),
+    "a failure under complete integrity": lambda r: r["integrity"].update(
+        failures=[{"code": "construction", "detail": "measure failed"}]
+    ),
+    "supported years with no artifacts": lambda r: r["corpus"]["events"][EVENT].update(
+        artifacts=0
+    ),
+    "nothing replayed": lambda r: r["corpus"]["events"][EVENT].update(
+        artifacts=0, supported_years=[]
+    ),
+    "an event with no years": lambda r: r["corpus"]["events"][EVENT].update(
+        supported_years=[], unsupported_years=[]
+    ),
+}
+
+
+@pytest.mark.parametrize("name", sorted(CONTRADICTIONS))
+def test_a_receipt_that_contradicts_itself_fails(name):
+    """Interrupted review of #574: each of these was schema-valid, correctly
+    digested, and passed the gate because the summary counters were zero."""
+
+    receipt = _edit(_clean_receipt(), CONTRADICTIONS[name])
+    _validator("replay_receipt.v1.json").validate(receipt)
+    gate = _gate(receipt)
+    assert gate.status == "fail", (name, gate.summary)
+    assert "contradicts itself or the request" in gate.summary
+
+
+def test_blocking_flags_count_wherever_the_receipt_states_them():
+    """A row's flag blocks even when its counter is understated."""
+
+    receipt = _with_counts(sealed=False, numerical=1)
+    receipt["diff"]["flagged_rows"].append(
+        {**receipt["diff"]["flagged_rows"][0], "flags": ["mapping"], "year": 2030}
+    )
+    receipt["diff"]["flag_counts"]["mapping"] = 1
+    assert _gate(_sealed(receipt)).status == "fail"
+    assert "mapping" in _gate(_sealed(receipt)).summary
+
+
+def test_an_inert_measure_year_without_a_flag_needs_review():
+    receipt = _edit(
+        _clean_receipt(),
+        lambda r: r["corpus"]["events"][EVENT].update(
+            inert_measure_years=[{"measure_key": "uc_childcare", "year": 2026}]
+        ),
+    )
+    gate = _gate(receipt)
+    assert gate.status == "needs_review" and "inert" in gate.summary
+
+
+def test_the_comparison_base_must_be_the_one_requested():
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    base_variant = _clean_receipt()["diff"]["base_variant_id"]
+    raw["compare_to"] = base_variant
+    explicit = ReplayQualificationRequest.model_validate(raw)
+    receipt = _clean_receipt()
+    receipt["request_id"] = explicit.request_id
+    assert _gate(_sealed(receipt), request=explicit).status == "pass"
+    raw["compare_to"] = "pe6.2.6_uk2.125.1_policyengine-uk-data-1.58.0"
+    other = ReplayQualificationRequest.model_validate(raw)
+    receipt = _clean_receipt()
+    receipt["request_id"] = other.request_id
+    gate = _gate(_sealed(receipt), request=other)
+    assert gate.status == "fail" and "asked for a comparison" in gate.summary
+
+
+def test_incomplete_or_failed_integrity_fails():
+    doc = copy.deepcopy(golden("replay_receipt_golden"))
+    doc["diff"]["flag_counts"] = {key: 0 for key in doc["diff"]["flag_counts"]}
+    doc["integrity"]["status"] = "incomplete"
+    assert _gate(_sealed(doc)).status == "fail"
+    doc["integrity"] = {
+        "status": "failed",
+        "failures": [{"code": "non_finite", "detail": "NaN in VAT"}],
+    }
+    doc["status"] = "failed"
+    assert _gate(_sealed(doc)).status == "fail"
+
+
+def test_execution_of_other_artifacts_fails():
+    for mutate in (
+        lambda d: d["execution"].update(dataset_sha256="0" * 64),
+        lambda d: d["execution"].update(packaged_bundle_manifest_sha256="0" * 64),
+        lambda d: d["execution"]["packages"]["policyengine-uk"].update(version="9.9.9"),
+        lambda d: d["execution"]["release_bundle"].update(model_version="9.9.9"),
+    ):
+        doc = copy.deepcopy(_with_counts())
+        mutate(doc)
+        assert _gate(_sealed(doc)).status == "fail"
+
+
+@pytest.mark.parametrize("package", ["policyengine-uk", "policyengine-core"])
+@pytest.mark.parametrize("wheel_sha256", [None, "0" * 64])
+def test_replay_requires_exact_country_and_core_wheels(package, wheel_sha256):
+    doc = _clean_receipt()
+    installed = doc["execution"]["packages"][package]
+    if wheel_sha256 is None:
+        installed.pop("wheel_sha256")
+    else:
+        installed["wheel_sha256"] = wheel_sha256
+    gate = _gate(_sealed(doc))
+    assert gate.status == "fail"
+    assert f"{package} wheel hash" in gate.summary
+
+
+@pytest.mark.parametrize("wheel_sha256", [None, "0" * 64])
+def test_released_bundle_replay_requires_requested_wrapper_wheel(wheel_sha256):
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    raw["kind"] = "released_bundle"
+    raw["candidate"]["policyengine"]["source"] = {"pypi": {"wheel_sha256": "a" * 64}}
+    request = ReplayQualificationRequest.model_validate(raw)
+    doc = _clean_receipt()
+    doc["request_id"] = request.request_id
+    if wheel_sha256 is not None:
+        doc["execution"]["packages"]["policyengine"]["wheel_sha256"] = wheel_sha256
+    gate = _gate(_sealed(doc), request=request)
+    assert gate.status == "fail"
+    assert "policyengine wheel hash" in gate.summary
+
+
+def test_released_bundle_replay_accepts_exact_requested_wrapper_wheel():
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    raw["kind"] = "released_bundle"
+    raw["candidate"]["policyengine"]["source"] = {"pypi": {"wheel_sha256": "a" * 64}}
+    request = ReplayQualificationRequest.model_validate(raw)
+    doc = _clean_receipt()
+    doc["request_id"] = request.request_id
+    doc["execution"]["packages"]["policyengine"]["wheel_sha256"] = "a" * 64
+    assert _gate(_sealed(doc), request=request).status == "pass"
+
+
+def test_unbound_receipts_fail():
+    sealed = _with_counts()
+    tampered = copy.deepcopy(sealed)
+    tampered["diff"]["flag_counts"]["numerical"] = 0
+    tampered["corpus"]["corpus_version"] = "tampered"
+    assert _gate(tampered).status == "fail"  # digest no longer matches the bytes
+    request = ReplayQualificationRequest.model_validate(
+        golden("qualification_request_golden")
+    )
+    other = _status("0" * 64, sealed)
+    assert _gate(sealed, request=request, status=other).status == "fail"
+
+
+def test_rejected_receipt_fails_with_its_code():
+    doc = copy.deepcopy(golden("replay_receipt_golden"))
+    doc.update(
+        status="rejected",
+        rejection={"code": "artifact_hash_mismatch", "detail": "dataset bytes differ"},
+        corpus=None,
+        diff=None,
+        variant_id=None,
+        variant_identity_sha256=None,
+        execution=None,
+    )
+    doc["integrity"] = {"status": "not_run", "failures": []}
+    gate = _gate(_sealed(doc))
+    assert gate.status == "fail"
+    assert "artifact_hash_mismatch" in gate.summary
+
+
+def test_status_must_name_terminal_receipt():
+    with pytest.raises(ValidationError):
+        ReplayStatus.model_validate(
+            {
+                "schema": "policyengine.uk-replay-status/v1",
+                "request_id": "a" * 64,
+                "status": "complete",
+                "history": [
+                    {
+                        "status": "complete",
+                        "at": "2026-10-15T10:00:00Z",
+                        "run_url": "https://github.com/PolicyEngine/policyengine-scorecard/actions/runs/7",
+                    }
+                ],
+            }
+        )
+
+
+def test_requests_without_a_run_id_are_valid_as_in_the_schema():
+    raw = copy.deepcopy(golden("qualification_request_golden"))
+    raw["requested_by"].pop("workflow_run_id")
+    _validator("qualification_request.v1.json").validate(raw)
+    assert ReplayQualificationRequest.model_validate(raw).document() == raw
+
+
+def test_early_failure_receipt_fails_the_gate():
+    doc = copy.deepcopy(golden("replay_receipt_golden"))
+    doc.update(
+        status="failed",
+        variant_id=None,
+        variant_identity_sha256=None,
+        execution=None,
+        corpus=None,
+        diff=None,
+    )
+    doc["integrity"] = {
+        "status": "failed",
+        "failures": [
+            {"code": "wheel_download_failed", "detail": "pip download failed"}
+        ],
+    }
+    sealed = _sealed(doc)
+    _validator("replay_receipt.v1.json").validate(sealed)
+    gate = _gate(sealed)
+    assert gate.status == "fail" and "wheel_download_failed" in gate.summary
diff --git a/tests/test_qualification_ledger_script.py b/tests/test_qualification_ledger_script.py
new file mode 100644
index 00000000..5e71953f
--- /dev/null
+++ b/tests/test_qualification_ledger_script.py
@@ -0,0 +1,111 @@
+"""The ledger publish script (.github/qualification-ledger.sh) under concurrency.
+
+Invariant: publishes from concurrent runs never lose evidence. Each run's new
+directory ends up on the ledger branch, including when both runs found no
+ledger yet and each made the branch's first commit.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "qualification-ledger.sh"
+
+pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="needs git")
+
+
+def _run(*args, cwd, check=True):
+    env = {
+        **os.environ,
+        "GIT_AUTHOR_NAME": "test",
+        "GIT_AUTHOR_EMAIL": "test@example.org",
+        "GIT_COMMITTER_NAME": "test",
+        "GIT_COMMITTER_EMAIL": "test@example.org",
+        "GIT_TERMINAL_PROMPT": "0",
+        "LEDGER_RETRY_DELAY": "0",
+    }
+    return subprocess.run(
+        args,
+        cwd=cwd,
+        env=env,
+        check=check,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+
+
+def _clone(root: Path, origin: Path, name: str) -> Path:
+    clone = root / name
+    _run(
+        "git", "clone", "--quiet", "--branch", "main", str(origin), str(clone), cwd=root
+    )
+    return clone
+
+
+def _origin(root: Path) -> Path:
+    origin = root / "origin.git"
+    _run("git", "init", "--quiet", "--bare", "-b", "main", str(origin), cwd=root)
+    seed = root / "seed"
+    _run("git", "clone", "--quiet", str(origin), str(seed), cwd=root)
+    (seed / "seed").write_text("seed")
+    _run("git", "add", "seed", cwd=seed)
+    _run("git", "commit", "--quiet", "-m", "seed", cwd=seed)
+    _run("git", "push", "--quiet", "origin", "HEAD:refs/heads/main", cwd=seed)
+    return origin
+
+
+def _ledger_files(origin: Path) -> set[str]:
+    listing = _run(
+        "git", "ls-tree", "-r", "--name-only", "uk-qualification-ledger", cwd=origin
+    )
+    return set(listing.stdout.split())
+
+
+@pytest.mark.parametrize("ledger_exists", [False, True])
+def test_concurrent_publishes_keep_both_runs_evidence(tmp_path, ledger_exists):
+    """Interrupted review of #574: the second first-ever publish failed on HEAD~1."""
+
+    origin = _origin(tmp_path)
+    if ledger_exists:
+        first = _clone(tmp_path, origin, "first")
+        _run("bash", str(SCRIPT), "checkout", "ledger", cwd=first)
+        (first / "ledger" / "existing").write_text("existing")
+        _run("bash", str(SCRIPT), "publish", "ledger", "existing", cwd=first)
+    clones = []
+    for name in ("a", "b"):
+        clone = _clone(tmp_path, origin, name)
+        _run("bash", str(SCRIPT), "checkout", "ledger", cwd=clone)
+        (clone / "ledger" / "uk" / name).mkdir(parents=True)
+        (clone / "ledger" / "uk" / name / "receipt.json").write_text(name)
+        clones.append(clone)
+    for clone, name in zip(clones, ("a", "b")):
+        result = _run(
+            "bash",
+            str(SCRIPT),
+            "publish",
+            "ledger",
+            f"attempt {name}",
+            cwd=clone,
+            check=False,
+        )
+        assert result.returncode == 0, result.stdout
+    files = _ledger_files(origin)
+    assert {"uk/a/receipt.json", "uk/b/receipt.json"} <= files
+    if ledger_exists:
+        assert "existing" in files
+
+
+def test_an_unchanged_ledger_publishes_nothing(tmp_path):
+    origin = _origin(tmp_path)
+    clone = _clone(tmp_path, origin, "a")
+    _run("bash", str(SCRIPT), "checkout", "ledger", cwd=clone)
+    (clone / "ledger" / "x").write_text("x")
+    _run("bash", str(SCRIPT), "publish", "ledger", "first", cwd=clone)
+    again = _run("bash", str(SCRIPT), "publish", "ledger", "again", cwd=clone)
+    assert "Ledger unchanged." in again.stdout
diff --git a/tests/test_qualified_pair_certification.py b/tests/test_qualified_pair_certification.py
new file mode 100644
index 00000000..3ce1d8cf
--- /dev/null
+++ b/tests/test_qualified_pair_certification.py
@@ -0,0 +1,591 @@
+"""Certification on an automated qualification of the exact pair.
+
+Invariants:
+
+- ``qualified_exact_pair`` is used only when neither the build-time model
+  nor a publisher claim covers the model; it is exact (one model version,
+  one core version, one data build) and names the qualified pair;
+- the core must be the build core, publisher-claimed, or qualified;
+- at runtime a qualified certificate is honoured only for exactly its model
+  version and data build, online and offline;
+- a bundle certificate on that basis needs a promotable ledger receipt for
+  exactly what it certifies (``verify_certification_evidence``).
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from policyengine.provenance.certification import (
+    BASIS_QUALIFIED_PAIR,
+    CertificationError,
+    build_country_manifest_payload,
+    validate_release_manifest,
+)
+from policyengine.provenance.manifest import (
+    DataCertification,
+    DataReleaseManifest,
+    DataReleaseManifestUnavailableError,
+    certify_data_release_compatibility,
+    get_data_release_manifest,
+)
+from policyengine.provenance.qualification_ledger import (
+    latest_receipts,
+    pending_attempts,
+    verify_certification_evidence,
+    write_json,
+)
+from tests.qualification_fixtures import gates, make_pair, make_receipt
+
+PAIR_ID = "a" * 64
+
+
+def _uk_manifest(
+    model_claims=("==2.122.2",), core_claims=("==3.32.13",)
+) -> DataReleaseManifest:
+    return DataReleaseManifest.model_validate(
+        {
+            "schema_version": 1,
+            "data_package": {"name": "policyengine-uk-data", "version": "1.58.0"},
+            "compatible_model_packages": [
+                {"name": "policyengine-uk", "specifier": spec} for spec in model_claims
+            ],
+            "compatible_core_packages": [
+                {"name": "policyengine-core", "specifier": spec} for spec in core_claims
+            ],
+            "default_datasets": {"national": "enhanced_frs_2024_25"},
+            "build": {
+                "build_id": "policyengine-uk-data-1.58.0",
+                "built_with_core_package": {
+                    "name": "policyengine-core",
+                    "version": "3.32.13",
+                },
+                "built_with_model_package": {
+                    "name": "policyengine-uk",
+                    "version": "2.122.2",
+                    "data_build_fingerprint": "sha256:build",
+                },
+            },
+            "artifacts": {
+                "enhanced_frs_2024_25": {
+                    "kind": "microdata",
+                    "path": "enhanced_frs_2024_25.h5",
+                    "repo_id": "policyengine/policyengine-uk-data-private",
+                    "revision": "1.58.0",
+                    "sha256": "d" * 64,
+                    "size_bytes": 10,
+                }
+            },
+        }
+    )
+
+
+class TestValidateWithQualification:
+    def test_uncovered_model_is_admitted_only_with_a_pair_id(self):
+        with pytest.raises(CertificationError, match="qualification"):
+            validate_release_manifest(_uk_manifest(), "policyengine-uk", "2.126.8")
+        basis, warnings = validate_release_manifest(
+            _uk_manifest(core_claims=("==3.33.0",)),
+            "policyengine-uk",
+            "2.126.8",
+            core_version="3.33.0",
+            qualified_pair_id=PAIR_ID,
+        )
+        assert basis == BASIS_QUALIFIED_PAIR
+        assert any("exactly policyengine-uk==2.126.8" in item for item in warnings)
+
+    def test_stronger_bases_win_over_qualification(self):
+        basis, _ = validate_release_manifest(
+            _uk_manifest(), "policyengine-uk", "2.122.2", qualified_pair_id=PAIR_ID
+        )
+        assert basis == "built_with_model_package"
+        basis, _ = validate_release_manifest(
+            _uk_manifest(model_claims=("==2.122.2", "==2.126.8")),
+            "policyengine-uk",
+            "2.126.8",
+            qualified_pair_id=PAIR_ID,
+        )
+        assert basis == "legacy_compatible_model_package"
+
+    @pytest.mark.parametrize(
+        ("model_version", "model_claims"),
+        [("2.122.2", ("==2.122.2",)), ("2.126.8", ("==2.122.2", "==2.126.8"))],
+        ids=["build-time model", "publisher-claimed model"],
+    )
+    def test_an_unclaimed_core_makes_the_basis_the_exact_pair(
+        self, model_version, model_claims
+    ):
+        """Interrupted review of #574: with the model on a stronger basis, a
+        core the publisher never claimed was certified on that basis, and the
+        certificate named no pair, so no evidence was ever required."""
+
+        basis, warnings = validate_release_manifest(
+            _uk_manifest(model_claims=model_claims),
+            "policyengine-uk",
+            model_version,
+            core_version="9.99.0",
+            qualified_pair_id=PAIR_ID,
+        )
+        assert basis == BASIS_QUALIFIED_PAIR
+        assert any("policyengine-core==9.99.0" in item for item in warnings)
+        with pytest.raises(CertificationError, match="policyengine-core 9.99.0"):
+            validate_release_manifest(
+                _uk_manifest(model_claims=model_claims),
+                "policyengine-uk",
+                model_version,
+                core_version="9.99.0",
+            )
+
+    def test_qualified_pair_needs_its_core_version(self):
+        with pytest.raises(CertificationError, match="core"):
+            validate_release_manifest(
+                _uk_manifest(), "policyengine-uk", "2.126.8", qualified_pair_id=PAIR_ID
+            )
+
+    def test_core_outside_claims_is_refused_unless_qualified(self):
+        with pytest.raises(CertificationError, match="policyengine-core 3.34.0"):
+            validate_release_manifest(
+                _uk_manifest(model_claims=("==2.126.8",)),
+                "policyengine-uk",
+                "2.126.8",
+                core_version="3.34.0",
+            )
+        basis, warnings = validate_release_manifest(
+            _uk_manifest(),
+            "policyengine-uk",
+            "2.126.8",
+            core_version="3.34.0",
+            qualified_pair_id=PAIR_ID,
+        )
+        assert basis == BASIS_QUALIFIED_PAIR
+        assert any("outside the publisher's core claims" in item for item in warnings)
+
+    def test_build_core_and_claimed_core_pass(self):
+        for core in ("3.32.13",):
+            validate_release_manifest(
+                _uk_manifest(), "policyengine-uk", "2.122.2", core_version=core
+            )
+
+    def test_payload_names_the_pair_and_core(self):
+        manifest = _uk_manifest()
+        payload = build_country_manifest_payload(
+            country="uk",
+            manifest=manifest,
+            uri_parts={
+                "repo_type": "model",
+                "repo_id": "policyengine/policyengine-uk-data-private",
+                "revision": "f" * 40,
+                "path": "releases/1.58.0/release_manifest.json",
+            },
+            policyengine_version="6.2.7",
+            model_package="policyengine-uk",
+            model_version="2.126.8",
+            model_wheel={
+                "sha256": "a" * 64,
+                "url": "https://files.pythonhosted.org/x.whl",
+            },
+            compatibility_basis=BASIS_QUALIFIED_PAIR,
+            core_version="3.33.0",
+            qualified_pair_id=PAIR_ID,
+        )
+        certification = payload["certification"]
+        assert certification["compatibility_basis"] == BASIS_QUALIFIED_PAIR
+        assert certification["certified_for_core_version"] == "3.33.0"
+        assert certification["qualification_pair_id"] == PAIR_ID
+        assert certification["qualification_ledger_url"].endswith(f"/uk/{PAIR_ID}")
+
+
+def _response(payload):
+    response = MagicMock()
+    response.status_code = 200
+    response.text = json.dumps(payload)
+    response.content = response.text.encode()
+    response.raise_for_status.return_value = None
+    return response
+
+
+def _qualified(model="2.126.8", build_id="policyengine-uk-data-1.58.0"):
+    return DataCertification(
+        compatibility_basis="qualified_exact_pair",
+        certified_for_model_version=model,
+        certified_for_core_version="3.33.0",
+        data_build_id=build_id,
+        data_build_fingerprint="sha256:build",
+        qualification_pair_id=PAIR_ID,
+    )
+
+
+class TestRuntimeAcceptance:
+    def setup_method(self):
+        get_data_release_manifest.cache_clear()
+
+    def _certify(
+        self, bundled, runtime_model, remote=None, fingerprint=None, core="3.33.0"
+    ):
+        manifest = MagicMock(certification=bundled)
+        manifest.model_package.name = "policyengine-uk"
+        manifest.data_package = MagicMock(
+            release_manifest_revision=None, repo_id="r", version="1.58.0"
+        )
+        patches = [
+            patch(
+                "policyengine.provenance.manifest.get_release_manifest",
+                return_value=manifest,
+            )
+        ]
+        if remote is None:
+            patches.append(
+                patch(
+                    "policyengine.provenance.manifest.get_data_release_manifest",
+                    side_effect=DataReleaseManifestUnavailableError("private"),
+                )
+            )
+        else:
+            patches.append(
+                patch(
+                    "policyengine.provenance.manifest.get_data_release_manifest",
+                    return_value=remote,
+                )
+            )
+        with patches[0], patches[1]:
+            return certify_data_release_compatibility(
+                "uk",
+                runtime_model_version=runtime_model,
+                runtime_data_build_fingerprint=fingerprint,
+                runtime_core_version=core,
+            )
+
+    def test_online_exact_model_and_build_is_honoured(self):
+        certification = self._certify(_qualified(), "2.126.8", remote=_uk_manifest())
+        assert certification.compatibility_basis == "qualified_exact_pair"
+
+    def test_online_other_model_version_is_refused(self):
+        with pytest.raises(ValueError, match="not certified"):
+            self._certify(_qualified(), "2.126.9", remote=_uk_manifest())
+
+    def test_online_other_data_build_is_refused(self):
+        with pytest.raises(ValueError, match="not certified"):
+            self._certify(
+                _qualified(build_id="policyengine-uk-data-1.59.0"),
+                "2.126.8",
+                remote=_uk_manifest(),
+            )
+
+    def test_online_other_core_is_refused(self):
+        """Interrupted review of #574: an exact pair was honoured whatever core ran."""
+
+        with pytest.raises(ValueError, match="not certified"):
+            self._certify(_qualified(), "2.126.8", remote=_uk_manifest(), core="3.34.0")
+
+    @pytest.mark.parametrize(
+        ("runtime_model", "fingerprint", "claims"),
+        [
+            ("2.122.2", None, ()),
+            ("2.126.9", "sha256:build", ()),
+            ("2.126.9", None, ("==2.126.9",)),
+        ],
+        ids=["build-model", "fingerprint", "publisher-claim"],
+    )
+    def test_online_stronger_basis_cannot_bypass_qualified_model(
+        self, runtime_model, fingerprint, claims
+    ):
+        """The qualified core must not extend qualification to another model."""
+
+        with pytest.raises(ValueError, match="not certified.*model"):
+            self._certify(
+                _qualified(),
+                runtime_model,
+                remote=_uk_manifest(model_claims=claims),
+                fingerprint=fingerprint,
+            )
+
+    @pytest.mark.parametrize(
+        ("runtime_model", "fingerprint", "claims"),
+        [
+            ("2.122.2", None, ()),
+            ("2.126.8", "sha256:build", ()),
+            ("2.126.8", None, ("==2.126.8",)),
+        ],
+        ids=["build-model", "fingerprint", "publisher-claim"],
+    )
+    @pytest.mark.parametrize("remote_build", ["another-build", None])
+    def test_online_stronger_basis_cannot_bypass_qualified_build(
+        self, runtime_model, fingerprint, claims, remote_build
+    ):
+        remote = _uk_manifest(model_claims=claims)
+        remote.build.build_id = remote_build
+        with pytest.raises(ValueError, match="not certified.*data build"):
+            self._certify(
+                _qualified(model=runtime_model),
+                runtime_model,
+                remote=remote,
+                fingerprint=fingerprint,
+            )
+
+    def test_online_publisher_claim_cannot_bypass_missing_qualified_build(self):
+        remote = _uk_manifest(model_claims=("==2.126.8",))
+        remote.build = None
+        with pytest.raises(ValueError, match="not certified.*data build"):
+            self._certify(_qualified(), "2.126.8", remote=remote)
+
+    @pytest.mark.parametrize(
+        ("runtime_model", "fingerprint", "claims", "expected_basis"),
+        [
+            ("2.122.2", None, (), "exact_build_model_version"),
+            (
+                "2.126.8",
+                "sha256:build",
+                (),
+                "matching_data_build_fingerprint",
+            ),
+            (
+                "2.126.8",
+                None,
+                ("==2.126.8",),
+                "legacy_compatible_model_package",
+            ),
+        ],
+        ids=["build-model", "fingerprint", "publisher-claim"],
+    )
+    def test_online_stronger_basis_cannot_bypass_qualified_core(
+        self, runtime_model, fingerprint, claims, expected_basis
+    ):
+        remote = _uk_manifest(model_claims=claims)
+        bundled = _qualified(model=runtime_model)
+        assert (
+            self._certify(
+                bundled, runtime_model, remote=remote, fingerprint=fingerprint
+            ).compatibility_basis
+            == expected_basis
+        )
+        with pytest.raises(ValueError, match="not certified"):
+            self._certify(
+                bundled,
+                runtime_model,
+                remote=remote,
+                fingerprint=fingerprint,
+                core="99.0.0",
+            )
+
+    def test_online_build_model_requires_installed_qualified_core(self):
+        from importlib.metadata import PackageNotFoundError
+
+        with patch(
+            "importlib.metadata.version",
+            side_effect=PackageNotFoundError("policyengine-core"),
+        ):
+            with pytest.raises(ValueError, match="not certified"):
+                self._certify(
+                    _qualified(model="2.122.2"),
+                    "2.122.2",
+                    remote=_uk_manifest(),
+                    core=None,
+                )
+
+    def test_offline_other_core_is_not_certified(self):
+        certification = self._certify(_qualified(), "2.126.8", core="3.34.0")
+        assert certification.compatibility_basis == (
+            "unverified_data_release_manifest_unavailable"
+        )
+
+    def test_the_installed_core_is_read_when_none_is_given(self):
+        with patch("importlib.metadata.version", return_value="3.34.0") as installed:
+            with pytest.raises(ValueError, match="not certified"):
+                self._certify(_qualified(), "2.126.8", remote=_uk_manifest(), core=None)
+        installed.assert_called_with("policyengine-core")
+
+    def test_other_bases_make_no_runtime_core_claim(self):
+        legacy = DataCertification(
+            compatibility_basis="legacy_compatible_model_package",
+            certified_for_model_version="2.126.8",
+            certified_for_core_version="3.33.0",
+            data_build_id="policyengine-uk-data-1.58.0",
+        )
+        assert (
+            self._certify(legacy, "2.126.8", core="3.34.0").compatibility_basis
+            == "legacy_compatible_model_package"
+        )
+
+    def test_offline_fingerprint_difference_is_expected(self):
+        certification = self._certify(
+            _qualified(), "2.126.8", fingerprint="sha256:runtime"
+        )
+        assert certification.compatibility_basis == "qualified_exact_pair"
+
+
+# --- Ledger evidence ---------------------------------------------------------
+
+
+def _record_for(receipt):
+    pair = receipt.pair
+    return {
+        "certification": {
+            "compatibility_basis": "qualified_exact_pair",
+            "qualification_pair_id": pair.pair_id,
+            "certified_for_model_version": pair.model.version,
+            "certified_for_core_version": pair.core.version,
+            "data_build_id": pair.data.build_id,
+        },
+        "model_package": {
+            "version": pair.model.version,
+            "sha256": pair.model.wheel_sha256,
+        },
+        "data_package": {
+            "version": pair.data.release_version,
+            "release_manifest_revision": pair.data.release_manifest_revision,
+        },
+        "default_dataset": pair.data.dataset,
+        "certified_data_artifact": {
+            "dataset": pair.data.dataset,
+            "sha256": pair.data.dataset_sha256,
+        },
+        "datasets": {
+            pair.data.dataset: {
+                "repo_id": pair.data.repo_id,
+                "path": pair.data.dataset_path,
+                "revision": pair.data.dataset_revision,
+                "sha256": pair.data.dataset_sha256,
+            }
+        },
+    }
+
+
+def test_other_bases_need_no_ledger_evidence():
+    assert (
+        verify_certification_evidence(
+            {
+                "certification": {
+                    "compatibility_basis": "legacy_compatible_model_package"
+                }
+            },
+            None,
+        )
+        == []
+    )
+
+
+def test_matching_promotable_receipt_is_sufficient():
+    receipt = make_receipt()
+    assert verify_certification_evidence(_record_for(receipt), receipt) == []
+
+
+REFERENCE_FIELDS = ["repo_id", "path", "revision", "sha256"]
+
+
+@pytest.mark.parametrize("field", REFERENCE_FIELDS)
+def test_the_dataset_reference_the_runtime_resolves_is_evidence_too(field):
+    """Interrupted review of #574: a record pointing its default dataset at
+    another repository, path, revision or file verified against the receipt."""
+
+    receipt = make_receipt()
+    record = _record_for(receipt)
+    record["datasets"][receipt.pair.data.dataset][field] = "f" * 64
+    problems = verify_certification_evidence(record, receipt)
+    assert any("dataset reference" in problem for problem in problems)
+    del record["datasets"][receipt.pair.data.dataset]
+    assert verify_certification_evidence(record, receipt)
+
+
+def test_a_record_without_the_model_wheel_hash_is_refused():
+    receipt = make_receipt()
+    record = _record_for(receipt)
+    del record["model_package"]["sha256"]
+    problems = verify_certification_evidence(record, receipt)
+    assert any("model wheel sha256" in problem for problem in problems)
+
+
+def test_missing_or_unpromotable_receipt_is_refused():
+    receipt = make_receipt()
+    record = _record_for(receipt)
+    assert verify_certification_evidence(record, None)
+    pending = make_receipt(gate_results=gates(replay="needs_review"))
+    assert verify_certification_evidence(_record_for(pending), pending)
+
+
+FIELDS = [
+    ("certification", "certified_for_model_version"),
+    ("certification", "certified_for_core_version"),
+    ("certification", "data_build_id"),
+    ("model_package", "version"),
+    ("model_package", "sha256"),
+    ("data_package", "version"),
+    ("data_package", "release_manifest_revision"),
+    ("certified_data_artifact", "sha256"),
+    ("certified_data_artifact", "dataset"),
+]
+
+
+@settings(max_examples=60, deadline=None)
+@given(field=st.sampled_from(FIELDS), value=st.text(min_size=1, max_size=8))
+def test_any_certified_field_differing_from_the_receipt_is_refused(field, value):
+    receipt = make_receipt()
+    record = copy.deepcopy(_record_for(receipt))
+    if record[field[0]][field[1]] == value:
+        return
+    record[field[0]][field[1]] = value
+    assert verify_certification_evidence(record, receipt)
+
+
+def test_ledger_listing(tmp_path):
+    qualified = make_receipt()
+    write_json(
+        tmp_path / "uk" / qualified.pair_id / "qualification_receipt.json",
+        qualified.canonical_payload(),
+    )
+    pending = (
+        tmp_path / "uk" / ("b" * 64) / "attempts" / "abc" / "replay" / "request.json"
+    )
+    write_json(pending, {"schema": "x"})
+    assert [r.pair_id for r in latest_receipts(tmp_path)] == [qualified.pair_id]
+    assert pending_attempts(tmp_path) == [pending.parent.parent]
+
+
+def test_committed_bundle_has_ledger_evidence_for_qualified_pairs():
+    """The release gate: an exact-pair certificate ships only with its evidence."""
+
+    from policyengine.provenance.qualification_ledger import fetch_ledger_receipt
+
+    bundle_path = (
+        Path(__file__).resolve().parents[1]
+        / "src"
+        / "policyengine"
+        / "data"
+        / "bundle"
+        / "manifest.json"
+    )
+    bundle = json.loads(bundle_path.read_text())
+    checked = 0
+    for country_id, record in bundle["data_releases"].items():
+        certification = record.get("certification") or {}
+        if certification.get("compatibility_basis") != "qualified_exact_pair":
+            continue
+        receipt = fetch_ledger_receipt(
+            country_id, certification["qualification_pair_id"]
+        )
+        assert verify_certification_evidence(record, receipt) == []
+        checked += 1
+    qualified = [
+        country_id
+        for country_id, record in bundle["data_releases"].items()
+        if (record.get("certification") or {}).get("compatibility_basis")
+        == "qualified_exact_pair"
+    ]
+    assert checked == len(qualified)
+
+
+def test_pair_id_changes_with_any_pinned_identity():
+    base = make_pair()
+    for variant in (
+        make_pair(model="2.126.9"),
+        make_pair(core="3.33.1"),
+        make_pair(data="1.59.0"),
+        make_pair(dataset_sha="0" * 64),
+    ):
+        assert variant.pair_id != base.pair_id
diff --git a/tests/test_uk_candidate_gates.py b/tests/test_uk_candidate_gates.py
new file mode 100644
index 00000000..c051d536
--- /dev/null
+++ b/tests/test_uk_candidate_gates.py
@@ -0,0 +1,2277 @@
+"""Mandatory local qualification gates (no network, no credentials).
+
+Invariants:
+
+- installed files match the wheel byte for byte, or the gate fails: mutating
+  any single installed file fails it;
+- no gate can skip: a missing harness is ``unavailable`` and a crash is
+  ``error``; ``run_gates`` returns exactly one result per gate;
+- inputs newly missing from the data are exactly (missing now) − (missing
+  before); a critical input that becomes unusable always fails;
+- metric review is monotone: shrinking every move toward zero never makes
+  the status worse.
+"""
+
+from __future__ import annotations
+
+import base64
+import copy
+import hashlib
+import io
+import json
+import sys
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pandas as pd
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from policyengine.provenance import uk_candidate as gates_module
+from policyengine.provenance.manifest import DataReleaseManifest
+from policyengine.provenance.uk_candidate import (
+    budget_inputs,
+    compare_metric_reports,
+    core_compatibility,
+    data_access,
+    dataset_bytes,
+    installed_versions,
+    metrics_review,
+    reform_contract,
+    run_gates,
+    supported_years,
+    wheel_hashes,
+)
+from policyengine.provenance.wheels import (
+    parse_record,
+    record_digest_to_hex,
+    verify_installed_against_wheel,
+)
+from tests.qualification_fixtures import HARNESS, make_pair, make_receipt
+
+# --- Installed versions --------------------------------------------------------
+
+
+class FakeMetadata:
+    PackageNotFoundError = KeyError
+
+    def __init__(self, versions, requires):
+        self.versions, self._requires = versions, requires
+
+    def version(self, name):
+        return self.versions[name]
+
+    def requires(self, name):
+        return self._requires.get(name, [])
+
+
+def test_installed_versions_pass_and_fail():
+    pair = make_pair()
+    versions = {
+        "policyengine": "6.2.6",
+        "policyengine-uk": "2.126.8",
+        "policyengine-core": "3.33.0",
+        "numpy": "2.1.0",
+    }
+    requires = {
+        "policyengine-uk": ["policyengine-core>=3.32.27", "numpy>=1.26"],
+        "policyengine": ["pydantic; extra == 'docs'"],
+    }
+    result, _ = installed_versions(
+        pair, HARNESS, metadata=FakeMetadata(versions, requires)
+    )
+    assert result.status == "pass", result.summary
+    result, evidence = installed_versions(
+        pair,
+        HARNESS,
+        metadata=FakeMetadata({**versions, "policyengine-uk": "2.126.6"}, requires),
+    )
+    assert result.status == "fail" and "2.126.6" in result.summary
+    result, _ = installed_versions(
+        pair,
+        HARNESS,
+        metadata=FakeMetadata({**versions, "policyengine-core": "3.30.0"}, requires),
+    )
+    assert result.status == "fail"
+    result, _ = installed_versions(
+        pair,
+        HARNESS,
+        metadata=FakeMetadata(
+            versions, {**requires, "policyengine-core": ["missing-pkg>=1"]}
+        ),
+    )
+    assert result.status == "fail" and "missing-pkg" in result.summary
+
+
+# --- Wheel RECORD verification ----------------------------------------------------
+
+
+def _b64(data: bytes) -> str:
+    return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode().rstrip("=")
+
+
+FILES = {
+    "policyengine_uk/__init__.py": b"VERSION = '2.126.8'\n",
+    "policyengine_uk/system.py": b"system = object()\n",
+    "policyengine_uk/parameters/rate.yaml": b"values:\n  2027-04-06: 0.2\n",
+}
+
+
+def _build(tmp_path: Path, files=FILES):
+    dist_info = "policyengine_uk-2.126.8.dist-info"
+    record_lines = [
+        f"{path},sha256={_b64(data)},{len(data)}" for path, data in files.items()
+    ]
+    record_lines.append(f"{dist_info}/RECORD,,")
+    wheel = tmp_path / "policyengine_uk-2.126.8-py3-none-any.whl"
+    with zipfile.ZipFile(wheel, "w") as archive:
+        for path, data in files.items():
+            archive.writestr(path, data)
+        archive.writestr(f"{dist_info}/RECORD", "\n".join(record_lines) + "\n")
+    site = tmp_path / "site"
+    for path, data in files.items():
+        (site / path).parent.mkdir(parents=True, exist_ok=True)
+        (site / path).write_bytes(data)
+    installed_record = record_lines + [
+        f"{dist_info}/INSTALLER,sha256={_b64(b'uv')},2",
+        f"{dist_info}/REQUESTED,,",
+    ]
+    return wheel, site, installed_record
+
+
+class FakeDistribution:
+    def __init__(self, site: Path, record_lines):
+        self.site = site
+        self.files = []
+        for line in record_lines:
+            path, digest, *_ = line.split(",")
+            hash_ = (
+                SimpleNamespace(mode="sha256", value=digest.split("=", 1)[1])
+                if digest
+                else None
+            )
+            self.files.append(SimpleNamespace(hash=hash_, __str__=None, path=path))
+
+    def locate_file(self, path):
+        return self.site / path
+
+
+class _File(str):
+    pass
+
+
+def _distribution(site, record_lines):
+    dist = SimpleNamespace(site=site, files=[], locate_file=lambda path: site / path)
+    for line in record_lines:
+        path, digest, *_ = line.split(",")
+        item = _File(path)
+        item.hash = (
+            SimpleNamespace(mode="sha256", value=digest.split("=", 1)[1])
+            if digest
+            else None
+        )
+        dist.files.append(item)
+    return dist
+
+
+def _metadata_for(site, record_lines):
+    return SimpleNamespace(distribution=lambda name: _distribution(site, record_lines))
+
+
+def test_record_hash_decoding():
+    data = b"hello"
+    assert (
+        record_digest_to_hex(f"sha256={_b64(data)}") == hashlib.sha256(data).hexdigest()
+    )
+    parsed = parse_record(f"a.py,sha256={_b64(data)},5\nRECORD,,\n")
+    assert parsed == {"a.py": hashlib.sha256(data).hexdigest(), "RECORD": None}
+    with pytest.raises(ValueError):
+        record_digest_to_hex("md5=abc")
+
+
+def test_untouched_install_verifies(tmp_path):
+    wheel, site, record = _build(tmp_path)
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=_metadata_for(site, record)
+    )
+    assert evidence["problems"] == []
+    assert evidence["files_verified"] == len(FILES)
+
+
+@settings(max_examples=30, deadline=None)
+@given(index=st.integers(0, len(FILES) - 1), extra=st.binary(min_size=1, max_size=8))
+def test_any_mutated_installed_file_fails(tmp_path_factory, index, extra):
+    tmp_path = tmp_path_factory.mktemp("wheel")
+    wheel, site, record = _build(tmp_path)
+    target = site / list(FILES)[index]
+    target.write_bytes(target.read_bytes() + extra)
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=_metadata_for(site, record)
+    )
+    assert evidence["problems"]
+
+
+def test_extra_installed_file_fails(tmp_path):
+    wheel, site, record = _build(tmp_path)
+    (site / "policyengine_uk" / "injected.py").write_bytes(b"x = 1\n")
+    record = record + [f"policyengine_uk/injected.py,sha256={_b64(b'x = 1')},6"]
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=_metadata_for(site, record)
+    )
+    assert any("injected.py" in problem for problem in evidence["problems"])
+
+
+def test_wheel_hash_gate(tmp_path):
+    wheel, site, record = _build(tmp_path)
+    digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
+    pair = make_pair()
+    pair = pair.model_copy(
+        update={"model": pair.model.model_copy(update={"wheel_sha256": digest})}
+    )
+    (tmp_path / "core").mkdir()
+    core_wheel, core_site, core_record = _build(tmp_path / "core")
+    core_digest = hashlib.sha256(core_wheel.read_bytes()).hexdigest()
+    pair = pair.model_copy(
+        update={"core": pair.core.model_copy(update={"wheel_sha256": core_digest})}
+    )
+    metadata = SimpleNamespace(
+        distribution=lambda name: (
+            _distribution(site, record)
+            if name == "policyengine-uk"
+            else _distribution(core_site, core_record)
+        )
+    )
+    result, _ = wheel_hashes(
+        pair,
+        wheel_paths={"policyengine-uk": wheel, "policyengine-core": core_wheel},
+        pypi_digests={"policyengine-uk": digest, "policyengine-core": core_digest},
+        metadata=metadata,
+    )
+    assert result.status == "pass", result.summary
+    result, _ = wheel_hashes(
+        pair,
+        wheel_paths={"policyengine-uk": wheel, "policyengine-core": core_wheel},
+        pypi_digests={"policyengine-uk": "0" * 64, "policyengine-core": core_digest},
+        metadata=metadata,
+    )
+    assert result.status == "fail"
+
+
+# --- Core compatibility, data access, bytes, years -----------------------------------
+
+
+def _manifest(model_claims=(), core_claims=()):
+    return DataReleaseManifest.model_validate(
+        {
+            "schema_version": 1,
+            "data_package": {"name": "policyengine-uk-data", "version": "1.58.0"},
+            "compatible_model_packages": [
+                {"name": "policyengine-uk", "specifier": s} for s in model_claims
+            ],
+            "compatible_core_packages": [
+                {"name": "policyengine-core", "specifier": s} for s in core_claims
+            ],
+            "default_datasets": {"national": "enhanced_frs_2024_25"},
+            "build": {
+                "build_id": "b",
+                "built_with_model_package": {
+                    "name": "policyengine-uk",
+                    "version": "2.122.2",
+                },
+            },
+            "artifacts": {
+                "enhanced_frs_2024_25": {
+                    "kind": "microdata",
+                    "path": "enhanced_frs_2024_25.h5",
+                    "repo_id": "policyengine/policyengine-uk-data-private",
+                    "revision": "1.58.0",
+                    "sha256": "d" * 64,
+                },
+                "frs_2024_25": {
+                    "kind": "microdata",
+                    "path": "frs_2024_25.h5",
+                    "repo_id": "policyengine/policyengine-uk-data-private",
+                    "revision": "1.58.0",
+                },
+            },
+        }
+    )
+
+
+def test_core_compatibility():
+    pair = make_pair()
+    result, evidence = core_compatibility(
+        pair, _manifest(), model_requires_core=">=3.32.27"
+    )
+    assert result.status == "pass" and evidence["publisher_claim_covers_model"] is False
+    result, _ = core_compatibility(
+        pair, _manifest(["==2.126.8"], ["==3.33.0"]), model_requires_core=">=3.32.27"
+    )
+    assert result.details == {
+        "publisher_claim_covers_model": True,
+        "publisher_claim_covers_core": True,
+    }
+    result, _ = core_compatibility(pair, _manifest(), model_requires_core=">=3.34")
+    assert result.status == "fail"
+
+
+class _Response:
+    def __init__(self, status, content=b""):
+        self.status_code, self.content = status, content
+
+
+def test_data_access():
+    pair = make_pair()
+    manifest_bytes = _manifest().model_dump_json().encode()
+    pair = pair.model_copy(
+        update={
+            "data": pair.data.model_copy(
+                update={
+                    "release_manifest_sha256": hashlib.sha256(
+                        manifest_bytes
+                    ).hexdigest()
+                }
+            )
+        }
+    )
+
+    def get(url, headers=None, timeout=None):
+        return _Response(200 if headers else 401, manifest_bytes)
+
+    def head(url, headers=None, timeout=None, allow_redirects=True):
+        return _Response(200 if headers else 401)
+
+    result, evidence, manifest = data_access(pair, get=get, head=head, token="t")
+    assert result.status == "pass" and manifest is not None
+    assert evidence["anonymous_dataset_status"] == 401
+    result, _, _ = data_access(pair, get=get, head=head, token=None)
+    assert result.status == "fail"
+    result, _, _ = data_access(
+        pair, get=get, head=lambda *a, **k: _Response(200), token="t"
+    )
+    assert result.status == "fail" and "without credentials" in result.summary
+    result, _, _ = data_access(
+        pair,
+        get=get,
+        head=lambda url, headers=None, **k: _Response(
+            404
+            if headers and "frs_2024_25.h5" in url and "enhanced" not in url
+            else (200 if headers else 401)
+        ),
+        token="t",
+    )
+    assert result.status == "fail" and "frs_2024_25 returned HTTP 404" in result.summary
+
+
+def _tiny_dataset(path: Path, year=2024, extra_person=None):
+    person = pd.DataFrame(
+        {
+            "person_id": [1, 2],
+            "age": [30.0, 5.0],
+            "employment_income": [30000.0, 0.0],
+            **(extra_person or {}),
+        }
+    )
+    benunit = pd.DataFrame({"benunit_id": [1], "would_claim_uc": [True]})
+    household = pd.DataFrame(
+        {"household_id": [1], "household_weight": [100.0], "rent": [0.0]}
+    )
+    with pd.HDFStore(path, mode="w") as store:
+        store.put("person", person)
+        store.put("benunit", benunit)
+        store.put("household", household)
+        store.put("time_period", pd.Series([str(year)]))
+    return path
+
+
+def test_dataset_bytes(tmp_path):
+    path = _tiny_dataset(tmp_path / "data.h5")
+    digest = hashlib.sha256(path.read_bytes()).hexdigest()
+    pair = make_pair(dataset_sha=digest)
+    pair = pair.model_copy(
+        update={
+            "data": pair.data.model_copy(update={"size_bytes": path.stat().st_size})
+        }
+    )
+    assert dataset_bytes(pair, path)[0].status == "pass"
+    assert dataset_bytes(make_pair(), path)[0].status == "fail"
+
+
+@pytest.mark.parametrize(
+    ("projected", "previous", "status"),
+    [
+        (list(range(2024, 2032)), list(range(2024, 2031)), "pass"),
+        (list(range(2024, 2031)), list(range(2024, 2031)), "needs_review"),
+        (list(range(2024, 2030)), list(range(2024, 2031)), "fail"),
+        (list(range(2026, 2031)), None, "fail"),
+        ([2020, 2021], None, "fail"),
+    ],
+)
+def test_supported_years(tmp_path, projected, previous, status):
+    path = _tiny_dataset(tmp_path / "data.h5", year=max(projected[0], 2024))
+    result, evidence = supported_years(
+        make_pair(),
+        path,
+        required_years=list(range(2025, 2031)),
+        budget_horizon_years=list(range(2026, 2032)),
+        previous_supported_years=previous,
+        projected_years=lambda _: projected,
+    )
+    assert result.status == status, result.summary
+
+
+# --- Budget inputs -------------------------------------------------------------------
+
+INPUT_NAMES = [
+    "age",
+    "employment_income",
+    "rent",
+    "would_claim_uc",
+    "household_weight",
+    "new_input_a",
+    "new_input_b",
+]
+
+
+def _tables(columns):
+    tables = {
+        "person": pd.DataFrame({"age": [30.0, 5.0], "employment_income": [3.0e4, 0.0]})
+    }
+    tables["household"] = pd.DataFrame(
+        {"household_weight": [1.0, 2.0], "rent": [0.0, 9.0]}
+    )
+    tables["benunit"] = pd.DataFrame({"would_claim_uc": [True, False]})
+    for entity in tables:
+        tables[entity] = tables[entity][
+            [c for c in tables[entity].columns if c in columns]
+        ]
+    return tables
+
+
+@settings(max_examples=60, deadline=None)
+@given(
+    present=st.sets(st.sampled_from(INPUT_NAMES[:5]), min_size=1),
+    previous=st.sets(st.sampled_from(INPUT_NAMES)),
+)
+def test_newly_missing_is_missing_now_minus_missing_before(present, previous):
+    result, evidence = budget_inputs(
+        make_pair(),
+        Path("unused.h5"),
+        model_input_variables=INPUT_NAMES,
+        critical_inputs=[],
+        accepted_gaps={},
+        previous_missing_inputs=sorted(previous),
+        previous_unusable_critical=[],
+        tables=_tables(present),
+    )
+    missing = sorted(set(INPUT_NAMES) - present)
+    assert evidence["missing_inputs"] == missing
+    assert evidence["newly_missing_inputs"] == sorted(set(missing) - previous)
+
+
+def test_critical_input_regression_fails_and_standing_gaps_need_review():
+    common = dict(
+        model_input_variables=INPUT_NAMES,
+        accepted_gaps={},
+        previous_missing_inputs=None,
+    )
+    result, _ = budget_inputs(
+        make_pair(),
+        Path("x"),
+        critical_inputs=["rent", "age"],
+        previous_unusable_critical=[],
+        tables=_tables(
+            {"age", "employment_income", "household_weight", "would_claim_uc"}
+        ),
+        **common,
+    )
+    assert result.status == "fail" and "rent (missing)" in result.summary
+    degenerate = _tables(set(INPUT_NAMES))
+    degenerate["household"]["rent"] = 0.0
+    result, _ = budget_inputs(
+        make_pair(),
+        Path("x"),
+        critical_inputs=["rent"],
+        previous_unusable_critical=[],
+        tables=degenerate,
+        **common,
+    )
+    assert result.status == "fail" and "degenerate" in result.summary
+    result, _ = budget_inputs(
+        make_pair(),
+        Path("x"),
+        critical_inputs=["rent"],
+        previous_unusable_critical=["rent"],
+        tables=degenerate,
+        **common,
+    )
+    assert result.status == "needs_review"
+    result, _ = budget_inputs(
+        make_pair(),
+        Path("x"),
+        critical_inputs=["rent"],
+        previous_unusable_critical=["rent"],
+        tables=degenerate,
+        **{**common, "accepted_gaps": {"rent": "uk-data#999"}},
+    )
+    assert result.status == "pass"
+
+
+def test_critical_inputs_file_lists_real_model_inputs():
+    import json
+
+    from policyengine_uk.system import system
+
+    path = (
+        Path(gates_module.__file__).resolve().parents[1]
+        / "data"
+        / "qualification"
+        / "uk_budget_critical_inputs.json"
+    )
+    critical = json.loads(path.read_text())["critical"]
+    not_inputs = [
+        name
+        for name in critical
+        if name not in system.variables
+        or not system.variables[name].is_input_variable()
+    ]
+    assert not_inputs == []
+
+
+# --- Reform contract -----------------------------------------------------------------
+
+
+def _declared_coverage(source):
+    """ROUTES, CASES and PROPERTIES as a runner module declares them, read
+    from a report of a full run (each case limited to the routes it ran on)."""
+
+    source = source or {}
+    from policyengine.tax_benefit_models.uk import budget_reform_contract as runner
+
+    declared_checks = {
+        (case.id, check.target, check.name, check.year): check
+        for case in runner.CASES
+        for check in case.checks
+    }
+    by_case = {}
+    for row in source.get("cases") or []:
+        by_case.setdefault(row.get("case_id"), []).append(row)
+    return {
+        "ROUTES": tuple(source.get("routes") or ()),
+        "CASES": tuple(
+            SimpleNamespace(
+                id=case,
+                routes=tuple(dict.fromkeys(row.get("route") for row in rows)),
+                checks=tuple(
+                    SimpleNamespace(
+                        target=label.split(":")[0],
+                        name=label.split(":")[1],
+                        year=int(label.split(":")[2]),
+                        expected=getattr(
+                            declared_checks.get(
+                                (case, *label.split(":")[:2], int(label.split(":")[2]))
+                            ),
+                            "expected",
+                            None,
+                        ),
+                        pending=next(
+                            (
+                                row.get("decision_id")
+                                for row in rows
+                                if row.get("check") == label
+                            ),
+                            None,
+                        ),
+                    )
+                    for label in dict.fromkeys(row.get("check") for row in rows)
+                    if label
+                    and len(label.split(":")) == 3
+                    and label.split(":")[0]
+                    not in ("baseline_isolation", "unreformed_after_reform")
+                ),
+                expect_error="rejected"
+                if any(row.get("check") == "rejected" for row in rows)
+                else None,
+            )
+            for case, rows in by_case.items()
+            if case != "module_parameters_unchanged"
+        ),
+        "PROPERTIES": {
+            row.get("property_id"): "" for row in source.get("properties") or []
+        },
+        "UNSUPPORTED_ROUTES": {
+            row["route"]: row.get("reason", "")
+            for row in source.get("cases") or []
+            if row.get("status") == "unsupported"
+            and row.get("check") is None
+            and all(
+                other.get("status") == "unsupported" and other.get("check") is None
+                for other in source.get("cases") or []
+                if other.get("route") == row["route"]
+            )
+        },
+        "PENDING_DECISIONS": {
+            row["decision_id"]
+            for row in source.get("cases") or []
+            if row.get("decision_id")
+        },
+        "POPULATION_CHECKS": tuple(
+            dict.fromkeys(row["check_id"] for row in source.get("population") or [])
+        ),
+    }
+
+
+def _importer(report=None, raises=None, missing=False, coverage=None, declare=True):
+    """A runner module returning ``report``.
+
+    It declares the coverage of ``coverage`` (a report of a full run), by
+    default ``report`` itself, so a test of row statuses is not also a test
+    of coverage.
+    """
+
+    def runner(**kwargs):
+        if raises:
+            raise raises
+        return report
+
+    def importer(name):
+        if missing:
+            raise ModuleNotFoundError(name)
+        declared = (
+            _declared_coverage(coverage if coverage is not None else report)
+            if declare
+            else {}
+        )
+        return SimpleNamespace(run_budget_reform_contract=runner, **declared)
+
+    return importer
+
+
+ENTRY = "policyengine.tax_benefit_models.uk.budget_reform_contract:run_budget_reform_contract"
+
+
+def _run_level_row():
+    return {
+        "case_id": "module_parameters_unchanged",
+        "route": "all",
+        "check": "every parameter's dated values and metadata in system",
+        "status": "pass",
+    }
+
+
+@pytest.mark.parametrize(
+    ("kwargs", "status"),
+    [
+        ({"missing": True}, "unavailable"),
+        ({"raises": RuntimeError("boom")}, "error"),
+        (
+            {
+                "report": {
+                    "contract_version": "1.0.0",
+                    "cases": [
+                        {
+                            "case_id": "april",
+                            "route": "wrapper.policy",
+                            "status": "pass",
+                        },
+                        _run_level_row(),
+                    ],
+                }
+            },
+            "pass",
+        ),
+        (
+            {
+                "report": {
+                    "cases": [
+                        {
+                            "case_id": "april",
+                            "route": "country.reform_dict",
+                            "status": "fail",
+                        }
+                    ]
+                }
+            },
+            "fail",
+        ),
+        (
+            {
+                "report": {
+                    "cases": [
+                        {
+                            "case_id": "api",
+                            "route": "api.sim_api",
+                            "status": "unsupported",
+                        }
+                    ]
+                }
+            },
+            "error",
+        ),
+        (
+            {
+                "report": {
+                    "cases": [
+                        {
+                            "case_id": "api",
+                            "route": "api.sim_api",
+                            "status": "unsupported",
+                            "reason": "runs in sim-api",
+                        },
+                        {
+                            "case_id": "freeze",
+                            "route": "wrapper.policy",
+                            "status": "pending",
+                            "decision_id": "d1344",
+                        },
+                        _run_level_row(),
+                    ]
+                }
+            },
+            "pass",
+        ),
+        ({"report": {"cases": []}}, "error"),
+        (
+            {
+                "report": {
+                    "cases": [{"case_id": "x", "route": "r", "status": "pass"}],
+                    "properties": [
+                        {"property_id": "baseline_isolation", "status": "fail"}
+                    ],
+                }
+            },
+            "fail",
+        ),
+    ],
+)
+def test_reform_contract_statuses(kwargs, status):
+    result, _ = reform_contract(
+        entrypoint=ENTRY,
+        dataset_path=None,
+        seed=0,
+        max_examples=5,
+        importer=_importer(**kwargs),
+    )
+    assert result.status == status, result.summary
+
+
+def test_no_contract_configured_is_unavailable():
+    assert (
+        reform_contract(entrypoint=None, dataset_path=None, seed=0, max_examples=1)[
+            0
+        ].status
+        == "unavailable"
+    )
+
+
+CONTRACT_FIXTURES = Path(__file__).parent / "fixtures" / "uk_contract"
+CERTIFIED_SHA = "5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6"
+STATUS_RANK = {"pass": 0, "error": 1, "fail": 2}
+
+
+def _fixture(name: str) -> dict:
+    return json.loads((CONTRACT_FIXTURES / name).read_text())
+
+
+def _recount(report: dict) -> dict:
+    """Make a hand-edited report's summary and holds match its rows again."""
+
+    summary = dict.fromkeys(("pass", "fail", "error", "unsupported", "pending"), 0)
+    for section in ("cases", "properties", "population"):
+        for row in report.get(section) or []:
+            summary[row["status"]] += 1
+    report["summary"] = summary
+    report["holds"] = summary["fail"] == 0 and summary["error"] == 0
+    return report
+
+
+def _passing_report() -> dict:
+    """W1's real full run: every route, 20 examples, the certified dataset."""
+
+    return _fixture("full_released_uk_2.126.12.json")
+
+
+def _run_contract(
+    report, *, versions=None, dataset_sha=CERTIFIED_SHA, coverage=None, **kwargs
+):
+    """Gate ``report`` against the coverage of W1's real full run (or ``coverage``)."""
+
+    kwargs.setdefault("seed", 0)
+    kwargs.setdefault("max_examples", 20)
+    kwargs.setdefault("dataset_path", Path("enhanced_frs_2024_25.h5"))
+    return reform_contract(
+        entrypoint=ENTRY,
+        importer=_importer(
+            report=report,
+            coverage=coverage if coverage is not None else _passing_report(),
+        ),
+        expected_versions=versions or _passing_report()["versions"],
+        expected_dataset_sha256=dataset_sha,
+        **kwargs,
+    )[0]
+
+
+def _run_released_contract(report, *, pending_decisions=()):
+    """Gate a fixture through the real runner's declarations and route logic."""
+    import importlib
+    import types
+
+    from policyengine.tax_benefit_models.uk import budget_reform_contract as runner
+
+    if runner.CONTRACT_VERSION != report["contract_version"]:
+        pytest.skip(
+            f"the runner declares contract {runner.CONTRACT_VERSION}; refresh "
+            "tests/fixtures/uk_contract with a real full run"
+        )
+    proxy = types.ModuleType(runner.__name__)
+    proxy.__dict__.update(vars(runner))
+    proxy.run_budget_reform_contract = lambda **kwargs: report
+
+    def importer(name):
+        return proxy if name == runner.__name__ else importlib.import_module(name)
+
+    return reform_contract(
+        entrypoint=ENTRY,
+        importer=importer,
+        expected_versions=report["versions"],
+        expected_dataset_sha256=CERTIFIED_SHA,
+        seed=0,
+        max_examples=20,
+        dataset_path=Path("enhanced_frs_2024_25.h5"),
+        pending_decisions=pending_decisions,
+    )[0]
+
+
+def test_the_released_models_full_run_passes_the_runners_own_declarations():
+    """W1's full run remains valid against the real runner's declarations."""
+
+    result = _run_released_contract(_fixture("full_released_uk_2.126.12.json"))
+    assert result.status == "pass", result.summary
+    assert result.details["counts"] == {
+        "pass": 673,
+        "fail": 0,
+        "error": 0,
+        "unsupported": 79,
+        "pending": 6,
+    }
+
+
+@pytest.mark.parametrize("section", ["cases", "population"])
+def test_supported_failures_cannot_be_relabelled_unsupported(section):
+    report = _fixture("full_released_uk_2.126.12.json")
+    row = next(
+        row
+        for row in report[section]
+        if row["status"] == "pass" and row["route"] == "country.reform_dict"
+    )
+    row["status"] = "fail"
+    assert _run_released_contract(_recount(report)).status == "fail"
+    row.update(status="unsupported", reason="this candidate does not support it")
+    result = _run_released_contract(_recount(report))
+    assert result.status == "error", result.summary
+    assert any(
+        "undeclared unsupported" in problem for problem in result.details["problems"]
+    )
+
+
+def test_population_cannot_exempt_every_required_check():
+    report = _fixture("full_released_uk_2.126.12.json")
+    assert len(report["population"]) == 19
+    for row in report["population"]:
+        row.update(status="unsupported", reason="no population support")
+    result = _run_released_contract(_recount(report))
+    assert result.status == "error", result.summary
+    assert (
+        len([p for p in result.details["problems"] if "undeclared unsupported" in p])
+        == 19
+    )
+
+
+@pytest.mark.parametrize("route", ["api.sim_api", "wrapper.household"])
+def test_declared_case_exemptions_require_the_runners_reason(route):
+    report = _fixture("full_released_uk_2.126.12.json")
+    row = next(
+        row
+        for row in report["cases"]
+        if row["route"] == route and row["status"] == "unsupported"
+    )
+    row["reason"] = "a different exemption"
+    result = _run_released_contract(report)
+    assert result.status == "error", result.summary
+    assert any("different reason" in p for p in result.details["problems"])
+
+
+def test_deleting_a_failed_run_level_check_cannot_qualify():
+    report = _fixture("full_released_uk_2.126.12.json")
+    row = next(
+        row
+        for row in report["cases"]
+        if row["case_id"] == "module_parameters_unchanged"
+    )
+    row["status"] = "fail"
+    assert _run_released_contract(_recount(report)).status == "fail"
+    report["cases"].remove(row)
+    result = _run_released_contract(_recount(report))
+    assert result.status == "error", result.summary
+    assert any(
+        "module_parameters_unchanged@all" in p for p in result.details["problems"]
+    )
+
+
+@pytest.mark.parametrize(
+    ("route", "check_prefix"),
+    [
+        ("country.reform_dict", "baseline_isolation:"),
+        ("country.scenario_parameter_changes", "baseline_isolation:"),
+        ("country.reform_from_dict", "baseline_isolation:"),
+        ("country.reform_dict", "unreformed_after_reform:gov."),
+        ("country.reform_dict", "unreformed_after_reform:income_tax:"),
+        ("wrapper.household", "unreformed_after_reform:"),
+        ("wrapper.policy", "unreformed_after_reform:"),
+    ],
+)
+def test_each_runner_isolation_row_is_required(route, check_prefix):
+    report = _fixture("full_released_uk_2.126.12.json")
+    omitted = next(
+        row
+        for row in report["cases"]
+        if row["route"] == route and (row.get("check") or "").startswith(check_prefix)
+    )
+    report["cases"].remove(omitted)
+    result = _run_released_contract(_recount(report))
+    assert result.status == "error", result.summary
+    assert any(omitted["check"] in p for p in result.details["problems"])
+
+
+def test_deleting_all_runner_isolation_rows_cannot_qualify():
+    report = _fixture("full_released_uk_2.126.12.json")
+    report["cases"] = [
+        row
+        for row in report["cases"]
+        if not (row.get("check") or "").startswith(
+            ("baseline_isolation:", "unreformed_after_reform:")
+        )
+    ]
+    result = _run_released_contract(_recount(report))
+    assert result.status == "error", result.summary
+    assert any("baseline_isolation:" in p for p in result.details["problems"])
+
+
+@pytest.mark.parametrize(
+    "check_prefix", ["baseline_isolation:", "unreformed_after_reform:"]
+)
+def test_failing_isolation_checks_cannot_be_relabelled_pending(check_prefix):
+    report = _fixture("full_released_uk_2.126.12.json")
+    row = next(
+        row
+        for row in report["cases"]
+        if row["route"] == "country.reform_dict"
+        and (row.get("check") or "").startswith(check_prefix)
+    )
+    row["status"] = "fail"
+    assert _run_released_contract(_recount(report)).status == "fail"
+    row.update(status="pending", decision_id="d1344")
+    result = _run_released_contract(_recount(report), pending_decisions=("d1344",))
+    assert result.status == "error", result.summary
+    assert any(
+        "isolation check" in p and "did not run" in p
+        for p in result.details["problems"]
+    )
+
+
+def test_w1_report_on_the_pinned_model_fails_on_its_defects():
+    """W1's real report on policyengine-uk 2.125.1: 66 failures and 35 errors."""
+
+    report = _fixture("pinned_uk_2.125.1_defects.json")
+    result = _run_contract(
+        report,
+        versions={"policyengine-uk": "2.125.1", "policyengine-core": "3.33.0"},
+        dataset_sha=None,
+        dataset_path=None,
+        max_examples=report["max_examples"],
+        coverage=report,
+    )
+    assert result.status == "fail"
+    assert result.summary.startswith("101 contract row(s) failed")
+    # This historical runner predates the always-emitted run-level check and
+    # after-run isolation rows. Its failures remain decisive; current coverage
+    # validation also correctly identifies those absent rows.
+    assert any("declared case check(s)" in p for p in result.details["problems"])
+    assert any(
+        "module_parameters_unchanged@all" in p for p in result.details["problems"]
+    )
+
+
+def test_w1_population_run_alone_is_incomplete():
+    """W1's real population-only run has no cases and no sampled properties."""
+
+    population_only = _fixture("population_pass_max_examples_0.json")
+    result = _run_contract(population_only, max_examples=0, coverage=population_only)
+    assert result.status == "error"
+    problems = result.details["problems"]
+    assert "the report has no cases" in problems
+    assert any("annual_values_match_the_oracle did not run" in p for p in problems)
+
+
+def test_w1_full_run_passes():
+    """W1's real all-pass report: 673 pass, 6 pending d1344, 79 unsupported."""
+
+    result = _run_contract(_passing_report())
+    assert result.status == "pass", result.summary
+    assert "every case, property and population check passed (673)" in result.summary
+    assert "6 pending a methodology call" in result.summary
+    assert result.details["problems"] == []
+
+
+def _drop(report, **sections):
+    """``report`` with rows removed, its summary and holds recounted."""
+
+    return _recount({**report, **sections})
+
+
+COVERAGE_GAPS = {
+    "each case reduced to one check per route": lambda r: _drop(
+        r,
+        cases=list(
+            {(row["case_id"], row["route"]): row for row in r["cases"]}.values()
+        ),
+    ),
+    "all but one population check": lambda r: _drop(r, population=r["population"][:1]),
+    "pending properties with invented decisions": lambda r: _drop(
+        r,
+        properties=[
+            {**row, "status": "pending", "decision_id": "invented", "examples_run": 0}
+            for row in r["properties"]
+        ],
+    ),
+    "one case on one route": lambda r: (
+        _drop(r, cases=r["cases"][:1], population=r["population"][:1], properties=[])
+        | {"routes": r["routes"][:1]}
+    ),
+    "a route": lambda r: _drop(
+        r, cases=[row for row in r["cases"] if row["route"] != "wrapper.policy"]
+    ),
+    "a route from the route list": lambda r: {**r, "routes": r["routes"][:-1]},
+    "a case on one route": lambda r: _drop(
+        r,
+        cases=[
+            row
+            for row in r["cases"]
+            if (row["case_id"], row["route"])
+            != ("extended_freeze", "wrapper.household")
+        ],
+    ),
+    "a sampled property": lambda r: _drop(r, properties=r["properties"][:1]),
+    "every sampled property": lambda r: _drop(r, properties=[]),
+    "the sampled examples": lambda r: {
+        **r,
+        "properties": [{**row, "examples_run": 0} for row in r["properties"]],
+    },
+    "most sampled examples": lambda r: {
+        **r,
+        "properties": [{**row, "examples_run": 19} for row in r["properties"]],
+    },
+}
+
+
+@pytest.mark.parametrize("name", sorted(COVERAGE_GAPS))
+def test_a_report_that_drops_coverage_does_not_qualify(name):
+    """Interrupted review of #574: a recounted partial report passed, as did
+    properties that passed on zero examples."""
+
+    report = COVERAGE_GAPS[name](copy.deepcopy(_passing_report()))
+    result = _run_contract(report)
+    assert result.status == "error", (name, result.summary)
+
+
+def test_a_runner_that_declares_no_coverage_cannot_qualify():
+    report = _passing_report()
+    result = reform_contract(
+        entrypoint=ENTRY,
+        dataset_path=Path("enhanced_frs_2024_25.h5"),
+        seed=0,
+        max_examples=20,
+        expected_versions={"policyengine-uk": "2.126.8", "policyengine-core": "3.33.0"},
+        expected_dataset_sha256=CERTIFIED_SHA,
+        importer=_importer(report=report, declare=False),
+    )[0]
+    assert result.status == "error"
+    assert "declares no ROUTES, CASES and PROPERTIES" in result.summary
+
+
+def test_population_coverage_uses_the_population_runners_declarations():
+    """A check added by the runner must be required without a gate-side list."""
+
+    report = _passing_report()
+    importer = _importer(report=report, coverage=report)
+    module = importer("contract")
+    del module.POPULATION_CHECKS
+    population = SimpleNamespace()
+    population.__doc__ = (
+        "Checks:\n\n``new_population_check``\n    Declared by the population runner."
+    )
+    result = reform_contract(
+        entrypoint=ENTRY,
+        dataset_path=Path("dataset.h5"),
+        seed=0,
+        max_examples=20,
+        importer=lambda name: (
+            population if name.endswith("budget_reform_population") else module
+        ),
+    )[0]
+    assert result.status == "error"
+    assert "new_population_check" in result.summary
+
+
+REPORT_TAMPERING = {
+    "invented pending decision": lambda r: _recount(
+        {
+            **r,
+            "cases": [
+                {**row, "decision_id": "invented"}
+                if row["status"] == "pending"
+                else row
+                for row in r["cases"]
+            ],
+        }
+    ),
+    "other model": lambda r: r["versions"].update({"policyengine-uk": "2.125.1"}),
+    "other core": lambda r: r["versions"].update({"policyengine-core": "3.30.0"}),
+    "other dataset": lambda r: r["dataset"].update(sha256="0" * 64),
+    "no population": lambda r: _recount({**r, "population": []}),
+    "other seed": lambda r: r.update(seed=7),
+    "fewer examples": lambda r: r.update(max_examples=5),
+    "summary": lambda r: r["summary"].update(
+        unsupported=r["summary"]["unsupported"] + 1
+    ),
+    "holds": lambda r: r.update(holds=False),
+    "pending without decision": lambda r: _recount(
+        {**r, "cases": [{**r["cases"][0], "status": "pending"}, *r["cases"][1:]]}
+    ),
+    "unsupported without reason": lambda r: _recount(
+        {
+            **r,
+            "population": [
+                {**r["population"][0], "status": "unsupported"},
+                *r["population"][1:],
+            ],
+        }
+    ),
+}
+
+
+@pytest.mark.parametrize("name", sorted(REPORT_TAMPERING))
+def test_a_report_that_is_not_a_full_run_of_this_candidate_is_an_error(name):
+    report = copy.deepcopy(_passing_report())
+    tampered = REPORT_TAMPERING[name](report) or report
+    assert _run_contract(tampered).status == "error"
+
+
+@settings(max_examples=40, deadline=None)
+@given(
+    data=st.data(),
+    worse=st.sampled_from(["fail", "error"]),
+    section=st.sampled_from(["cases", "properties", "population"]),
+)
+def test_a_worse_row_never_improves_the_gate(data, worse, section):
+    """Monotone: turning any row into a failure fails the gate."""
+
+    report = _passing_report()
+    index = data.draw(st.integers(0, len(report[section]) - 1))
+    report[section][index] = {**report[section][index], "status": worse}
+    result = _run_contract(_recount(report))
+    assert STATUS_RANK[result.status] >= STATUS_RANK["fail"]
+
+
+# --- Metrics review ------------------------------------------------------------------
+
+BOUNDS = {
+    "default": {"relative": 0.02, "absolute": 5e8},
+    "kinds": {"poverty": {"relative": 0.0, "absolute": 0.005}},
+}
+
+
+def _report(values, **extra):
+    return {
+        "status": "ok",
+        "metrics_version": "m1",
+        "partition_version": "p1",
+        "metrics": [
+            {"metric_id": mid, "year": year, "value": value, "kind": kind}
+            for (mid, year, kind), value in values.items()
+        ],
+        # Every real report carries checks; one always passes here.
+        "identities": [
+            {"identity_id": "weights:household", "status": "pass"},
+            *extra.pop("identities", []),
+        ],
+        **extra,
+    }
+
+
+BASE = {
+    ("income_tax", 2027, "receipt"): 3.0e11,
+    ("relative_poverty", 2027, "poverty"): 0.17,
+}
+
+
+def test_metric_comparison_rules():
+    assert compare_metric_reports(_report(BASE), _report(BASE), BOUNDS)[0] == "pass"
+    moved = {**BASE, ("income_tax", 2027, "receipt"): 3.1e11}
+    assert (
+        compare_metric_reports(_report(moved), _report(BASE), BOUNDS)[0]
+        == "needs_review"
+    )
+    assert (
+        compare_metric_reports(
+            _report(BASE, metrics_version="m2"), _report(BASE), BOUNDS
+        )[0]
+        == "fail"
+    )
+    assert (
+        compare_metric_reports(
+            _report(BASE, partition_version="p2"), _report(BASE), BOUNDS
+        )[0]
+        == "needs_review"
+    )
+    lost = {("income_tax", 2027, "receipt"): 3.0e11}
+    assert compare_metric_reports(_report(lost), _report(BASE), BOUNDS)[0] == "fail"
+    nan = {**BASE, ("income_tax", 2027, "receipt"): float("nan")}
+    assert compare_metric_reports(_report(nan), _report(BASE), BOUNDS)[0] == "fail"
+    warned = _report(
+        BASE, identities=[{"identity_id": "inert:alcohol_duty", "status": "warn"}]
+    )
+    status, _, evidence = compare_metric_reports(warned, _report(BASE), BOUNDS)
+    assert status == "pass" and evidence["warn_identities"] == ["inert:alcohol_duty"]
+
+
+def test_reports_that_measure_nothing_fail():
+    """Interrupted review of #574: two empty reports compared as a pass."""
+
+    assert compare_metric_reports({}, {}, BOUNDS)[0] == "fail"
+    full = _report(BASE)
+    for broken in (
+        {**full, "metrics": []},
+        {**full, "identities": []},
+        {**full, "status": "error"},
+        {key: value for key, value in full.items() if key != "status"},
+    ):
+        assert compare_metric_reports(broken, full, BOUNDS)[0] == "fail"
+        assert compare_metric_reports(full, broken, BOUNDS)[0] == "fail"
+
+
+@pytest.mark.parametrize("status", ["error", "unsupported", "pending", None, "PASS"])
+def test_a_check_with_an_unknown_status_is_not_a_pass(status):
+    candidate = _report(BASE, identities=[{"identity_id": "closure", "status": status}])
+    previous = _report(BASE, identities=[{"identity_id": "closure", "status": "pass"}])
+    result = compare_metric_reports(candidate, previous, BOUNDS)
+    assert result[0] == "fail" and "unknown status" in result[1]
+
+
+def test_a_check_that_disappears_is_lost_coverage():
+    previous = _report(
+        BASE,
+        identities=[
+            {"identity_id": "closure", "status": "pass"},
+            {"identity_id": "sign:vat", "status": "pass"},
+        ],
+    )
+    # A hard check that vanished under the same partition fails.
+    no_closure = _report(
+        BASE, identities=[{"identity_id": "sign:vat", "status": "pass"}]
+    )
+    result = compare_metric_reports(no_closure, previous, BOUNDS)
+    assert result[0] == "fail" and "checks disappeared: closure" in result[1]
+    # A no-worsening check that vanished needs a look.
+    no_sign = _report(BASE, identities=[{"identity_id": "closure", "status": "pass"}])
+    assert compare_metric_reports(no_sign, previous, BOUNDS)[0] == "needs_review"
+    # When the model's partition changed, vanished per-head checks need review.
+    repartitioned = _report(
+        BASE,
+        partition_version="p2",
+        identities=[{"identity_id": "sign:vat", "status": "pass"}],
+    )
+    assert compare_metric_reports(repartitioned, previous, BOUNDS)[0] == "needs_review"
+
+
+def _checks(**statuses):
+    return [
+        {"identity_id": key.replace("__", ":"), "status": value}
+        for key, value in statuses.items()
+    ]
+
+
+@pytest.mark.parametrize(
+    ("candidate", "previous", "status"),
+    [
+        # Hard checks fail whatever the previous pair did.
+        ({"finite__vat": "fail"}, {"finite__vat": "fail"}, "fail"),
+        ({"closure": "fail"}, {}, "fail"),
+        (
+            {"identity__partition_sum": "fail"},
+            {"identity__partition_sum": "pass"},
+            "fail",
+        ),
+        ({"determinism__repeat": "fail"}, {}, "fail"),
+        # Unknown check kinds are treated as hard: no silent skips.
+        ({"newkind__x": "fail"}, {"newkind__x": "fail"}, "fail"),
+        # Known-defect checks fail only as regressions.
+        (
+            {"counted_once__statutory_sick_pay": "fail"},
+            {"counted_once__statutory_sick_pay": "fail"},
+            "pass",
+        ),
+        (
+            {"counted_once__statutory_sick_pay": "fail"},
+            {"counted_once__statutory_sick_pay": "pass"},
+            "fail",
+        ),
+        ({"sign__vat": "fail"}, {}, "fail"),
+        ({"bounds__uc": "fail"}, {"bounds__uc": "fail"}, "pass"),
+        # The overlap guard fails on main for a known defect: no-worsening.
+        (
+            {"overlap_guard__property_transaction_tax_geography": "fail"},
+            {"overlap_guard__property_transaction_tax_geography": "fail"},
+            "pass",
+        ),
+        (
+            {"overlap_guard__property_transaction_tax_geography": "fail"},
+            {"overlap_guard__property_transaction_tax_geography": "pass"},
+            "fail",
+        ),
+        # A check skipped before that fails now has worsened.
+        (
+            {"counted_once__statutory_sick_pay": "fail"},
+            {"counted_once__statutory_sick_pay": "skipped"},
+            "fail",
+        ),
+        # Skipped is neither a pass nor a failure, but losing a passing
+        # check needs a look.
+        (
+            {"counted_once__winter_fuel": "skipped"},
+            {"counted_once__winter_fuel": "skipped"},
+            "pass",
+        ),
+        (
+            {"counted_once__winter_fuel": "skipped"},
+            {"counted_once__winter_fuel": "pass"},
+            "needs_review",
+        ),
+        # The reform checks are hard, whatever the previous pair did.
+        (
+            {"responsive__state_pension_plus_10pc": "fail"},
+            {"responsive__state_pension_plus_10pc": "fail"},
+            "fail",
+        ),
+        (
+            {"reform_runs__uc_taper_20pc": "fail"},
+            {"reform_runs__uc_taper_20pc": "fail"},
+            "fail",
+        ),
+        # Warnings are recorded; a check that newly warns needs review.
+        ({"inert__tobacco_duty": "warn"}, {"inert__tobacco_duty": "warn"}, "pass"),
+        (
+            {"inert__tobacco_duty": "warn"},
+            {"inert__tobacco_duty": "pass"},
+            "needs_review",
+        ),
+    ],
+)
+def test_check_classification(candidate, previous, status):
+    result = compare_metric_reports(
+        _report(BASE, identities=_checks(**candidate)),
+        _report(BASE, identities=_checks(**previous)),
+        BOUNDS,
+    )
+    assert result[0] == status, result[1]
+
+
+def test_a_known_failure_that_spreads_to_more_years_has_worsened():
+    """No-worsening by scope: the failing scopes must be a subset of the previous pair's."""
+
+    def check(scopes):
+        return _report(
+            BASE,
+            identities=[
+                {
+                    "identity_id": "sign:vat",
+                    "status": "fail",
+                    "failing_scopes": scopes,
+                }
+            ],
+        )
+
+    same = compare_metric_reports(
+        check(["2026", "2027"]), check(["2026", "2027"]), BOUNDS
+    )
+    fewer = compare_metric_reports(check(["2026"]), check(["2026", "2027"]), BOUNDS)
+    more = compare_metric_reports(
+        check(["2026", "2028"]), check(["2026", "2027"]), BOUNDS
+    )
+    assert (same[0], fewer[0], more[0]) == ("pass", "pass", "fail")
+    assert "sign:vat (fails in more scopes)" in more[1]
+    # Without scopes on both sides the failures cannot be told apart.
+    unscoped = _report(BASE, identities=[{"identity_id": "sign:vat", "status": "fail"}])
+    assert (
+        compare_metric_reports(check(["2026", "2028"]), unscoped, BOUNDS)[0] == "pass"
+    )
+
+
+@settings(max_examples=150, deadline=None)
+@given(
+    changes=st.lists(
+        st.floats(min_value=-0.5, max_value=0.5, allow_nan=False),
+        min_size=2,
+        max_size=2,
+    ),
+    shrink=st.floats(min_value=0.0, max_value=1.0),
+)
+def test_shrinking_moves_never_worsens_the_status(changes, shrink):
+    order = {"pass": 0, "needs_review": 1, "fail": 2}
+    keys = list(BASE)
+
+    def candidate(scale):
+        return _report(
+            {
+                key: BASE[key] * (1 + scale * change)
+                for key, change in zip(keys, changes)
+            }
+        )
+
+    full = compare_metric_reports(candidate(1.0), _report(BASE), BOUNDS)[0]
+    smaller = compare_metric_reports(candidate(shrink), _report(BASE), BOUNDS)[0]
+    assert order[smaller] <= order[full]
+
+
+def test_metrics_gate_splits_model_and_data_steps():
+    cross = {**BASE, ("income_tax", 2027, "receipt"): 3.2e11}
+    result, evidence = metrics_review(
+        candidate_report=_report(cross),
+        previous_report=_report(BASE),
+        bounds=BOUNDS,
+        cross_report=_report(cross),
+    )
+    assert result.details == {"model_only": "needs_review", "data_only": "pass"}
+    assert result.status == "needs_review"
+    assert (
+        metrics_review(candidate_report=None, previous_report=None, bounds=BOUNDS)[
+            0
+        ].status
+        == "unavailable"
+    )
+
+
+# --- Orchestration -------------------------------------------------------------------
+
+
+@settings(max_examples=50, deadline=None)
+@given(
+    outcomes=st.lists(
+        st.sampled_from(["pass", "fail", "raise"]), min_size=1, max_size=9
+    )
+)
+def test_run_gates_returns_one_result_per_gate(outcomes):
+    def make(index, outcome):
+        def function():
+            if outcome == "raise":
+                raise RuntimeError(f"gate {index} crashed")
+            return gates_module._result(f"wrong-id-{index}", outcome, "x", {})
+
+        return (f"gate_{index}", function)
+
+    results, evidence = run_gates([make(i, o) for i, o in enumerate(outcomes)])
+    assert [r.gate_id for r in results] == [f"gate_{i}" for i in range(len(outcomes))]
+    for result, outcome in zip(results, outcomes):
+        assert result.status == ("error" if outcome == "raise" else outcome)
+    assert set(evidence) == {f"gate_{i}" for i in range(len(outcomes))}
+
+
+# --- Previous-pair runners -----------------------------------------------------------
+
+
+def _write_previous_receipt(root, pair, evidence, *, attempt="a"):
+    from policyengine.provenance.qualification_ledger import write_json
+
+    receipt = make_receipt(pair)
+    receipt = receipt.model_copy(
+        update={
+            "gates": [
+                gate.model_copy(
+                    update={
+                        "evidence_sha256": gates_module.evidence_sha256(
+                            evidence[gate.gate_id]
+                        )
+                    }
+                )
+                if gate.gate_id in evidence
+                else gate
+                for gate in receipt.gates
+            ]
+        }
+    )
+    pair_dir = root / "uk" / pair.pair_id
+    write_json(pair_dir / "qualification_receipt.json", receipt.canonical_payload())
+    attempt_dir = pair_dir / "attempts" / attempt
+    write_json(attempt_dir / "qualification_receipt.json", receipt.canonical_payload())
+    write_json(attempt_dir / "harness.json", receipt.harness.canonical_payload())
+    for gate_id, value in evidence.items():
+        write_json(attempt_dir / "gates" / f"{gate_id}.json", value)
+    return receipt, attempt_dir
+
+
+def _previous_identity(pair):
+    return {
+        "bundle_version": HARNESS.policyengine_version,
+        "model_version": pair.model.version,
+        "core_version": pair.core.version,
+        "data_release_version": pair.data.release_version,
+        "dataset_sha256": pair.data.dataset_sha256,
+        "qualification_pair_id": pair.pair_id,
+        "model_wheel_sha256": pair.model.wheel_sha256,
+        "core_wheel_sha256": pair.core.wheel_sha256,
+    }
+
+
+def test_previous_evidence_matches_the_exact_certified_core_and_wheels(tmp_path):
+    wrong = make_pair(core="3.32.0")
+    certified = make_pair(core="3.33.0")
+    wrong_evidence = {
+        "supported_years": {"supported_years": [2025]},
+        "budget_inputs": {"missing_inputs": ["age"], "unusable_critical": []},
+    }
+    expected = {
+        "supported_years": {"supported_years": [2025, 2026]},
+        "budget_inputs": {"missing_inputs": [], "unusable_critical": []},
+    }
+    _write_previous_receipt(tmp_path, wrong, wrong_evidence)
+    _write_previous_receipt(tmp_path, certified, expected)
+    assert (
+        gates_module._previous_gate_evidence(tmp_path, _previous_identity(certified))
+        == expected
+    )
+    for wheel in ("core_wheel_sha256", "model_wheel_sha256"):
+        altered = {**_previous_identity(certified), wheel: "0" * 64}
+        assert gates_module._previous_gate_evidence(tmp_path, altered) == {}
+
+
+def test_previous_evidence_belongs_to_the_receipts_attempt(tmp_path):
+    from policyengine.provenance.qualification_ledger import write_json
+
+    pair = make_pair()
+    expected = {
+        "supported_years": {"supported_years": [2025, 2026]},
+        "budget_inputs": {"missing_inputs": [], "unusable_critical": []},
+    }
+    receipt, attempt = _write_previous_receipt(tmp_path, pair, expected)
+    unrelated = attempt.parent / "z"
+    other_receipt = receipt.model_copy(
+        update={"harness": HARNESS.model_copy(update={"git_commit": "1" * 40})}
+    )
+    write_json(
+        unrelated / "qualification_receipt.json", other_receipt.canonical_payload()
+    )
+    for name in expected:
+        write_json(unrelated / "gates" / f"{name}.json", {"untrusted": True})
+    assert (
+        gates_module._previous_gate_evidence(tmp_path, _previous_identity(pair))
+        == expected
+    )
+
+
+@pytest.mark.parametrize(
+    "corruption", ["digest", "shape", "receipt", "missing identity"]
+)
+def test_unverified_previous_evidence_is_recomputed(tmp_path, corruption):
+    from policyengine.provenance.qualification_ledger import write_json
+
+    pair = make_pair()
+    expected = {
+        "supported_years": {"supported_years": [2025, 2026]},
+        "budget_inputs": {"missing_inputs": [], "unusable_critical": []},
+    }
+    receipt, attempt = _write_previous_receipt(tmp_path, pair, expected)
+    previous = _previous_identity(pair)
+    if corruption == "digest":
+        write_json(
+            attempt / "gates" / "supported_years.json", {"supported_years": [2025]}
+        )
+    elif corruption == "shape":
+        invalid = {**expected, "supported_years": {"supported_years": [True]}}
+        _write_previous_receipt(tmp_path, pair, invalid)
+    elif corruption == "receipt":
+        payload = receipt.canonical_payload() | {"verdict": "needs_review"}
+        write_json(attempt.parent.parent / "qualification_receipt.json", payload)
+    else:
+        previous.pop("qualification_pair_id")
+        previous.pop("core_wheel_sha256")
+    assert gates_module._previous_gate_evidence(tmp_path, previous) == {}
+
+
+@pytest.mark.parametrize("corrupt_baseline", [False, True])
+def test_orchestration_hashes_every_finalized_gate_evidence(
+    tmp_path, monkeypatch, corrupt_baseline
+):
+    from policyengine.provenance import dataset_materialization
+    from policyengine.provenance.qualification_ledger import write_json
+
+    pair = make_pair()
+    config = json.loads(
+        (
+            Path(__file__).parents[1] / "src/policyengine/data/qualification/uk.json"
+        ).read_text()
+    )
+    monkeypatch.setattr(
+        dataset_materialization,
+        "materialize_dataset",
+        lambda *a, **k: SimpleNamespace(path=tmp_path / "dataset.h5"),
+    )
+    monkeypatch.setitem(
+        sys.modules,
+        "policyengine_uk.system",
+        SimpleNamespace(system=SimpleNamespace(variables={})),
+    )
+    baseline = {
+        "budget_inputs": {"missing_inputs": [], "unusable_critical": []},
+        "supported_years": {"supported_years": [2025]},
+    }
+    _, attempt = _write_previous_receipt(tmp_path, pair, baseline)
+    if corrupt_baseline:
+        write_json(
+            attempt / "gates" / "supported_years.json", {"supported_years": [2024]}
+        )
+    snapshots = []
+
+    def snapshot(*args, **kwargs):
+        snapshots.append(args[0])
+        return {
+            "missing_inputs": [],
+            "unusable_critical": [],
+            "supported_years": [2025],
+        }
+
+    monkeypatch.setattr(gates_module, "run_inputs_snapshot", snapshot)
+    monkeypatch.setattr(gates_module, "vendor_metrics_tool", lambda *a, **k: tmp_path)
+    monkeypatch.setattr(gates_module, "load_standard_reforms", lambda *a: [])
+    monkeypatch.setattr(gates_module, "previous_pair_python", lambda *a: "python")
+    monkeypatch.setattr(
+        gates_module,
+        "run_metrics_report",
+        lambda *a, **k: {"partition_source": "test", "dataset_path": "dataset.h5"},
+    )
+    monkeypatch.setattr(
+        gates_module, "_download", lambda *a, **k: tmp_path / "wheel.whl"
+    )
+    for name in (
+        "installed_versions",
+        "wheel_hashes",
+        "core_compatibility",
+        "dataset_bytes",
+        "supported_years",
+        "budget_inputs",
+        "reform_contract",
+        "metrics_review",
+        "reform_responsiveness",
+    ):
+        monkeypatch.setattr(
+            gates_module,
+            name,
+            lambda *a, _name=name, **k: gates_module._result(
+                _name, "pass", "test", {"before_metadata": _name}
+            ),
+        )
+    monkeypatch.setattr(
+        gates_module,
+        "data_access",
+        lambda *a, **k: (*gates_module._result("data_access", "pass", "test", {}), {}),
+    )
+    resolved = SimpleNamespace(
+        pair=pair,
+        model_wheel=SimpleNamespace(
+            url="model", filename="model.whl", sha256=pair.model.wheel_sha256
+        ),
+        core_wheel=SimpleNamespace(
+            url="core", filename="core.whl", sha256=pair.core.wheel_sha256
+        ),
+        model_requires_core=">=3.0",
+    )
+    results, evidence = gates_module.run_candidate_gates(
+        resolved,
+        HARNESS,
+        config=config,
+        work_dir=tmp_path,
+        previous_certificate=_previous_identity(pair),
+        ledger_root=tmp_path,
+    )
+    mismatches = [
+        result.gate_id
+        for result in results
+        if result.evidence_sha256
+        != gates_module.evidence_sha256(evidence[result.gate_id])
+    ]
+    assert mismatches == []
+    assert snapshots == (["python"] if corrupt_baseline else [])
+    assert evidence["budget_inputs"]["previous_source"] == (
+        f"computed with policyengine {HARNESS.policyengine_version}"
+        if corrupt_baseline
+        else "ledger"
+    )
+
+
+@settings(max_examples=15, deadline=None)
+@given(
+    present=st.sets(st.sampled_from(["age", "employment_income", "rent"]), min_size=1),
+    critical=st.lists(
+        st.sampled_from(["age", "rent", "would_claim_uc", "missing_x"]),
+        unique=True,
+        max_size=3,
+    ),
+    projected=st.lists(st.integers(2022, 2032), min_size=1, max_size=6, unique=True),
+)
+def test_inputs_runner_agrees_with_the_gates(
+    tmp_path_factory, present, critical, projected
+):
+    """Differential: the previous-pair runner and the in-process gates agree."""
+
+    import sys
+
+    from policyengine.provenance.uk_candidate import (
+        dataset_columns,
+        run_inputs_snapshot,
+    )
+
+    directory = tmp_path_factory.mktemp("runner")
+    extra = {"rent_person": [1.0, 0.0]} if "rent" in present else {}
+    path = _tiny_dataset(directory / "data.h5", year=2024, extra_person=extra)
+    inputs = [
+        "age",
+        "employment_income",
+        "rent",
+        "would_claim_uc",
+        "household_weight",
+        "missing_x",
+    ]
+    snapshot = run_inputs_snapshot(
+        sys.executable,
+        critical=critical,
+        out=directory / "snapshot.json",
+        dataset_path=path,
+        model_input_variables=inputs,
+        projected_years=projected,
+    )
+    tables = dataset_columns(path)
+    _, evidence = budget_inputs(
+        make_pair(),
+        path,
+        model_input_variables=inputs,
+        critical_inputs=critical,
+        accepted_gaps={},
+        previous_missing_inputs=None,
+        previous_unusable_critical=None,
+        tables=tables,
+    )
+    assert snapshot["missing_inputs"] == evidence["missing_inputs"]
+    assert snapshot["unusable_critical"] == evidence["unusable_critical"]
+    _, years = supported_years(
+        make_pair(),
+        path,
+        required_years=[],
+        budget_horizon_years=[],
+        previous_supported_years=None,
+        projected_years=lambda _: sorted(projected),
+    )
+    assert snapshot["supported_years"] == years["supported_years"]
+
+
+STANDARD_REFORMS_FIXTURE = (
+    Path(__file__).parent / "fixtures" / "uk_metrics" / "standard_reforms.yaml"
+)
+
+
+def _tool_tarball(commit="c" * 40, include_partition=True, include_reforms=True):
+    import tarfile
+
+    buffer = io.BytesIO()
+    prefix = f"policyengine-uk-{commit}"
+    files = {
+        f"{prefix}/policyengine_uk/utils/aggregate_impact/__init__.py": b"TOOL_SCHEMA = 'x/1'\n",
+        f"{prefix}/policyengine_uk/utils/aggregate_impact/api.py": b"def compute_budget_metrics(sim, **kwargs):\n    return {}\n",
+        f"{prefix}/policyengine_uk/utils/other.py": b"not copied\n",
+    }
+    if include_partition:
+        files[f"{prefix}/policyengine_uk/data/fiscal_partition.yaml"] = b"heads: []\n"
+    if include_reforms:
+        files[
+            f"{prefix}/policyengine_uk/utils/aggregate_impact/standard_reforms.yaml"
+        ] = STANDARD_REFORMS_FIXTURE.read_bytes()
+    with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
+        for name, data in files.items():
+            info = tarfile.TarInfo(name)
+            info.size = len(data)
+            archive.addfile(info, io.BytesIO(data))
+    return buffer.getvalue()
+
+
+def test_metrics_tool_is_vendored_from_one_commit(tmp_path):
+    from policyengine.provenance.uk_candidate import vendor_metrics_tool
+
+    calls = []
+
+    def get(url, timeout=None):
+        calls.append(url)
+        response = SimpleNamespace(
+            content=_tool_tarball(), raise_for_status=lambda: None
+        )
+        return response
+
+    tool = {
+        "repository": "PolicyEngine/policyengine-uk",
+        "commit": "c" * 40,
+        "path": "policyengine_uk/utils/aggregate_impact",
+    }
+    root = vendor_metrics_tool(tool, tmp_path, get=get)
+    assert calls == [
+        f"https://codeload.github.com/PolicyEngine/policyengine-uk/tar.gz/{'c' * 40}"
+    ]
+    assert sorted(p.name for p in (root / "pe_uk_aggregate_impact").iterdir()) == [
+        "__init__.py",
+        "api.py",
+        "standard_reforms.yaml",
+    ]
+    assert (root / "fiscal_partition.yaml").read_text() == "heads: []\n"
+    assert (
+        vendor_metrics_tool(
+            tool, tmp_path, get=lambda *a, **k: pytest.fail("refetched")
+        )
+        == root
+    )
+
+
+def test_metrics_report_runner_command(tmp_path):
+    import json as _json
+
+    from policyengine.provenance.uk_candidate import METRICS_RUNNER, run_metrics_report
+
+    seen = {}
+
+    def run(command, check, cwd):
+        seen["command"] = command
+        Path(command[-1]).write_text(_json.dumps({"status": "ok", "metrics": []}))
+
+    report = run_metrics_report(
+        "/venv/bin/python",
+        tmp_path / "tool",
+        years=[2026, 2027],
+        out=tmp_path / "report.json",
+        dataset_path=tmp_path / "data.h5",
+        run=run,
+    )
+    assert report == {"status": "ok", "metrics": []}
+    python, flag, code, vendor, settings, out = seen["command"]
+    assert (python, flag, code) == ("/venv/bin/python", "-c", METRICS_RUNNER)
+    assert _json.loads(settings)["years"] == [2026, 2027]
+    assert _json.loads(settings)["run_guard"] is True
+    # The runner must not pass ``reforms``: the tool then costs its own
+    # standard set, whereas an empty list would cost none.
+    assert "reforms=" not in METRICS_RUNNER
+
+
+def test_the_standard_reforms_are_read_from_the_vendored_tool(tmp_path):
+    from policyengine.provenance.uk_candidate import (
+        load_standard_reforms,
+        vendor_metrics_tool,
+    )
+
+    tool = {
+        "repository": "PolicyEngine/policyengine-uk",
+        "commit": "c" * 40,
+        "path": "policyengine_uk/utils/aggregate_impact",
+    }
+    root = vendor_metrics_tool(
+        tool,
+        tmp_path,
+        get=lambda url, timeout=None: SimpleNamespace(
+            content=_tool_tarball(), raise_for_status=lambda: None
+        ),
+    )
+    reforms = load_standard_reforms(root, "standard_reforms.yaml")
+    assert len(reforms) == 10
+    assert reforms[0] == {
+        "id": "basic_rate_plus_1pp",
+        "name": "Raise basic rate by 1pp",
+        "variable": "income_tax",
+        "direction": "up",
+        "minimum": 1.5e9,
+    }
+    assert {reform["id"] for reform in reforms} >= {
+        "uc_standard_allowance_plus_10pc",
+        "state_pension_plus_10pc",
+    }
+    with pytest.raises(ValueError, match="ships no"):
+        load_standard_reforms(root, "missing.yaml")
+
+
+@pytest.mark.parametrize(
+    "entry",
+    [
+        {
+            "name": "No id",
+            "responds": {"variable": "x", "direction": "up", "minimum": 1},
+        },
+        {"id": "no_response", "name": "No response"},
+        {
+            "id": "bad",
+            "responds": {"variable": "x", "direction": "sideways", "minimum": 1},
+        },
+        {"id": "zero", "responds": {"variable": "x", "direction": "up", "minimum": 0}},
+    ],
+    ids=["no id", "no response", "bad direction", "zero minimum"],
+)
+def test_a_standard_reform_without_a_stated_response_is_refused(tmp_path, entry):
+    import yaml
+
+    from policyengine.provenance.uk_candidate import load_standard_reforms
+
+    package = tmp_path / "pe_uk_aggregate_impact"
+    package.mkdir()
+    (package / "standard_reforms.yaml").write_text(yaml.safe_dump({"reforms": [entry]}))
+    with pytest.raises(ValueError):
+        load_standard_reforms(tmp_path, "standard_reforms.yaml")
+
+
+# --- Reform responsiveness -----------------------------------------------------------
+
+RESPONSE_YEARS = [2025, 2026, 2027, 2028, 2029, 2030, 2031]
+
+
+def _standard_reforms(tmp_path_factory=None):
+    import yaml
+
+    return [
+        {
+            "id": entry["id"],
+            "name": entry["name"],
+            "variable": entry["responds"]["variable"],
+            "direction": entry["responds"]["direction"],
+            "minimum": float(entry["responds"]["minimum"]),
+        }
+        for entry in yaml.safe_load(STANDARD_REFORMS_FIXTURE.read_text())["reforms"]
+    ]
+
+
+def _tool_check(reform, changes):
+    """The tool's responsive verdict, restated from reforms.responsiveness()."""
+
+    sign = 1.0 if reform["direction"] == "up" else -1.0
+    return bool(changes) and all(
+        sign * change >= reform["minimum"] for change in changes.values()
+    )
+
+
+def _responsive_report(scale=5.0, overrides=None, years=RESPONSE_YEARS, lie=None):
+    """A report in which each reform moves its target by scale x its minimum.
+
+    ``overrides`` replaces single values (None removes the row). The
+    ``responsive`` checks follow the tool's rule, unless ``lie`` names a
+    reform id whose check is inverted.
+    """
+
+    overrides = overrides or {}
+    metrics, identities = [], []
+    for reform in _standard_reforms():
+        sign = 1.0 if reform["direction"] == "up" else -1.0
+        changes = {}
+        for year in years:
+            value = overrides.get(
+                (reform["id"], year), sign * reform["minimum"] * scale
+            )
+            if value is not None:
+                changes[year] = value
+                metrics.append(
+                    {
+                        "metric_id": f"reform_targets.{reform['id']}",
+                        "year": year,
+                        "value": value,
+                        "kind": "reform_target",
+                    }
+                )
+        finite = {y: v for y, v in changes.items() if v == v}
+        ok = len(finite) == len(years) and _tool_check(reform, finite)
+        if lie == reform["id"]:
+            ok = not ok
+        identities.append(
+            {"identity_id": f"reform_runs:{reform['id']}", "status": "pass"}
+        )
+        identities.append(
+            {
+                "identity_id": f"responsive:{reform['id']}",
+                "status": "pass" if ok else "fail",
+                "detail": "" if ok else f"expected {reform['variable']} to move",
+            }
+        )
+    return {
+        "status": "ok",
+        "years": list(years),
+        "metrics": metrics,
+        "identities": identities,
+    }
+
+
+def _responsiveness(report, reforms=None, years=RESPONSE_YEARS):
+    from policyengine.provenance.uk_candidate import reform_responsiveness
+
+    return reform_responsiveness(
+        report=report,
+        standard_reforms=_standard_reforms() if reforms is None else reforms,
+        years=years,
+    )[0]
+
+
+def test_reforms_that_all_respond_pass():
+    result = _responsiveness(_responsive_report())
+    assert result.status == "pass", result.summary
+    assert "All 10 standard reforms" in result.summary
+    assert "2025-26 to 2031-32" in result.summary
+
+
+UC_ALLOWANCE = "uc_standard_allowance_plus_10pc"
+STATE_PENSION = "state_pension_plus_10pc"
+
+
+@pytest.mark.parametrize(
+    ("overrides", "reform_id"),
+    [
+        ({(UC_ALLOWANCE, year): 0.0 for year in RESPONSE_YEARS}, UC_ALLOWANCE),
+        ({(STATE_PENSION, year): 4.0e7 for year in RESPONSE_YEARS}, STATE_PENSION),
+        ({(UC_ALLOWANCE, 2027): 0.0}, UC_ALLOWANCE),
+        ({(STATE_PENSION, 2031): -1.3e10}, STATE_PENSION),
+        ({("basic_rate_plus_1pp", 2030): None}, "basic_rate_plus_1pp"),
+        ({("basic_rate_plus_1pp", 2030): float("nan")}, "basic_rate_plus_1pp"),
+    ],
+    ids=[
+        "exactly zero",
+        "nearly inert",
+        "one inert year",
+        "wrong sign",
+        "missing",
+        "nan",
+    ],
+)
+def test_a_reform_that_does_not_respond_fails_qualification(overrides, reform_id):
+    """The backtest's two modes (exactly £0, nearly inert) and their neighbours."""
+
+    result = _responsiveness(_responsive_report(overrides=overrides))
+    assert result.status == "fail"
+    assert f"({reform_id}) did not respond" in result.summary, result.summary
+
+
+def test_a_run_that_costed_no_reforms_cannot_pass():
+    """Presence is required: an empty or partial check set is a failure."""
+
+    report = _responsive_report()
+    report["identities"] = []
+    result = _responsiveness(report)
+    assert result.status == "fail" and "did not run" in result.summary
+    report = _responsive_report()
+    report["identities"] = [
+        row
+        for row in report["identities"]
+        if row["identity_id"] != f"responsive:{STATE_PENSION}"
+    ]
+    result = _responsiveness(report)
+    assert result.status == "fail"
+    assert "no responsive check in the report" in result.summary
+    report = _responsive_report()
+    for row in report["identities"]:
+        if row["identity_id"] == f"reform_runs:{UC_ALLOWANCE}":
+            row["status"] = "fail"
+    assert f"({UC_ALLOWANCE}) did not run" in _responsiveness(report).summary
+
+
+def test_the_tools_verdict_is_checked_against_its_own_numbers():
+    """Differential: a pass the reform_targets values do not support is an error."""
+
+    inert = {(UC_ALLOWANCE, year): 0.0 for year in RESPONSE_YEARS}
+    lying_pass = _responsive_report(overrides=inert, lie=UC_ALLOWANCE)
+    result = _responsiveness(lying_pass)
+    assert result.status == "error"
+    assert "do not meet the minimum" in result.summary
+    lying_fail = _responsive_report(lie=STATE_PENSION)
+    assert _responsiveness(lying_fail).status == "fail"
+    short = _responsive_report(years=RESPONSE_YEARS[:-1])
+    assert _responsiveness(short).status in ("fail", "error")
+
+
+def test_responsiveness_is_never_skipped_or_guessed():
+    from policyengine.provenance.qualification import (
+        REQUIRED_GATES,
+        GateResult,
+        qualification_verdict,
+    )
+
+    assert _responsiveness(None).status == "unavailable"
+    assert _responsiveness(_responsive_report(), reforms=[]).status == "unavailable"
+    assert "reform_responsiveness" in REQUIRED_GATES
+    for status, verdict in (("unavailable", "incomplete"), ("fail", "rejected")):
+        gates = [
+            GateResult(
+                gate_id=name,
+                status=status if name == "reform_responsiveness" else "pass",
+                summary="x",
+            )
+            for name in REQUIRED_GATES
+        ]
+        assert qualification_verdict(gates) == verdict
+    missing = [
+        GateResult(gate_id=name, status="pass", summary="x")
+        for name in REQUIRED_GATES
+        if name != "reform_responsiveness"
+    ]
+    assert qualification_verdict(missing) == "incomplete"
+
+
+@settings(max_examples=100, deadline=None)
+@given(
+    low=st.floats(min_value=-3.0, max_value=6.0),
+    high=st.floats(min_value=-3.0, max_value=6.0),
+)
+def test_responsiveness_is_monotone_in_the_size_of_the_response(low, high):
+    """Shrinking every response, or flipping its sign, never turns fail into pass."""
+
+    low, high = sorted((low, high))
+    order = {"fail": 0, "pass": 1}
+    weaker = _responsiveness(_responsive_report(scale=low)).status
+    stronger = _responsiveness(_responsive_report(scale=high)).status
+    assert order[weaker] <= order[stronger]
+    assert (weaker == "pass") == (low >= 1.0)
+    assert (stronger == "pass") == (high >= 1.0)
+
+
+REAL_REPORT = (
+    Path(__file__).parent / "fixtures" / "uk_metrics" / "budget_metrics_report.json"
+)
+
+
+def _real_report():
+    return json.loads(REAL_REPORT.read_text())
+
+
+def test_the_real_report_on_the_certified_data_passes_responsiveness():
+    """W3's real report: ten reforms, each clearing its minimum in every year."""
+
+    report = _real_report()
+    result, evidence = reform_responsiveness_gate(report)
+    assert result.status == "pass", result.summary
+    assert "All 10 standard reforms" in result.summary
+    rows = {row["id"]: row for row in evidence["reforms"]}
+    uc = rows["uc_standard_allowance_plus_10pc"]["target_changes"]
+    pension = rows["state_pension_plus_10pc"]["target_changes"]
+    assert all(3.0e9 < value < 3.4e9 for value in uc.values())
+    assert all(12.3e9 < value < 14.5e9 for value in pension.values())
+
+
+def reform_responsiveness_gate(report):
+    from policyengine.provenance.uk_candidate import reform_responsiveness
+
+    return reform_responsiveness(
+        report=report, standard_reforms=_standard_reforms(), years=RESPONSE_YEARS
+    )
+
+
+@pytest.mark.parametrize("reform_id", [UC_ALLOWANCE, STATE_PENSION])
+def test_the_backtest_failures_fail_on_the_real_report(reform_id):
+    """Nikhil's backtest: UC allowance +10% returned exactly £0; State Pension +10% barely moved."""
+
+    report = _real_report()
+    for row in report["metrics"]:
+        if row["metric_id"] == f"reform_targets.{reform_id}":
+            row["value"] = 0.0 if reform_id == UC_ALLOWANCE else row["value"] * 0.01
+    # The tool's own check would say fail; a report that still says pass is an error.
+    assert reform_responsiveness_gate(report)[0].status == "error"
+    for row in report["identities"]:
+        if row["identity_id"] == f"responsive:{reform_id}":
+            row["status"] = "fail"
+            row["detail"] = "did not move"
+    result = reform_responsiveness_gate(report)[0]
+    assert result.status == "fail"
+    assert f"({reform_id}) did not respond" in result.summary
+
+
+def test_the_real_report_fits_the_metrics_review_classification():
+    """Every check that fails on main is a no-worsening check, and every
+    metric kind has a review bound, so a pair compared with itself passes."""
+
+    from policyengine.provenance.uk_candidate import classify_check
+
+    report = _real_report()
+    bounds = json.loads(
+        (
+            Path(__file__).parents[1]
+            / "src/policyengine/data/qualification/uk_review_bounds.json"
+        ).read_text()
+    )
+    failing = sorted(
+        row["identity_id"] for row in report["identities"] if row["status"] == "fail"
+    )
+    assert failing == [
+        "counted_once:statutory_maternity_pay",
+        "counted_once:statutory_sick_pay",
+        "overlap_guard:property_transaction_tax_geography",
+        "sign:vat",
+    ]
+    assert {classify_check(check_id) for check_id in failing} == {"regression"}
+    assert {row["kind"] for row in report["metrics"]} <= set(bounds["kinds"])
+    status, summary, evidence = compare_metric_reports(report, report, bounds)
+    assert status == "pass", summary
+    assert sorted(evidence["preexisting_check_failures"]) == failing
+    # The same failures against a pair that passed them are regressions.
+    clean = _real_report()
+    for row in clean["identities"]:
+        if row["status"] == "fail":
+            row["status"] = "pass"
+    assert compare_metric_reports(report, clean, bounds)[0] == "fail"
+
+
+def test_a_real_failure_that_spread_to_a_new_year_has_worsened():
+    """Scope-based no-worsening on the tool's real failing_scopes."""
+
+    bounds = json.loads(
+        (
+            Path(__file__).parents[1]
+            / "src/policyengine/data/qualification/uk_review_bounds.json"
+        ).read_text()
+    )
+    report = _real_report()
+    scopes = {
+        row["identity_id"]: row["failing_scopes"]
+        for row in report["identities"]
+        if row["status"] == "fail"
+    }
+    assert scopes["sign:vat"] == [str(year) for year in RESPONSE_YEARS]
+    assert scopes["counted_once:statutory_sick_pay"] == [""]
+    # The previous pair failed sign:vat in every year but the last.
+    earlier = _real_report()
+    for row in earlier["identities"]:
+        if row["identity_id"] == "sign:vat":
+            row["failing_scopes"] = row["failing_scopes"][:-1]
+    status, summary, _ = compare_metric_reports(report, earlier, bounds)
+    assert status == "fail" and "sign:vat (fails in more scopes)" in summary
+    # A candidate that fails it in fewer years than before has not worsened.
+    assert compare_metric_reports(earlier, report, bounds)[0] == "pass"
+
+
+def test_reform_checks_are_hard_in_the_metrics_review():
+    from policyengine.provenance.uk_candidate import classify_check
+
+    assert classify_check("reform_runs:basic_rate_plus_1pp") == "hard"
+    assert classify_check("responsive:state_pension_plus_10pc") == "hard"
+
+
+def test_metrics_with_nan_values_fail_rather_than_crash():
+    nan = {**BASE, ("income_tax", 2027, "receipt"): float("nan")}
+    result, evidence = metrics_review(
+        candidate_report=_report(nan), previous_report=_report(BASE), bounds=BOUNDS
+    )
+    assert result.status == "fail"
+    assert result.evidence_sha256
diff --git a/tests/test_uk_candidate_metric_baselines.py b/tests/test_uk_candidate_metric_baselines.py
new file mode 100644
index 00000000..712a65a6
--- /dev/null
+++ b/tests/test_uk_candidate_metric_baselines.py
@@ -0,0 +1,110 @@
+"""Review bounds require usable baseline values for each candidate metric."""
+
+import copy
+
+import pytest
+
+from policyengine.provenance.uk_candidate import compare_metric_reports, metrics_review
+
+BOUNDS = {"default": {"relative": 0.02, "absolute": 5e8}}
+
+
+def _report():
+    return {
+        "status": "ok",
+        "metrics_version": "m1",
+        "partition_version": "p1",
+        "metrics": [
+            {
+                "metric_id": "income_tax",
+                "year": 2027,
+                "kind": "receipt",
+                "value": 3e11,
+            },
+            {
+                "metric_id": "relative_poverty",
+                "year": 2027,
+                "kind": "poverty",
+                "value": 0.17,
+            },
+        ],
+        "identities": [{"identity_id": "weights:household", "status": "pass"}],
+    }
+
+
+@pytest.mark.parametrize(
+    "baseline_value", [float("nan"), float("inf"), -float("inf"), None, "invalid"]
+)
+def test_unusable_baseline_cannot_claim_a_large_move_is_within_bounds(baseline_value):
+    candidate, previous = _report(), _report()
+    candidate["metrics"][0]["value"] = 1e12
+    previous["metrics"][0]["value"] = baseline_value
+
+    status, summary, _ = compare_metric_reports(candidate, previous, BOUNDS)
+
+    assert status == "fail"
+    assert "previous" in summary and "non-finite" in summary
+    gate, _ = metrics_review(
+        candidate_report=candidate, previous_report=previous, bounds=BOUNDS
+    )
+    assert gate.status == "fail"
+    assert gate.evidence_sha256
+
+
+def test_a_baseline_row_without_a_value_cannot_pass():
+    candidate, previous = _report(), _report()
+    del previous["metrics"][0]["value"]
+    assert compare_metric_reports(candidate, previous, BOUNDS)[0] == "fail"
+
+
+def test_a_metric_without_a_baseline_requires_review():
+    candidate, previous = _report(), _report()
+    previous["metrics"] = previous["metrics"][1:]
+    status, summary, evidence = compare_metric_reports(candidate, previous, BOUNDS)
+    assert status == "needs_review"
+    assert "no baseline" in summary and "income_tax" in summary
+    assert evidence["metrics_without_baseline"] == ["income_tax 2027"]
+
+
+@pytest.mark.parametrize("side", ["candidate", "previous"])
+def test_a_duplicate_metric_cannot_hide_an_unusable_value(side):
+    candidate, previous = _report(), _report()
+    report = candidate if side == "candidate" else previous
+    report["metrics"].insert(0, {**report["metrics"][0], "value": float("nan")})
+    status, summary, _ = compare_metric_reports(candidate, previous, BOUNDS)
+    assert status == "fail"
+    assert "duplicate metrics" in summary and side in summary
+
+
+def test_duplicate_finite_baselines_cannot_select_the_most_favourable_comparison():
+    candidate, previous = _report(), _report()
+    candidate["metrics"][0]["value"] = 1e12
+    previous["metrics"].append({**previous["metrics"][0], "value": 1e12})
+    status, summary, _ = compare_metric_reports(candidate, previous, BOUNDS)
+    assert status == "fail"
+    assert "previous report has duplicate metrics: income_tax 2027" in summary
+
+
+def test_an_unusable_cross_report_cannot_pass_either_review_step():
+    candidate, previous, cross = _report(), _report(), _report()
+    cross["metrics"][0]["value"] = float("nan")
+    gate, evidence = metrics_review(
+        candidate_report=candidate,
+        previous_report=previous,
+        cross_report=cross,
+        bounds=BOUNDS,
+    )
+    assert gate.status == "fail"
+    assert "model_only" in evidence and "data_only" in evidence
+    assert "data_only: the previous report has non-finite" in gate.summary
+
+
+def test_valid_baselines_preserve_bounds_and_do_not_mutate_reports():
+    candidate, previous = _report(), _report()
+    original = copy.deepcopy((candidate, previous))
+    assert compare_metric_reports(candidate, previous, BOUNDS)[0] == "pass"
+    assert (candidate, previous) == original
+    candidate["metrics"][0]["value"] = 1e12
+    status, _, evidence = compare_metric_reports(candidate, previous, BOUNDS)
+    assert status == "needs_review"
+    assert evidence["moves_beyond_bounds"][0]["metric_id"] == "income_tax"
diff --git a/tests/test_uk_qualification_pipeline.py b/tests/test_uk_qualification_pipeline.py
new file mode 100644
index 00000000..4b212969
--- /dev/null
+++ b/tests/test_uk_qualification_pipeline.py
@@ -0,0 +1,1184 @@
+"""Orchestration of UK candidate qualification (no network).
+
+Invariants:
+
+- resolution keeps the certified core when the model accepts it and never
+  picks a prerelease, yanked or unpublished version;
+- the decision never qualifies a pair main already certifies or one main
+  supersedes, and never repeats a terminal attempt;
+- rewriting the certified-version test constants leaves no old value behind
+  and touches only quoted literals;
+- the replay request built for a candidate is valid against the scorecard's
+  vendored schema and binds the candidate commit, wheels and dataset;
+- promotion planning readies only the selected target and closes exactly the
+  candidates it or main supersede.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import jsonschema
+import pytest
+
+from policyengine.provenance.qualification import ReplayStatus
+from policyengine.provenance.qualification_ledger import write_json
+from policyengine.provenance.uk_qualification import (
+    accept_review,
+    assemble_receipt,
+    build_replay_request,
+    candidate_action,
+    candidate_branch,
+    certified_constant_replacements,
+    choose_core_version,
+    core_requirement,
+    latest_data_release,
+    plan_promotion,
+    read_replay,
+    resolve_candidate,
+    rewrite_certified_constants,
+)
+from tests.qualification_fixtures import (
+    HARNESS,
+    ISSUED,
+    VENDORED_SCHEMAS,
+    gates,
+    golden,
+    make_pair,
+    make_receipt,
+    sha,
+)
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BUNDLE = json.loads(
+    (REPO_ROOT / "src/policyengine/data/bundle/manifest.json").read_text()
+)
+
+
+def _wheel(name, version, digest):
+    return {
+        "packagetype": "bdist_wheel",
+        "filename": f"{name.replace('-', '_')}-{version}-py3-none-any.whl",
+        "url": f"https://files.pythonhosted.org/packages/{name}-{version}.whl",
+        "digests": {"sha256": digest},
+    }
+
+
+MANIFEST = {
+    "schema_version": 1,
+    "data_package": {"name": "policyengine-uk-data", "version": "1.58.0"},
+    "default_datasets": {"national": "enhanced_frs_2024_25"},
+    "build": {"build_id": "policyengine-uk-data-1.58.0"},
+    "artifacts": {
+        "enhanced_frs_2024_25": {
+            "kind": "microdata",
+            "path": "enhanced_frs_2024_25.h5",
+            "repo_id": "policyengine/policyengine-uk-data-private",
+            "revision": "1.58.0",
+            "sha256": "d" * 64,
+            "size_bytes": 128369110,
+        }
+    },
+}
+
+
+def _resolver(**overrides):
+    projects = {
+        "policyengine-uk": {
+            "releases": {
+                "2.126.6": [_wheel("policyengine-uk", "2.126.6", "1" * 64)],
+                "2.126.8": [_wheel("policyengine-uk", "2.126.8", "2" * 64)],
+                "2.127.0rc1": [_wheel("policyengine-uk", "2.127.0rc1", "3" * 64)],
+                "2.127.1": [
+                    {**_wheel("policyengine-uk", "2.127.1", "4" * 64), "yanked": True}
+                ],
+            }
+        },
+        "policyengine-uk/2.126.8": {
+            "info": {"requires_dist": ["policyengine-core>=3.32.27"]}
+        },
+        "policyengine-uk/2.126.6": {
+            "info": {"requires_dist": ["policyengine-core>=3.34.0"]}
+        },
+        "policyengine-core": {
+            "releases": {
+                v: [_wheel("policyengine-core", v, v.replace(".", "")[0] * 64)]
+                for v in ("3.33.0", "3.34.0", "3.34.1", "3.35.0rc1")
+            }
+        },
+    }
+    manifest_bytes = json.dumps(MANIFEST).encode()
+    kwargs = dict(
+        current_core="3.33.0",
+        pypi_project=lambda path: projects[path],
+        hf_revision_sha=lambda repo, rev: "f" * 40,
+        hf_tags=lambda repo: ["1.57.4", "1.58.0", "v1.11.1", "version_a"],
+        hf_file=lambda repo, rev, path: manifest_bytes,
+    )
+    kwargs.update(overrides)
+    return resolve_candidate(**kwargs), manifest_bytes
+
+
+def test_resolution_picks_newest_stable_and_keeps_an_accepted_core():
+    resolved, manifest_bytes = _resolver()
+    pair = resolved.pair
+    assert pair.model.version == "2.126.8"
+    assert pair.model.wheel_sha256 == "2" * 64
+    assert pair.core.version == "3.33.0"
+    assert pair.data.release_version == "1.58.0"
+    assert pair.data.release_manifest_revision == "f" * 40
+    assert (
+        pair.data.release_manifest_sha256 == hashlib.sha256(manifest_bytes).hexdigest()
+    )
+    assert resolved.manifest_uri.startswith(
+        "hf://model/policyengine/policyengine-uk-data-private@"
+    )
+
+
+def test_resolution_moves_core_only_when_the_model_requires_it():
+    resolved, _ = _resolver(model_version="2.126.6")
+    assert resolved.pair.core.version == "3.34.1"
+
+
+def test_core_requirement_and_choice():
+    assert (
+        core_requirement(["numpy>=1", "policyengine_core>=3.32.27,<4"])
+        == "<4,>=3.32.27"
+    )
+    with pytest.raises(ValueError):
+        core_requirement(["numpy"])
+    with pytest.raises(ValueError):
+        choose_core_version("3.33.0", ">=9", [])
+
+
+def test_latest_data_release_ignores_non_version_tags():
+    assert latest_data_release(["1.57.4", "1.58.0", "v1.11.1", "version_a"]) == "1.58.0"
+
+
+def test_resolution_refuses_unpublished_versions():
+    with pytest.raises(ValueError):
+        _resolver(model_version="9.9.9")
+
+
+def test_actions(tmp_path):
+    current = BUNDLE["data_releases"]["uk"]
+    certified = make_pair(
+        model=current["model_package"]["version"],
+        data=current["data_package"]["version"],
+        core=BUNDLE["packages"]["policyengine-core"]["version"],
+        dataset_sha=current["certified_data_artifact"]["sha256"],
+    )
+    assert (
+        candidate_action(certified, BUNDLE, tmp_path, base_commit="a" * 40)[0]
+        == "already_certified"
+    )
+    older = make_pair(model="2.100.0", data="1.50.0", core="3.30.0")
+    assert candidate_action(older, BUNDLE, tmp_path, base_commit="a" * 40)[0] == "stale"
+    newer = make_pair(model="2.200.0", data="1.58.0", core="3.33.0")
+    assert (
+        candidate_action(newer, BUNDLE, tmp_path, base_commit="a" * 40)[0] == "qualify"
+    )
+    rejected = make_receipt(pair=newer, gate_results=gates(wheel_hashes="fail"))
+    write_json(
+        tmp_path / "uk" / newer.pair_id / "qualification_receipt.json",
+        rejected.canonical_payload(),
+    )
+    assert (
+        candidate_action(newer, BUNDLE, tmp_path, base_commit="a" * 40)[0]
+        == "duplicate"
+    )
+    assert (
+        candidate_action(newer, BUNDLE, tmp_path, base_commit="a" * 40, force=True)[0]
+        == "qualify"
+    )
+
+
+def test_incomplete_pairs_requalify_on_a_new_base_only(tmp_path):
+    pair = make_pair(model="2.200.0")
+    incomplete = make_receipt(
+        pair=pair, gate_results=gates(reform_contract="unavailable")
+    )
+    write_json(
+        tmp_path / "uk" / pair.pair_id / "qualification_receipt.json",
+        incomplete.canonical_payload(),
+    )
+    (tmp_path / "uk" / pair.pair_id / "attempts" / ("a" * 12)).mkdir(parents=True)
+    assert (
+        candidate_action(pair, BUNDLE, tmp_path, base_commit="a" * 40)[0] == "duplicate"
+    )
+    assert (
+        candidate_action(pair, BUNDLE, tmp_path, base_commit="b" * 40)[0] == "qualify"
+    )
+
+
+def _new_record(old):
+    new = json.loads(json.dumps(old))
+    new["model_package"]["version"] = "2.126.8"
+    new["certification"]["built_with_model_version"] = "2.126.0"
+    new["certification"]["data_build_id"] = "policyengine-uk-data-1.59.0"
+    new["data_package"]["version"] = "1.59.0"
+    new["data_package"]["release_manifest_revision"] = "1" * 40
+    return new
+
+
+def test_constant_rewrite_leaves_no_old_values(tmp_path):
+    for relative in (
+        "tests/test_models.py",
+        "tests/test_release_manifests.py",
+        "tests/test_uk_regions.py",
+    ):
+        (tmp_path / relative).parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy(REPO_ROOT / relative, tmp_path / relative)
+    old = BUNDLE["data_releases"]["uk"]
+    new = _new_record(old)
+    replacements = certified_constant_replacements(old, new)
+    changed = rewrite_certified_constants(tmp_path, replacements)
+    assert set(changed) == {
+        "tests/test_models.py",
+        "tests/test_release_manifests.py",
+        "tests/test_uk_regions.py",
+    }
+    for relative in changed:
+        text = (tmp_path / relative).read_text()
+        for before, after in replacements:
+            assert f'"{before}"' not in text and f'@{before}"' not in text
+    manifests = (tmp_path / "tests/test_release_manifests.py").read_text()
+    assert 'UK_MODEL_VERSION = "2.126.8"' in manifests
+    assert 'UK_DATA_RELEASE_PATH = "releases/1.59.0/release_manifest.json"' in manifests
+    assert 'UK_DATA_RELEASE_REVISION = "' + "1" * 40 + '"' in manifests
+    assert rewrite_certified_constants(tmp_path, replacements) == []
+
+
+@pytest.mark.parametrize("qualification_only", [False, True])
+def test_preparation_generates_new_sidecars_and_verifies_the_exact_pair(
+    tmp_path, monkeypatch, qualification_only
+):
+    """Run preparation, certification and real TRO generation without a network."""
+
+    from policyengine.provenance import certification, manifest
+    from policyengine.provenance.uk_qualification import prepare_candidate_commit
+
+    monkeypatch.syspath_prepend(str(REPO_ROOT / "scripts"))
+    import bundle as maintenance
+    import certify_data_release as certify_script
+    import generate_bundle_artifacts as artifacts
+    import generate_trace_tros as tros
+
+    release = json.loads(json.dumps(MANIFEST))
+    release["build"].update(
+        built_with_model_package={
+            "name": "policyengine-uk",
+            "version": "2.100.0" if qualification_only else "2.126.8",
+        },
+        built_with_core_package={
+            "name": "policyengine-core",
+            "version": "3.0.0" if qualification_only else "3.33.0",
+        },
+    )
+    release_bytes = json.dumps(release).encode()
+    resolved, _ = _resolver(hf_file=lambda *args: release_bytes)
+    pair = resolved.pair
+    bundle_dir = tmp_path / "src/policyengine/data/bundle"
+    bundle_dir.mkdir(parents=True)
+    source = json.loads(json.dumps(BUNDLE))
+    source["data_releases"].pop("us")
+    source["packages"].pop("policyengine-us")
+    for packages in source["extras"].values():
+        if "policyengine-us" in packages:
+            packages.remove("policyengine-us")
+    bundle_path = bundle_dir / "manifest.json"
+    write_json(bundle_path, source)
+    sidecar = bundle_dir / "uk.trace.tro.jsonld"
+    old_sidecar = (
+        REPO_ROOT / "src/policyengine/data/bundle/uk.trace.tro.jsonld"
+    ).read_bytes()
+    sidecar.write_bytes(old_sidecar)
+    shutil.copy(REPO_ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
+    (tmp_path / "changelog.d").mkdir()
+    for name in ("test_models.py", "test_release_manifests.py", "test_uk_regions.py"):
+        (tmp_path / "tests").mkdir(exist_ok=True)
+        shutil.copy(REPO_ROOT / "tests" / name, tmp_path / "tests" / name)
+
+    def country_manifest(country):
+        assert country == "uk"
+        result = manifest.CountryReleaseManifest.model_validate(
+            json.loads(bundle_path.read_text())["data_releases"][country]
+        )
+        result.source_sha256 = hashlib.sha256(bundle_path.read_bytes()).hexdigest()
+        return result
+
+    def data_manifest(country=None):
+        result = manifest.DataReleaseManifest.model_validate_json(release_bytes)
+        result.source_sha256 = hashlib.sha256(release_bytes).hexdigest()
+        return result
+
+    monkeypatch.setattr(certify_script, "REPO_ROOT", tmp_path)
+    monkeypatch.setattr(maintenance, "REPO_ROOT", tmp_path)
+    monkeypatch.setattr(artifacts, "REPO_ROOT", tmp_path)
+    monkeypatch.setattr(artifacts, "BUNDLE_MANIFEST", bundle_path)
+    monkeypatch.setattr(artifacts, "PYPROJECT", tmp_path / "pyproject.toml")
+    load_bundle = artifacts.load_bundle_manifest
+    monkeypatch.setattr(
+        artifacts, "load_bundle_manifest", lambda path=bundle_path: load_bundle(path)
+    )
+    monkeypatch.setattr(tros, "BUNDLE_MANIFEST", bundle_path)
+    monkeypatch.setattr(tros, "BUNDLE_TRO_DIR", bundle_dir)
+    monkeypatch.setattr(tros, "BUNDLE_LOCK", tmp_path / "uv.lock")
+    monkeypatch.setattr(tros, "get_release_manifest", country_manifest)
+    monkeypatch.setattr(tros, "get_data_release_manifest", data_manifest)
+    monkeypatch.setattr(
+        certification,
+        "fetch_release_manifest",
+        lambda uri, **kw: (
+            data_manifest(),
+            pair.data.release_manifest_sha256,
+            certification.parse_manifest_uri(uri),
+        ),
+    )
+    monkeypatch.setattr(
+        certification,
+        "fetch_pypi_wheel_metadata",
+        lambda *args: {
+            "sha256": pair.model.wheel_sha256,
+            "url": resolved.model_wheel.url,
+        },
+    )
+    monkeypatch.setattr(certification, "head_artifact", lambda *args, **kw: True)
+    monkeypatch.setattr(
+        certification, "required_supplemental_release_files", lambda *args: []
+    )
+    calls = []
+
+    def run(command, *, cwd, env=None):
+        calls.append(command)
+        assert cwd == tmp_path
+        if command[1] == "scripts/bundle.py":
+            assert maintenance.main(command[2:]) == 0
+        elif command[:2] == ["uv", "lock"]:
+            (tmp_path / "uv.lock").write_text(
+                "[[package]]\n"
+                'name = "policyengine-uk"\n'
+                f'version = "{pair.model.version}"\n'
+                'source = { registry = "https://pypi.org/simple" }\n'
+                f'wheels = [{{url = "{resolved.model_wheel.url}", hash = "sha256:{pair.model.wheel_sha256}"}}]\n'
+            )
+        else:
+            assert command[1] == "scripts/release_lock.py"
+        return subprocess.CompletedProcess(command, 0)
+
+    prepared = prepare_candidate_commit(
+        tmp_path, resolved, python=sys.executable, run=run, refresh_snapshots=False
+    )
+    assert sidecar.read_bytes() != old_sidecar
+    assert pair.data.release_manifest_sha256.encode() in sidecar.read_bytes()
+    assert prepared["compatibility_basis"] == (
+        "qualified_exact_pair" if qualification_only else "built_with_model_package"
+    )
+    assert (tmp_path / prepared["changelog_fragment"]).is_file()
+    assert maintenance.main(["check", "--include-tros", "--strict-tros"]) == 0
+    assert calls[-1][2:] == ["check", "--include-tros", "--strict-tros"]
+    if qualification_only:
+        changed = json.loads(bundle_path.read_bytes())
+        changed["packages"]["policyengine-core"]["version"] = "99.0.0"
+        write_json(bundle_path, changed)
+        with pytest.raises(ValueError, match="runtime core.*qualified exact pair"):
+            tros.generated_tros(strict=True)
+
+
+def test_replay_request_is_valid_for_the_scorecard():
+    pair = make_pair()
+    release_bundle = dict(
+        golden("qualification_request_golden")["candidate"]["expected_release_bundle"]
+    )
+    request = build_replay_request(
+        pair, HARNESS, release_bundle=release_bundle, issued_by=ISSUED
+    )
+    schema = json.loads(
+        (VENDORED_SCHEMAS / "qualification_request.v1.json").read_text()
+    )
+    jsonschema.Draft202012Validator(schema).validate(request.document())
+    candidate = request.document()["candidate"]
+    assert candidate["policyengine"]["source"] == {
+        "git": {
+            "repository": "PolicyEngine/policyengine.py",
+            "commit": HARNESS.git_commit,
+        }
+    }
+    assert candidate["policyengine_uk"]["wheel_sha256"] == pair.model.wheel_sha256
+    assert candidate["data"]["sha256"] == pair.data.dataset_sha256
+    assert request.document()["requested_by"]["pair_id"] == pair.pair_id
+    again = build_replay_request(
+        pair, HARNESS, release_bundle=release_bundle, issued_by=ISSUED
+    )
+    assert again.request_id == request.request_id
+
+
+def test_the_real_cli_generates_replay_requests_despite_country_import_suppression(
+    tmp_path,
+):
+    """The real CLI can read the UK certificate despite automatic import suppression."""
+
+    from importlib.util import find_spec
+
+    if find_spec("policyengine_uk") is None:
+        pytest.skip("Replay request generation requires the installed UK model")
+    attempt = tmp_path / "attempt"
+    write_json(attempt / "candidate.json", {"pair": make_pair().canonical_payload()})
+    write_json(attempt / "harness.json", HARNESS.canonical_payload())
+    env = dict(os.environ)
+    env["POLICYENGINE_SKIP_COUNTRY_IMPORTS"] = "1"
+    env.pop("GITHUB_OUTPUT", None)
+    result = subprocess.run(
+        [
+            sys.executable,
+            str(REPO_ROOT / "scripts/check_uk_candidate.py"),
+            "request-replay",
+            "--attempt",
+            str(attempt),
+        ],
+        cwd=REPO_ROOT,
+        env=env,
+        capture_output=True,
+        text=True,
+        timeout=600,
+    )
+    assert result.returncode == 0, result.stdout + result.stderr
+    request = json.loads((attempt / "replay/request.json").read_text())
+    schema = json.loads(
+        (VENDORED_SCHEMAS / "qualification_request.v1.json").read_text()
+    )
+    jsonschema.Draft202012Validator(schema).validate(request)
+    candidate = request["candidate"]
+    assert candidate["policyengine"]["source"]["git"]["commit"] == HARNESS.git_commit
+    bundle = candidate["expected_release_bundle"]
+    record = BUNDLE["data_releases"]["uk"]
+    assert bundle["model_version"] == record["model_package"]["version"]
+    assert bundle["data_version"] == record["data_package"]["version"]
+    assert bundle["default_dataset"] == record["default_dataset"]
+
+
+def test_reading_replay_state():
+    pair = make_pair()
+    request = build_replay_request(
+        pair,
+        HARNESS,
+        release_bundle=golden("qualification_request_golden")["candidate"][
+            "expected_release_bundle"
+        ],
+        issued_by=ISSUED,
+    )
+    gate, status, receipt = read_replay(
+        request, fetch=lambda ref, path: None, state_ref="replay-state"
+    )
+    assert gate.status == "unavailable" and status is None
+    running = ReplayStatus.model_validate(
+        {
+            "schema": "policyengine.uk-replay-status/v1",
+            "request_id": request.request_id,
+            "status": "running",
+            "history": [
+                {
+                    "status": "running",
+                    "at": "2026-10-15T10:00:00Z",
+                    "run_url": "https://github.com/PolicyEngine/policyengine-scorecard/actions/runs/3",
+                }
+            ],
+        }
+    ).document()
+    gate, status, _ = read_replay(
+        request,
+        fetch=lambda ref, path: (
+            json.dumps(running).encode() if path.endswith("STATUS.json") else None
+        ),
+        state_ref="replay-state",
+    )
+    assert gate.status == "unavailable" and status.status == "running"
+    terminal = {
+        **running,
+        "status": "complete",
+        "receipt_sha256": "a" * 64,
+        "receipt_commit": "c" * 40,
+    }
+    gate, _, _ = read_replay(
+        request,
+        fetch=lambda ref, path: (
+            json.dumps(terminal).encode() if path.endswith("STATUS.json") else None
+        ),
+        state_ref="replay-state",
+    )
+    assert gate.status == "fail" and "no REPLAY_RECEIPT.json" in gate.summary
+
+
+def test_assembled_receipt_without_replay_is_incomplete():
+    pair = make_pair()
+    local = [gate for gate in gates() if gate.gate_id != "replay"]
+    receipt = assemble_receipt(
+        pair,
+        HARNESS,
+        local,
+        issued_by=ISSUED,
+        completed_at="2026-10-15T10:00:00Z",
+        previous=None,
+        replay=None,
+    )
+    assert receipt.verdict == "incomplete"
+    assert [gate.gate_id for gate in receipt.gates] == [gate.gate_id for gate in local]
+
+
+def test_acceptance_is_bound_to_the_receipt():
+    receipt = make_receipt(gate_results=gates(metrics_review="needs_review"))
+    with pytest.raises(ValueError):
+        accept_review(
+            receipt,
+            reviewer="maria",
+            accepted_receipt_sha256=sha("1"),
+            explanation="x",
+            accepted_at="t",
+        )
+    reviewed = accept_review(
+        receipt,
+        reviewer="maria",
+        accepted_receipt_sha256=receipt.unreviewed_sha256,
+        explanation="Council tax move follows the 2025-26 band revaluation in uk-data.",
+        accepted_at="2026-10-16T09:00:00Z",
+    )
+    assert reviewed.promotable
+    with pytest.raises(ValueError):
+        accept_review(
+            make_receipt(),
+            reviewer="m",
+            accepted_receipt_sha256=sha("1"),
+            explanation="x",
+            accepted_at="t",
+        )
+
+
+def test_promotion_plan():
+    current = (
+        BUNDLE["data_releases"]["uk"]["model_package"]["version"],
+        BUNDLE["data_releases"]["uk"]["data_package"]["version"],
+        BUNDLE["packages"]["policyengine-core"]["version"],
+    )
+    newest = make_receipt(
+        pair=make_pair(model="2.200.0", data=current[1], core=current[2])
+    )
+    older = make_receipt(
+        pair=make_pair(model="2.199.0", data=current[1], core=current[2])
+    )
+    rejected = make_receipt(
+        pair=make_pair(model="2.201.0", data=current[1], core=current[2]),
+        gate_results=gates(replay="fail"),
+    )
+    stale = make_receipt(pair=make_pair(model="2.0.0", data="1.0.0", core="3.0.0"))
+    branches = {
+        candidate_branch(r.pair): r.harness.git_commit
+        for r in (newest, older, rejected, stale)
+    }
+    branches["candidate/uk/unknown"] = "0" * 40
+    plan = plan_promotion([newest, older, rejected, stale], BUNDLE, branches)
+    assert plan.target == newest
+    assert plan.ready_branch == candidate_branch(newest.pair)
+    assert plan.ready_commit == newest.harness.git_commit
+    assert plan.close_branches == sorted(
+        [candidate_branch(older.pair), candidate_branch(stale.pair)]
+    )
+    assert candidate_branch(rejected.pair) not in plan.close_branches
+
+
+def _promotable_pair_and_branch(core=None):
+    current = BUNDLE["data_releases"]["uk"]["data_package"]["version"]
+    core = core or BUNDLE["packages"]["policyengine-core"]["version"]
+    return make_pair(model="2.200.0", data=current, core=core)
+
+
+def test_pairs_that_share_versions_never_share_a_branch():
+    """Interrupted review of #574: a qualified pair readied the branch of an
+    incomplete pair with the same model and data but another core."""
+
+    qualified_pair = _promotable_pair_and_branch()
+    other_core = _promotable_pair_and_branch(core="3.99.0")
+    assert qualified_pair.pair_id != other_core.pair_id
+    assert candidate_branch(qualified_pair) != candidate_branch(other_core)
+    assert qualified_pair.pair_id[:12] in candidate_branch(qualified_pair)
+    qualified = make_receipt(pair=qualified_pair)
+    incomplete = make_receipt(pair=other_core, gate_results=gates(replay="unavailable"))
+    plan = plan_promotion(
+        [qualified, incomplete],
+        BUNDLE,
+        {candidate_branch(other_core): incomplete.harness.git_commit},
+    )
+    assert plan.target == qualified
+    assert plan.ready_branch is None
+    assert "has no open pull request" in plan.reason
+
+
+def test_a_branch_that_moved_off_the_qualified_commit_is_not_promoted():
+    """The replayed and tested commit must be the promoted commit."""
+
+    qualified = make_receipt(pair=_promotable_pair_and_branch())
+    branch = candidate_branch(qualified.pair)
+    plan = plan_promotion([qualified], BUNDLE, {branch: "f" * 40})
+    assert plan.target == qualified and plan.ready_branch is None
+    assert "not the qualified commit" in plan.reason
+    plan = plan_promotion([qualified], BUNDLE, {branch: qualified.harness.git_commit})
+    assert plan.ready_branch == branch
+
+
+def test_promote_readies_and_merges_only_the_qualified_commit(monkeypatch, tmp_path):
+    import argparse
+    import importlib.util
+
+    qualified = make_receipt(pair=_promotable_pair_and_branch())
+    branch = candidate_branch(qualified.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 = []
+    monkeypatch.setattr(cli, "latest_receipts", lambda root: [qualified])
+    monkeypatch.setattr(
+        cli, "subprocess", SimpleNamespace(run=lambda argv, **k: calls.append(argv))
+    )
+    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
+    assert calls == []
+    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 calls[0][:3] == ["gh", "pr", "ready"] and calls[0][3] == branch
+    assert calls[1][-2:] == ["--match-head-commit", qualified.harness.git_commit]
+
+
+def test_a_forced_rerun_stages_a_new_attempt(tmp_path):
+    """Interrupted review of #574: a forced run reused the existing attempt key,
+    so the workflow's duplicate guard stopped it."""
+
+    from policyengine.provenance.qualification_ledger import attempt_directory
+    from policyengine.provenance.uk_qualification import attempt_key
+
+    pair = _promotable_pair_and_branch()
+    base = "a" * 40
+    assert attempt_key(pair, tmp_path, base_commit=base, force=True) == base[:12]
+    attempt_directory(tmp_path, "uk", pair.pair_id, base[:12]).mkdir(parents=True)
+    assert attempt_key(pair, tmp_path, base_commit=base) == base[:12]
+    assert attempt_key(pair, tmp_path, base_commit=base, force=True) == f"{base[:12]}-2"
+    attempt_directory(tmp_path, "uk", pair.pair_id, f"{base[:12]}-2").mkdir(
+        parents=True
+    )
+    assert attempt_key(pair, tmp_path, base_commit=base, force=True) == f"{base[:12]}-3"
+
+
+@pytest.mark.parametrize(
+    ("pr_state", "reset_fails"),
+    [("ready", False), ("draft", False), ("absent", False), ("ready", True)],
+)
+def test_the_workflow_drafts_existing_prs_before_replacing_their_head(
+    tmp_path, pr_state, reset_fails
+):
+    """Execute the workflow shell with local gh/git stand-ins and no remote writes."""
+
+    import yaml
+
+    workflow = yaml.safe_load(
+        (REPO_ROOT / ".github/workflows/certify-uk-release.yaml").read_text()
+    )
+    step = next(
+        step
+        for step in workflow["jobs"]["prepare"]["steps"]
+        if step.get("name") == "Commit and push the candidate commit"
+    )
+    state_path = tmp_path / "pr-state.json"
+    state_path.write_text(json.dumps({"status": pr_state}))
+    log_path = tmp_path / "commands.jsonl"
+    fake = f"""#!{sys.executable}
+import json
+import os
+import sys
+from pathlib import Path
+
+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"]:
+    if state["status"] == "ready":
+        print("574")
+elif command == "gh" and args[:2] == ["pr", "ready"]:
+    assert args[2:] == ["574", "--undo"]
+    if os.environ["RESET_FAILS"] == "1":
+        sys.exit(42)
+    state["status"] = "draft"
+    state_path.write_text(json.dumps(state))
+elif command == "git" and args[0] == "push":
+    if state["status"] == "ready":
+        sys.exit(91)
+"""
+    binary_dir = tmp_path / "bin"
+    binary_dir.mkdir()
+    for command in ("gh", "git"):
+        executable = binary_dir / command
+        executable.write_text(fake)
+        executable.chmod(0o755)
+    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": "candidate/uk/2.126.8-1.58.0",
+            "PAIR_ID": "a" * 64,
+            "GH_TOKEN": "local-workflow-test",
+            "PR_STATE": str(state_path),
+            "COMMAND_LOG": str(log_path),
+            "RESET_FAILS": "1" if reset_fails else "0",
+        },
+        capture_output=True,
+        text=True,
+        timeout=30,
+    )
+    commands = [json.loads(line) for line in log_path.read_text().splitlines()]
+    pushes = [i for i, command in enumerate(commands) if command[:2] == ["git", "push"]]
+    resets = [
+        i for i, command in enumerate(commands) if command[:3] == ["gh", "pr", "ready"]
+    ]
+    if reset_fails:
+        assert result.returncode == 42, result.stdout + result.stderr
+        assert not pushes
+    else:
+        assert result.returncode == 0, result.stdout + result.stderr
+        assert len(pushes) == 1
+        if pr_state == "ready":
+            assert len(resets) == 1 and resets[0] < pushes[0]
+            assert json.loads(state_path.read_text())["status"] == "draft"
+        else:
+            assert not resets
+    assert step["env"]["GH_TOKEN"] == "${{ steps.app-token.outputs.token }}"
+
+
+# --- Attempt lifecycle -----------------------------------------------------------------
+
+
+def test_attempt_lifecycle(tmp_path):
+    from policyengine.provenance.qualification import GateResult
+    from policyengine.provenance.qualification_ledger import (
+        attempt_directory,
+        read_json,
+    )
+    from policyengine.provenance.uk_qualification import (
+        attempt_is_waiting_for_replay,
+        finalize_attempt,
+        stage_attempt,
+        write_gate_results,
+    )
+
+    pair = make_pair()
+    attempt = attempt_directory(tmp_path, "uk", pair.pair_id, "abcdef123456")
+    stage_attempt(
+        attempt,
+        {
+            "pair": pair.canonical_payload(),
+            "pair_id": pair.pair_id,
+            "previous_certificate": None,
+        },
+        HARNESS,
+    )
+    local = [gate for gate in gates() if gate.gate_id != "replay"]
+    write_gate_results(attempt, local, {gate.gate_id: {"ok": True} for gate in local})
+    assert not attempt_is_waiting_for_replay(attempt)
+    receipt = finalize_attempt(
+        attempt, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T10:00:00Z"
+    )
+    assert receipt.verdict == "incomplete"  # the replay never ran
+    assert (
+        read_json(tmp_path / "uk" / pair.pair_id / "qualification_receipt.json")[
+            "verdict"
+        ]
+        == "incomplete"
+    )
+
+    request = build_replay_request(
+        pair,
+        HARNESS,
+        release_bundle=golden("qualification_request_golden")["candidate"][
+            "expected_release_bundle"
+        ],
+        issued_by=ISSUED,
+    )
+    write_json(attempt / "replay" / "request.json", request.document())
+    assert attempt_is_waiting_for_replay(attempt)
+    write_json(
+        attempt / "replay" / "replay.result.json",
+        GateResult(gate_id="replay", status="pass", summary="ok").canonical_payload(),
+    )
+    assert not attempt_is_waiting_for_replay(attempt)
+    final = finalize_attempt(
+        attempt, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T11:00:00Z"
+    )
+    assert final.verdict == "qualified"
+    assert final.replay.request_id == request.request_id
+    assert (attempt / "receipts" / f"{receipt.receipt_sha256}.json").is_file()
+    assert (attempt / "receipts" / f"{final.receipt_sha256}.json").is_file()
+    assert (
+        read_json(attempt / "receipts" / f"{receipt.receipt_sha256}.json")
+        == receipt.canonical_payload()
+    )
+
+
+def test_older_attempt_finalization_preserves_latest_staged_attempt(tmp_path):
+    from policyengine.provenance.qualification_ledger import (
+        attempt_directory,
+        read_json,
+    )
+    from policyengine.provenance.uk_qualification import (
+        finalize_attempt,
+        stage_attempt,
+        write_gate_results,
+    )
+
+    pair = make_pair()
+    # The staging order deliberately differs from directory/commit order.
+    older = attempt_directory(tmp_path, "uk", pair.pair_id, "ffffffffffff")
+    newer = attempt_directory(tmp_path, "uk", pair.pair_id, "aaaaaaaaaaaa")
+    payload = {"pair": pair.canonical_payload(), "previous_certificate": None}
+    stage_attempt(older, payload, HARNESS)
+    newer_harness = HARNESS.model_copy(update={"git_commit": "a" * 40})
+    stage_attempt(newer, payload, newer_harness)
+    for attempt in (older, newer):
+        write_gate_results(attempt, gates(), {})
+    newest = finalize_attempt(
+        newer, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T10:00:00Z"
+    )
+    old = finalize_attempt(
+        older, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T11:00:00Z"
+    )
+    latest = read_json(tmp_path / "uk" / pair.pair_id / "qualification_receipt.json")
+    assert latest == newest.canonical_payload()
+    assert read_json(older / "qualification_receipt.json") == old.canonical_payload()
+    assert read_json(newer / "qualification_receipt.json") == newest.canonical_payload()
+
+
+def test_terminal_attempt_receipt_is_immutable_and_finalization_is_idempotent(tmp_path):
+    from policyengine.provenance.qualification_ledger import attempt_directory
+    from policyengine.provenance.uk_qualification import (
+        finalize_attempt,
+        stage_attempt,
+        write_gate_results,
+    )
+
+    pair = make_pair()
+    attempt = attempt_directory(tmp_path, "uk", pair.pair_id, "abcdef123456")
+    stage_attempt(attempt, {"pair": pair.canonical_payload()}, HARNESS)
+    write_gate_results(attempt, gates(), {})
+    original = finalize_attempt(
+        attempt, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T10:00:00Z"
+    )
+    original_bytes = (attempt / "qualification_receipt.json").read_bytes()
+    write_gate_results(attempt, gates(replay="fail"), {})
+    repeated = finalize_attempt(
+        attempt, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T11:00:00Z"
+    )
+    assert repeated == original
+    assert (attempt / "qualification_receipt.json").read_bytes() == original_bytes
+
+
+def test_new_staged_attempt_advances_a_legacy_latest_receipt(tmp_path):
+    from policyengine.provenance.qualification_ledger import (
+        attempt_directory,
+        read_json,
+    )
+    from policyengine.provenance.uk_qualification import (
+        finalize_attempt,
+        stage_attempt,
+        write_gate_results,
+    )
+
+    legacy = make_receipt()
+    latest_path = tmp_path / "uk" / legacy.pair_id / "qualification_receipt.json"
+    write_json(latest_path, legacy.canonical_payload())
+    attempt = attempt_directory(tmp_path, "uk", legacy.pair_id, "new-attempt")
+    stage_attempt(attempt, {"pair": legacy.pair.canonical_payload()}, HARNESS)
+    write_gate_results(attempt, gates(), {})
+    current = finalize_attempt(
+        attempt, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T11:00:00Z"
+    )
+    assert read_json(latest_path) == current.canonical_payload()
+
+
+@pytest.mark.parametrize(
+    "interrupted", ["missing", "older", "missing-pointer", "missing-receipt"]
+)
+def test_terminal_finalization_repairs_interrupted_latest_publication(
+    tmp_path, interrupted
+):
+    from policyengine.provenance.qualification_ledger import (
+        attempt_directory,
+        read_json,
+    )
+    from policyengine.provenance.uk_qualification import (
+        finalize_attempt,
+        stage_attempt,
+        write_gate_results,
+    )
+
+    pair = make_pair()
+    payload = {"pair": pair.canonical_payload()}
+    older = attempt_directory(tmp_path, "uk", pair.pair_id, "older")
+    stage_attempt(older, payload, HARNESS)
+    write_gate_results(older, gates(), {})
+    old_receipt = finalize_attempt(
+        older, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T09:00:00Z"
+    )
+    current = attempt_directory(tmp_path, "uk", pair.pair_id, "newer")
+    stage_attempt(current, payload, HARNESS.model_copy(update={"git_commit": "a" * 40}))
+    write_gate_results(current, gates(), {})
+    receipt = finalize_attempt(
+        current, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T10:00:00Z"
+    )
+    original_bytes = (current / "qualification_receipt.json").read_bytes()
+    pair_dir = tmp_path / "uk" / pair.pair_id
+    latest = pair_dir / "qualification_receipt.json"
+    pointer = pair_dir / "latest_attempt.json"
+    if interrupted == "older":
+        write_json(latest, old_receipt.canonical_payload())
+        write_json(pointer, {"attempt_key": "older", "sequence": 1})
+    else:
+        if interrupted != "missing-pointer":
+            latest.unlink()
+        if interrupted != "missing-receipt":
+            pointer.unlink()
+    repeated = finalize_attempt(
+        current, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T11:00:00Z"
+    )
+    assert repeated == receipt
+    assert (current / "qualification_receipt.json").read_bytes() == original_bytes
+    assert read_json(latest) == receipt.canonical_payload()
+    assert read_json(pointer) == {"attempt_key": "newer", "sequence": 2}
+    finalize_attempt(
+        older, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T12:00:00Z"
+    )
+    assert read_json(latest) == receipt.canonical_payload()
+
+
+def test_stale_latest_pointer_cannot_replace_a_newer_incomplete_receipt(tmp_path):
+    from policyengine.provenance.qualification_ledger import (
+        attempt_directory,
+        read_json,
+    )
+    from policyengine.provenance.uk_qualification import (
+        finalize_attempt,
+        stage_attempt,
+        write_gate_results,
+    )
+
+    pair = make_pair()
+    payload = {"pair": pair.canonical_payload()}
+    older = attempt_directory(tmp_path, "uk", pair.pair_id, "older")
+    stage_attempt(older, payload, HARNESS)
+    write_gate_results(older, gates(), {})
+    finalize_attempt(
+        older, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T09:00:00Z"
+    )
+    newer = attempt_directory(tmp_path, "uk", pair.pair_id, "newer")
+    stage_attempt(newer, payload, HARNESS.model_copy(update={"git_commit": "a" * 40}))
+    write_gate_results(newer, gates(replay="unavailable"), {})
+    current = finalize_attempt(
+        newer, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T10:00:00Z"
+    )
+    pair_dir = tmp_path / "uk" / pair.pair_id
+    write_json(
+        pair_dir / "latest_attempt.json", {"attempt_key": "older", "sequence": 1}
+    )
+    finalize_attempt(
+        older, tmp_path, issued_by=ISSUED, completed_at="2026-10-15T11:00:00Z"
+    )
+    assert (
+        read_json(pair_dir / "qualification_receipt.json")
+        == current.canonical_payload()
+    )
+    assert read_json(pair_dir / "latest_attempt.json") == {
+        "attempt_key": "newer",
+        "sequence": 2,
+    }
+
+
+# --- Release dispatch ----------------------------------------------------------------
+
+
+def test_release_payload_keeps_version_first_and_carries_identity():
+    from policyengine.provenance.receipt import certificate_sha256
+    from policyengine.provenance.uk_qualification import release_dispatch_payload
+
+    version = BUNDLE["policyengine_version"]
+    payload = release_dispatch_payload(BUNDLE, version=version)
+    assert payload["event_type"] == "policyengine-release"
+    body = payload["client_payload"]
+    assert list(body)[0] == "version" and body["version"] == version
+    uk = body["bundle"]["uk"]
+    record = BUNDLE["data_releases"]["uk"]
+    assert uk["model_version"] == record["model_package"]["version"]
+    assert uk["dataset_sha256"] == record["certified_data_artifact"]["sha256"]
+    assert uk["certificate_sha256"] == certificate_sha256(record)
+    assert body["qualification"] is None  # the committed pair was not auto-qualified
+    assert len(body) <= 10  # repository_dispatch limit on top-level keys
+    with pytest.raises(ValueError):
+        release_dispatch_payload(BUNDLE, version="0.0.0")
+
+
+def test_release_payload_names_the_qualification_receipt():
+    from policyengine.provenance.uk_qualification import release_dispatch_payload
+
+    bundle = json.loads(json.dumps(BUNDLE))
+    receipt = make_receipt()
+    bundle["data_releases"]["uk"]["certification"].update(
+        compatibility_basis="qualified_exact_pair",
+        qualification_pair_id=receipt.pair_id,
+    )
+    payload = release_dispatch_payload(
+        bundle,
+        version=bundle["policyengine_version"],
+        fetch_receipt=lambda country, pair_id: receipt,
+    )
+    assert payload["client_payload"]["qualification"] == {
+        "uk": {
+            "pair_id": receipt.pair_id,
+            "verdict": "qualified",
+            "promotable": True,
+            "receipt_sha256": receipt.receipt_sha256,
+        }
+    }
+
+
+def test_released_bundle_request_is_valid_for_the_scorecard():
+    from policyengine.provenance.uk_qualification import released_bundle_request
+
+    record = BUNDLE["data_releases"]["uk"]
+    dataset = record["default_dataset"]
+    manifest = {
+        "schema_version": 1,
+        "data_package": {
+            "name": "policyengine-uk-data",
+            "version": record["data_package"]["version"],
+        },
+        "default_datasets": {"national": dataset},
+        "artifacts": {
+            dataset: {
+                "kind": "microdata",
+                "path": record["datasets"][dataset]["path"],
+                "repo_id": record["datasets"][dataset]["repo_id"],
+                "revision": record["datasets"][dataset]["revision"],
+                "sha256": record["datasets"][dataset]["sha256"],
+                "size_bytes": 128369110,
+            }
+        },
+    }
+    core = BUNDLE["packages"]["policyengine-core"]["version"]
+    request = released_bundle_request(
+        BUNDLE,
+        version=BUNDLE["policyengine_version"],
+        wheel_sha256=sha("7"),
+        packaged_manifest_sha256=sha("8"),
+        release_bundle=golden("qualification_request_golden")["candidate"][
+            "expected_release_bundle"
+        ],
+        hf_file=lambda repo, rev, path: json.dumps(manifest).encode(),
+        pypi_project=lambda name: {
+            "releases": {core: [_wheel("policyengine-core", core, sha("6"))]}
+        },
+        requested_by={
+            "repository": "PolicyEngine/policyengine.py",
+            "workflow_run_id": 5,
+            "commit": "e" * 40,
+        },
+    )
+    schema = json.loads(
+        (VENDORED_SCHEMAS / "qualification_request.v1.json").read_text()
+    )
+    jsonschema.Draft202012Validator(schema).validate(request.document())
+    assert request.kind == "released_bundle"
+    assert request.document()["candidate"]["policyengine"]["source"] == {
+        "pypi": {"wheel_sha256": sha("7")}
+    }
+
+
+def test_expected_release_bundle_uses_the_scorecard_certification_environment(
+    monkeypatch, tmp_path
+):
+    """W6 interface point 4: no tokens, no proxy exceptions, no network."""
+
+    from policyengine.provenance import uk_qualification
+
+    environ = {
+        "PATH": "/usr/bin",
+        "HF_TOKEN": "t",
+        "HUGGING_FACE_TOKEN": "t",
+        "HUGGINGFACE_HUB_TOKEN": "t",
+        "NO_PROXY": "huggingface.co",
+        "no_proxy": "huggingface.co",
+        "HTTPS_PROXY": "http://corporate:3128",
+        "POLICYENGINE_SKIP_COUNTRY_IMPORTS": "1",
+    }
+    env = uk_qualification.certification_environment(environ)
+    assert env["PATH"] == "/usr/bin"
+    for name in ("HF_TOKEN", "HUGGING_FACE_TOKEN", "HUGGINGFACE_HUB_TOKEN"):
+        assert name not in env
+    assert "NO_PROXY" not in env and "no_proxy" not in env
+    for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
+        assert env[name] == env[name.lower()] == "http://127.0.0.1:9"
+    assert env["HF_HUB_OFFLINE"] == "1"
+
+    seen = {}
+
+    def run(args, *, cwd, env, check, capture_output, text):
+        seen["env"] = env
+        return type("Done", (), {"stdout": '{"policyengine_version": "6.2.6"}\n'})()
+
+    monkeypatch.setattr(uk_qualification.subprocess, "run", run)
+    monkeypatch.setattr(uk_qualification.os, "environ", environ)
+    assert uk_qualification.expected_release_bundle("python", cwd=tmp_path) == {
+        "policyengine_version": "6.2.6"
+    }
+    assert seen["env"] == env
+
+
+def test_the_certification_environment_falls_back_to_the_packaged_certificate():
+    """Executed: with the proxies, the manifest request fails and the bundled
+    certification is used (the mechanism W6's recipe relies on)."""
+
+    import os
+    import subprocess
+    import sys
+
+    from policyengine.provenance import uk_qualification
+
+    script = (
+        "import json\n"
+        "from policyengine.provenance import manifest\n"
+        "try:\n"
+        "    manifest.get_data_release_manifest('uk')\n"
+        "    print('reached')\n"
+        "except manifest.DataReleaseManifestUnavailableError:\n"
+        "    print('unavailable')\n"
+    )
+    result = subprocess.run(
+        [sys.executable, "-c", script],
+        env=uk_qualification.certification_environment(os.environ),
+        capture_output=True,
+        text=True,
+        timeout=60,
+    )
+    assert result.stdout.strip().splitlines()[-1] == "unavailable", result.stderr
diff --git a/tests/test_wheels.py b/tests/test_wheels.py
new file mode 100644
index 00000000..057a4287
--- /dev/null
+++ b/tests/test_wheels.py
@@ -0,0 +1,272 @@
+"""Installed files are verified byte for byte against a wheel's RECORD.
+
+Invariants:
+
+- an untouched install verifies, including the console-script launchers an
+  installer generates for the wheel's declared entry points;
+- mutating any single installed file, or installing a file the wheel lacks
+  (a launcher it does not declare, a stray ``.pyc`` or ``INSTALLER`` outside
+  where installers write them), fails verification;
+- the wheel itself verifies only when its members and its RECORD match one to
+  one: changing, adding or removing any archive member fails, so a copied
+  RECORD in a junk zip never names a wheel the files did not come from.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from policyengine.provenance.wheels import (
+    parse_record,
+    record_digest_to_hex,
+    verify_installed_against_wheel,
+    verify_wheel_archive,
+)
+
+DIST_INFO = "policyengine_uk-2.126.8.dist-info"
+FILES = {
+    "policyengine_uk/__init__.py": b"VERSION = '2.126.8'\n",
+    "policyengine_uk/system.py": b"system = object()\n",
+    "policyengine_uk/parameters/rate.yaml": b"values:\n  2027-04-06: 0.2\n",
+    f"{DIST_INFO}/entry_points.txt": (
+        b"[console_scripts]\npolicyengine-uk = policyengine_uk.cli:main\n\n"
+        b"[gui_scripts]\npolicyengine-uk-gui = policyengine_uk.gui:main\n\n"
+        b"[policyengine.plugins]\nnot-a-script = policyengine_uk.plugin\n"
+    ),
+}
+LAUNCHERS = {
+    "../../../bin/policyengine-uk": b"#!/venv/bin/python\nimport policyengine_uk\n",
+    "../../../bin/policyengine-uk-gui": b"#!/venv/bin/python\n",
+}
+
+
+def _b64(data: bytes) -> str:
+    return base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode().rstrip("=")
+
+
+def _line(path: str, data: bytes) -> str:
+    return f"{path},sha256={_b64(data)},{len(data)}"
+
+
+def write_wheel(path: Path, members: dict, record_lines: list[str]) -> Path:
+    with zipfile.ZipFile(path, "w") as archive:
+        for name, data in members.items():
+            archive.writestr(name, data)
+        archive.writestr(f"{DIST_INFO}/RECORD", "\n".join(record_lines) + "\n")
+    return path
+
+
+def build_wheel(tmp_path: Path, files=FILES, launchers=LAUNCHERS):
+    """A wheel, an installed copy of it, and the installed RECORD lines.
+
+    The install adds what installers add: INSTALLER, REQUESTED and a launcher
+    in ``bin`` for each console and GUI script the wheel declares.
+    """
+
+    record = [_line(path, data) for path, data in files.items()]
+    record.append(f"{DIST_INFO}/RECORD,,")
+    wheel = write_wheel(
+        tmp_path / "policyengine_uk-2.126.8-py3-none-any.whl", files, record
+    )
+    site = tmp_path / "venv" / "lib" / "python3.13" / "site-packages"
+    for path, data in {**files, **launchers}.items():
+        (site / path).parent.mkdir(parents=True, exist_ok=True)
+        (site / path).write_bytes(data)
+    installed = (
+        record
+        + [_line(path, data) for path, data in launchers.items()]
+        + [
+            f"{DIST_INFO}/INSTALLER,sha256={_b64(b'uv')},2",
+            f"{DIST_INFO}/REQUESTED,,",
+        ]
+    )
+    return wheel, site, installed
+
+
+class _File(str):
+    pass
+
+
+def fake_metadata(site: Path, record_lines):
+    def distribution(name):
+        files = []
+        for line in record_lines:
+            path, digest, *_ = line.split(",")
+            item = _File(path)
+            item.hash = (
+                SimpleNamespace(mode="sha256", value=digest.split("=", 1)[1])
+                if digest
+                else None
+            )
+            files.append(item)
+        return SimpleNamespace(files=files, locate_file=lambda path: site / path)
+
+    return SimpleNamespace(distribution=distribution)
+
+
+def test_record_hash_decoding():
+    data = b"hello"
+    assert (
+        record_digest_to_hex(f"sha256={_b64(data)}") == hashlib.sha256(data).hexdigest()
+    )
+    assert parse_record(f"a.py,sha256={_b64(data)},5\nRECORD,,\n") == {
+        "a.py": hashlib.sha256(data).hexdigest(),
+        "RECORD": None,
+    }
+    with pytest.raises(ValueError):
+        record_digest_to_hex("md5=abc")
+
+
+def test_untouched_install_verifies(tmp_path):
+    wheel, site, record = build_wheel(tmp_path)
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=fake_metadata(site, record)
+    )
+    assert evidence["problems"] == []
+    assert evidence["files_verified"] == len(FILES)
+    assert evidence["archive_members_verified"] == len(FILES)
+    assert evidence["generated_launchers"] == sorted(LAUNCHERS)
+    assert evidence["wheel_sha256"] == hashlib.sha256(wheel.read_bytes()).hexdigest()
+
+
+def test_windows_launchers_are_recognised(tmp_path):
+    launchers = {
+        "../../Scripts/policyengine-uk.exe": b"MZ",
+        "../../Scripts/policyengine-uk-gui-script.pyw": b"import x\n",
+    }
+    wheel, site, record = build_wheel(tmp_path, launchers=launchers)
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=fake_metadata(site, record)
+    )
+    assert evidence["problems"] == []
+    assert evidence["generated_launchers"] == sorted(launchers)
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "../../../bin/not-a-script",  # a plugin entry point, not a script
+        "../../../bin/undeclared",
+        "../../../share/policyengine-uk",  # declared name, not a scripts dir
+        "bin/policyengine-uk",  # inside site-packages
+        "policyengine_uk/injected.pyc",  # not in __pycache__
+        "policyengine_uk/INSTALLER",  # not in .dist-info
+    ],
+)
+def test_files_installers_do_not_write_fail(tmp_path, path):
+    wheel, site, record = build_wheel(tmp_path)
+    (site / path).parent.mkdir(parents=True, exist_ok=True)
+    (site / path).write_bytes(b"x")
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk",
+        wheel,
+        metadata=fake_metadata(site, record + [_line(path, b"x")]),
+    )
+    assert evidence["problems"] == [f"{path}: installed but not in the wheel"]
+
+
+def test_compiled_bytecode_in_pycache_is_allowed(tmp_path):
+    wheel, site, record = build_wheel(tmp_path)
+    path = "policyengine_uk/__pycache__/system.cpython-313.pyc"
+    (site / path).parent.mkdir(parents=True, exist_ok=True)
+    (site / path).write_bytes(b"pyc")
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk",
+        wheel,
+        metadata=fake_metadata(site, record + [_line(path, b"pyc")]),
+    )
+    assert evidence["problems"] == []
+
+
+def test_a_junk_wheel_with_a_copied_record_does_not_verify(tmp_path):
+    """Round-2 review N4: the RECORD alone proves nothing about the archive."""
+
+    wheel, site, record = build_wheel(tmp_path)
+    junk = write_wheel(
+        tmp_path / "junk-2.126.8-py3-none-any.whl",
+        {"policyengine_uk/__init__.py": b"import os; os.system('x')\n"},
+        [line for line in record if line.split(",")[0] in FILES or "RECORD" in line],
+    )
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", junk, metadata=fake_metadata(site, record)
+    )
+    assert any(
+        "bytes differ from the wheel's RECORD" in p for p in evidence["problems"]
+    )
+    assert any("not in the wheel" in p for p in evidence["problems"])
+
+
+ARCHIVE_EDITS = ["change", "add", "remove", "unhash", "hash record"]
+
+
+@settings(max_examples=40, deadline=None)
+@given(
+    edit=st.sampled_from(ARCHIVE_EDITS),
+    index=st.integers(0, len(FILES) - 1),
+    extra=st.binary(min_size=1, max_size=8),
+)
+def test_any_edit_to_the_archive_fails(tmp_path_factory, edit, index, extra):
+    directory = tmp_path_factory.mktemp("archive")
+    members = dict(FILES)
+    record = [_line(path, data) for path, data in FILES.items()]
+    record.append(f"{DIST_INFO}/RECORD,,")
+    target = list(FILES)[index]
+    if edit == "change":
+        members[target] = members[target] + extra
+    elif edit == "add":
+        members["policyengine_uk/smuggled.py"] = extra
+    elif edit == "remove":
+        del members[target]
+    elif edit == "unhash":
+        record[index] = f"{target},,"
+    elif edit == "hash record":
+        record[-1] = f"{DIST_INFO}/RECORD,sha256={_b64(extra)},{len(extra)}"
+    wheel = write_wheel(directory / "w-1-py3-none-any.whl", members, record)
+    assert verify_wheel_archive(wheel)["problems"]
+
+
+def test_an_untouched_archive_verifies(tmp_path):
+    wheel, _, _ = build_wheel(tmp_path)
+    archive = verify_wheel_archive(wheel)
+    assert archive["problems"] == []
+    assert archive["members_verified"] == len(FILES)
+    assert archive["scripts"] == {"policyengine-uk", "policyengine-uk-gui"}
+
+
+def test_a_wheel_needs_exactly_one_record(tmp_path):
+    wheel = tmp_path / "w-1-py3-none-any.whl"
+    with zipfile.ZipFile(wheel, "w") as archive:
+        archive.writestr("x.py", b"x")
+    with pytest.raises(ValueError, match="0 RECORD"):
+        verify_wheel_archive(wheel)
+    with pytest.raises(ValueError, match="twice"):
+        parse_record("a.py,,\na.py,,\n")
+
+
+@settings(max_examples=30, deadline=None)
+@given(index=st.integers(0, len(FILES) - 1), extra=st.binary(min_size=1, max_size=8))
+def test_any_mutated_installed_file_fails(tmp_path_factory, index, extra):
+    wheel, site, record = build_wheel(tmp_path_factory.mktemp("wheel"))
+    target = site / list(FILES)[index]
+    target.write_bytes(target.read_bytes() + extra)
+    assert verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=fake_metadata(site, record)
+    )["problems"]
+
+
+def test_extra_installed_file_fails(tmp_path):
+    wheel, site, record = build_wheel(tmp_path)
+    (site / "policyengine_uk" / "injected.py").write_bytes(b"x = 1\n")
+    record = record + [f"policyengine_uk/injected.py,sha256={_b64(b'x = 1')},6"]
+    evidence = verify_installed_against_wheel(
+        "policyengine-uk", wheel, metadata=fake_metadata(site, record)
+    )
+    assert any("injected.py" in problem for problem in evidence["problems"])
diff --git a/uv.lock b/uv.lock
index ed2d9338..3af2ce40 100644
--- a/uv.lock
+++ b/uv.lock
@@ -197,7 +197,7 @@ name = "cffi"
 version = "2.0.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "pycparser" },
+    { name = "pycparser", marker = "implementation_name != 'PyPy'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
 wheels = [
@@ -1767,7 +1767,7 @@ name = "pexpect"
 version = "4.9.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "ptyprocess" },
+    { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
 wheels = [
@@ -1836,6 +1836,7 @@ dev = [
     { name = "pytest" },
     { name = "pytest-asyncio" },
     { name = "pytest-cov" },
+    { name = "rfc8785" },
     { name = "ruff" },
     { name = "spm-calculator" },
     { name = "towncrier" },
@@ -1891,11 +1892,12 @@ requires-dist = [
     { name = "policyengine-us", marker = "extra == 'models'", specifier = "==2.2.1" },
     { name = "policyengine-us", marker = "extra == 'us'", specifier = "==2.2.1" },
     { name = "psutil", specifier = ">=5.9.0" },
-    { name = "pydantic", specifier = ">=2.0.0" },
+    { name = "pydantic", specifier = ">=2.10.0" },
     { name = "pytest", marker = "extra == 'dev'" },
     { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.26.0" },
     { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" },
     { name = "requests", specifier = ">=2.31.0" },
+    { name = "rfc8785", marker = "extra == 'dev'", specifier = ">=0.1.4" },
     { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" },
     { name = "spm-calculator", marker = "extra == 'dev'", specifier = "==1.0.0" },
     { name = "spm-calculator", marker = "extra == 'models'", specifier = "==1.0.0" },
@@ -2445,6 +2447,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
 ]
 
+[[package]]
+name = "rfc8785"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" },
+]
+
 [[package]]
 name = "rpds-py"
 version = "0.27.1"