Repository navigation
Description
What happens today
- The inputs. Alcohol and tobacco duty (Add household alcohol, tobacco, vehicle and road fuel gas duties #1876) read household quantity inputs:
beer_litres,cider_litres,sparkling_cider_litres,wine_litres,spirits_litres,other_fermented_litres;cigarettes_per_week;hand_rolling_tobacco_grams_per_week,cigars_grams_per_week,other_tobacco_grams_per_week,heated_tobacco_grams_per_week.
- No uprating. None of these is in
policyengine_uk/data/uprating_indices.yaml(main at 402e7c6), and none declaresuprating. When a dataset is projected, every later year keeps the base year's quantities and charges that year's duty rates on them. - Why it matters now. So far no dataset has supplied these inputs, so the duties were zero and the gap had no effect. UK: impute alcohol, tobacco and gambling consumption so policyengine-uk's excise duties stop scoring zero microcosm#1359 will fill them at FY2024-25 clearances, so every projection from those releases will hold them flat.
How large the drift is
OBR's March 2026 EFO forecasts clearances to fall, fastest for tobacco (EFO receipts tables 3.11 and 3.12). The table compares each forecast with the 2024-25 outturn and shows how far a flat input would sit above the forecast.
| Clearances | 2024-25 outturn | 2026-27 forecast | 2030-31 forecast | Flat input above forecast, 2026-27 | Flat input above forecast, 2030-31 |
|---|---|---|---|---|---|
| Cigarettes (million sticks) | 13,578 | 10,936 | 7,565 | 24% | 79% |
| Hand-rolling tobacco (thousand kg) | 4,029 | 3,294 | 2,487 | 22% | 62% |
| Beer and cider (thousand hl of alcohol) | 2,168 | 2,048 | 2,101 | 6% | 3% |
| Wine (thousand hl of alcohol) | 1,620 | 1,521 | 1,569 | 6% | 3% |
| Spirits (thousand hl of alcohol) | 1,290 | 1,237 | 1,263 | 4% | 2% |
These percentages are before the population adjustment below. Forward-year tobacco duty, and any reform scored on it, would come out too high, by more each year.
Proposed change
Follow the road-fuel inputs. petrol_spending and diesel_spending uprate by gov.economic_assumptions.yoy_growth.obr.petrol_spending_litre_proxy and diesel_spending_litre_proxy. Those indices are derived from OBR road-fuel volume growth, net of population growth, with a price term because those inputs are spending.
- New indices. Add per-head volume growth indices from OBR's clearance forecasts, (1 + clearance growth) / (1 + population growth) − 1, one per product group:
- beer and cider (table 3.12) for
beer_litres,cider_litresandsparkling_cider_litres; - wine (table 3.12, which includes other fermented products) for
wine_litresandother_fermented_litres; - spirits (table 3.12, which includes spirit-based ready-to-drinks) for
spirits_litres; - cigarettes (table 3.11) for
cigarettes_per_week; - hand-rolling tobacco (table 3.11) for
hand_rolling_tobacco_grams_per_week; - other tobacco products (table 3.11) for
cigars_grams_per_week,other_tobacco_grams_per_weekandheated_tobacco_grams_per_week.
- beer and cider (table 3.12) for
- Mapping. Map each input to its index in
data/uprating_indices.yaml. - No price term. These inputs are quantities, not spending.
- Periods. The OBR figures are fiscal-year, so the periods should follow the convention of the fuel entries.
- Tests:
- a household's quantities projected one year forward by each index;
- a check that a later year's duty uses the uprated quantity.
Not covered
- Strengths and draught shares. The
*_abvand*_draught_shareinputs are ratios and need no uprating. - Cigarette price. The
cigarette_price_per_packdefault (ONS CZMP) is held flat after 2024. A forward price path would be a separate change, because it feeds the ad valorem element and the minimum excise tax test.
We are not taking this on now; this issue records the gap. Related: #1872, #1873, #1876; PolicyEngine/microcosm#1359.
Activity
Grow the gambling bases per head of population
Divide a base's tail in receipts_tail, beside #2596's clearance tail
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Verdict: REQUEST_CHANGES at
7555d402d7295aec4fa0e1df23434319150a3d7a(branchfix-2596-excise-quantity-indices, delta from510410e5a).The two layout checks work. Each witness is refused by the check the author names, with the exact message, and the phonetic-run fix holds. But with the Hypothesis version
uv.lockpins, the delta's change to the arbitrary-text property fails deterministically. CI installsuv.lockexactly (uv sync --frozen), so CI would go red, and the body's "193 passed" and invariant 5 don't hold in CI's environment.P1
P1:
test_any_text_no_one_has_read_stops_a_real_clearance_tablefails under the locked Hypothesis (6.170.11), and its oracle and invariant 5 claim a refusal the importer doesn't make.Command, in a clean detached checkout after
uv sync --frozen --extra dev, withHUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset:The property uses
derandomize=True, so it fails the same way on every run with this lock. At510410e5a, the same venv passes it (1 passed). The author's 193 passes came from another environment, a different Hypothesis version that drew other examples.Cause. A new row holding only "Non-cigarette clearances ('000s kg)", inserted directly above "of which:", sits between the total and the parts. The oracle at
test_import_obr_forecasts.py:4180(real_row[spec.unit_cell] < place <= real_row[spec.row]) expects the part check to refuse it. Butvalidate_clearance_part(import_obr_forecasts.py:1399–1405) accepts a "total" row with the label and no numbers. That is judgment call 2, which droppedade1df1ce's requirement that the total be a line with values. I reproduced it independently, at row level, on all three releases (March 2025, March 2026 and October 2026 Table 3.11/4.11): with that row inserted, all three tobacco lines read unchanged. The read itself isn't wrong, because the parts still sit under a row stating '000s kg. The test and the body's invariant 5 ("stops hand-rolled or other tobacco when it lands between that part and the non-cigarette total's row") are what's wrong.Fix (recommended): require at least one number in the total's row, as
ade1df1cedid. I made the change below in a scratch copy, and the whole file then passes, 193 passed in 110 s:Then update
validate_clearance_part's docstring, "a row that holdsspec.unit_cellas its first text and only numbers after it", to "a row that holdsspec.unit_cellas its first text and one or more numbers after it, and nothing else". Update the body's bullet the same way: "hand-rolled or other tobacco (part_of_total) does not sit under an "of which:" row directly below the non-cigarette total's row, which holds its label and its figures, with only the total's other parts between". The README (:523–527) and the guide (excise-duties.md:298–300) say "directly below the non-cigarette total's label"; change both to "directly below the non-cigarette total's row, which holds its label and figures". Rerun the test file underuv sync --frozenand put that count in the body's results table.The other fix, narrowing the oracle to leave out this case, would leave the guard weaker than
ade1df1ce's for no gain, so I don't recommend it.P3
import_obr_forecasts.py:1407printsspec.unit_cell!r, which reads"non-cigarette clearances ('000s kg)"in lower case, while the sheet says "Non-cigarette clearances ('000s kg)". Whoever runs the importer searches the sheet for the quoted text. Optional replacement:f"{where} lacks the non-cigarette total's label ({spec.unit_cell!r}, as receipts_label gives it) on the row directly above ...". If it changes, updatenot_under_totalin the tests to match.uv sync --frozen. On this machine the whole file ran in one run of about 110 s with-n 4, so it needs no split.Checked and sound
Ruling 1, layout checks: closed in code (apart from the P1 gap in the total's row).
cigarette-unit-year-header: rows {6: none, 7: cigarette label beside row 4's years, 10: total};cigarette-unit-of-which: {5: none, 6: cigarette line, 10: total}; parts halved).Table 4.11 (sheet 4.11) holds {'B': 'Cigarette clearances (million sticks)'} in the fiscal-year row over the per_capita.tobacco_clearances.hand_rolling_tobacco line. A text beside the years could give the line another unit, so the importer refuses any text there.(other tobacco the same).... lacks "non-cigarette clearances ('000s kg)" on the row directly above the "of which:" row over the per_capita.tobacco_clearances.hand_rolling_tobacco line. ....in_year_rowandnot_under_totalin the tests character for character, andtest_a_layout_that_puts_the_parts_under_another_unit_stops_the_runpasses for all three layouts. Its assertions check the hand-rolled −0.585697/−0.171394 and other-tobacco −0.568094/−0.136189 figures the body quotes.The importer still interprets no words.
part_of_totalspec field.spec.unit_cell, both onCLEARANCE_TABLE_TEXTS. Nothing parses a unit out of a string.find_receipts_linecallsvalidate_clearance_tablebefore the beside, year-row and part checks).Bypass attempts on the restored checks (by reading). None gives a wrong read:
Each one either breaks the two-rows-above requirement, and the importer refuses, or leaves the parts directly under the total. Cigarettes carry their unit in their own label, and the alcohol list holds one unit.
Ruling 2, phonetic runs: closed.
_string_textreads only direct<t>and<r>/<t>, in both paths.test_a_phonetic_run_cannot_end_a_clearance_tableand the extended run property pass.Mutants, in scratch copies, run on the layout, phonetic, formatting-run, list and arbitrary-text tests (10):
510410e5a's.//main:tjoinThe arbitrary-text property also fails under each mutant, but it fails unmutated too, so it adds no evidence until P1 is fixed.
Full file at head: 192 passed, 1 failed (the P1 property), 105 s. The byte-for-byte rebuild of
yoy_growth.yaml, both real release pairs, the repeated-heading test and the unit-cell test are among the passes.Lint:
uvx ruff@0.17.2 format --checkandcheckare clean on the two changed Python files, andgit diff --check 510410e5a..HEADis clean.Merges (
git merge-tree --write-tree --name-only 7555d402d… <sha>): main2da212abc…, Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #27610037137ad…, Derive economic assumption index horizon from the growth series #2756c34662736e0c…, Round projected State Pension weekly rates to the nearest 5p each April #2751b5de2c789, and Classify the Budget helper variables in the fiscal partition #27636ca062e68a92…merge cleanly.17dbd906755e…(fix-2721) conflicts in the economic assumptions README andtest_import_obr_forecasts.pyonly. That matches the body's overlap note.Voice table rows: the README
:525and the test docstring:4180replacements, and the reflowed paragraphs at importer:1143–1150and tests:545–552,:614–617,:3185–3189and:4114–4124, landed as specified.What I couldn't check
reader,richandrowschecks (the census of 20 sheets, 1,843 rows and 15,604 cells), and the review-round mutant driver's eight-row table. The witness scripts aren't on GitHub. The byte-for-byte test passing here (120,408 bytes, SHA-256846cbe78…24a8) coversrebuild's main claim, but not the 17-series March-over-March figure beyondtest_both_real_release_pairs_read_every_seriespassing.Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.
Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
PR_TITLE.txtGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
PR_BODY.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Head:
8f3f0f396e9318de59032110aaa16351273f9ac3on branchclaude/fix-2596-hypothesis-check-zr68rb. It fast-forwardsfix-2596-excise-quantity-indicesfrom7555d402d7295aec4fa0e1df23434319150a3d7awith three commits (45bd632e4,8d9763345,8f3f0f396). This session may push only to its ownclaude/...branch, so I didn't movefix-2596-excise-quantity-indices. Fast-forward it to this head. I opened no PR.Before starting: the last push to
fix-2596-excise-quantity-indiceswas the reviewed head7555d402d, committed at 10:13 UTC. That was 5 h 45 min before I started, so it falls inside the 6-hour window. It's the base this job names, and the issue's only comment was the review, so I went ahead and posted the pickup comment. Separately,origin/mainhas moved past the held2da212abcto46a60678f(#2751 and #2757 merged). I checked merges against both.Environment:
uv sync --frozen --extra dev; Hypothesis prints 6.170.11;HUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset. At the base7555d402d,uv run pytest policyengine_uk/tests/test_import_obr_forecasts.py -n 4 -q -o xfail_strict=truegave 1 failed, 192 passed in 113.45 s. The one failure istest_any_text_no_one_has_read_stops_a_real_clearance_table, on the same draw the review shows (March 2025 tobacco,"non-cigarette clearances ('000s kg)", new row 6).Findings
P1, the total's row.
policyengine_uk/utils/import_obr_forecasts.py:1402–1407now addsor not total[1:](:1405), as the review's block does. The total's row must hold its label, one or more numbers after it, and nothing else. I didn't change the oracle.:1376–1382) uses the review's text.ClearanceSeriesSpec's docstring (:730–733) said the importer requires "that label" there. It now says "that label and the total's figures".:527–529anddocs/engineering/excise-duties.md:298–300now read "directly below the non-cigarette total's row, which holds its label and figures".git grep -n "non-cigarette total's label"finds no copy of the old phrase. The hits left are the README's unit sentence (:519), a test docstring about clearing the label, and the new message.PR_BODY.mdline 27 now carries the review's bullet. I added "A row that holds the total's label and no figures does not count as the total."Deterministic regression test.
test_a_total_label_without_figures_does_not_count_as_the_total(policyengine_uk/tests/test_import_obr_forecasts.py:4005–4029) is parametrized over March 2025, March 2026 and October 2026. It reads each real tobacco table and inserts{"B": "Non-cigarette clearances ('000s kg)"}directly above "of which:". It then asserts the exactnot_under_totalmessage for hand-rolled and other tobacco, and that cigarettes read unchanged. It uses no Hypothesis.or not total[1:]removed, all 3 cases fail, and so does the arbitrary-text property: 4 failed, 9 passed of the 13 tests listed under mutants.P3-1, the refusal message. I used the review's wording, extended to the figures the check now requires (
import_obr_forecasts.py:1408–1415):… lacks the non-cigarette total's label ('non-cigarette clearances (\'000s kg)', as receipts_label gives it) and its figures on the row directly above the "of which:" row over the … line. The line takes its unit from that total's label, so the importer needs the label and figures there, ….not_under_total(tests :3876–3887) matches it character for character, and the layout test and the property assert it.receipts_labelnormalises it.Mutants. I ran scratch copies on 13 tests: the 3 layouts, the 3 new example tests, the arbitrary-text property, the 3 formatting-run edits, the formatting-run property, the phonetic-run test and the list test. Unmutated, all 13 pass. The results below are at
8d9763345; they were identical at45bd632e4.8f3f0f396changes only README line breaks after that..//main:tjoin: 2 failed (test_a_string_reads_whole_in_any_formatting_runsand the phonetic-run test).All four mutants were killed. I updated the body's three affected mutation rows and added a fourth, with the head and the 13-test selection, and rewrote the paragraph above the table to match. The other
433b20fb7rows (reader, heading, walk) keep their head.P3-2 and the results table. Whole file in one run at the final head
8f3f0f396under the locked environment: 196 passed, 0 failed, 112.08 s (-n 4), and 196 passed in 120.51 s at8d9763345. I reran every row of the results table at8d9763345, and the test file and the wholecode_healthagain at8f3f0f396; each row names its head:846cbe78…99e24a8;test_excise_quantity_projection.py: 10 passed in 124 s;code_health: 3,069 passed in 251 s (226 s at8f3f0f396);test_changelog_fragments.py: 103 passed;test_uprating_declarations.py: 8 passed;test_cached_arrays_not_written_in_place.py: 36 passed;test_parameter_descriptions.py: 24 passed;The sentence above the table now names the base's 1 failed/192 passed, lists the files changed between
7555d402dand8f3f0f396, and says8f3f0f396changes only three README lines after8d9763345.Merges (
git merge-tree --write-tree --name-only 8f3f0f396 <sha>):2da212abc: clean.origin/main46a60678f: clean.0037137ad: clean, and it's an ancestor.17dbd9067: conflicts inpolicyengine_uk/parameters/gov/economic_assumptions/README.mdandpolicyengine_uk/tests/test_import_obr_forecasts.pyonly. The importer andyoy_growth.yamlauto-merge.The body's overlap note says so. I didn't rerun Round projected State Pension weekly rates to the nearest 5p each April #2751, Derive economic assumption index horizon from the growth series #2756 or Classify the Budget helper variables in the fiscal partition #2763, so the note cites them at
7555d402d.Beyond the review
test_test_module_caches_released.py::test_every_test_module_cache_is_releasedfailed at7555d402d(and at45bd632e4). The branch's cachesreal_clearance_rowsandcurrent_lawwere never cleared, so CI's code_health would have gone red. Commit8d9763345adds module-scoped autouse fixtures that clear them:test_import_obr_forecasts.py:3556–3561andtest_excise_quantity_projection.py:85–90. The checker now passes (5 passed), and so does the whole directory (3,069 passed). This change is whytest_excise_quantity_projection.pywas rerun.8f3f0f396rewraps only the three README lines the P1 wording changed, so the line width holds without moving other lines. That keeps the conflict with fix-2721 as small as before..github/check_fix_pr.pypasses on the new body at8f3f0f396("Changed executable tests: 4"). I leftPR_TITLE.txtand the changelog fragment unchanged; the fragment still holds.Lint
At
8f3f0f396, all of these are clean:uvx ruff@0.17.2 format --check .(2,156 files);uvx ruff@0.17.2 check .;uvx ruff@0.17.2 check --select F811,F821 policyengine_uk;git diff --check 7555d402d HEAD.Skipped
Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Verdict: REQUEST_CHANGES at
8f3f0f396e9318de59032110aaa16351273f9ac3(branchclaude/fix-2596-hypothesis-check-zr68rb, delta from7555d402d7295aec4fa0e1df23434319150a3d7a).The P1 is closed. Under the locked environment the base fails as the last round found and the head passes the whole file. The total's row must now hold its label, one or more numbers and nothing else. The new example test fails when the clause is reverted, and all four of the hand-off's mutants are killed. The cache fixtures fix a real
code_healthfailure at the base.There's one P2. Three refusals in
validate_clearance_partthat no test pins down survive the whole file as mutants: "nothing else" on the total's row (new in this delta), "no other cell" on the "of which:" row, and adjacency (no empty row). It's the same fragility the P1 exposed: only the property's derandomised draws claim this coverage, and under this lock they don't reach these cases. The code refuses all three today, so nothing reads wrong.P2
P2: three refusals in
validate_clearance_parthave no test. Mutants that drop them survive all 196 tests.In a scratch copy at the head, I ran each mutant alone on the whole of
test_import_obr_forecasts.py(uv run pytest policyengine_uk/tests/test_import_obr_forecasts.py -n 4 -q -o xfail_strict=true, locked environment):import_obr_forecasts.py:1402–1407,:1397)or not all(NUMBER_RE.fullmatch(text) for text in total[1:]), so any text after the total's label passesof_which != ["of which:"]→of_which[:1] != ["of which:"], so the "of which:" row may hold other cellsabove, so empty rows may sit between the total, "of which:" and the partsAt the head, unmutated, the importer refuses each of these layouts. I built them at row level from each release's real tobacco table, with
real_clearance_rowsandfind_receipts_line:In each case, on all three releases, the importer refuses hand-rolled and other tobacco and reads cigarettes unchanged. So the code is right, but nothing pins it down. The first two layouts are the same kind of attack as
cigarette-unit-year-header: a listed label on a header row over the parts.Invariant 5 and the property's docstring (
test_import_obr_forecasts.py:4150–4161) say a listed text "between the part and the total's row" stops the part. The property's oracle (real_row[spec.unit_cell] < place <= real_row[spec.row]) does expect a refusal for a cell in the "of which:" row. But under Hypothesis 6.170.11 its 200 derandomised examples never draw one: the second mutant passes it. The total's row never gets a listed text, because the property leaves out rows with numbers (line_rows).Fix: extend the new example test to the layouts, so the importer's message is asserted on each release without Hypothesis. Replace
test_a_total_label_without_figures_does_not_count_as_the_total(test_import_obr_forecasts.py:4005–4029) with:In all three releases, column N of the total's row and column C of the "of which:" row are empty (
real_clearance_rowsgivesNoneor no key), so each layout adds a text and overwrites none. Then rerun the three mutants above and add them to the body's mutation table with the head and the counts. Update the body's test bullet ("A fourth inserts a row that holds only …") and invariant 5's example-test sentence to name the five layouts.P3
The refusal message blames the total's row even when the "of which:" row is at fault. At
import_obr_forecasts.py:1408–1415, the message says the table "lacks the non-cigarette total's label (…) and its figures on the row directly above the "of which:" row". With "Cigarette clearances (million sticks)" beside "of which:" (October 2026 Table 4.11), the total's row still holds its label and figures, but the importer printsTable 4.11 (sheet 4.11) lacks the non-cigarette total's label ("non-cigarette clearances ('000s kg)", as receipts_label gives it) and its figures on the row directly above …. The same message appears with an empty row in either place, and when no "of which:" row exists. The base's message had the same gap, but this delta rewrote it, so now is the time. Replace it with:Update
not_under_total(test_import_obr_forecasts.py:3876–3887) to match.Body, "gains 33 tests over Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761". The file has 84
def test_functions at Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761's head0037137ad, 114 at7555d402dand 115 at8f3f0f396.git diff 0037137ad 8f3f0f396 -- policyengine_uk/tests/test_import_obr_forecasts.pyadds 31 and removes none. The last body's "30" counted functions; this delta adds one function with three cases. Replace with "test_import_obr_forecasts.pygains 31 test functions over Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761" (or the new count after the P2 fix).Body, overlap note.
mainnow sits at47bda0a7a9f06b59bb262e9b4dffc05ab2428b5d.git merge-tree --write-tree --name-only 8f3f0f396 47bda0a7amerges cleanly (tree5eec41e5c…). Name it beside2da212abcand46a60678f, or replace those with it.Checked and sound
All runs below were in detached worktrees at each SHA, after
uv sync --frozen --extra dev, withHUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset, on 4 cores.Environment.
uv run python -c "import hypothesis; print(hypothesis.__version__)"prints6.170.11in both worktrees.P1 (previous round): closed.
7555d402d:uv run pytest policyengine_uk/tests/test_import_obr_forecasts.py -n 4 -q -o xfail_strict=truegives 1 failed, 192 passed in 91.35 s. The failure istest_any_text_no_one_has_read_stops_a_real_clearance_table, on the same draw (listed,"non-cigarette clearances ('000s kg)", column A, new row 6).8f3f0f396: 196 passed in 83.46 s.validate_clearance_part(:1364–1415):total[:1] != [spec.unit_cell],not total[1:]andnot all(NUMBER_RE.fullmatch(...) for text in total[1:])together require the label first, at least one number after it, and only numbers after it.texts()drops empty cells. The docstring (:1376–1382) andClearanceSeriesSpec's docstring (:730–733) say so.{"B": label, "C": "1"}) above "of which:" counts as the total. I checked all three releases: all three lines read unchanged, and the parts still sit under a row that states '000s kg.The new regression test. With
or not total[1:]removed in a scratch copy,test_a_total_label_without_figures_does_not_count_as_the_totalfails on all 3 releases and the arbitrary-text property fails: 4 failed, 9 passed of the 13-test selection, as the hand-off says. The test uses no Hypothesis. It asserts the exactnot_under_totalmessage for both parts and that cigarettes read unchanged.Mutants, each alone in a scratch copy at the head, on the hand-off's 13 tests (3 layouts, 3 new cases, the arbitrary-text property, 3 formatting-run edits, the formatting-run property, the phonetic-run test, the list test). Unmutated, all 13 pass.
cigarette-unit-second-year-row,cigarette-unit-year-header, the propertycigarette-unit-of-which, the 3 new cases, the property.//main:tjointest_a_phonetic_run_cannot_end_a_clearance_table,test_a_string_reads_whole_in_any_formatting_runstotal[:1] != [unit_cell]→unit_cell not in totalThese match the body's four mutation rows and counts.
Cache fixtures.
code_health/test_test_module_caches_released.pyat7555d402d: 1 failed, 4 passed, with{'test_excise_quantity_projection.py': ['current_law'], 'test_import_obr_forecasts.py': ['real_clearance_rows']}not cleared. At the head: 5 passed. The wholecode_healthdirectory at the head: 3,069 passed in 182 s, which includestest_changelog_fragments.py,test_uprating_declarations.pyandtest_cached_arrays_not_written_in_place.py. Each fixture's comment says what it does.test_excise_quantity_projection.pypasses 10 in 88 s at the head and 10 in 85 s at the base, so clearing the cache adds no material time.Docs the delta touched.
:525–530anddocs/engineering/excise-duties.md:298–300say "directly below the non-cigarette total's row, which holds its label and figures", and that matches the code.git grep -n "non-cigarette total's label"finds only the README's unit sentence (:519), a test docstring (:3842), the message and its test copy.git diff --stat 7555d402d 8f3f0f396shows.Rebuild. I called
compute_receipts_growthandrender_receipts_growth_yamldirectly, not through the tests, on the real fixture workbooks at the head. March 2026 over March 2025 and October 2026 over March 2026 each read 17 series, 13 underper_capita. October 2026 over March 2026 renders 120,408 bytes, SHA-256846cbe78c74ff9a1c94ce39c5e4b8bf275645b78943d3c2d84dda830099e24a8, equal to the committedyoy_growth.yaml.Merges (
git merge-tree --write-tree --name-only 8f3f0f396 <sha>):main2da212abc: clean.46a60678f: clean.main47bda0a7a: clean.0037137ad(fromgh api …/pulls/2761, open): clean, and it's an ancestor of the head.fix-2721-import-nlw-forecasthead17dbd906755e…(fromgh api …/branches/…): conflicts inpolicyengine_uk/parameters/gov/economic_assumptions/README.mdandpolicyengine_uk/tests/test_import_obr_forecasts.pyonly, as the body says.Lint.
uvx ruff@0.17.2 format --checkandcheckare clean on the three changed Python files, andgit diff --check 7555d402d 8f3f0f396is clean.What I couldn't check
test_parameter_descriptions.py, gambling, population vintage, projection horizon and the three YAML files. The delta touches none of their inputs..github/check_fix_pr.pyon the body, and the whole-repository ruff run: I linted only the changed files.Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.
Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
PR_TITLE.txtGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
PR_BODY.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Head:
0a68b3fcf37b9e56fb7547edac24fd79bd38bfd7on branchclaude/fix-2596-clearance-refusals-rhf7yx. It adds one commit to8f3f0f396e9318de59032110aaa16351273f9ac3(claude/fix-2596-hypothesis-check-zr68rb). I did not push tofix-2596-excise-quantity-indices, and I opened no PR. The hub documents are in the two comments above.Findings and how I resolved them
P2: three refusals had no test. Before relying on the review's layouts, I checked its claim at row level with
real_clearance_rows. In March 2025, March 2026 and October 2026, column N of the total's row and column C of the "of which:" row are both empty: they are missing, orNonein October 2026. So each layout adds a text and overwrites none. Inpolicyengine_uk/tests/test_import_obr_forecasts.py:4006–4054, I replacedtest_a_total_label_without_figures_does_not_count_as_the_totalwith the review'sTOTAL_LAYOUTS(five layouts) andtest_a_part_needs_its_total_directly_above_of_which, parametrized over the layouts and the three releases (15 cases). The test keeps the existing loop overTOBACCO_LINESunchanged and uses no Hypothesis. A comment aboveTOTAL_LAYOUTSrecords the empty-cell fact.shard_durations.tomldoesn't list the old test, so I left it alone.P3-1: the refusal message.
policyengine_uk/utils/import_obr_forecasts.py:1408–1416now uses the review's message word for word. I checked it against every wayvalidate_clearance_partrefuses (:1402–1407):not_under_total(test_import_obr_forecasts.py:3876–3888) now matches it. All 15 layout cases and the existing callers assert the exact text.P3-2: the test count.
grep -c '^def test_'ontest_import_obr_forecasts.pygives 84 at #2761's head0037137ad, 114 at7555d402d, 115 at8f3f0f396and 115 at0a68b3fcf.git diff 0037137ad 0a68b3fcfadds 31 and removes none. The body now says "gains 31 test functions over #2761 (84 … 115 …)". The P2 fix swaps one function for one, so the count of functions stays the same and the cases rise by 12. I rewrote the "A fourth …" bullet and invariant 5's example-test sentence to name all five layouts. I also updated thepart_of_totalbullet under "Importer" to describe the whole layout.P3-3: the overlap note. It now names
mainat47bda0a7aand #2761 at0037137ad, both checked at the new head. The older2da212abcand46a60678fresults stay, attributed to8f3f0f396.Merges (
git merge-tree --write-tree --name-only 0a68b3fcf <sha>)main47bda0a7a9f06b59bb262e9b4dffc05ab2428b5d(stillmain's tip when I checked): clean, treee30e1734d. It doesn't conflict, so I didn't mergemain.0037137adc62ebc58e1f974661faae567cd3d31c(fromgh api …/pulls/2761, open): clean, tree502bcfdaa. It's an ancestor of the head.fix-2721-import-nlw-forecast17dbd906755e11c3d13d4264aef0ad46fbf12587(fromgh api …/branches/…): conflicts inpolicyengine_uk/parameters/gov/economic_assumptions/README.mdandpolicyengine_uk/tests/test_import_obr_forecasts.pyonly. The importer auto-merges. These are the same two files the body already names.Tests
All runs used
uv sync --frozen --extra dev, Hypothesis 6.170.11 (printed),-n 4on 4 cores, withHUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset, at0a68b3fcf:test_import_obr_forecasts.pywith-o xfail_strict=true: 208 passed in 97 s. That's 196 − 3 + 15.test_excise_quantity_projection.py: 10 passed in 119 s.policyengine_uk/tests/code_health: 3,069 passed in 231 s..github/check_fix_pr.pyon the final title and body. I built the changes file the way the workflow does, asgit diff --name-status --no-renames -zfrom the merge base withorigin/main, and passed--head-sha 0a68b3fcf…. Result: PASS, "Changed executable tests: 4"..github/check_changelog_fragments.py --base <merge-base> --head HEAD: OK.uvx ruff@0.17.0anduvx ruff@0.17.2,format --checkandcheckon the two changed files: clean with both.uvx ruff@0.17.2 format --check .reports 2,156 files formatted.check .andcheck --select F811,F821 policyengine_ukboth pass.git diff --check 8f3f0f396 HEADis clean.Mutations
I made each mutation alone, in its own detached worktree at
0a68b3fcf. Each ran on the whole oftest_import_obr_forecasts.py(-n 4 -o xfail_strict=true, using the locked venv withPYTHONPATHset to the worktree; I confirmed thatpolicyengine_uk.__file__resolved to the worktree). Unmutated, all 208 pass.or not all(NUMBER_RE.fullmatch(text) for text in total[1:])(review P2 mutant 1)text-on-total-rowin all three releases. It survived at8f3f0f396of_which != ["of which:"]→of_which[:1] != …(P2 mutant 2)text-on-of-which-rowin all three releases. It survived at8f3f0f396above(P2 mutant 3)empty-row-above-of-whichandempty-row-below-of-whichin all three releases. It survived at8f3f0f396cigarette-unit-second-year-row,cigarette-unit-year-headerand the arbitrary-text propertycigarette-unit-of-which, all 15TOTAL_LAYOUTScases and the property.//main:tjointest_a_string_reads_whole_in_any_formatting_runsandtest_a_phonetic_run_cannot_end_a_clearance_tableor not total[1:])label-without-figurescases and the propertyThe body's mutation table now gives these seven rows at
0a68b3fcfon 208 tests. The four older rows used to cite8d9763345on 13 tests; I replaced them with these reruns.Skipped, and why
test_parameter_descriptions.py, the gambling, population-vintage and projection-horizon tests, the three YAML files, thecode_healthsingle-file rows, and the importer rebuild. They stay attributed to8d9763345. This commit changes only an error string and tests, and the byte-for-byte rebuild test is inside the 208 that passed.Judgment calls
ruff@0.17.0in one place and0.17.2in another. I ran both on the changed files, and they agree.f"... {'.'.join(spec.path)} ...". In the test file, ruff rewrote it tof'{".".join(spec.path)} …'to match its quote style. The string is the same.origin/mainat2da212abc, where that run happened. I didn't rerun it against47bda0a7a.Generated by Claude Code