Skip to content

Count corporate SDLT once and let SDLT abolition remove all of it - #2235

Draft
MaxGhenis wants to merge 7 commits into
mainfrom
fix/corporate-sdlt-double-count
Draft

MaxGhenis wants to merge 7 commits into
mainfrom
fix/corporate-sdlt-double-count

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2159

Summary

Corporate SDLT was counted twice in household_tax, gov_tax and pre_budget_change_household_tax, and abolishing SDLT left one copy of it in all three. This PR counts it once and makes abolition remove all of it. It also adds a tax-head partition, specified independently of the model's own lists, with tests that every total counts each of its heads exactly once.

National effect on certified data: all three totals fall by £3.131bn a year from 2025-26 to 2030-31, and household net income rises by the same amount. Poverty rates don't change. Measured by real runs, below.

The bug

  • expected_sdlt returned stamp_duty_land_tax + property_sale_rate + corporate_sdlt (variables/gov/hmrc/expected_sdlt.py on main).

  • GOV_TAX_VARIABLES, HOUSEHOLD_TAX_VARIABLES and PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES list both expected_sdlt and corporate_sdlt, so corporate SDLT entered each total twice.

  • gov.hmrc.stamp_duty.abolish zeroed expected_sdlt only. corporate_sdlt, listed separately, survived abolition in full.

  • property_sale_rate is a share (0.045, unit /1), and adding it gave every household £0.045 of SDLT, including households in Scotland and Wales. Add dataset schema, remove State variables and add HBAI net income #1155 turned the original * into +.

    The datasets don't need the scaling anyway. policyengine-uk-data draws purchasers directly: property_purchased is a seeded draw at 3.85% in datasets/frs.py. So stamp_duty_land_tax is already the expected liability.

The fix

  • expected_sdlt returns stamp_duty_land_tax: the household's own purchases and leases only.
  • corporate_sdlt returns 0 when gov.hmrc.stamp_duty.abolish is set.
  • No household is sdlt_liable once SDLT is abolished, so the stamp_duty_land_tax liability is zero too. Before, it kept showing the pre-abolition liability. expected_sdlt keeps its own check, so abolition also zeroes it when the liability is entered directly.
  • The totals' lists are unchanged, so each part is counted once, and abolition zeroes both.
  • The high value council tax surcharge tests drop five property_sale_rate: 0 overrides that existed only to cancel the stray £0.045.

Tax-head partition

New file: policyengine_uk/data/fiscal_partition.yaml (plain YAML, schema_version: 1).

Heads. Each head is one row of the OBR's current receipts table (EFO November 2025, Table A.5), split by payer or nation where the model splits it, and measured by exactly one variable. Its fields:

  • obr_line, or not_an_obr_current_receipt with a reason (for example, student loan repayments are a financial transaction);
  • obr_table_3_1, the line of the March 2026 EFO's Table 3.1 that holds it, where that table's footnotes say so;
  • report_group and payer;
  • the totals it belongs to;
  • sign, and zero_at_baseline for the reform-only heads;
  • reads, which declares the reads that are eligibility rather than inclusion.

SDLT is two heads on one receipts row ("Property transaction taxes"): sdlt_household (expected_sdlt) and sdlt_corporate (corporate_sdlt).

Coverage. The 29 heads sit on 12 of Table A.5's 30 tax rows. uncovered_receipts lists the other 18 (corporation tax, inheritance tax, air passenger duty and so on), so every row is either measured or declared uncovered.

Exclusions. 73 variables that must never be summed alongside the heads, each with its kind:

  • component: inside a head, e.g. stamp_duty_land_tax, the NI classes, the income tax parts and reductions.
  • measure_within: e.g. salary_sacrifice_pension_ni_*, already charged through employment_income.
  • composite: e.g. corporate_tax_incidence, which is corporate_sdlt + business_rates.
  • duplicate: baseline_business_rates.
  • baseline_measure: baseline_vat.
  • assessment: a tax amount worked out for a means test or a ceiling, e.g. uc_income_tax_on_earnings, ni_class_4_maximum.
  • change_measure: a wage change already inside the incomes a head taxes, e.g. employer_ni_fixed_employer_cost_change.

Overlap guards.

  • SDLT is split between household and company payers.
  • SDLT, LTT and LBTT are geographically exclusive, and so are council tax and NI domestic rates.
  • VAT and employer NI appear as a level in gov_tax and as a change in household_tax, never in the same total.
  • Council tax is counted gross, with Council Tax Reduction counted as spending. The OBR's row is net of reduction schemes, so comparisons need the reduction netted off.
  • income_tax includes the High Income Child Benefit Charge, so benefits must be counted gross of it.
  • Business rates, corporate SDLT and three reform-only heads (corporate tax change, carbon tax, land value tax) share the shareholding key; each is a different receipt.
  • Private school VAT may overlap VAT on imputed consumption.

Independence. The heads come from the OBR table and each variable's formula, not from the model's lists; a differential test compares the two. A second specification was written blind by a GPT-6.1 Sol lane that was not allowed to read the model's lists or this file (~/reviews/uk-corporate-sdlt-2159/spec-out.md). Reconciling the two:

Blind specification This partition
Same variable for income tax, both NI heads, VAT, private school VAT, fuel, alcohol and tobacco duties, VED, the licence fee, business rates, CGT, NI domestic rates, the surcharge, both SDLT heads and the four hypothetical taxes Agreed
Council tax: the net variable council_tax_less_benefit, matching the OBR's net receipts Kept gross council_tax, which is what the totals sum, with the reduction in spending. Recorded on the head and as a guard
LTT and LBTT: the unscaled liabilities, because expected_ltt and expected_lbtt discount twice Kept the variables the totals sum, with #2246 noted on both heads. #2315 fixes it
Student loan repayments: outside both tax totals Kept as the model counts them, flagged not_an_obr_current_receipt for separate reporting
VAT change, employer NI response incidence, the two unallocated tax changes: exclusions from a total of levels Heads of the totals that use them, zero_at_baseline
18 receipt rows with no household measure Adopted as uncovered_receipts
About 20 further exclusions (income tax reductions, Class 4 ceiling, Universal Credit tax assessments, wage-change measures) and three guards Adopted
stamp_duty_land_tax still shows a liability after abolition Fixed here: abolition now makes no household sdlt_liable

I checked the two OBR claims I adopted at the source: Table A.5's rows and footnotes, and the OBR's council tax page (receipts include Northern Irish domestic rates and are net of reduction schemes).

Shared with W3. W3 (per-PR national impact reports, #2335) reads this file directly for its tax heads and partition identity, and adds spending_heads to it. The schema was agreed with that workstream. Both landing orders were checked: on the combined state this PR's tests and #2335's 17 partition and guard tests pass.

Invariants

  1. Partition, differential. For each total, the variables the model sums are exactly the partition's heads for it, each listed once. No exclusion is listed. Every tax row of Table A.5 is a head's row or declared uncovered, never both.
  2. No overlap, traced. Within a total, no head's formula reaches another head of that total unless the partition declares the read. The declared reads are TV licence → over-75 discount → Pension Credit income → income tax and NI, and private school VAT → attendance imputation. Every declared read must still occur.
  3. Coefficient one. Raising any head by 1,024, with every other input unchanged, raises each total that includes it by exactly 1,024 per member, and moves no other head of those totals.
  4. Identity. On random households, in 2025-2030, at current law and with SDLT or council tax abolished, each total equals the sum of its heads, less those dropped_when names. Every sign: nonnegative head is ≥ 0. Every zero_at_baseline head is 0 at current law. At most one of SDLT, LTT and LBTT is non-zero per household.
  5. SDLT once. expected_sdlt equals stamp_duty_land_tax. Removing corporate SDLT revenue lowers each total by exactly corporate_sdlt. Abolition lowers each total by exactly stamp_duty_land_tax + corporate_sdlt, and leaves stamp_duty_land_tax, expected_sdlt and corporate_sdlt all zero. With a dataset, weighted corporate_sdlt is the corporate revenue statistic.
  6. Abolition removes SDLT and nothing else. A Welsh shareholder keeps business rates and loses corporate SDLT.

Tests

File Covers
tests/test_fiscal_partition.py 1–4 (schema, differential, traced guard, 29 perturbation cases, Hypothesis identity)
tests/test_sdlt_counted_once_properties.py 5 (Hypothesis, two households, 2024–2027)
tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml, expected_sdlt.yaml, sdlt_liable.yaml Fixed cases for 5 and 6

Red on main, green here. I ran the final test_fiscal_partition.py (static, traced and SDLT perturbation cases) against main's model code (3f174d0):

  • the traced guard fails for all three totals, because expected_sdlt reaches corporate_sdlt;
  • raising corporate_sdlt by 1,024 moves gov_tax by 2,048.

On this branch (3ada49f) all 39 partition tests and the SDLT property test pass locally, and so do the 24 SDLT YAML cases. CI runs the full suite.

National impact (real runs)

Setup.

  • Data: policyengine-uk-data 1.58.0, enhanced_frs_2024_25.h5, sha256 5ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6, the certified release.
  • Code: base is main at 6d89338 and head is this branch's model code. Both are policyengine-uk 2.126.8.
  • Runtime: both use policyengine-core 3.33.0, the certified bundle's core. The repo lock pins 3.32.27.
  • Each figure is a weighted total from Microsimulation runs: no scaling and no interpolation. 36 runs in all: 2 versions × 3 scenarios × 6 years.

Current law, £bn a year

2025-26 2026-27 2027-28 2028-29 2029-30 2030-31
gov_tax, base 978.967 1,021.444 1,060.540 1,096.037 1,133.645 1,172.964
gov_tax, head 975.836 1,018.312 1,057.409 1,092.905 1,130.514 1,169.833
Change in gov_tax, household_tax, pre_budget_change_household_tax and gov_balance −3.131 −3.131 −3.131 −3.131 −3.131 −3.131
Change in household_net_income +3.131 +3.131 +3.131 +3.131 +3.131 +3.131
expected_sdlt, base → head 19.448 → 16.316 20.440 → 17.308 21.414 → 18.283 22.393 → 19.262 23.395 → 20.264 24.493 → 21.362

The −£3.131bn is £3.130bn of corporate SDLT plus about £1.4m of stray £0.045s across roughly 31m households. corporate_sdlt (£3.130bn) and stamp_duty_land_tax are unchanged.

Accounting checks on the data

Check (every year 2025-26 to 2030-31) Base Head
gov_tax change from zeroing corporate SDLT revenue, ÷ corporate_sdlt 2.0000 1.0000
Corporate SDLT left after abolition £3.130bn £0
Households with ≥2 of SDLT, LTT, LBTT non-zero 86–96 0
Non-finite outputs none none

SDLT abolition costing. The measured effect barely moves. Abolishing SDLT changes gov_tax by −£20.440bn on base and −£20.438bn on head in 2026-27, and likewise in every year. Base's baseline held corporate SDLT twice and its abolished scenario once, so the difference was one copy either way. What changes is the level of both scenarios.

Distribution. Head − base, in household net income per household, weighted mean £, 2026-27:

D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 All
45.94 60.27 53.38 86.63 81.15 82.47 93.36 114.09 164.62 223.17 99.84

Corporate SDLT is allocated by shareholding, so the removed copy is worth more to higher deciles. The other years are in the evidence folder.

Poverty. BHC and AHC, absolute and relative: unchanged to four decimal places in every year. HBAI income does not read these variables; hbai_excluded_income lists corporate_tax_incidence, but no current HBAI income formula reads it.

Things this does not change

Follow-ups.

  • LTT and LBTT are discounted twice (expected_sdlt adds the property sale rate; expected_lbtt and expected_ltt discount purchases twice #2246). On 1.58.0 in 2026-27, LBTT is £2.31bn but expected_lbtt is £0.104bn. Land transaction tax is £0.070bn but expected_ltt is £0.003bn. The partition records this as a data_note on both heads. The fix should follow this PR's convention, the purchase draw being the expectation.
  • Corporate SDLT is held at 2020 revenue. £0.705bn residential plus £2.425bn non-residential, from gov.hmrc.stamp_duty.statistics, never uprated. Update corporate SDLT and business rates statistics and project them with OBR receipts #2322 updates it.
  • Alcohol, tobacco and vehicle excise duties are £0 for every household on 1.58.0 in every year. Recorded as data_notes; W3's report flags inert heads.
  • Private school VAT is zero at current law. gov.contrib.labour.private_school_vat is 0, so the head is reform-only. A guard records the possible overlap with VAT on imputed consumption. Charge VAT on private school fees at current law #2319 addresses it.
  • 76 households have negative VAT on 1.58.0 (−£72m weighted in 2026-27), from negative imputed consumption or full-rate share. Recorded on the vat head; policyengine-uk-data#565.
  • uk-data test. policyengine-uk-data's tests/test_property_purchased.py compares weighted expected_sdlt with total SDLT receipts, using wide bounds (£5–50bn). expected_sdlt is now household-only (£17.3bn in 2026-27), still inside the bounds. A tighter benchmark should add corporate_sdlt. That change goes in the batched data release.

Merge

Lands on the hub's gates. The earlier "hold until 2 November" was set before cos d1152, in which Max ruled no model freeze for the 28 October Budget. Evidence and scripts: ~/reviews/uk-corporate-sdlt-2159/ (impact.py, compare.py, impact.md, the runs).

axiom: n/a: this corrects how the model's tax totals add up its own variables (corporate SDLT incidence, a modelling convention), not a statutory rule rulespec-uk encodes

🤖 Generated with Claude Code

Reproducer: policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml, policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml, policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml, policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml::No household is liable once Stamp Duty is abolished, policyengine_uk/tests/test_fiscal_partition.py::test_partition_schema, policyengine_uk/tests/test_fiscal_partition.py::test_partition_heads_are_the_variables_each_total_sums

@MaxGhenis
MaxGhenis requested a review from vahid-ahmadi October 9, 2026 14:33
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Local targeted runs at 685c1e7 (policyengine-uk installed editable, Python 3.13), through the shared heavy-test lock. CI runs the full suite.

  • policyengine-core test on tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml, expected_sdlt.yaml and tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml: 22 passed.
  • pytest policyengine_uk/tests/test_sdlt_counted_once_properties.py: 1 passed (15 Hypothesis examples, derandomised) in 97 s.

An independent review is running; findings will follow here.

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Independent review (GPT-6.1 Sol, 9 Oct) asked for changes; all addressed at 2d606f7ca:

  • Effect section was wrong (major). Abolition does not save £3.13bn more: the old abolished scenario still carried corporate SDLT, so abolition saved S + C + r before and S + C now. The body now gives the per-household algebra and labels it as deduced from the formulas, not measured on production data.
  • Docstring overstated the data (minor). expected_sdlt now says the FRS-based datasets impute an annual purchaser indicator with a seeded draw (policyengine-uk-data datasets/frs.py:1874-1879).
  • uk-data consumer (minor). tests/test_property_purchased.py:74 compares weighted expected_sdlt with total receipts; noted as a follow-up for the next batched data release.
  • Added Fixes #2159 and the axiom: line; the HVCTS note now says five overrides were removed.

Mutation check, run locally: with expected_sdlt adding corporate_sdlt back, corporate_sdlt.yaml failed 3 of 5 cases and the property test failed at its first assertion (test_sdlt_counted_once_properties.py:144). Restored; the working tree is clean.

MaxGhenis and others added 3 commits October 11, 2026 11:07
expected_sdlt added corporate_sdlt, and household_tax, gov_tax and
pre_budget_change_household_tax list both, so corporate SDLT was counted
twice. gov.hmrc.stamp_duty.abolish zeroed expected_sdlt only, leaving one
copy of corporate SDLT in the totals after abolition.

expected_sdlt is now the household's own SDLT (stamp_duty_land_tax), and
corporate_sdlt returns 0 when SDLT is abolished. The totals lists are
unchanged, so each part is counted once.

expected_sdlt also added property_sale_rate (a 0.045 share) as pounds, a
slip from #1155, which changed the multiplication to an addition. Population
datasets already mark only purchasers, so no scaling is applied; the
high-value surcharge tests no longer need to zero the parameter.

Adds YAML cases and a Hypothesis property test on a two-household
population (baseline, abolition, and corporate SDLT revenue set to zero).

Fixes #2159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
policyengine_uk/data/fiscal_partition.yaml divides the tax totals into heads,
one OBR current-receipts line per head (split by payer where the model
splits it), each measured by one variable. It lists the variables that must
never be summed alongside the heads, with the reason, and the places double
counting could occur. The heads were written from the OBR classification
and the variables' formulas, not from GOV_TAX_VARIABLES and its siblings.

test_fiscal_partition.py checks the two specifications against each other:
- each total sums exactly the partition's heads for it;
- traced: no head reaches another head of its total unless the partition
  declares the read (the TV licence discount's Pension Credit test, the
  private school attendance imputation), and every declared read occurs;
- perturbation: raising one head by 1,024 raises each total that includes
  it by 1,024 per member and moves no other head of that total;
- on random households, 2025-2030, at current law and with SDLT or council
  tax abolished: each total equals the sum of its heads, signs hold,
  reform-only heads are zero at current law, and at most one of SDLT, LTT
  and LBTT is non-zero.

On main's formulas the traced test fails for all three totals (expected_sdlt
reaches corporate_sdlt) and raising corporate_sdlt moves gov_tax by 2,048.

Adds corporate SDLT fixtures for a Welsh shareholder: abolition removes
corporate SDLT and keeps business rates. Trims the SDLT property test to 10
examples now that the identity test covers abolition too.

Refs #2159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the fix/corporate-sdlt-double-count branch from 2d606f7 to 02a3c1e Compare October 11, 2026 15:43
Each head now records the line of OBR EFO March 2026 Table 3.1 that holds
it, where the table's footnotes say so ("Capital taxes" includes capital
gains tax and property transaction taxes; "PSNB-neutral receipts" includes
council tax), and null where they don't (vehicle excise duty, the licence
fee, Northern Ireland domestic rates). A sources block records what was
read and when. The schema test checks the values against the table's
lines. The excise heads' data notes cite issues 1872-1874.

Refs #2159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 11, 2026
YAML cases hand-compute LBTT (SSI 2015/126 Tables A and B, first-time
buyer relief) and LTT (WSI 2018/128 Tables 1 to 3) on purchases in
Scotland and Wales and check expected = liability = the household's
tax totals. The Hypothesis test checks expected equals realised and,
independently, that zeroing every LTT and LBTT rate lowers each total
by exactly the liabilities. A differential test compares the model with
the statutory tables; the schedules it has stale (issues 1795, 1796) are
strict expected failures.

On the #2235 head (41e970b), 9 of the 17 YAML cases and the property
test fail; here all pass.

The partition's data notes now give the measured 1.58.0 figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Stacked on this PR: #2319 (fixes #2318) makes VAT on private school fees current law from 1 January 2025 (Finance Act 2025 ss.47 and 49), and updates this file's private school entries to match:

  • private_school_vat is no longer zero at baseline and moves to gov_tax only;
  • a new private_school_vat_change head in household_tax;
  • the exclusion baseline_private_school_vat;
  • the vat guard becomes overlapping_base;
  • the identity property draws pupils.

#2319 is rebased on 5f08c244c. Once this merges, #2319 retargets to main.

MaxGhenis added a commit that referenced this pull request Oct 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 11, 2026 19:31
The aggregate impact report (#2335) adds gov_spending, household_benefits
and spending_heads to fiscal_partition.yaml. The tax tests now check the
three tax totals only, require that spending heads borrow no tax head id or
variable and that no spending head names a tax total, and apply dropped_when
to the tax totals alone. Checked against #2335's file with these tax
sections merged in: this file's tests and #2335's partition and guard tests
pass.

The vat head records that 76 households have negative VAT on
policyengine-uk-data 1.58.0 (consumption or the full-rate expenditure share
is negative in the data; policyengine-uk-data issue 565).

Refs #2159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the fix/corporate-sdlt-double-count branch from 5f08c24 to 47eb1f7 Compare October 11, 2026 23:32
…ility

A second specification of the tax heads was written from OBR EFO November
2025 Table A.5 and the variables' formulas by a reviewer who was not shown
the model's lists or the partition file. Reconciling the two:

- obr_line is now the Table A.5 row (SDLT, LTT and LBTT sit on "Property
  transaction taxes"; the surcharge and Northern Irish domestic rates on
  "Council tax"), and uncovered_receipts lists the 18 rows the model has no
  household measure for. The schema test checks that the heads' rows and
  the uncovered rows account for every tax row of the table.
- Council tax is recorded as gross of Council Tax Reduction, which the model
  counts as spending; the OBR's row is net. A guard says so.
- About 20 more exclusions: income tax reductions, the Class 4 ceiling,
  the tax amounts Universal Credit assesses, wage-change measures, and
  Child Benefit net of the High Income Child Benefit Charge. Two new kinds,
  assessment and change_measure, hold the ones a means test may read.
- Where the blind specification chose a different variable (net council
  tax, unscaled LTT and LBTT, student loans outside the totals), the head
  says so.

Both specifications flagged that stamp_duty_land_tax still showed a
liability after gov.hmrc.stamp_duty.abolish. No household is sdlt_liable
once SDLT is abolished, so the liability is now zero too; expected_sdlt
keeps its own check for a liability entered directly.

Refs #2159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Thanks Max. Reviewed at 3ada49f. The fix is right and the partition is a really useful guard. Nothing blocking from me; a few small points below.

What I checked

  • The bug is real on main. expected_sdlt adds corporate_sdlt (expected_sdlt.py:15-18), and gov_tax.py, household_tax.py and pre_budget_change_household_tax.py all list both, so corporate SDLT goes in twice and abolition leaves one copy behind.
  • The fix. expected_sdlt is now just stamp_duty_land_tax, corporate_sdlt is zero under gov.hmrc.stamp_duty.abolish, and sdlt_liable turns off with it. The only readers of sdlt_liable are stamp_duty_land_tax (defined_for) and the tests, so making it abolition-aware doesn't reach anything else. corporate_tax_incidence and hbai_excluded_income now lose corporate SDLT under abolition too, which is what you'd want.
  • Tests. I used a fresh uv sync environment in a worktree (policyengine-core 3.32.27), not the shared venv. At the head, the two new Python files pass (40), and so do the stamp duty and council tax YAML directories (36). I copied the new tests onto main (0bc08f2), and they fail there: 6 of the 40 Python tests (the traced guard for all three totals, sdlt_corporate entering once, the sum identity, and the SDLT property test) and 13 of 24 YAML cases.
  • National effect. I re-ran it on the certified Microcosm UK 2024-25 release (sha256 aa31bdf6…) rather than the Enhanced FRS, comparing the PR's merge base (3f174d07) with the head in the same environment:
2025-26 2029-30
gov_tax, base → head 911.695 → 908.564 1,060.817 → 1,057.686
Change in gov_tax, household_tax, pre_budget_change_household_tax −3.131 −3.131
Change in household_net_income +3.132 +3.131
Saving from abolishing SDLT, base → head 18.286 → 18.285 21.873 → 21.872

So the −£3.131bn reproduces on a second dataset. Abolition now removes exactly stamp_duty_land_tax + corporate_sdlt (£15.155bn + £3.130bn in 2025-26), where base left the £3.130bn corporate copy in.

  • Other users. policyengine.py, the API and the 2026 Budget dashboard don't read expected_sdlt or corporate_sdlt directly; they get the corrected totals. policyengine-uk-data's test_sdlt_total_reasonable reads expected_sdlt against a £5–50bn band, which the new value still clears.

Findings

  1. Scorecard evidence goes stale (should, other repo). policyengine-scorecard's docs/uk_replay/evidence/classification_sources.json keeps a copy of the old expected_sdlt.py source, and its budget_2018:tax:016 row lists expected_sdlt as a mechanism variable. Neither breaks, but the snapshot will no longer match, so it's worth refreshing when this lands.
  2. SDLT and LTT/LBTT are now on different bases until Stop discounting LTT and LBTT twice #2315 (should). expected_sdlt is the unscaled liability, while expected_ltt.py:13-15 and expected_lbtt.py:13-15 still multiply by property_sale_rate. That was already inconsistent on main, and the partition records it under expected_sdlt adds the property sale rate; expected_lbtt and expected_ltt discount purchases twice #2246, so it's fine here. But it means Welsh and Scottish property transaction taxes are about 4.5% of what they should be in the same totals, so I'd merge Stop discounting LTT and LBTT twice #2315 straight after this.
  3. property_sale_rate has only those two readers left (nit). Once Stop discounting LTT and LBTT twice #2315 lands it may be unused. Its description ("This percentage of properties are sold every year") and the TODO in stamp_duty/property_sale_rate.yaml could say it's only the LTT/LBTT scaling, or the parameter could go.
  4. The level is still high (outside this PR). On certified Microcosm 2025-26, household SDLT is £15.16bn plus £3.13bn corporate, £18.3bn in all. policyengine-uk-data's own test cites £13.9bn of official SDLT for 2024-25. The double count was part of the gap and this PR removes it. The rest looks like the purchase rate or the price distribution in the data, which deserves its own issue.

Happy to approve once you've seen these. None of them needs a change in this PR.

🤖 Generated with Claude Code

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Corporate SDLT is counted twice in household and government tax totals

2 participants