Skip to content

Alcohol and tobacco quantity inputs are not uprated, so later years charge duty on base-year volumes #2596

Open

Description

@juaristi22

What happens today

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_litres and sparkling_cider_litres;
    • wine (table 3.12, which includes other fermented products) for wine_litres and other_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_week and heated_tobacco_grams_per_week.
  • 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 *_abv and *_draught_share inputs are ratios and need no uprating.
  • Cigarette price. The cigarette_price_per_pack default (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

  1. self-assigned this
    on Oct 26, 2026
  2. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    Ready for hub review

    Verdict: REQUEST_CHANGES at 7555d402d7295aec4fa0e1df23434319150a3d7a (branch fix-2596-excise-quantity-indices, delta from 510410e5a).

    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.lock pins, the delta's change to the arbitrary-text property fails deterministically. CI installs uv.lock exactly (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_table fails 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, with HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET unset:

    uv run pytest policyengine_uk/tests/test_import_obr_forecasts.py -n 4 -q -o xfail_strict=true
    ...
    E   Failed: DID NOT RAISE ValueError
    E   Failing test case: test_any_text_no_one_has_read_stops_a_real_clearance_table(
    E       table=('March 2025', 'tobacco clearances'), data=data(...))
    E   Draw 1 (listed): True
    E   Draw 2 (text): "non-cigarette clearances ('000s kg)"
    E   Draw 3 (column): 'A'
    E   Draw 4 (new row): True
    E   Draw 5 (row): 6
    FAILED ...::test_any_text_no_one_has_read_stops_a_real_clearance_table
    1 failed, 192 passed in 105.36s
    

    The property uses derandomize=True, so it fails the same way on every run with this lock. At 510410e5a, 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. But validate_clearance_part (import_obr_forecasts.py:1399–1405) accepts a "total" row with the label and no numbers. That is judgment call 2, which dropped ade1df1ce'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 ade1df1ce did. I made the change below in a scratch copy, and the whole file then passes, 193 passed in 110 s:

        if (
            of_which != ["of which:"]
            or total[:1] != [spec.unit_cell]
            or not total[1:]
            or not all(NUMBER_RE.fullmatch(text) for text in total[1:])
        ):

    Then update validate_clearance_part's docstring, "a row that holds spec.unit_cell as its first text and only numbers after it", to "a row that holds spec.unit_cell as 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 under uv sync --frozen and 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

    1. Refusal message quotes the normalised label. import_obr_forecasts.py:1407 prints spec.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, update not_under_total in the tests to match.
    2. Body, test plan results row. "193 passed, 0 failed, over four runs" was true only outside the locked environment. After the P1 fix, replace it with the count from a run under 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).

      • I rebuilt both review-round witnesses independently at row level from the real October 2026 Table 4.11, without the test helper (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).
      • Cigarettes read 12,224.568 for 2025 in both.
      • Year-header: both parts refused with 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).
      • Of-which: both refused with ... 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. ....
      • These match in_year_row and not_under_total in the tests character for character, and test_a_layout_that_puts_the_parts_under_another_unit_stops_the_run passes 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.

      • Parts come from the explicit part_of_total spec field.
      • The year-row check compares no text. It refuses any non-year text.
      • The part check compares for equality with the literal "of which:" and with spec.unit_cell, both on CLEARANCE_TABLE_TEXTS. Nothing parses a unit out of a string.
      • The closed-list check still runs first (find_receipts_line calls validate_clearance_table before the beside, year-row and part checks).
    • Bypass attempts on the restored checks (by reading). None gives a wrong read:

      • a listed label in a value column of a non-year row (not a line, since labels count only left of the first year column);
      • a heading on a markers row;
      • a fresh year row between "of which:" and the parts;
      • an empty row between them.

      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_text reads only direct <t> and <r>/<t>, in both paths. test_a_phonetic_run_cannot_end_a_clearance_table and the extended run property pass.

    • Mutants, in scratch copies, run on the layout, phonetic, formatting-run, list and arbitrary-text tests (10):

      Mutant Result
      Drop the year-row check Killed: the second-year-row and year-header layouts fail
      Drop the part check Killed: the of-which layout fails
      Restore 510410e5a's .//main:t join Killed: the run property and the phonetic test fail

      The 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 --check and check are clean on the two changed Python files, and git diff --check 510410e5a..HEAD is clean.

    • Merges (git merge-tree --write-tree --name-only 7555d402d… <sha>): main 2da212abc…, Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761 0037137ad…, Derive economic assumption index horizon from the growth series #2756 c34662736e0c…, Round projected State Pension weekly rates to the nearest 5p each April #2751 b5de2c789, and Classify the Budget helper variables in the fiscal partition #2763 6ca062e68a92… merge cleanly. 17dbd906755e… (fix-2721) conflicts in the economic assumptions README and test_import_obr_forecasts.py only. That matches the body's overlap note.

    • Voice table rows: the README :525 and the test docstring :4180 replacements, and the reflowed paragraphs at importer :1143–1150 and tests :545–552, :614–617, :3185–3189 and :4114–4124, landed as specified.

    What I couldn't check

    • The author's runs that exist only as hub logs: the 13 adversarial cases, the reader, rich and rows checks (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-256 846cbe78…24a8) covers rebuild's main claim, but not the 17-series March-over-March figure beyond test_both_real_release_pairs_read_every_series passing.
    • The other test files in the body's table, which this delta doesn't touch.
    • The body's remaining numbers outside the delta (the duty tables and differentials), which this delta doesn't change.

    Generated by Claude Code

  3. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.


    Generated by Claude Code

  4. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    PR_TITLE.txt

    Grow alcohol and tobacco quantities with OBR clearances per head
    

    Generated by Claude Code

  5. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    PR_BODY.md

    Fixes #2596
    
    ## What and why
    
    Alcohol and tobacco duty read eleven household quantity inputs, and none of them had an uprating index. Dataset projection therefore kept the base-year quantities in every later year, and the model charged each year's duty rates on them. In its October 2026 EFO the OBR forecasts 2031-32 cigarette clearances 47.2% below its March 2026 EFO's 2024-25 outturn. Household weights also grow by population, 1.54% over those years. A flat input's weighted total would therefore end 92.4% above the forecast clearances for cigarettes. The other lines would end 79.9% above (hand-rolled tobacco), 62.7% (other tobacco products), 12.2% (beer and cider), 10.3% (wine) and 8.3% (spirits).
    
    This PR stacks on #2761 and adds six per-head clearance series, one for each product group the OBR forecasts:
    
    | Inputs | Series (`gov.economic_assumptions.yoy_growth.obr.per_capita.`) | OBR line |
    | --- | --- | --- |
    | `cigarettes_per_week` | `tobacco_clearances.cigarettes` | Table 4.11, "Cigarette clearances (million sticks)" |
    | `hand_rolling_tobacco_grams_per_week` | `tobacco_clearances.hand_rolling_tobacco` | Table 4.11, "Hand rolled tobacco clearances" |
    | `cigars_grams_per_week`, `other_tobacco_grams_per_week`, `heated_tobacco_grams_per_week` | `tobacco_clearances.other_tobacco_products` | Table 4.11, "Other tobacco products" |
    | `beer_litres`, `cider_litres`, `sparkling_cider_litres` | `alcohol_clearances.beer_and_cider` | Table 4.12, "Beer and cider" |
    | `wine_litres`, `other_fermented_litres` | `alcohol_clearances.wine` | Table 4.12, "Wine" (includes other fermented products) |
    | `spirits_litres` | `alcohol_clearances.spirits` | Table 4.12, "Spirits" (includes spirit-based ready-to-drinks) |
    
    - **Importer.** `import_obr_forecasts.py` adds `ClearanceSeriesSpec` and six `CLEARANCE_SERIES_SPECS` to `RECEIPTS_SERIES_SPECS`, so the run that refreshes the receipts series rewrites these too. A clearance spec takes no rates and no outturn of its own.
      - It finds each line by table title and row label, and links the outturn year to the previous EFO's outturn, as it does for the receipts series.
      - It interprets no words in the clearance tables. `CLEARANCE_TABLE_TEXTS` lists each text, other than titles, numbers and fiscal years, in the March 2025, March 2026 and October 2026 tables: 7 for the tobacco clearances and 9 for the alcohol clearance forecasts. It compares a cell's text in lower case and single spaces, without a footnote number after a label's last letter, as `find_receipts_line` reads row labels.
      - It reads the whole text of each cell. The reader takes shared and inline strings through one helper, `_string_text`, which joins a string's own `<t>` and the `<t>` of each formatting run, so a cell whose text sits in several runs reads whole. It leaves out the `<t>` of a phonetic run (`<rPh>`). `read_sheet_rows` also keeps each row's number on the worksheet (`SheetRow.number`). No cell in the three receipts releases changes value: they hold no inline strings and no phonetic runs, and the shared-string reader already joined every run.
      - Before it reads a line's values, it compares the text cells of the line's table with the list, in every column, from the table's title through its configured end: the row above a title `CLEARANCE_TABLE_ENDS` lists, or the sheet's last row. It skips the title cell, numbers and fiscal years. The OBR follows the alcohol clearances with the alcohol receipts forecasts, so the check leaves out their "£ billion" cell and note. The importer checks no cell above a table's title or below its end, and the economic assumptions README asks whoever runs it to read any note there.
      - In both EFOs it stops before writing anything when:
        - a cell holds any other text, such as a note that states another unit or scale, a line for another product or a unit column's heading. It names the table and each such cell, and asks whoever runs it to read the text, convert the table's figures to the listed units if it states another, and add the text to the list;
        - the table lacks the cell in which the OBR states a line's unit (`unit_cell`): "Cigarette clearances (million sticks)", "Non-cigarette clearances ('000s kg)" for both tobacco parts, or Table 4.12's "Note: All clearances are in thousands of hectolitres of alcohol.";
        - a text sits beside the years in the fiscal-year row over a clearance line, such as a unit column's heading or a line's label;
        - 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 (`validate_clearance_part`). A row that holds the total's label and no figures does not count as the total;
        - the sheet repeats the table's number and title. It names the rows, since it would check the text under one heading and could read a line under another;
        - it finds a cell beside a line's label and values, on any receipts or clearance line.
    
        Without these checks, if a note put the October 2026 cigarette forecasts in ten million sticks, with the figures divided by ten, the importer would read 2026-27 cigarette growth of -91.0% in place of -10.3%. If a second Table 4.12 heading below the alcohol receipts forecasts held the alcohol lines, with the spirits forecasts in ten thousand hectolitres, the importer would read 2026-27 spirits growth of -90.1% in place of -0.8%. If the OBR stated kg for other tobacco beside its line, the importer would read the line in the non-cigarette total's thousand kg, at growth of 999.0 for clearances that did not change.
    
        A table can keep every text on the list and still put a line under another unit. Two rearranged copies of the real October 2026 Table 4.11 put hand-rolled and other tobacco under the cigarette label: one moves the label beside a second row of years over them, and one moves the cigarette line directly above "of which:". Both halve the two parts' figures. Without the two layout checks, the importer would read 2025-26 growth of -58.6% for hand-rolled tobacco in place of -17.1%, and -56.8% for other tobacco in place of -13.6%. A phonetic run can also spell "4.12 Alcohol receipts forecasts" in a cell with no text of its own. A reader that kept its `<t>` would end the alcohol clearances at that cell, above a note in ten thousand hectolitres, and the importer would read the same -90.1% spirits growth.
      - The importer writes every series under `per_capita`, these six and #2761's seven gambling bases, through one `per_head_growth`, which computes `(1 + aggregate growth) / (1 + ons.population growth) - 1`. It divides one plus each year's growth before rounding (`ReceiptsGrowth.ratios`) by one plus that year's `ons.population` growth, subtracts one and rounds once to six decimals. It reads the population from the `yoy_growth.yaml` it rewrites. For a gambling base it divides the base's growth, receipts over the rate the model charges, whether the importer links the line to the previous EFO or to its own outturn.
      - After the forecast it holds the clearances flat, as `road_fuel_volume` holds road fuel volume.
    - **Series.** The importer writes the series in `yoy_growth.yaml` from the October 2026 tables over the March 2026 ones, and a test rebuilds the file byte for byte from the real tables.
    - **Inputs.** Each input declares its group's index (`gov.economic_assumptions.indices.obr.per_capita.*`). `uprating_indices.yaml` maps it to the series, so dataset projection to 2032 and Core's uprating after it use the same index.
    - **Fixtures.** We added sheets 3.11 and 3.12 (March 2025 and March 2026) and 4.11 and 4.12 (October 2026) to the real-table fixtures. Each kept sheet's XML and the shared strings match the originals byte for byte, and we list the originals' hashes in the fixture README.
    - **Docs.** We document the projection of alcohol and tobacco quantities in a new section of `docs/engineering/excise-duties.md`, with every source file, sheet, row and hash, and list the series and the refresh step in the economic assumptions README.
    
    Dataset projection keeps the strengths (`*_abv`), draught shares and `cigarette_price_per_pack` at their base-year values, since they measure ratios and a price, as the issue says.
    
    ### Why per head: how projection applies weights
    
    From `policyengine_uk/data/economic_assumptions.py`:
    
    - `extend_single_year_dataset` copies the base year into each later year.
    - `apply_single_year_uprating` multiplies each column `uprating_indices.yaml` lists by one plus its series' growth for that year.
    - The file lists `household_weight` under `ons.population`.
    
    So, for household i in year t, with population growth p(t) and quantity series g(t):
    
    - the weight is w(i, t) = w(i, t - 1) × (1 + p(t));
    - the quantity is q(i, t) = q(i, t - 1) × (1 + g(t));
    - the weighted total is Q(t) = (1 + p(t)) × (1 + g(t)) × Q(t - 1).
    
    With g(t) = (1 + c(t)) / (1 + p(t)) - 1, where c(t) is clearance growth, Q(t) = (1 + c(t)) × Q(t - 1), so the total follows the clearances. Growing quantities by c(t) itself would count population growth twice. `ons.population` grows 1.54% from 2024-25 to 2031-32, so totals grown that way would end 1.54% above the clearances. In the OBR's outturn, cigarette clearances fell 9.97%, from 13,578.2 million sticks in 2024-25 (March 2026 EFO) to 12,224.6 million in 2025-26 (October 2026 EFO). Population grows 0.33% in 2025, so the importer writes 12,224.6 / 13,578.2 / 1.0033 - 1 = -0.102653.
    
    By 2031-32 the indices (2024 = 1) stand at:
    
    | Group | Index |
    | --- | ---: |
    | Cigarettes | 0.520 |
    | Hand-rolled tobacco | 0.556 |
    | Other tobacco products | 0.615 |
    | Beer and cider | 0.891 |
    | Wine | 0.907 |
    | Spirits | 0.924 |
    
    ### Budget measures, and what they mean for reforms
    
    The OBR incorporates the Budget's measures in its October 2026 forecast (EFO paragraph 3.2). In policy Table 1.1 it lists no change to either duty rate. It lists three measures there that change the two duties' receipts, each by less than £13 million a year:
    
    - measure 19, Small Producer Relief from 1 February 2027: alcohol duty £1.5 million to £9.9 million a year lower;
    - measure 45, HMRC's tax-gap measures: tobacco duty up to £12.7 million and alcohol duty up to £4.8 million a year higher;
    - measure 95, low-value imports from October 2028: up to £0.8 million and £0.4 million a year lower.
    
    The OBR still assumes rising duty rates. It attributes alcohol's receipts growth to "increases to the duty rate that offset an assumed average 1 per cent decline in alcohol consumption in each year" (paragraph 4.43). For tobacco it says "higher duty rates only partly offset falling consumption" (paragraph 4.44). So the OBR forecasts the clearances along its consumption trend, under its own path of rate rises.
    
    The dataset projection reads no duty rate (`uprating_input_paths`), so when a reform changes alcohol or tobacco duty rates, the model moves receipts through the rates alone and leaves quantities where they were. It applies no consumption response, as in a static costing, and a test checks it.
    
    ### Projected duty on one constructed household
    
    A constructed household, not survey data, buys 300 litres of 5% beer, 60 of 4.5% cider, 20 of 7% sparkling cider, 80 of 12% wine, 10 of 40% spirits and 15 of 5% other fermented drinks a year. Each week it buys 70 cigarettes at £16 a pack, 25 g of hand-rolling tobacco, 5 g of cigars, 3 g of other tobacco and 4 g of heated tobacco. Its duty from the 2024 inputs, on #2761 at d08fbb6f1 (quantities flat) and on this branch (quantities per head):
    
    | Model year | Alcohol duty, flat (£) | Alcohol duty, this PR (£) | Change | Tobacco duty, flat (£) | Tobacco duty, this PR (£) | Change |
    | --- | ---: | ---: | ---: | ---: | ---: | ---: |
    | 2024 | 809.16 | 809.16 | 0.0% | 2,435.77 | 2,435.77 | 0.0% |
    | 2025 | 828.98 | 804.23 | -3.0% | 2,568.19 | 2,251.62 | -12.3% |
    | 2026 | 853.88 | 818.22 | -4.2% | 2,785.28 | 2,215.55 | -20.5% |
    | 2027 | 853.88 | 801.76 | -6.1% | 2,921.07 | 2,114.66 | -27.6% |
    | 2028 | 853.88 | 795.21 | -6.9% | 2,921.07 | 1,937.37 | -33.7% |
    | 2029 | 853.88 | 789.18 | -7.6% | 2,921.07 | 1,779.19 | -39.1% |
    | 2030 | 853.88 | 779.57 | -8.7% | 2,921.07 | 1,674.52 | -42.7% |
    | 2031 | 853.88 | 770.34 | -9.8% | 2,921.07 | 1,569.66 | -46.3% |
    | 2032 | 853.88 | 768.65 | -10.0% | 2,921.07 | 1,566.21 | -46.4% |
    
    ### Gap to the OBR's receipts forecast
    
    We put the UK's 2024-25 clearances (March 2026 Tables 3.11 and 3.12) in a second constructed household, also not survey data, at weight 1, so its weighted duty covers the whole UK. We assume:
    
    - beer and cider all as 4% beer, with no draught relief;
    - wine and other fermented products as 12% wine;
    - spirits at 40%;
    - other tobacco products as other smoking tobacco;
    - cigarettes at the default price.
    
    We compare its duty with the OBR's alcohol receipts by group (accrued, Tables 3.12 and 4.12, rows 16 to 18) and tobacco duties (cash, Tables 3.8 and 4.8, row 27).
    
    | Duty | 2024-25 | 2026-27 | 2031-32 | 2031-32, quantities flat (#2761 at d08fbb6f1) |
    | --- | ---: | ---: | ---: | ---: |
    | Beer and cider | +22.4% | +22.9% | +6.3% | +19.3% |
    | Wine | +2.5% | +1.1% | -12.5% | -3.5% |
    | Spirits | -0.2% | +1.8% | -12.0% | -4.8% |
    | Alcohol, total | +7.6% | +7.9% | -6.8% | +2.8% |
    | Tobacco | +1.9% | -0.4% | -18.0% | +54.6% |
    
    The model's alcohol duty exceeds the OBR's receipts by 7.6% in 2024-25 and 7.9% in 2026-27, then by 3.5% in 2027-28 and 0.3% in 2028-29. It first falls short in 2029-30, by 2.5%, and by 6.8% in 2031-32. Its tobacco duty exceeds the OBR's 2024-25 receipts by 1.9%, falls short by 0.8% in 2025-26 and 0.4% in 2026-27, and then by more each year, to 18.0% in 2031-32.
    
    - In 2024-25, charging all beer and cider at the beer rate with no draught relief lifts that line 22.4% above its receipts. On a real dataset the model charges each household's own product mix, such as the one PolicyEngine/microcosm#1359 will supply.
    - After 2026-27 the model holds its current-law rates: alcohol at its 1 February 2026 values, tobacco at its 1 October 2026 values and the default cigarette price at its 2024 average. The OBR assumes rising rates. Quantities move with the clearances in both, so the gap moves with duty per unit cleared:
    
      | | Model duty per unit, 2031-32 vs 2024-25 | OBR receipts per unit, 2031-32 vs 2024-25 |
      | --- | ---: | ---: |
      | Alcohol (per hectolitre of alcohol) | +5.8%: +5.7% by 2026-27, then flat but for product mix | +22.1% |
      | Tobacco (volume index weighted by 2024-25 duty) | +19.2%: +19.0% by 2027-28, after the October 2026 rise, then flat but for product mix | +48.2% |
    
    - On #2761, with quantities flat, the model charges later rates on 2024-25 volumes, and tobacco duty ends 54.6% above the forecast.
    
    ## Invariants
    
    These hold for every input:
    
    1. **Each index stays positive.** For every series the importer divides a ratio of positive levels by one plus population growth, so each stays above -100%. Each index starts at 1 in 2024 and stays positive to 2039. The importer refuses a series whose five-decimal index would reach zero, in `compute_receipts_growth`, in `per_head_growth` and through #2761's `check_index_stays_positive`.
    2. **Quantity = base × compounded growth.** Dataset projection multiplies each 2024 quantity by its series' compounded growth. The property checks 2024–2032 to 1e-12 relative, compares the compounded factor with the five-decimal index to 1e-4 relative, and checks Core against that index to 1e-6 relative. After 2039, Core holds quantity and weight indices fixed, while explicit dataset extension continues the per-head and population growth.
    3. **Weighted totals follow the clearances.** In every year, (1 + series) × (1 + population growth) equals `1 + aggregate clearance growth` to within 5e-7 × (1 + population growth), and equals 1 after the forecast. Weights grow by population alone.
    4. **Per-head growth rounds once.** `per_head_growth` divides `1 + aggregate growth` before rounding by `1 + population growth`, subtracts one, and rounds once. For any growth and population growth, the product `(1 + per-head value) × (1 + population growth)` matches `1 + aggregate growth` within the stated tolerance, 5e-7 × (1 + population growth). A series that holds a duty's base, as #2761's gambling series do, grows per head by that base's growth, not its receipts'.
    5. **The importer reads a clearance line only from text someone has read, in the OBR's layout.** A property puts one text in a real clearance table of any of the three releases: in an empty cell of any row from the title through the table's end, in any column, or on a new row. For any text outside the table's list, drawn from `st.text()` or from unit-shaped strings, the importer refuses every line of the table. It names the cell, or finds no line or table where the text took a label's or the title's place. A listed text other than a line's label or a marker, outside the lines, stops every line when it lands in the fiscal-year row, and stops hand-rolled or other tobacco when it lands between that part and the non-cigarette total's row. Anywhere else the importer reads every line as before. An example test, independent of the draws, inserts a row that holds only the non-cigarette total's label directly above "of which:" in each release's tobacco clearances, and the importer refuses both parts and reads cigarettes as before. A second property splits any text of characters from U+0020 to U+D7FF into one to four formatting runs, with or without a phonetic run. The reader returns the runs' whole text and none of the phonetic run's, from an inline string and from a shared string alike. The importer stops a line with any cell beside its label and values, and stops every line of a table whose sheet repeats its heading. A test checks that each list equals the texts in the three releases' tables, so no unread or stale entry survives.
    6. **A duty-rate reform moves receipts by the rate alone.** Scaling every alcohol and tobacco rate by k in a year from 2027-28 leaves every projected quantity bit-identical and multiplies each duty by k. The uprating fingerprint stays unchanged, because the projection reads no duty rate.
    7. **A rerun of the importer changes nothing until population changes.** Rerunning it on the committed file with the real October 2026 and March 2026 tables leaves the file byte-identical. Editing `ons.population` without rerunning it changes none of the 13 per-head series, so the six clearance series and seven gambling bases keep the values the importer wrote for the old population. Raising 2026 population growth from 0.2% to 0.3% then leaves weighted quantities 1.000998 times the clearances from 2026 on, and the byte-for-byte test fails. Rerunning the importer rewrites each per-head series' 2026 value and nothing else.
    
    ## Test plan
    
    - [x] For policy changes: YAML tests whose expected values come from an external source. Not applicable: this projects data inputs from an OBR forecast and changes no rule. The new tests pin the OBR's published clearances (receipts Tables 3.11, 3.12, 4.11 and 4.12).
    - [x] `make format`: run as `uvx ruff@0.17.2 format --check .` and `uvx ruff@0.17.2 check .` on the whole repository, `uvx ruff@0.17.2 check --select F811,F821 policyengine_uk`, and `git diff --check`, all clean at `8f3f0f396`.
    - [x] Changelog fragment `changelog.d/2596.fixed.md`
    
    **New tests:**
    
    - `test_excise_quantity_projection.py`: 10 tests, 2 of them Hypothesis property tests (projection and reform).
    - `test_import_obr_forecasts.py` gains 33 tests over #2761, including four Hypothesis properties: rendered clearances, per-head rounding, one arbitrary text in a real clearance table, and a text in formatting runs. One parametrized test edits the real October 2026 tables in 33 ways. It changes units and scales, product labels, listed texts, headings, marker and year rows, and cells that should hold numbers. Another writes a note into the real Table 4.11 as two formatting runs, in three ways, each with a first run that would pass alone. A third moves rows of the real Table 4.11 in three ways that keep every text on the list and put the two tobacco parts under the cigarette label. A fourth inserts a row that holds only "Non-cigarette clearances ('000s kg)" directly above "of which:" in each of the three releases' tobacco clearances. One test repeats Table 4.12's heading below the alcohol receipts forecasts, and another fills the empty cell above their title with a phonetic run that spells it. We added clearance tables to the synthetic workbooks, and the byte-for-byte test covers the clearance series.
    
    **Results (local, `uv sync --frozen --extra dev`, Hypothesis 6.170.11, `-n 4`, `HUGGING_FACE_TOKEN` and `POLICYENGINE_UK_DEFAULT_DATASET` unset):** I ran every row below at `8f3f0f396` or at `8d9763345`, one file at a time; `8f3f0f396` changes only three lines of the economic assumptions README after `8d9763345`. Before the change, at `7555d402d`, the same environment ran `test_import_obr_forecasts.py` to 192 passed and 1 failed in 113 seconds: `test_any_text_no_one_has_read_stops_a_real_clearance_table` drew a row that holds only the non-cigarette total's label above "of which:", which the part check accepted. Between `7555d402d` and `8f3f0f396` only the importer, its tests, `test_excise_quantity_projection.py` (a fixture that clears its cache), the economic assumptions README and `docs/engineering/excise-duties.md` change.
    
    | File | Head | Result |
    | --- | --- | --- |
    | Importer on the real tables, through `compute_receipts_growth` and `render_receipts_growth_yaml` | `8d9763345` | March 2026 over March 2025 reads all 17 series, 13 of them per head; October 2026 over March 2026 reads 17, 13 of them per head, and rewrites `yoy_growth.yaml` byte for byte (120,408 bytes, SHA-256 `846cbe78c74ff9a1c94ce39c5e4b8bf275645b78943d3c2d84dda830099e24a8`) |
    | `test_import_obr_forecasts.py` | `8f3f0f396` | 196 passed, 0 failed, in one run of 112 seconds |
    | `test_excise_quantity_projection.py` | `8d9763345` | 10 passed, in 124 seconds |
    | `code_health` (the whole directory) | `8f3f0f396` | 3,069 passed, in 226 seconds |
    | `code_health/test_changelog_fragments.py` | `8d9763345` | 103 passed, with `towncrier` installed |
    | `code_health/test_uprating_declarations.py` | `8d9763345` | 8 passed |
    | `code_health/test_cached_arrays_not_written_in_place.py` | `8d9763345` | 36 passed |
    | `test_parameter_descriptions.py` | `8d9763345` | 24 passed |
    | `test_gambling_duty_base_projection.py` | `8d9763345` | 23 passed |
    | `test_population_vintage.py` | `8d9763345` | 95 passed |
    | `test_projection_horizon.py` | `8d9763345` | 14 passed |
    | `policy/baseline/gov/hmrc/alcohol_duty/alcohol_duty.yaml` | `8d9763345` | 15 passed |
    | `policy/baseline/gov/hmrc/tobacco_duty/tobacco_duty.yaml` | `8d9763345` | 12 passed |
    | `policy/baseline/gov/hmrc/excise_integration.yaml` | `8d9763345` | 2 passed |
    
    I left these files to CI's full suite: `test_uprating_inputs_reach_data.py`, `test_excise_schedules.py`, `test_energy_dataset_projection.py`, `test_corporate_receipts_statistics.py`, `test_long_run_joins.py`, `test_lagged_series.py`, `test_dated_reform_routes.py`, `test_ni_metadata_compat.py`, `test_rail_usage_uprating.py` and `test_road_fuel_volume_uprating.py`. The repository's checkers pass at `8f3f0f396`: `.github/check_changelog_fragments.py` and `.github/check_fix_pr.py` (on this body).
    
    **Differential against #2761 at d08fbb6f1:** I projected two constructed datasets in this branch and in #2761: two households at weights 700 and 300 buying every product, through 2074, and the national construct above, to 2032.
    
    - Stored columns differ only in the eleven quantity inputs, from 2025 to 2074 (3,264 cell values compared).
    - I compared 16 outputs per year to 2032 (288 values). The two duties move, and `household_tax`, `gov_tax`, `household_net_income` and `gov_balance` move by the duty change, within £0.003. Real household net income, equivalised household net income and net income after housing costs move with net income. Income tax, NI, benefits, market income, HBAI net income, VAT and gambling duty stay identical. Other outputs that read net income move too; I compared only these 16.
    - For the first household alone in 2025:
    
      | Output | #2761 | This PR |
      | --- | ---: | ---: |
      | Real household net income | £21,031.73 | £21,383.67 |
      | Equivalised household net income | £30,443.92 | £30,953.36 |
      | Net income after housing costs | £13,998.53 | £14,339.85 |
    
    - Through the dataset, the weighted volume of all 11 inputs follows the aggregate clearances in all 51 years from 2024 to 2074, to a maximum relative error of 2.06414×10⁻⁶ (561 checks). Through Core from a 2032 projection, it does so to 1.40668×10⁻⁵ (209 checks).
    - This branch adds 12 parameters to the tree, the six series and their indices, and changes or removes none. It changes the `uprating` of the eleven inputs and no other variable.
    
    The differential ran at the branch's earlier heads; after them the branch changes the importer's checks, its tests and docs, and no series, input or parameter.
    
    **Mutations (scratch copies):** I applied each mutation alone and ran the tests in its row. I ran the older importer mutations through a harness that loads the importer and runs its test file without importing the rest of the package; unmutated, the importer passes all 43 clearance tests and all 7 per-head tests there. The clearance mutations ran at `65f3b3f4a`. Later commits add the reader's run joining and row numbers, the repeated-heading refusal at the top of `validate_clearance_table`, the spec's `part_of_total` and the two layout checks that run after it, and change the spec's error message, comments and docstrings; the walk and the checks those mutations change keep their code. The per-head mutations ran at `ade1df1ce` and the three projection mutations at `f0f3d37e4`; no later commit changes the code they mutate or the assertions of the tests they ran. Five mutations of the XLSX reader and the heading check, and the walk mutation, ran at `433b20fb7` through the test file itself. The reader and heading mutations ran on the five tests that cover them, the list test, the real-pair test and the phonetic-run test (8 tests); the walk mutation on the 33 edits and the arbitrary-text property (34 tests). Unmutated, all 41 tests of those selections pass. The two layout mutations, the phonetic-run join and the total's-figures mutation ran at `8d9763345` on 13 tests: the three layouts, the three new total's-label tests, the arbitrary-text property, the three formatting-run edits, the formatting-run property, the phonetic-run test and the list test. Unmutated, all 13 pass.
    
    | Mutation | Tests run | Failed |
    | --- | --- | --- |
    | Drop the refusal of a repeated heading | 8 tests | 1: `test_a_repeated_clearance_heading_stops_the_run` |
    | Read only the first run of an inline string | 8 tests | 5: the three formatting-run edits, the formatting-run property, and `test_a_phonetic_run_cannot_end_a_clearance_table`, whose empty first run the mutation reads as no value |
    | Read only the first run of a shared string, and every run of an inline one | 8 tests | 2: the formatting-run property and `test_the_read_texts_are_the_texts_of_the_three_releases`, since the real alcohol footnotes keep their numbers ("1", "2") in a run of their own |
    | Name the repeated heading's rows by their place in the list, not their number on the worksheet | 8 tests | 1: `test_a_repeated_clearance_heading_stops_the_run` |
    | Join every `<t>` of a string, a phonetic run's included (the `.//main:t` join), at `8d9763345` | 13 tests | 2: `test_a_string_reads_whole_in_any_formatting_runs` and `test_a_phonetic_run_cannot_end_a_clearance_table` |
    | Once the check meets the fiscal-year row, skip every later cell under a year column | 34 tests | 3: the edits that put a note in E40 and "nan" in D5, and the arbitrary-text property |
    | Drop the fiscal-year-row check, at `8d9763345` | 13 tests | 3: both layouts that put the cigarette label in a row of years, and the arbitrary-text property |
    | Drop the part-of-total check, at `8d9763345` | 13 tests | 5: the layout that moves the cigarette line above "of which:", the three total's-label tests and the arbitrary-text property |
    | Accept a total's row that holds the label and no numbers, at `8d9763345` | 13 tests | 4: the three total's-label tests and the arbitrary-text property |
    | Check only each row's first text (the title row's second) | 43 clearance tests | 8, including the kg cell beside a real line, a unit in the "of which:" row, text where a number belongs and the property |
    | End the table one row early | 43 clearance tests | 19, including every note added below the real Table 4.11 and the text in Table 4.12's last row |
    | Skip the alcohol table | 43 clearance tests | 11 |
    | Skip the tobacco table | 43 clearance tests | 29 |
    | Accept a listed text with more words | 43 clearance tests | 19, including both extra-words edits |
    | Skip the title's row | 43 clearance tests | 2: the title-row edit and the property |
    | Skip the title, marker and year rows | 43 clearance tests | 4: the three heading-row edits and the property |
    | Drop the unit-cell requirement | 43 clearance tests | 1: `test_a_real_table_without_its_unit_cell_refuses_the_lines_it_describes` |
    | End the table at any title | 43 clearance tests | 1: `test_the_alcohol_receipts_table_stays_outside_the_clearance_table`; the title-shaped note, added later, fails too |
    | Skip any cell that contains a number, not only one that holds a number | 43 clearance tests | 16 |
    | Skip the check | 43 clearance tests | 38 |
    | Make `growth_in_effect` return zero before population's first entry | 7 per-head tests | 1: `test_population_growth_before_its_first_entry_is_that_entry` |
    | Skip `per_head_growth`'s index check | 7 per-head tests | 1: `test_per_head_growth_refuses_an_index_that_rounds_to_zero` |
    | Drop `per_head_growth`'s division by population | 7 per-head tests | 7 |
    | Divide one plus the rounded growth, not the growth before rounding | 7 per-head tests | 5, including `test_real_remote_gaming_base_per_head_rounds_once` |
    | Commit clearance series that count population twice | 3 projection tests | 3 |
    | Map cider to the wine series | 4 projection tests | 3, before the run's 10-minute cap stopped the fourth |
    | Take cigarettes out of the uprating map and drop their index | 4 projection tests | 2, before the run's 10-minute cap stopped the other two |
    
    **Overlap:**
    
    - This branch merges #2761 at `0037137ad`. The two #2761 commits after `d08fbb6f1`, the differential's base above, change documentation and one importer comment. At `8f3f0f396`, `git merge-tree` finds no conflict with `main` at `2da212abc` or at `46a60678f`, or with #2761 at `0037137ad`. At `7555d402d` it found none with #2751 (`b5de2c789`), #2756 (`c34662736`) or #2763 (`6ca062e68`).
    - The `fix-2721-import-nlw-forecast` branch (`17dbd906755e11c3d13d4264aef0ad46fbf12587`, no PR yet) conflicts with it at `8f3f0f396` in the economic assumptions README and `test_import_obr_forecasts.py`; the importer itself merges without conflict. In the README, keep this branch's clearance text at the end of step 6, then the NLW branch's national living wage step as step 7 and the horizon step as step 8. In the tests, keep both branches' imports, fixtures and tests. The two branches' last Hypothesis functions, `test_per_head_growth_times_population_growth_is_the_series_growth` here and `test_the_importer_projects_rates_as_the_model_does` there, share their `@settings` and `@given` lines as merge context, so give each function its own copy.
    
    ## Reproducer
    
    Reproducer: policyengine_uk/tests/test_excise_quantity_projection.py::test_weighted_quantities_follow_the_clearances
    
    On `origin/main` (2da212abc) it fails at the first projected year: weighted cigarette quantities grow by population (1.0033), where the OBR's clearances fall to 0.9003.
    
    axiom: n/a: this projects data inputs from an OBR forecast and changes no rule.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    

    Generated by Claude Code

  6. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    Ready for hub review

    Head: 8f3f0f396e9318de59032110aaa16351273f9ac3 on branch claude/fix-2596-hypothesis-check-zr68rb. It fast-forwards fix-2596-excise-quantity-indices from 7555d402d7295aec4fa0e1df23434319150a3d7a with three commits (45bd632e4, 8d9763345, 8f3f0f396). This session may push only to its own claude/... branch, so I didn't move fix-2596-excise-quantity-indices. Fast-forward it to this head. I opened no PR.

    Before starting: the last push to fix-2596-excise-quantity-indices was the reviewed head 7555d402d, 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/main has moved past the held 2da212abc to 46a60678f (#2751 and #2757 merged). I checked merges against both.

    Environment: uv sync --frozen --extra dev; Hypothesis prints 6.170.11; HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET unset. At the base 7555d402d, uv run pytest policyengine_uk/tests/test_import_obr_forecasts.py -n 4 -q -o xfail_strict=true gave 1 failed, 192 passed in 113.45 s. The one failure is test_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

    1. P1, the total's row. policyengine_uk/utils/import_obr_forecasts.py:1402–1407 now adds or 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.

      • The docstring (: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".
      • README :527–529 and docs/engineering/excise-duties.md:298–300 now 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.md line 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."
      • Invariant 5's sentence on refusals between a part and the total's row is now true of the code. I added a sentence on the new example test.
    2. 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 exact not_under_total message for hand-rolled and other tobacco, and that cigarettes read unchanged. It uses no Hypothesis.

      • Revert check: in a scratch copy with 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.
    3. 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.

      • Judgment call: I didn't quote the sheet's own capitalisation. When the row is missing the importer has no sheet cell to quote, and the message now says outright that it gives the label as receipts_label normalises it.
    4. 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 at 45bd632e4. 8f3f0f396 changes only README line breaks after that.

      • Drop the fiscal-year-row check: 3 failed (second-year-row, year-header and the property).
      • Drop the part check: 5 failed (of-which layout, all 3 new tests and the property).
      • Restore the .//main:t join: 2 failed (test_a_string_reads_whole_in_any_formatting_runs and the phonetic-run test).
      • Accept a total's row with no numbers: 4 failed (all 3 new tests and the property).

      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 433b20fb7 rows (reader, heading, walk) keep their head.

    5. P3-2 and the results table. Whole file in one run at the final head 8f3f0f396 under the locked environment: 196 passed, 0 failed, 112.08 s (-n 4), and 196 passed in 120.51 s at 8d9763345. I reran every row of the results table at 8d9763345, and the test file and the whole code_health again at 8f3f0f396; each row names its head:

      • the importer on the real tables: 17/13 series for both pairs, and a byte-for-byte rebuild of 120,408 bytes with SHA-256 846cbe78…99e24a8;
      • test_excise_quantity_projection.py: 10 passed in 124 s;
      • whole code_health: 3,069 passed in 251 s (226 s at 8f3f0f396);
      • 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;
      • gambling: 23 passed;
      • population vintage: 95 passed;
      • projection horizon: 14 passed;
      • YAML: alcohol 15, tobacco 12, excise integration 2 passed.

      The sentence above the table now names the base's 1 failed/192 passed, lists the files changed between 7555d402d and 8f3f0f396, and says 8f3f0f396 changes only three README lines after 8d9763345.

    6. Merges (git merge-tree --write-tree --name-only 8f3f0f396 <sha>):

      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

    • code_health failed at the base. test_test_module_caches_released.py::test_every_test_module_cache_is_released failed at 7555d402d (and at 45bd632e4). The branch's caches real_clearance_rows and current_law were never cleared, so CI's code_health would have gone red. Commit 8d9763345 adds module-scoped autouse fixtures that clear them: test_import_obr_forecasts.py:3556–3561 and test_excise_quantity_projection.py:85–90. The checker now passes (5 passed), and so does the whole directory (3,069 passed). This change is why test_excise_quantity_projection.py was rerun.
    • README wrap: 8f3f0f396 rewraps 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.
    • Body: "gains 30 tests" is now 33. The lint bullet now says I ran ruff on the whole repository. .github/check_fix_pr.py passes on the new body at 8f3f0f396 ("Changed executable tests: 4"). I left PR_TITLE.txt and 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

  7. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    Ready for hub review

    Verdict: REQUEST_CHANGES at 8f3f0f396e9318de59032110aaa16351273f9ac3 (branch claude/fix-2596-hypothesis-check-zr68rb, delta from 7555d402d7295aec4fa0e1df23434319150a3d7a).

    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_health failure at the base.

    There's one P2. Three refusals in validate_clearance_part that 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_part have 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):

    Mutant (import_obr_forecasts.py:1402–1407, :1397) Result
    Drop or not all(NUMBER_RE.fullmatch(text) for text in total[1:]), so any text after the total's label passes Survived: 196 passed
    of_which != ["of which:"] → of_which[:1] != ["of which:"], so the "of which:" row may hold other cells Survived: 196 passed
    Drop empty rows from above, so empty rows may sit between the total, "of which:" and the parts Survived: 196 passed

    At 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_rows and find_receipts_line:

    • "Cigarette clearances (million sticks)" in column C of the "of which:" row;
    • the same text in column N of the total's row;
    • an empty row inserted directly above "of which:";
    • an empty row inserted directly below it.

    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:

    TOTAL_LAYOUTS = {
        # A row that holds the total's label and none of its figures.
        "label-without-figures": lambda rows, of_which: rows.insert(
            of_which, {"B": "Non-cigarette clearances ('000s kg)"}
        ),
        # The total's row holds another text after its figures.
        "text-on-total-row": lambda rows, of_which: rows[of_which - 1].update(
            {"N": "Cigarette clearances (million sticks)"}
        ),
        # The "of which:" row holds another cell.
        "text-on-of-which-row": lambda rows, of_which: rows[of_which].update(
            {"C": "Cigarette clearances (million sticks)"}
        ),
        "empty-row-above-of-which": lambda rows, of_which: rows.insert(of_which, {}),
        "empty-row-below-of-which": lambda rows, of_which: rows.insert(of_which + 1, {}),
    }
    
    
    @pytest.mark.parametrize("layout", sorted(TOTAL_LAYOUTS))
    @pytest.mark.parametrize("release", ["March 2025", "March 2026", "October 2026"])
    def test_a_part_needs_its_total_directly_above_of_which(release, layout):
        """Change the rows over the parts of the real tobacco clearances in one
        way. The importer refuses hand-rolled and other tobacco with the part
        check's message and reads cigarettes as before."""
        _, sheet = REAL_CLEARANCE_TABLES[release, TOBACCO_CLEARANCES]
        real, start, end, _ = real_clearance_rows(release, TOBACCO_CLEARANCES)
        (of_which,) = [
            i for i in range(start, end) if receipts_label(real[i].get("B")) == "of which:"
        ]
        rows = [dict(row) for row in real]
        TOTAL_LAYOUTS[layout](rows, of_which)
        ...  # the existing loop over TOBACCO_LINES, unchanged

    In all three releases, column N of the total's row and column C of the "of which:" row are empty (real_clearance_rows gives None or 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

    1. 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 prints Table 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:

      raise ValueError(
          f"{where} lacks the layout that gives the {'.'.join(spec.path)} line "
          'its unit: an "of which:" row with no other cell directly above the '
          "line and the total's other parts, and directly above that row the "
          f"non-cigarette total's row, which holds its label ({spec.unit_cell!r}, "
          "as receipts_label gives it), its figures and nothing else. The line "
          "takes its unit from that total's label, so the importer refuses any "
          "other layout."
      )

      Update not_under_total (test_import_obr_forecasts.py:3876–3887) to match.

    2. 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 head 0037137ad, 114 at 7555d402d and 115 at 8f3f0f396. git diff 0037137ad 8f3f0f396 -- policyengine_uk/tests/test_import_obr_forecasts.py adds 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.py gains 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).

    3. Body, overlap note. main now sits at 47bda0a7a9f06b59bb262e9b4dffc05ab2428b5d. git merge-tree --write-tree --name-only 8f3f0f396 47bda0a7a merges cleanly (tree 5eec41e5c…). Name it beside 2da212abc and 46a60678f, or replace those with it.

    Checked and sound

    All runs below were in detached worktrees at each SHA, after uv sync --frozen --extra dev, with HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET unset, on 4 cores.

    • Environment. uv run python -c "import hypothesis; print(hypothesis.__version__)" prints 6.170.11 in both worktrees.

    • P1 (previous round): closed.

      • At 7555d402d: uv run pytest policyengine_uk/tests/test_import_obr_forecasts.py -n 4 -q -o xfail_strict=true gives 1 failed, 192 passed in 91.35 s. The failure is test_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).
      • At 8f3f0f396: 196 passed in 83.46 s.
      • validate_clearance_part (:1364–1415): total[:1] != [spec.unit_cell], not total[1:] and not 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) and ClearanceSeriesSpec's docstring (:730–733) say so.
      • One edge, recorded but not a finding: a row with the label and a single stray number ({"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_total fails 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 exact not_under_total message 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.

      Mutant Result
      Drop the fiscal-year-row check Killed, 3 failed: cigarette-unit-second-year-row, cigarette-unit-year-header, the property
      Drop the part check Killed, 5 failed: cigarette-unit-of-which, the 3 new cases, the property
      Restore the .//main:t join Killed, 2 failed: test_a_phonetic_run_cannot_end_a_clearance_table, test_a_string_reads_whole_in_any_formatting_runs
      Accept a total's row with no numbers Killed, 4 failed: the 3 new cases, the property
      Mine: total[:1] != [unit_cell] → unit_cell not in total Survived (13 passed; 196 on the whole file). It's equivalent: with only numbers allowed after the first text, the label can only sit first
      Mine: the three in P2 Survived, 13 passed and 196 on the whole file

      These match the body's four mutation rows and counts.

    • Cache fixtures. code_health/test_test_module_caches_released.py at 7555d402d: 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 whole code_health directory at the head: 3,069 passed in 182 s, which includes test_changelog_fragments.py, test_uprating_declarations.py and test_cached_arrays_not_written_in_place.py. Each fixture's comment says what it does. test_excise_quantity_projection.py passes 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.

      • README :525–530 and docs/engineering/excise-duties.md:298–300 say "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.
      • The README rewrap leaves one short line ("and values. When a sheet repeats a"). Markdown renders it the same, and it keeps the fix-2721 conflict unchanged.
      • Invariant 5's sentences on the total's row and the example test are true of the code, subject to the P2 on what the property actually draws.
      • The results-table sentence lists exactly the five files git diff --stat 7555d402d 8f3f0f396 shows.
    • Rebuild. I called compute_receipts_growth and render_receipts_growth_yaml directly, 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 under per_capita. October 2026 over March 2026 renders 120,408 bytes, SHA-256 846cbe78c74ff9a1c94ce39c5e4b8bf275645b78943d3c2d84dda830099e24a8, equal to the committed yoy_growth.yaml.

    • Merges (git merge-tree --write-tree --name-only 8f3f0f396 <sha>):

      • main 2da212abc: clean.
      • 46a60678f: clean.
      • Current main 47bda0a7a: clean.
      • Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761 head 0037137ad (from gh api …/pulls/2761, open): clean, and it's an ancestor of the head.
      • fix-2721-import-nlw-forecast head 17dbd906755e… (from gh api …/branches/…): conflicts in policyengine_uk/parameters/gov/economic_assumptions/README.md and policyengine_uk/tests/test_import_obr_forecasts.py only, as the body says.
    • Lint. uvx ruff@0.17.2 format --check and check are clean on the three changed Python files, and git diff --check 7555d402d 8f3f0f396 is clean.

    What I couldn't check

    • The results-table rows I didn't rerun: test_parameter_descriptions.py, gambling, population vintage, projection horizon and the three YAML files. The delta touches none of their inputs.
    • The differential against Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761, the older mutation rows and the CI-only files, which the body attributes to earlier heads.
    • .github/check_fix_pr.py on the body, and the whole-repository ruff run: I linted only the changed files.
    • The hand-off's 112 s and 120 s timings, which are from the author's machine; mine were 83 s at the head.

    Generated by Claude Code

  8. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.


    Generated by Claude Code

  9. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    PR_TITLE.txt

    Grow alcohol and tobacco quantities with OBR clearances per head
    

    Generated by Claude Code

  10. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    PR_BODY.md

    Fixes #2596
    
    ## What and why
    
    Alcohol and tobacco duty read eleven household quantity inputs, and none of them had an uprating index. Dataset projection therefore kept the base-year quantities in every later year, and the model charged each year's duty rates on them. In its October 2026 EFO the OBR forecasts 2031-32 cigarette clearances 47.2% below its March 2026 EFO's 2024-25 outturn. Household weights also grow by population, 1.54% over those years. A flat input's weighted total would therefore end 92.4% above the forecast clearances for cigarettes. The other lines would end 79.9% above (hand-rolled tobacco), 62.7% (other tobacco products), 12.2% (beer and cider), 10.3% (wine) and 8.3% (spirits).
    
    This PR stacks on #2761 and adds six per-head clearance series, one for each product group the OBR forecasts:
    
    | Inputs | Series (`gov.economic_assumptions.yoy_growth.obr.per_capita.`) | OBR line |
    | --- | --- | --- |
    | `cigarettes_per_week` | `tobacco_clearances.cigarettes` | Table 4.11, "Cigarette clearances (million sticks)" |
    | `hand_rolling_tobacco_grams_per_week` | `tobacco_clearances.hand_rolling_tobacco` | Table 4.11, "Hand rolled tobacco clearances" |
    | `cigars_grams_per_week`, `other_tobacco_grams_per_week`, `heated_tobacco_grams_per_week` | `tobacco_clearances.other_tobacco_products` | Table 4.11, "Other tobacco products" |
    | `beer_litres`, `cider_litres`, `sparkling_cider_litres` | `alcohol_clearances.beer_and_cider` | Table 4.12, "Beer and cider" |
    | `wine_litres`, `other_fermented_litres` | `alcohol_clearances.wine` | Table 4.12, "Wine" (includes other fermented products) |
    | `spirits_litres` | `alcohol_clearances.spirits` | Table 4.12, "Spirits" (includes spirit-based ready-to-drinks) |
    
    - **Importer.** `import_obr_forecasts.py` adds `ClearanceSeriesSpec` and six `CLEARANCE_SERIES_SPECS` to `RECEIPTS_SERIES_SPECS`, so the run that refreshes the receipts series rewrites these too. A clearance spec takes no rates and no outturn of its own.
      - It finds each line by table title and row label, and links the outturn year to the previous EFO's outturn, as it does for the receipts series.
      - It interprets no words in the clearance tables. `CLEARANCE_TABLE_TEXTS` lists each text, other than titles, numbers and fiscal years, in the March 2025, March 2026 and October 2026 tables: 7 for the tobacco clearances and 9 for the alcohol clearance forecasts. It compares a cell's text in lower case and single spaces, without a footnote number after a label's last letter, as `find_receipts_line` reads row labels.
      - It reads the whole text of each cell. The reader takes shared and inline strings through one helper, `_string_text`, which joins a string's own `<t>` and the `<t>` of each formatting run, so a cell whose text sits in several runs reads whole. It leaves out the `<t>` of a phonetic run (`<rPh>`). `read_sheet_rows` also keeps each row's number on the worksheet (`SheetRow.number`). No cell in the three receipts releases changes value: they hold no inline strings and no phonetic runs, and the shared-string reader already joined every run.
      - Before it reads a line's values, it compares the text cells of the line's table with the list, in every column, from the table's title through its configured end: the row above a title `CLEARANCE_TABLE_ENDS` lists, or the sheet's last row. It skips the title cell, numbers and fiscal years. The OBR follows the alcohol clearances with the alcohol receipts forecasts, so the check leaves out their "£ billion" cell and note. The importer checks no cell above a table's title or below its end, and the economic assumptions README asks whoever runs it to read any note there.
      - In both EFOs it stops before writing anything when:
        - a cell holds any other text, such as a note that states another unit or scale, a line for another product or a unit column's heading. It names the table and each such cell, and asks whoever runs it to read the text, convert the table's figures to the listed units if it states another, and add the text to the list;
        - the table lacks the cell in which the OBR states a line's unit (`unit_cell`): "Cigarette clearances (million sticks)", "Non-cigarette clearances ('000s kg)" for both tobacco parts, or Table 4.12's "Note: All clearances are in thousands of hectolitres of alcohol.";
        - a text sits beside the years in the fiscal-year row over a clearance line, such as a unit column's heading or a line's label;
        - hand-rolled or other tobacco (`part_of_total`) does not sit in the OBR's layout (`validate_clearance_part`): an "of which:" row with no other cell directly above the line and the total's other parts, and directly above that row the non-cigarette total's row, which holds its label, its figures and nothing else. A row that holds the total's label and no figures does not count as the total, and an empty row on either side of "of which:" breaks the layout. The message names the whole layout the importer needs;
        - the sheet repeats the table's number and title. It names the rows, since it would check the text under one heading and could read a line under another;
        - it finds a cell beside a line's label and values, on any receipts or clearance line.
    
        Without these checks, if a note put the October 2026 cigarette forecasts in ten million sticks, with the figures divided by ten, the importer would read 2026-27 cigarette growth of -91.0% in place of -10.3%. If a second Table 4.12 heading below the alcohol receipts forecasts held the alcohol lines, with the spirits forecasts in ten thousand hectolitres, the importer would read 2026-27 spirits growth of -90.1% in place of -0.8%. If the OBR stated kg for other tobacco beside its line, the importer would read the line in the non-cigarette total's thousand kg, at growth of 999.0 for clearances that did not change.
    
        A table can keep every text on the list and still put a line under another unit. Two rearranged copies of the real October 2026 Table 4.11 put hand-rolled and other tobacco under the cigarette label: one moves the label beside a second row of years over them, and one moves the cigarette line directly above "of which:". Both halve the two parts' figures. Without the two layout checks, the importer would read 2025-26 growth of -58.6% for hand-rolled tobacco in place of -17.1%, and -56.8% for other tobacco in place of -13.6%. A phonetic run can also spell "4.12 Alcohol receipts forecasts" in a cell with no text of its own. A reader that kept its `<t>` would end the alcohol clearances at that cell, above a note in ten thousand hectolitres, and the importer would read the same -90.1% spirits growth.
      - The importer writes every series under `per_capita`, these six and #2761's seven gambling bases, through one `per_head_growth`, which computes `(1 + aggregate growth) / (1 + ons.population growth) - 1`. It divides one plus each year's growth before rounding (`ReceiptsGrowth.ratios`) by one plus that year's `ons.population` growth, subtracts one and rounds once to six decimals. It reads the population from the `yoy_growth.yaml` it rewrites. For a gambling base it divides the base's growth, receipts over the rate the model charges, whether the importer links the line to the previous EFO or to its own outturn.
      - After the forecast it holds the clearances flat, as `road_fuel_volume` holds road fuel volume.
    - **Series.** The importer writes the series in `yoy_growth.yaml` from the October 2026 tables over the March 2026 ones, and a test rebuilds the file byte for byte from the real tables.
    - **Inputs.** Each input declares its group's index (`gov.economic_assumptions.indices.obr.per_capita.*`). `uprating_indices.yaml` maps it to the series, so dataset projection to 2032 and Core's uprating after it use the same index.
    - **Fixtures.** We added sheets 3.11 and 3.12 (March 2025 and March 2026) and 4.11 and 4.12 (October 2026) to the real-table fixtures. Each kept sheet's XML and the shared strings match the originals byte for byte, and we list the originals' hashes in the fixture README.
    - **Docs.** We document the projection of alcohol and tobacco quantities in a new section of `docs/engineering/excise-duties.md`, with every source file, sheet, row and hash, and list the series and the refresh step in the economic assumptions README.
    
    Dataset projection keeps the strengths (`*_abv`), draught shares and `cigarette_price_per_pack` at their base-year values, since they measure ratios and a price, as the issue says.
    
    ### Why per head: how projection applies weights
    
    From `policyengine_uk/data/economic_assumptions.py`:
    
    - `extend_single_year_dataset` copies the base year into each later year.
    - `apply_single_year_uprating` multiplies each column `uprating_indices.yaml` lists by one plus its series' growth for that year.
    - The file lists `household_weight` under `ons.population`.
    
    So, for household i in year t, with population growth p(t) and quantity series g(t):
    
    - the weight is w(i, t) = w(i, t - 1) × (1 + p(t));
    - the quantity is q(i, t) = q(i, t - 1) × (1 + g(t));
    - the weighted total is Q(t) = (1 + p(t)) × (1 + g(t)) × Q(t - 1).
    
    With g(t) = (1 + c(t)) / (1 + p(t)) - 1, where c(t) is clearance growth, Q(t) = (1 + c(t)) × Q(t - 1), so the total follows the clearances. Growing quantities by c(t) itself would count population growth twice. `ons.population` grows 1.54% from 2024-25 to 2031-32, so totals grown that way would end 1.54% above the clearances. In the OBR's outturn, cigarette clearances fell 9.97%, from 13,578.2 million sticks in 2024-25 (March 2026 EFO) to 12,224.6 million in 2025-26 (October 2026 EFO). Population grows 0.33% in 2025, so the importer writes 12,224.6 / 13,578.2 / 1.0033 - 1 = -0.102653.
    
    By 2031-32 the indices (2024 = 1) stand at:
    
    | Group | Index |
    | --- | ---: |
    | Cigarettes | 0.520 |
    | Hand-rolled tobacco | 0.556 |
    | Other tobacco products | 0.615 |
    | Beer and cider | 0.891 |
    | Wine | 0.907 |
    | Spirits | 0.924 |
    
    ### Budget measures, and what they mean for reforms
    
    The OBR incorporates the Budget's measures in its October 2026 forecast (EFO paragraph 3.2). In policy Table 1.1 it lists no change to either duty rate. It lists three measures there that change the two duties' receipts, each by less than £13 million a year:
    
    - measure 19, Small Producer Relief from 1 February 2027: alcohol duty £1.5 million to £9.9 million a year lower;
    - measure 45, HMRC's tax-gap measures: tobacco duty up to £12.7 million and alcohol duty up to £4.8 million a year higher;
    - measure 95, low-value imports from October 2028: up to £0.8 million and £0.4 million a year lower.
    
    The OBR still assumes rising duty rates. It attributes alcohol's receipts growth to "increases to the duty rate that offset an assumed average 1 per cent decline in alcohol consumption in each year" (paragraph 4.43). For tobacco it says "higher duty rates only partly offset falling consumption" (paragraph 4.44). So the OBR forecasts the clearances along its consumption trend, under its own path of rate rises.
    
    The dataset projection reads no duty rate (`uprating_input_paths`), so when a reform changes alcohol or tobacco duty rates, the model moves receipts through the rates alone and leaves quantities where they were. It applies no consumption response, as in a static costing, and a test checks it.
    
    ### Projected duty on one constructed household
    
    A constructed household, not survey data, buys 300 litres of 5% beer, 60 of 4.5% cider, 20 of 7% sparkling cider, 80 of 12% wine, 10 of 40% spirits and 15 of 5% other fermented drinks a year. Each week it buys 70 cigarettes at £16 a pack, 25 g of hand-rolling tobacco, 5 g of cigars, 3 g of other tobacco and 4 g of heated tobacco. Its duty from the 2024 inputs, on #2761 at d08fbb6f1 (quantities flat) and on this branch (quantities per head):
    
    | Model year | Alcohol duty, flat (£) | Alcohol duty, this PR (£) | Change | Tobacco duty, flat (£) | Tobacco duty, this PR (£) | Change |
    | --- | ---: | ---: | ---: | ---: | ---: | ---: |
    | 2024 | 809.16 | 809.16 | 0.0% | 2,435.77 | 2,435.77 | 0.0% |
    | 2025 | 828.98 | 804.23 | -3.0% | 2,568.19 | 2,251.62 | -12.3% |
    | 2026 | 853.88 | 818.22 | -4.2% | 2,785.28 | 2,215.55 | -20.5% |
    | 2027 | 853.88 | 801.76 | -6.1% | 2,921.07 | 2,114.66 | -27.6% |
    | 2028 | 853.88 | 795.21 | -6.9% | 2,921.07 | 1,937.37 | -33.7% |
    | 2029 | 853.88 | 789.18 | -7.6% | 2,921.07 | 1,779.19 | -39.1% |
    | 2030 | 853.88 | 779.57 | -8.7% | 2,921.07 | 1,674.52 | -42.7% |
    | 2031 | 853.88 | 770.34 | -9.8% | 2,921.07 | 1,569.66 | -46.3% |
    | 2032 | 853.88 | 768.65 | -10.0% | 2,921.07 | 1,566.21 | -46.4% |
    
    ### Gap to the OBR's receipts forecast
    
    We put the UK's 2024-25 clearances (March 2026 Tables 3.11 and 3.12) in a second constructed household, also not survey data, at weight 1, so its weighted duty covers the whole UK. We assume:
    
    - beer and cider all as 4% beer, with no draught relief;
    - wine and other fermented products as 12% wine;
    - spirits at 40%;
    - other tobacco products as other smoking tobacco;
    - cigarettes at the default price.
    
    We compare its duty with the OBR's alcohol receipts by group (accrued, Tables 3.12 and 4.12, rows 16 to 18) and tobacco duties (cash, Tables 3.8 and 4.8, row 27).
    
    | Duty | 2024-25 | 2026-27 | 2031-32 | 2031-32, quantities flat (#2761 at d08fbb6f1) |
    | --- | ---: | ---: | ---: | ---: |
    | Beer and cider | +22.4% | +22.9% | +6.3% | +19.3% |
    | Wine | +2.5% | +1.1% | -12.5% | -3.5% |
    | Spirits | -0.2% | +1.8% | -12.0% | -4.8% |
    | Alcohol, total | +7.6% | +7.9% | -6.8% | +2.8% |
    | Tobacco | +1.9% | -0.4% | -18.0% | +54.6% |
    
    The model's alcohol duty exceeds the OBR's receipts by 7.6% in 2024-25 and 7.9% in 2026-27, then by 3.5% in 2027-28 and 0.3% in 2028-29. It first falls short in 2029-30, by 2.5%, and by 6.8% in 2031-32. Its tobacco duty exceeds the OBR's 2024-25 receipts by 1.9%, falls short by 0.8% in 2025-26 and 0.4% in 2026-27, and then by more each year, to 18.0% in 2031-32.
    
    - In 2024-25, charging all beer and cider at the beer rate with no draught relief lifts that line 22.4% above its receipts. On a real dataset the model charges each household's own product mix, such as the one PolicyEngine/microcosm#1359 will supply.
    - After 2026-27 the model holds its current-law rates: alcohol at its 1 February 2026 values, tobacco at its 1 October 2026 values and the default cigarette price at its 2024 average. The OBR assumes rising rates. Quantities move with the clearances in both, so the gap moves with duty per unit cleared:
    
      | | Model duty per unit, 2031-32 vs 2024-25 | OBR receipts per unit, 2031-32 vs 2024-25 |
      | --- | ---: | ---: |
      | Alcohol (per hectolitre of alcohol) | +5.8%: +5.7% by 2026-27, then flat but for product mix | +22.1% |
      | Tobacco (volume index weighted by 2024-25 duty) | +19.2%: +19.0% by 2027-28, after the October 2026 rise, then flat but for product mix | +48.2% |
    
    - On #2761, with quantities flat, the model charges later rates on 2024-25 volumes, and tobacco duty ends 54.6% above the forecast.
    
    ## Invariants
    
    These hold for every input:
    
    1. **Each index stays positive.** For every series the importer divides a ratio of positive levels by one plus population growth, so each stays above -100%. Each index starts at 1 in 2024 and stays positive to 2039. The importer refuses a series whose five-decimal index would reach zero, in `compute_receipts_growth`, in `per_head_growth` and through #2761's `check_index_stays_positive`.
    2. **Quantity = base × compounded growth.** Dataset projection multiplies each 2024 quantity by its series' compounded growth. The property checks 2024–2032 to 1e-12 relative, compares the compounded factor with the five-decimal index to 1e-4 relative, and checks Core against that index to 1e-6 relative. After 2039, Core holds quantity and weight indices fixed, while explicit dataset extension continues the per-head and population growth.
    3. **Weighted totals follow the clearances.** In every year, (1 + series) × (1 + population growth) equals `1 + aggregate clearance growth` to within 5e-7 × (1 + population growth), and equals 1 after the forecast. Weights grow by population alone.
    4. **Per-head growth rounds once.** `per_head_growth` divides `1 + aggregate growth` before rounding by `1 + population growth`, subtracts one, and rounds once. For any growth and population growth, the product `(1 + per-head value) × (1 + population growth)` matches `1 + aggregate growth` within the stated tolerance, 5e-7 × (1 + population growth). A series that holds a duty's base, as #2761's gambling series do, grows per head by that base's growth, not its receipts'.
    5. **The importer reads a clearance line only from text someone has read, in the OBR's layout.** A property puts one text in a real clearance table of any of the three releases: in an empty cell of any row from the title through the table's end, in any column, or on a new row. For any text outside the table's list, drawn from `st.text()` or from unit-shaped strings, the importer refuses every line of the table. It names the cell, or finds no line or table where the text took a label's or the title's place. A listed text other than a line's label or a marker, outside the lines, stops every line when it lands in the fiscal-year row, and stops hand-rolled or other tobacco when it lands between that part and the non-cigarette total's row. Anywhere else the importer reads every line as before. An example test, independent of the draws, changes each release's tobacco clearances in five ways: it inserts a row that holds only the non-cigarette total's label directly above "of which:", writes "Cigarette clearances (million sticks)" after the total's figures or beside "of which:", or inserts an empty row directly above or directly below "of which:". In each case the importer refuses both parts with the part check's message and reads cigarettes as before. A second property splits any text of characters from U+0020 to U+D7FF into one to four formatting runs, with or without a phonetic run. The reader returns the runs' whole text and none of the phonetic run's, from an inline string and from a shared string alike. The importer stops a line with any cell beside its label and values, and stops every line of a table whose sheet repeats its heading. A test checks that each list equals the texts in the three releases' tables, so no unread or stale entry survives.
    6. **A duty-rate reform moves receipts by the rate alone.** Scaling every alcohol and tobacco rate by k in a year from 2027-28 leaves every projected quantity bit-identical and multiplies each duty by k. The uprating fingerprint stays unchanged, because the projection reads no duty rate.
    7. **A rerun of the importer changes nothing until population changes.** Rerunning it on the committed file with the real October 2026 and March 2026 tables leaves the file byte-identical. Editing `ons.population` without rerunning it changes none of the 13 per-head series, so the six clearance series and seven gambling bases keep the values the importer wrote for the old population. Raising 2026 population growth from 0.2% to 0.3% then leaves weighted quantities 1.000998 times the clearances from 2026 on, and the byte-for-byte test fails. Rerunning the importer rewrites each per-head series' 2026 value and nothing else.
    
    ## Test plan
    
    - [x] For policy changes: YAML tests whose expected values come from an external source. Not applicable: this projects data inputs from an OBR forecast and changes no rule. The new tests pin the OBR's published clearances (receipts Tables 3.11, 3.12, 4.11 and 4.12).
    - [x] `make format`: run as `uvx ruff@0.17.2 format --check .` and `uvx ruff@0.17.2 check .` on the whole repository, `uvx ruff@0.17.2 check --select F811,F821 policyengine_uk`, and `git diff --check`, all clean at `0a68b3fcf`.
    - [x] Changelog fragment `changelog.d/2596.fixed.md`
    
    **New tests:**
    
    - `test_excise_quantity_projection.py`: 10 tests, 2 of them Hypothesis property tests (projection and reform).
    - `test_import_obr_forecasts.py` gains 31 test functions over #2761 (84 at #2761's head `0037137ad`, 115 at `0a68b3fcf`), including four Hypothesis properties: rendered clearances, per-head rounding, one arbitrary text in a real clearance table, and a text in formatting runs. One parametrized test edits the real October 2026 tables in 33 ways. It changes units and scales, product labels, listed texts, headings, marker and year rows, and cells that should hold numbers. Another writes a note into the real Table 4.11 as two formatting runs, in three ways, each with a first run that would pass alone. A third moves rows of the real Table 4.11 in three ways that keep every text on the list and put the two tobacco parts under the cigarette label. A fourth changes the rows over the two tobacco parts in each of the three releases in five ways (`TOTAL_LAYOUTS`, 15 cases): a row that holds only "Non-cigarette clearances ('000s kg)" directly above "of which:", "Cigarette clearances (million sticks)" after the total's figures, the same text beside "of which:", and an empty row directly above or directly below "of which:". One test repeats Table 4.12's heading below the alcohol receipts forecasts, and another fills the empty cell above their title with a phonetic run that spells it. We added clearance tables to the synthetic workbooks, and the byte-for-byte test covers the clearance series.
    
    **Results (local, `uv sync --frozen --extra dev`, Hypothesis 6.170.11, `-n 4`, `HUGGING_FACE_TOKEN` and `POLICYENGINE_UK_DEFAULT_DATASET` unset):** I ran every row below at `0a68b3fcf`, `8f3f0f396` or `8d9763345`, one file at a time. `8f3f0f396` changes only three lines of the economic assumptions README after `8d9763345`, and `0a68b3fcf` changes only the part check's message in the importer and its tests after `8f3f0f396`. Before the total's-figures change, at `7555d402d`, the same environment ran `test_import_obr_forecasts.py` to 192 passed and 1 failed in 113 seconds: `test_any_text_no_one_has_read_stops_a_real_clearance_table` drew a row that holds only the non-cigarette total's label above "of which:", which the part check accepted. Between `7555d402d` and `0a68b3fcf` only the importer, its tests, `test_excise_quantity_projection.py` (a fixture that clears its cache), the economic assumptions README and `docs/engineering/excise-duties.md` change.
    
    | File | Head | Result |
    | --- | --- | --- |
    | Importer on the real tables, through `compute_receipts_growth` and `render_receipts_growth_yaml` | `8d9763345` | March 2026 over March 2025 reads all 17 series, 13 of them per head; October 2026 over March 2026 reads 17, 13 of them per head, and rewrites `yoy_growth.yaml` byte for byte (120,408 bytes, SHA-256 `846cbe78c74ff9a1c94ce39c5e4b8bf275645b78943d3c2d84dda830099e24a8`) |
    | `test_import_obr_forecasts.py` | `0a68b3fcf` | 208 passed, 0 failed, in one run of 97 seconds |
    | `test_excise_quantity_projection.py` | `0a68b3fcf` | 10 passed, in 119 seconds |
    | `code_health` (the whole directory) | `0a68b3fcf` | 3,069 passed, in 231 seconds |
    | `code_health/test_changelog_fragments.py` | `8d9763345` | 103 passed, with `towncrier` installed |
    | `code_health/test_uprating_declarations.py` | `8d9763345` | 8 passed |
    | `code_health/test_cached_arrays_not_written_in_place.py` | `8d9763345` | 36 passed |
    | `test_parameter_descriptions.py` | `8d9763345` | 24 passed |
    | `test_gambling_duty_base_projection.py` | `8d9763345` | 23 passed |
    | `test_population_vintage.py` | `8d9763345` | 95 passed |
    | `test_projection_horizon.py` | `8d9763345` | 14 passed |
    | `policy/baseline/gov/hmrc/alcohol_duty/alcohol_duty.yaml` | `8d9763345` | 15 passed |
    | `policy/baseline/gov/hmrc/tobacco_duty/tobacco_duty.yaml` | `8d9763345` | 12 passed |
    | `policy/baseline/gov/hmrc/excise_integration.yaml` | `8d9763345` | 2 passed |
    
    I left these files to CI's full suite: `test_uprating_inputs_reach_data.py`, `test_excise_schedules.py`, `test_energy_dataset_projection.py`, `test_corporate_receipts_statistics.py`, `test_long_run_joins.py`, `test_lagged_series.py`, `test_dated_reform_routes.py`, `test_ni_metadata_compat.py`, `test_rail_usage_uprating.py` and `test_road_fuel_volume_uprating.py`. The repository's checkers pass at `0a68b3fcf`: `.github/check_changelog_fragments.py` and `.github/check_fix_pr.py` (on this body).
    
    **Differential against #2761 at d08fbb6f1:** I projected two constructed datasets in this branch and in #2761: two households at weights 700 and 300 buying every product, through 2074, and the national construct above, to 2032.
    
    - Stored columns differ only in the eleven quantity inputs, from 2025 to 2074 (3,264 cell values compared).
    - I compared 16 outputs per year to 2032 (288 values). The two duties move, and `household_tax`, `gov_tax`, `household_net_income` and `gov_balance` move by the duty change, within £0.003. Real household net income, equivalised household net income and net income after housing costs move with net income. Income tax, NI, benefits, market income, HBAI net income, VAT and gambling duty stay identical. Other outputs that read net income move too; I compared only these 16.
    - For the first household alone in 2025:
    
      | Output | #2761 | This PR |
      | --- | ---: | ---: |
      | Real household net income | £21,031.73 | £21,383.67 |
      | Equivalised household net income | £30,443.92 | £30,953.36 |
      | Net income after housing costs | £13,998.53 | £14,339.85 |
    
    - Through the dataset, the weighted volume of all 11 inputs follows the aggregate clearances in all 51 years from 2024 to 2074, to a maximum relative error of 2.06414×10⁻⁶ (561 checks). Through Core from a 2032 projection, it does so to 1.40668×10⁻⁵ (209 checks).
    - This branch adds 12 parameters to the tree, the six series and their indices, and changes or removes none. It changes the `uprating` of the eleven inputs and no other variable.
    
    The differential ran at the branch's earlier heads; after them the branch changes the importer's checks, its tests and docs, and no series, input or parameter.
    
    **Mutations (scratch copies):** I applied each mutation alone and ran the tests in its row. I ran the older importer mutations through a harness that loads the importer and runs its test file without importing the rest of the package; unmutated, the importer passes all 43 clearance tests and all 7 per-head tests there. The clearance mutations ran at `65f3b3f4a`. Later commits add the reader's run joining and row numbers, the repeated-heading refusal at the top of `validate_clearance_table`, the spec's `part_of_total` and the two layout checks that run after it, and change the spec's error message, comments and docstrings; the walk and the checks those mutations change keep their code. The per-head mutations ran at `ade1df1ce` and the three projection mutations at `f0f3d37e4`; no later commit changes the code they mutate or the assertions of the tests they ran. Five mutations of the XLSX reader and the heading check, and the walk mutation, ran at `433b20fb7` through the test file itself. The reader and heading mutations ran on the five tests that cover them, the list test, the real-pair test and the phonetic-run test (8 tests); the walk mutation on the 33 edits and the arbitrary-text property (34 tests). Unmutated, all 41 tests of those selections pass. The seven mutations of the layout checks, the total's row and the phonetic-run join ran at `0a68b3fcf` on the whole of `test_import_obr_forecasts.py` (208 tests). Unmutated, all 208 pass.
    
    | Mutation | Tests run | Failed |
    | --- | --- | --- |
    | Drop the refusal of a repeated heading | 8 tests | 1: `test_a_repeated_clearance_heading_stops_the_run` |
    | Read only the first run of an inline string | 8 tests | 5: the three formatting-run edits, the formatting-run property, and `test_a_phonetic_run_cannot_end_a_clearance_table`, whose empty first run the mutation reads as no value |
    | Read only the first run of a shared string, and every run of an inline one | 8 tests | 2: the formatting-run property and `test_the_read_texts_are_the_texts_of_the_three_releases`, since the real alcohol footnotes keep their numbers ("1", "2") in a run of their own |
    | Name the repeated heading's rows by their place in the list, not their number on the worksheet | 8 tests | 1: `test_a_repeated_clearance_heading_stops_the_run` |
    | Join every `<t>` of a string, a phonetic run's included (the `.//main:t` join) | 208 tests | 2: `test_a_string_reads_whole_in_any_formatting_runs` and `test_a_phonetic_run_cannot_end_a_clearance_table` |
    | Drop the fiscal-year-row check | 208 tests | 3: both layouts that put the cigarette label in a row of years, and the arbitrary-text property |
    | Drop the part-of-total check | 208 tests | 17: the layout that moves the cigarette line above "of which:", all 15 `TOTAL_LAYOUTS` cases and the arbitrary-text property |
    | Accept a total's row that holds the label and no numbers | 208 tests | 4: the three `label-without-figures` cases and the arbitrary-text property |
    | Accept any text after the total's label, not only numbers (drop `or not all(NUMBER_RE.fullmatch(text) for text in total[1:])`) | 208 tests | 3: the three `text-on-total-row` cases |
    | Accept an "of which:" row that holds other cells (`of_which[:1] != ["of which:"]`) | 208 tests | 3: the three `text-on-of-which-row` cases |
    | Drop empty rows from the rows above the parts, so an empty row may sit beside "of which:" | 208 tests | 6: the `empty-row-above-of-which` and `empty-row-below-of-which` cases in all three releases |
    | Once the check meets the fiscal-year row, skip every later cell under a year column | 34 tests | 3: the edits that put a note in E40 and "nan" in D5, and the arbitrary-text property |
    | Check only each row's first text (the title row's second) | 43 clearance tests | 8, including the kg cell beside a real line, a unit in the "of which:" row, text where a number belongs and the property |
    | End the table one row early | 43 clearance tests | 19, including every note added below the real Table 4.11 and the text in Table 4.12's last row |
    | Skip the alcohol table | 43 clearance tests | 11 |
    | Skip the tobacco table | 43 clearance tests | 29 |
    | Accept a listed text with more words | 43 clearance tests | 19, including both extra-words edits |
    | Skip the title's row | 43 clearance tests | 2: the title-row edit and the property |
    | Skip the title, marker and year rows | 43 clearance tests | 4: the three heading-row edits and the property |
    | Drop the unit-cell requirement | 43 clearance tests | 1: `test_a_real_table_without_its_unit_cell_refuses_the_lines_it_describes` |
    | End the table at any title | 43 clearance tests | 1: `test_the_alcohol_receipts_table_stays_outside_the_clearance_table`; the title-shaped note, added later, fails too |
    | Skip any cell that contains a number, not only one that holds a number | 43 clearance tests | 16 |
    | Skip the check | 43 clearance tests | 38 |
    | Make `growth_in_effect` return zero before population's first entry | 7 per-head tests | 1: `test_population_growth_before_its_first_entry_is_that_entry` |
    | Skip `per_head_growth`'s index check | 7 per-head tests | 1: `test_per_head_growth_refuses_an_index_that_rounds_to_zero` |
    | Drop `per_head_growth`'s division by population | 7 per-head tests | 7 |
    | Divide one plus the rounded growth, not the growth before rounding | 7 per-head tests | 5, including `test_real_remote_gaming_base_per_head_rounds_once` |
    | Commit clearance series that count population twice | 3 projection tests | 3 |
    | Map cider to the wine series | 4 projection tests | 3, before the run's 10-minute cap stopped the fourth |
    | Take cigarettes out of the uprating map and drop their index | 4 projection tests | 2, before the run's 10-minute cap stopped the other two |
    
    **Overlap:**
    
    - This branch merges #2761 at `0037137ad`. The two #2761 commits after `d08fbb6f1`, the differential's base above, change documentation and one importer comment. At `0a68b3fcf`, `git merge-tree --write-tree` finds no conflict with `main` at `47bda0a7a` or with #2761 at `0037137ad`, an ancestor of this branch. At `8f3f0f396` it found none with `main` at `2da212abc` or at `46a60678f`. At `7555d402d` it found none with #2751 (`b5de2c789`), #2756 (`c34662736`) or #2763 (`6ca062e68`).
    - The `fix-2721-import-nlw-forecast` branch (`17dbd906755e11c3d13d4264aef0ad46fbf12587`, no PR yet) conflicts with it at `0a68b3fcf` in the economic assumptions README and `test_import_obr_forecasts.py`; the importer itself merges without conflict. In the README, keep this branch's clearance text at the end of step 6, then the NLW branch's national living wage step as step 7 and the horizon step as step 8. In the tests, keep both branches' imports, fixtures and tests. The two branches' last Hypothesis functions, `test_per_head_growth_times_population_growth_is_the_series_growth` here and `test_the_importer_projects_rates_as_the_model_does` there, share their `@settings` and `@given` lines as merge context, so give each function its own copy.
    
    ## Reproducer
    
    Reproducer: policyengine_uk/tests/test_excise_quantity_projection.py::test_weighted_quantities_follow_the_clearances
    
    On `origin/main` (2da212abc) it fails at the first projected year: weighted cigarette quantities grow by population (1.0033), where the OBR's clearances fall to 0.9003.
    
    axiom: n/a: this projects data inputs from an OBR forecast and changes no rule.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    

    Generated by Claude Code

  11. MaxGhenis commented on Nov 2, 2026

    @MaxGhenis
    Collaborator

    Ready for hub review

    Head: 0a68b3fcf37b9e56fb7547edac24fd79bd38bfd7 on branch claude/fix-2596-clearance-refusals-rhf7yx. It adds one commit to 8f3f0f396e9318de59032110aaa16351273f9ac3 (claude/fix-2596-hypothesis-check-zr68rb). I did not push to fix-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, or None in October 2026. So each layout adds a text and overwrites none. In policyengine_uk/tests/test_import_obr_forecasts.py:4006–4054, I replaced test_a_total_label_without_figures_does_not_count_as_the_total with the review's TOTAL_LAYOUTS (five layouts) and test_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 over TOBACCO_LINES unchanged and uses no Hypothesis. A comment above TOTAL_LAYOUTS records the empty-cell fact. shard_durations.toml doesn't list the old test, so I left it alone.

    P3-1: the refusal message. policyengine_uk/utils/import_obr_forecasts.py:1408–1416 now uses the review's message word for word. I checked it against every way validate_clearance_part refuses (:1402–1407):

    • no "of which:" row, or an "of which:" row with another cell: "an "of which:" row with no other cell";
    • an empty row between "of which:" and the parts: "directly above the line and the total's other parts";
    • an empty row above "of which:", a wrong label, a label with no figures, or a label with text after the figures: "directly above that row … holds its label …, its figures and nothing else".

    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_' on test_import_obr_forecasts.py gives 84 at #2761's head 0037137ad, 114 at 7555d402d, 115 at 8f3f0f396 and 115 at 0a68b3fcf. git diff 0037137ad 0a68b3fcf adds 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 the part_of_total bullet under "Importer" to describe the whole layout.

    P3-3: the overlap note. It now names main at 47bda0a7a and #2761 at 0037137ad, both checked at the new head. The older 2da212abc and 46a60678f results stay, attributed to 8f3f0f396.

    Merges (git merge-tree --write-tree --name-only 0a68b3fcf <sha>)

    • main 47bda0a7a9f06b59bb262e9b4dffc05ab2428b5d (still main's tip when I checked): clean, tree e30e1734d. It doesn't conflict, so I didn't merge main.
    • Project the gambling duty bases with the OBR's October 2026 betting and gaming forecast #2761 head 0037137adc62ebc58e1f974661faae567cd3d31c (from gh api …/pulls/2761, open): clean, tree 502bcfdaa. It's an ancestor of the head.
    • fix-2721-import-nlw-forecast 17dbd906755e11c3d13d4264aef0ad46fbf12587 (from gh api …/branches/…): conflicts in policyengine_uk/parameters/gov/economic_assumptions/README.md and policyengine_uk/tests/test_import_obr_forecasts.py only. 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 4 on 4 cores, with HUGGING_FACE_TOKEN and POLICYENGINE_UK_DEFAULT_DATASET unset, at 0a68b3fcf:

    • test_import_obr_forecasts.py with -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.py on the final title and body. I built the changes file the way the workflow does, as git diff --name-status --no-renames -z from the merge base with origin/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.0 and uvx ruff@0.17.2, format --check and check on the two changed files: clean with both. uvx ruff@0.17.2 format --check . reports 2,156 files formatted. check . and check --select F811,F821 policyengine_uk both pass. git diff --check 8f3f0f396 HEAD is clean.

    Mutations

    I made each mutation alone, in its own detached worktree at 0a68b3fcf. Each ran on the whole of test_import_obr_forecasts.py (-n 4 -o xfail_strict=true, using the locked venv with PYTHONPATH set to the worktree; I confirmed that policyengine_uk.__file__ resolved to the worktree). Unmutated, all 208 pass.

    Mutation Failed
    Drop or not all(NUMBER_RE.fullmatch(text) for text in total[1:]) (review P2 mutant 1) 3: text-on-total-row in all three releases. It survived at 8f3f0f396
    of_which != ["of which:"] → of_which[:1] != … (P2 mutant 2) 3: text-on-of-which-row in all three releases. It survived at 8f3f0f396
    Drop empty rows from above (P2 mutant 3) 6: empty-row-above-of-which and empty-row-below-of-which in all three releases. It survived at 8f3f0f396
    Drop the fiscal-year-row check 3: cigarette-unit-second-year-row, cigarette-unit-year-header and the arbitrary-text property
    Drop the part check 17: cigarette-unit-of-which, all 15 TOTAL_LAYOUTS cases and the property
    Restore the .//main:t join 2: test_a_string_reads_whole_in_any_formatting_runs and test_a_phonetic_run_cannot_end_a_clearance_table
    Accept a total's row with no numbers (drop or not total[1:]) 4: the three label-without-figures cases and the property

    The body's mutation table now gives these seven rows at 0a68b3fcf on 208 tests. The four older rows used to cite 8d9763345 on 13 tests; I replaced them with these reruns.

    Skipped, and why

    • I didn't rerun the results-table rows the delta can't reach: test_parameter_descriptions.py, the gambling, population-vintage and projection-horizon tests, the three YAML files, the code_health single-file rows, and the importer rebuild. They stay attributed to 8d9763345. This commit changes only an error string and tests, and the byte-for-byte rebuild test is inside the 208 that passed.
    • I didn't rerun the differential or the older mutation rows, which the body attributes to earlier heads.
    • I didn't run any survey-dataset tests: no private data is available here.

    Judgment calls

    • The brief gives ruff@0.17.0 in one place and 0.17.2 in another. I ran both on the changed files, and they agree.
    • The review's 5-backtick test used f"... {'.'.join(spec.path)} ...". In the test file, ruff rewrote it to f'{".".join(spec.path)} …' to match its quote style. The string is the same.
    • The body's Reproducer paragraph still names origin/main at 2da212abc, where that run happened. I didn't rerun it against 47bda0a7a.
    • I pushed the branch so the work wouldn't sit only in the container. It has no PR, so no CI started.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions