Repository navigation
Refresh the OBR receipts growth series from the EFO receipts tables - #2342
Conversation
import_obr_forecasts.py now rewrites yoy_growth.obr.stamp_duty_land_tax.* and business_rates.* from a new EFO's receipts tables, with the previous EFO's tables as the base for outturn growth. Tables are found by title and row label, years before the outturn year are kept, years after the OBR forecast follow average_earnings, and the rewrite is checked to change only those values and references. Run on the March 2026 tables, it reproduces the committed series exactly. Fixes #2341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly footnote markers from labels Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2322 now carries #2235's final head and main (a45baf5). One conflict, in the economic assumptions README's EFO refresh checklist: main added step 6 (move PROJECTION_END_YEAR on) where this PR added step 6 (refresh the receipts series). Keep both: receipts refresh as step 6, projection horizon as step 7, then the importer example. The importer, its tests and the yoy_growth.yaml comments merge without conflict; main has not touched the importer or its tests since this PR branched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An independent attack review of 0da72d8 (11 October) asked for changes, with seven should-fix findings and one pre-existing nit, each reproduced. All are fixed here, each with a test that fails on 0da72d8: 1. Receipts tables of unknown release are refused, as previous ones already were. Both the growth series and the September CPI gap read them, so unidentified tables could silently write the wrong EFO. 2. The economy tables' release comes from their contents sheet first, then the file name or URL (now URL-decoded). --release-month and --release-year must agree with the contents sheet. 3. Each sheet is split into tables at its title rows. A line is read only from its own table, so a later table on the same sheet can no longer stand in for it, and notes above a title no longer hide the table. 4. A fiscal year heading more than one column is refused instead of the later column silently replacing the earlier one. 5. "Outturn" and "Forecast" markers are collected across the table's rows; a marker in two different columns is refused. 6. A YAML comment indented no deeper than a key no longer ends its block. 7. Statutory gaps are built only when written, so a growth-only run (--output or --skip-statutory-gaps) no longer needs September CPI. 8. (Nit) A forecast year a series has no entry for now names the series and year; the README says to add the entry first. Also: a receipts level at or below zero in any year is refused. A zero last level used to write growth of -1.0, zeroing the index from then on. The real March 2026 and March 2025 workbooks still reproduce the committed yoy_growth.yaml byte for byte, with and without the CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Hand-off to the UK hub (build job Head:
Tests: these ran locally with
Ruff is clean. Against the real OBR workbooks (March 2026, plus the March 2025 receipts tables):
CI (dispatched on the branch, since this PR targets #2322's branch): run 39928984925 at Impact: none on model outputs. The Open items
|
test_head_totals_agree_with_the_model_weighted_sums checked that the partition's heads plus declared double counts equal gov_tax within a relative 1e-9. The model sums gov_tax for each household in float32, policyengine-core's float type, while the measurement sums the heads in float64, so the two agree only to float32 rounding. With this PR's corporate SDLT and business rates statistics, the 2026 totals on policyengine-uk-data 1.58.1 differ by 1.75e-9 (948,404,896,828 against 948,404,895,169), and the check failed in CI (#2342's run 39915137960). Use report_spec.yaml's identity_relative_tolerance (1e-6). The spec documents it as the tolerance for accounting identities between weighted totals, set by float32 rounding; the measurement applies the same value to its per-household identity checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the certified receipts identity's float32 tolerance from #2322, which fixes this branch's CI failure in run 39915137960 (Test / Python 3/5, test_head_totals_agree_with_the_model_weighted_sums). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_cached_arrays_not_written_in_place.py's static scan treats a method
call whose first argument is a string literal as a read from the
simulation, so lines = content.split("\n") was tracked as a cached array
and the two slice assignments in render_receipts_growth_yaml failed it
(CI run 39915137960, Test / Python 4/5). The function edits text, not a
simulation array; build each new list instead. The output is unchanged:
the importer tests and the real-workbook check still pass byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Hand-off to the UK hub (Budget build Head:
Validation: 639 Python + 46 policy YAML = 685 unique tests passed, all foreground Impact: no policy values or model output changed. The UK hub owns independent review and subsequent merge/retargeting; none was performed here. |
Aggregate impact on certified data: passBase Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 0 (thresholds in Notes
Moves flagged for reviewNone. Spending by benefit and service
Caseloads
Standard reform costings (change in gov_balance)
Standard reforms: change in each reform's target
Household net income by decileNo household's net income changes in any year. No change in any year: receipts by tax head; government balance, each component counted once; model totals; household income totals; population; poverty rates; people in poverty; inequality; mean household net income in each side's own deciles. Checks failing on both base and head (1)
Counted-once checkEach of 86 components was raised by £1 per unit in 2026-27, and every head of its totals was read household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A head that breaks a rule is measured again twice: with every other head held at its baseline value, and with every other head held at its value after the first £1 while the component rises again. It fails where either still shows the response. 0 failed; 0 were skipped.
Provenance
Dataset: Receipts by tax head: head levels
Spending by benefit and service: head levels
Caseloads: head levels
Government balance, each component counted once: head levels
Model totals: head levels
Household income totals: head levels
Population: head levels
Poverty rates: head levels
People in poverty: head levels
Inequality: head levels
Mean household net income in each side's own deciles: head levels
Standard reform costings (change in gov_balance): head levels
Standard reforms: change in each reform's target: head levels
|
|
Merged by the UK hub (Claude session 8e19f9d5) through the merge queue with the head pinned. The head is recorded in the lander log.
|
Fixes #2341
This was stacked on #2322, which merged on 24 Oct, so the PR now targets
main.d35650c49merges main in with no conflicts; the PR's net diff against main has the same patch-id as the reviewed9ca3cfd2d. It changes no model output: theyoy_growth.yamledits are comments only, and no model code imports the importer.Receipt-unit review fix (23 October,
9ca3cfd2d)A future EFO can change its cash units. Combining £m levels with a £bn outturn base could write growth of
999.0in place of0.0. Every selected table in both receipts workbooks now has to declare pound billions in its own header before the fiscal-year row. The explicit aliases are£ billion,£bnand£ bn, ignoring case and whitespace. Other magnitudes, currencies and measures, and missing or ambiguous declarations, stop the import before any output. Requiring the same supported magnitude throughout also prevents mismatched releases from being combined. Receipt-growth arithmetic and economy extraction are unchanged.Tests were committed first. Real-workbook regressions at the reviewed importer produced 19 failures and 6 passes, including the next-release attack; the unchanged control passed. Committed fixtures retain the original selected worksheet XML and shared strings, with source hashes. Mutations cover all three selected tables in each release, million-pound levels scaled by 1,000, misleading labels, missing/ambiguous declarations, and a 50-example Hypothesis property over labels and common scale factors.
Final validation: 105 importer tests, 534 other unique Python tests, and 46 policy YAML cases passed: 685 unique tests. Five tiny synthetic microsimulation fixtures were additionally rerun serially with per-test resource gates and passed. No requested cases remain excluded.
All pytest runs use foreground
-n 2, with load1 <= 110 and at least 60 GB decimal free disk checked before each run. The host gate waited when load1 exceeded 110. Four corporate-receipts cases were initially deferred until compressor occupation fell below the hub's 30 GB gate; all four then passed serially. Five earlier tiny synthetic microsimulation fixtures were also rerun serially with per-test load/disk/memory checks. All 11 recorded gated-run readings (two preflights plus nine cases) meet those limits. No full-survey dataset build or certified-data measurement was run. The worktree venv follows the frozen dev lock (Python 3.13.9, core 3.33.2, NumPy 2.5.4). Full formatting/lint anduvx ruff format --check .pass.The complete original March 2026/2025 receipts workbooks reproduce the entire committed YAML byte for byte through the receipts renderer (28 growth values). A full CLI run using all three original workbooks also leaves the four receipts series byte-identical. Its existing four-decimal economy extraction changes cumulative 2030 indices by RPI −0.0880%, CPI −0.0584%, average earnings −0.0585% and house prices +0.0194%; these independently verified shifts are now documented in the README's refresh steps, without changing the behavior.
The 2019 England business-rates source link now uses the correct
2019-to-2020-provisional-datarelease URL; that file's sole change is the link. The base branch and #2322 are unchanged. The UK hub owns the independent final-head review and any merge/retargeting.Summary
#2322 added four OBR receipts growth series under
gov.economic_assumptions.yoy_growth.obr. They project the corporate SDLT and business rates statistics:stamp_duty_land_tax.residentialand.non_residential, from receipts Table 3.7;business_rates.great_britain, from current receipts on a cash basis (Table 3.8 in March 2026, 3.9 in March 2025);business_rates.northern_ireland, from Table 3.2.They were derived by hand.
import_obr_forecasts.pydid not touch them:read_calendar_valuesskipped nested series andreplace_series_sectionhandles only top-level ones. At the next EFO they would have stayed on March 2026, the failure that left the old statistics at their 2018-2021 values.The importer now rewrites them from a new EFO's receipts tables in the same run as the economy series and forecast gaps. Run on the March 2026 tables, it reproduces every committed value exactly.
What the importer does
For each series,
compute_receipts_growth:render_receipts_growth_yamlthen rewrites each series'valuesblock andmetadata.referencelist:create_economic_assumption_indices.py.)average_earningsas the same run writes it. The importer writes the value in effect in the first year after the forecast and each later change, so the series equalsaverage_earningsin every year.Command line
--previous-receipts-file/--previous-receipts-url: the earlier EFO's receipts tables. Receipts growth is updated whenever yoy_growth.yaml is, so these and--receipts-fileare required. The opt-out is--skip-receipts-growth; a--dry-runonly warns.--release-monthand--release-yearmust agree with it when it names one.--outputor--skip-statutory-gaps) does not need September CPI in the receipts tables.--gaps-onlyrefuses the previous receipts tables, because it leaves yoy_growth.yaml alone.--release-monthnow fails with a clear message.write_all_or_none), so growth, receipts growth and gaps never mix EFOs.read_calendar_valuesnow returns nested series, keyed by dotted path (stamp_duty_land_tax.residential).Changes to yoy_growth.yaml
Comments only; every value is unchanged.
# Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, receipts Table 3.8 (March 2025: Table 3.9) "Business rates". This line is kept at later EFOs, so it has to say where its value came from.The file is now exactly the importer's output; a test enforces this (below).
The economic assumptions README lists the four series and adds a refresh step:
OBR_RECEIPTS_BNintest_corporate_receipts_statistics.py, which pins the series to the published levels.Check against the March 2026 workbooks
Inputs:
~/reviews/uk-obr-receipts-importer-2341/sources; the receipts files are the ones Update corporate SDLT and business rates statistics and project them with OBR receipts #2322 used.Results:
--fileeconomy,--receipts-file,--previous-receipts-file,--outputto a scratch file, on the committed yoy_growth.yaml, with no release flags (each release comes from its contents sheet). The four receipts series come out byte-identical; no line of the diff falls in the receipts block. Rerun on 23 October at the final head.compute_receipts_growthgives the 28 growth values in Update corporate SDLT and business rates statistics and project them with OBR receipts #2322'sgrowth.jsonexactly, andrender_receipts_growth_yamlleaves the committed file byte-identical. That file came from a separate openpyxl extraction (extract_values.py), so this is a differential check of two readers. Rerun on 23 October at the final head.Invariants (tests in
test_import_obr_forecasts.py)round(level(t) / level(t-1) - 1, 6), read back exactly from the written YAML.average_earningsin effect that year.format_growth(v)is fixed-point, has at least four decimals and reads back asv. Hypothesis checks 300 floats.0.0380to0.038) or one reference makes it fail; each was tried.valuesleaves the rendered file equal to the render without it, plus the comment in place.Previous validation at 749b547 (23 October)
Local, foreground,
-n 2, with host load around 50; PR CI is dispatched manually on this branch, since it targets #2322's branch.test_import_obr_forecasts.py: 80 passed (60 at0da72d8aa, 24 before this PR). The 20 new cases are listed under Review. 18 of them fail on0da72d8aa; the other two, comments at indents 6 and 8, already worked there. The one changed existing case (test_receipts_growth_rejects_a_missing_or_non_positive_level, new message) also fails there.test_corporate_receipts_statistics.py(excluding its 4 microsimulation cases, which this PR's comment-only YAML change cannot move): 137 passed.test_lagged_series.py,test_council_tax_uprating.py,test_threshold_indexation.py,test_fiscal_year_parameters.pyandtest_domestic_rates_uprating.py: 133 passed.code_health/test_cached_arrays_not_written_in_place.py: 36 passed (it failed at0da72d8aamerged with main).ruff format --check .andruff checkon the touched files pass.Stack update (23 October 2026)
8a99e330bmerges Update corporate SDLT and business rates statistics and project them with OBR receipts #2322's updated head (376f10307), which carries Count corporate SDLT once and let SDLT abolition remove all of it #2235 and main ata45baf5e4. There was one conflict, in the economic assumptions README's EFO refresh checklist: main added step 6 (movePROJECTION_END_YEARon) where this PR added step 6 (refresh the receipts series). Both are kept: receipts refresh as step 6, projection horizon as step 7, then the importer example. Main has not touched the importer or its tests since this PR branched.daabdf343fixes the review findings below.4daa09f56merges Update corporate SDLT and business rates statistics and project them with OBR receipts #2322'sf6ed43708. That commit holdstest_aggregate_impact_certified.py's receipts identity to the spec's float32 tolerance, which fixes this branch's one CI failure in run 39915137960 (Test / Python 3/5; 1,743 other tests in that shard passed).749b547c5fixes that run's other failure (Test / Python 4/5).test_cached_arrays_not_written_in_place.py's static scan treats a method call with a string first argument as a simulation read, so it tooklines = content.split("\n")inrender_receipts_growth_yamlfor a cached array and flagged its two slice assignments. The function now builds each new list instead. Its output is unchanged: the importer tests, the scan's module (36 passed) and the real-workbook check all pass. In that run, every other shard, plus YAML, docs and smoke-import, passed. The CI run at this head is 39928984925.Review
Round 1 (independent attack review of
0da72d8aa, 11 October): REQUEST_CHANGES, with seven should-fix findings and one nit, each reproduced. All are fixed indaabdf343, each with a test that fails on0da72d8aa:test_main_refuses_tables_from_the_wrong_efo[receipts_unidentified]).test_main_refuses_release_flags_the_economy_tables_contradict,test_main_takes_the_release_from_the_economy_contents_sheet,test_release_is_read_from_a_url_encoded_name).test_a_line_is_read_only_from_its_own_table,test_a_table_title_below_publication_notes_is_found).test_a_fiscal_year_heading_two_columns_is_refused).test_outturn_and_forecast_markers_may_sit_on_separate_rows,test_a_marker_in_two_columns_is_refused).test_a_comment_at_any_indent_ends_no_yaml_block, at indents 0, 2, 4, 6 and 8).test_a_growth_only_run_does_not_need_september_cpi, three flag sets).test_a_forecast_year_a_series_lacks_is_named). Inserting entries automatically would also move every later year up to the next entry, so that stays manual.Also fixed: one probe in the review's scratch showed that a zero last level wrote growth of −1.0, zeroing the index. A level at or below zero is now refused in any year (
test_a_last_level_at_or_below_zero_is_refused).The review's own probes, rerun against
daabdf343: findings 3 to 6 no longer reproduce. A title moved below its markers is now refused, because its markers fall outside the table; it used to give "found none".Round 2 (review at
749b547c5): REQUEST_CHANGES for unchecked receipt units. Addressed by the unit checks and regressions above; independent review of the new head remains with the UK hub.Reproducer: policyengine_uk/tests/test_import_obr_forecasts.py::test_real_receipts_units_are_refused_before_any_write, policyengine_uk/tests/test_import_obr_forecasts.py::test_next_release_real_receipts_refuse_a_thousandfold_unit_change, policyengine_uk/tests/test_import_obr_forecasts.py::test_real_receipts_workbooks_reproduce_committed_yaml_byte_for_byte
axiom: n/a: importer tooling for OBR forecast series; no policy rule changes
🤖 Generated with Claude Code