Repository navigation
Conversation
|
Local targeted runs at 685c1e7 (policyengine-uk installed editable, Python 3.13), through the shared heavy-test lock. CI runs the full suite.
An independent review is running; findings will follow here. |
|
Independent review (GPT-6.1 Sol, 9 Oct) asked for changes; all addressed at
Mutation check, run locally: with |
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>
2d606f7 to
02a3c1e
Compare
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>
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>
|
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:
#2319 is rebased on |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
5f08c24 to
47eb1f7
Compare
…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>
|
Thanks Max. Reviewed at What I checked
So the −£3.131bn reproduces on a second dataset. Abolition now removes exactly
Findings
Happy to approve once you've seen these. None of them needs a change in this PR. 🤖 Generated with Claude Code |
Fixes #2159
Summary
Corporate SDLT was counted twice in
household_tax,gov_taxandpre_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_sdltreturnedstamp_duty_land_tax + property_sale_rate + corporate_sdlt(variables/gov/hmrc/expected_sdlt.pyon main).GOV_TAX_VARIABLES,HOUSEHOLD_TAX_VARIABLESandPRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLESlist bothexpected_sdltandcorporate_sdlt, so corporate SDLT entered each total twice.gov.hmrc.stamp_duty.abolishzeroedexpected_sdltonly.corporate_sdlt, listed separately, survived abolition in full.property_sale_rateis 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_purchasedis a seeded draw at 3.85% indatasets/frs.py. Sostamp_duty_land_taxis already the expected liability.The fix
expected_sdltreturnsstamp_duty_land_tax: the household's own purchases and leases only.corporate_sdltreturns 0 whengov.hmrc.stamp_duty.abolishis set.sdlt_liableonce SDLT is abolished, so thestamp_duty_land_taxliability is zero too. Before, it kept showing the pre-abolition liability.expected_sdltkeeps its own check, so abolition also zeroes it when the liability is entered directly.property_sale_rate: 0overrides 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, ornot_an_obr_current_receiptwith 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_groupandpayer;totalsit belongs to;sign, andzero_at_baselinefor 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) andsdlt_corporate(corporate_sdlt).Coverage. The 29 heads sit on 12 of Table A.5's 30 tax rows.
uncovered_receiptslists 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:
stamp_duty_land_tax, the NI classes, the income tax parts and reductions.salary_sacrifice_pension_ni_*, already charged throughemployment_income.corporate_tax_incidence, which iscorporate_sdlt + business_rates.baseline_business_rates.baseline_vat.uc_income_tax_on_earnings,ni_class_4_maximum.employer_ni_fixed_employer_cost_change.Overlap guards.
gov_taxand as a change inhousehold_tax, never in the same total.income_taxincludes the High Income Child Benefit Charge, so benefits must be counted gross of it.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:council_tax_less_benefit, matching the OBR's net receiptscouncil_tax, which is what the totals sum, with the reduction in spending. Recorded on the head and as a guardexpected_lttandexpected_lbttdiscount twicenot_an_obr_current_receiptfor separate reportingzero_at_baselineuncovered_receiptsstamp_duty_land_taxstill shows a liability after abolitionsdlt_liableI 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_headsto 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
dropped_whennames. Everysign: nonnegativehead is ≥ 0. Everyzero_at_baselinehead is 0 at current law. At most one of SDLT, LTT and LBTT is non-zero per household.expected_sdltequalsstamp_duty_land_tax. Removing corporate SDLT revenue lowers each total by exactlycorporate_sdlt. Abolition lowers each total by exactlystamp_duty_land_tax + corporate_sdlt, and leavesstamp_duty_land_tax,expected_sdltandcorporate_sdltall zero. With a dataset, weightedcorporate_sdltis the corporate revenue statistic.Tests
tests/test_fiscal_partition.pytests/test_sdlt_counted_once_properties.pytests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml,expected_sdlt.yaml,sdlt_liable.yamlRed on main, green here. I ran the final
test_fiscal_partition.py(static, traced and SDLT perturbation cases) against main's model code (3f174d0):expected_sdltreachescorporate_sdlt;corporate_sdltby 1,024 movesgov_taxby 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.
enhanced_frs_2024_25.h5, sha2565ba399ea5ab9a6c7d4e9aaf6c623080beb34d9ad5f330f6244dbad72b8d960b6, the certified release.Microsimulationruns: no scaling and no interpolation. 36 runs in all: 2 versions × 3 scenarios × 6 years.Current law, £bn a year
gov_tax, basegov_tax, headgov_tax,household_tax,pre_budget_change_household_taxandgov_balancehousehold_net_incomeexpected_sdlt, base → headThe −£3.131bn is £3.130bn of corporate SDLT plus about £1.4m of stray £0.045s across roughly 31m households.
corporate_sdlt(£3.130bn) andstamp_duty_land_taxare unchanged.Accounting checks on the data
gov_taxchange from zeroing corporate SDLT revenue, ÷corporate_sdltSDLT abolition costing. The measured effect barely moves. Abolishing SDLT changes
gov_taxby −£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:
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_incomelistscorporate_tax_incidence, but no current HBAI income formula reads it.Things this does not change
Follow-ups.
expected_lbttis £0.104bn. Land transaction tax is £0.070bn butexpected_lttis £0.003bn. The partition records this as adata_noteon both heads. The fix should follow this PR's convention, the purchase draw being the expectation.gov.hmrc.stamp_duty.statistics, never uprated. Update corporate SDLT and business rates statistics and project them with OBR receipts #2322 updates it.data_notes; W3's report flags inert heads.gov.contrib.labour.private_school_vatis 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.vathead; policyengine-uk-data#565.tests/test_property_purchased.pycompares weightedexpected_sdltwith total SDLT receipts, using wide bounds (£5–50bn).expected_sdltis now household-only (£17.3bn in 2026-27), still inside the bounds. A tighter benchmark should addcorporate_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