OBR policy costings (mode 2): registry, offline certified compute, staging, descriptive comparison - #56
OBR policy costings (mode 2): registry, offline certified compute, staging, descriptive comparison#56MaxGhenis wants to merge 81 commits into
Conversation
…anifest publication (mirrors restage); reopen guard permits hash-only verification sweeps
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Review — verified by running the branch locally (head 9a7b09d)
How verified: ran the exact CI commands in a clean worktree, the full suite with and without PyYAML, --dry-run, a registry consistency script over obr_measure_reforms.yaml, and a trial merge of current main.
Finding 1 — CI is green only by runner-image luck: PyYAML is an undeclared dependency
tests/test_obr_costings_registry.py:25 (and pipeline/compute_uk_obr_costings.py:38, compare_uk_obr_costings.py:32) hard-import yaml, but CI runs uv run --with pytest pytest with no PyYAML anywhere in the repo. The job passes on this exact SHA (run 32075753535, 306 passed) because ubuntu-latest preinstalls PyYAML in the base interpreter uv picks up. On any machine without that — e.g. uv-managed CPython on macOS — the same command dies at collection:
tests/test_obr_costings_registry.py:25: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
!!!!!!!! Interrupted: 1 error during collection !!!!!!!!
So a runner-image update silently reds the whole pytest job, and no contributor can reproduce CI locally. Cheapest durable fix: add --with pyyaml to the pytest line in ci.yml — that also un-skips the yaml-gated tests the #67/#68 lanes had to importorskip for exactly this reason (both of those registries shipped JSON to stay CI-safe; declaring the dep once lets future lanes use YAML honestly).
Observation (by design, worth stating)
--dry-run hard-requires policyengine-uk (compute_uk_obr_costings.py:2952-2954) — deliberate, since the dry run's value is resolving every parameter path against the installed pe-uk tree, but it means the advertised "registry validation, no sims" cannot run on unmanaged machines at all; the schema half lives only in pytest. Fine as-is given the tests, just not obvious from the flag's help text.
Verified sound
- Suite: 306 passed / 14 skipped with PyYAML present — matches the CI run byte-for-byte; ruff format clean.
- Registry: 26 measures, zero duplicate
measure_keys, triage exactly 3 expressible / 18 partial / 5 not_expressible as the PR body states; every expressible measure carries ape_reformdict. - Orientation identities are real tests, not tautologies:
test_measure_orientation_for_both_constructions_and_channelsparametrizes both constructions × both channels with hand-specified expected(literal_delta, pe_value)pairs, and unknown channel/construction raise. - Staged contract:
results/uk/staged/obr_costings.jsonlis exactly the 20 rows claimed; COMPARISON.csv carries 26 rows. - No rebase risk: a trial merge of current main (post #48/#70–#73) completes with zero conflicts.
The smoke-run divergences this lane surfaced are already being decomposed in #67 (per-measure axes registry; the employer-NICs row is the worked example), so the two lanes join up as intended.
Gate round 1 — clean compute hygiene; six findings, three blockingWhat passes cleanly: offline/local-only enforcement, engine/bundle/data pins on the compute side, serial one-at-a-time simulation, signed descriptive comparisons with forced
🤖 Generated with Claude Code |
1. The focal divergences are no longer wholly undecomposed. Recipes are EXECUTED now, not asserted: a `computed` component names an executor the pipeline runs against the comparison rows on every run and may not carry a hard-coded value at all. The adjacent-CY 3:1 interpolation is the first, and it sizes FY2026-27 for both March-2026 measures from the fixture's own runs — +£1.443bn on the PA/HRT freezes and +£23.5m on the additional-rate reduction, the figures the review worked by hand. Both residuals are now smaller than their gaps. FY2027-28 needs a CY2028 run the fixture does not have, so it stays unsized and names the missing run rather than extrapolating. 2. The diagnostics can fail. `residual` is no longer an axis — it was both a registry axis and the quantity the pipeline computes, so a component could be filed under the name of the thing it explained. The employer-NICs "behavioural" term becomes static_vs_post_behavioural_joint on a new joint_behavioural_and_scope axis: it subtracts a full-measure static total from a mapped-head post-behavioural subtotal, so it carries scope and basis together and cannot be attributed to behaviour. Employment Allowance becomes `derived` — it sums Table 1.3 and Table 1.4 and the source prints neither total. HICBC's welfare_head_missing declares that it overlaps its own take-up axis; overlapping valued components are recorded and withhold explained_share, because two terms sharing a channel are not additive. And explained_share outside [0, 1] is withheld with a stated reason: the synthetic test used to assert 1.5, which is a residual of the opposite sign wearing an explanation's name. 3. The axis definitions match the engines. The registry claimed OBR has "no comparable pass-through"; the October 2024 EFO assumes 60% in 2025-26 rising to 76%, with indirect effects reported separately — so the recipe now says a PE incidence 1->0 pair sizes PE's WHOLE wage channel and the wedge needs OBR's separately-reported leg too. The PA/HRT baseline recipe no longer proposes substituting earnings determinants PE 2.89.2 already uses (differencing a world against itself) and separates data uprating from threshold indexation; the counterfactual it actually needs, obr_announcement_baseline_efo_march_2026, is now a REGISTERED world — it was claimed as a baseline while being registered nowhere. Every CY derivation states the equal-accrual assumption and the FY-END-year identity convention. 4. It stands alone and it persists. A clean invocation fell over on #56's absent COMPARISON.csv; it now falls back to the committed fixture and SAYS SO in the output and in a PROVENANCE.json, and it compares the fixture against #56's live file when both exist so a frozen snapshot cannot silently miss later baseline fixes. scorecard_db/ingest_obr_divergence.py registers the lane in build_db — the artifact used to vanish on every CI rebuild — and writes it as "not run" when no decomposition exists rather than writing nothing. Suite 277 passed, two builds agree on content_hash, no-drift clean, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
|
Flagging a CI issue on this branch that isn't about its contents. This PR is running an older workflow than I found this auditing all 16 open PRs after hitting it on two of my own (#49 and #64, both since fixed). Four branches were affected; this is one of the two remaining. The fix is mechanical and nothing about your own changes needs to move: I haven't pushed to your branch. Happy to if you'd rather I did — say the word. There's also a structural fix in flight at #95: a Worth prioritising this one over #69: #67 consumes this branch's |
Builds the lane in #54 (Max's steer, 2026-08-16: center PE model development on the Scorecard — compute PE counterparts to OBR's published scores first, add machinery only when a comparison needs it). Static half; #55 is the macro half.
What's here
data/uk/obr_measure_reforms.yaml— measure → reform registry, 26 measures (3 expressible / 18 partial / 5 not_expressible), each with the verbatim OBR description, a policyengine-uk parameter dict (or null), construction, per-head mapping to PE variables, and notes on what is partial. Every parameter path resolves against the installed pe-uk 2.89.2 tree; the PA/HRT freeze reverses to Table 3.19's published with-indexation PA/HRT path (assumption-matched to OBR's own counterfactual; the PE HRT parameter is a band width, handled).pipeline/compute_uk_obr_costings.py— offline-hardened managed sims on the certified bundle (populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z, SHA-256 verified against the release manifest before any sim), plain-dict reforms, year-major with baseline reuse, per-head GBP aggregates in OBR'spositive_gain_to_exchequerconvention, measure orientation (reversal_on_certified_world⇒ measure Δ = −(reversal − baseline); the literal reversal delta is kept in the artifact), per-run JSON artifacts,--dry-run(registry validation, no sims),--restage(re-derive staging from artifacts, no sims).results/uk/staged/obr_costings.jsonl— 20 rows under the campaign ingest contract (external_claim_matchdescriptor exactly as the harvest carries it, engine version, full bundle id, status/construction/run_id/annotations).pipeline/compare_uk_obr_costings.py+results/uk/obr_costings/COMPARISON.{md,csv}— descriptive rows: measure × FY, OBR, PE, signed ratio, bin, named axes (static vs behavioural-adjusted; certified-world vs announcement baseline; CY-proxies-FY; head scope), remainderunexplained. No score-like summary statistic;benchmark_class = different_modelthroughout.tests/test_obr_costings_registry.py— 33 cases (registry schema, engine path/variable resolution, NICs head aggregate, sign and orientation identities for both constructions and channels, staged-row contract, artifact provenance trace, ambiguous-source refusal, forced offline mode, output-free dry run, comparison bins at every boundary). Suite: 200 passed.Smoke run (6 measures × FY2026-27/2027-28, certified bundle, policyengine 5.0.2 / pe-uk 2.89.2)
GBP bn, positive = gain to the Exchequer. Descriptive bins, not scores.
Axes named on every row; measure-specific ones worth flagging: HICBC — PE's child-benefit claiming is fixed in a static run, so the reform-induced claiming OBR costs does not move (a concrete PE-improvement candidate surfaced by the comparison); employer NICs — income-tax + NICs heads only, no Employment Allowance/firm mechanics (partial by construction); the personal-tax gaps sit where OBR's behavioural adjustments bite hardest (additional rate ≈ 2×). Everything beyond the named axes is
unexplained.Diagnostic (not staged): PA 12,570 → 13,070 for 2026 = income tax −£4.475bn static.
Not in this PR
ingest_campaign.pystill needs an OBR-aware attachment seam (deliberately rejects UK familyobrtoday). Stage first, ingest second, as the US campaign did.Sol-built (offline lane) then Sol clean-context reviewed; the one construction defect found in the first pass (literal reversal deltas staged un-oriented, every bin
opposite_sign) was fixed and restaged from artifacts before this PR.🤖 Generated with Claude Code