From e1740f0fdefc1588c048f1e192c5c829f90aa3e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 23:54:40 -0400 Subject: [PATCH 1/7] Count corporate SDLT once and let SDLT abolition remove all of it 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 --- changelog.d/corporate-sdlt-once.fixed.md | 1 + .../high_value_council_tax_surcharge.yaml | 5 - .../gov/hmrc/stamp_duty/corporate_sdlt.yaml | 132 +++++++++++++ .../gov/hmrc/stamp_duty/expected_sdlt.yaml | 68 +++++++ .../test_sdlt_counted_once_properties.py | 185 ++++++++++++++++++ .../variables/gov/hmrc/corporate_sdlt.py | 10 +- .../variables/gov/hmrc/expected_sdlt.py | 14 +- 7 files changed, 403 insertions(+), 12 deletions(-) create mode 100644 changelog.d/corporate-sdlt-once.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml create mode 100644 policyengine_uk/tests/test_sdlt_counted_once_properties.py diff --git a/changelog.d/corporate-sdlt-once.fixed.md b/changelog.d/corporate-sdlt-once.fixed.md new file mode 100644 index 0000000000..5e090b8715 --- /dev/null +++ b/changelog.d/corporate-sdlt-once.fixed.md @@ -0,0 +1 @@ +- Count corporate SDLT once in `household_tax`, `gov_tax` and `pre_budget_change_household_tax`. `expected_sdlt` no longer adds `corporate_sdlt`, which those totals already list, and `gov.hmrc.stamp_duty.abolish` now zeroes `corporate_sdlt` as well as `expected_sdlt`, so abolishing SDLT removes all of it. `expected_sdlt` also no longer adds `property_sale_rate` (a 0.045 share) to every household as £0.045 of SDLT. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml index f0f966504f..72c6e11539 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml @@ -7,7 +7,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 0 - name: High value council tax surcharge applies in England and flows into aggregates @@ -19,7 +18,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 2_500 household_tax: 2_500 @@ -34,7 +32,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 2_550 household_tax: 2_550 @@ -48,7 +45,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 0 household_tax: 0 @@ -63,7 +59,6 @@ main_residential_property_purchased: 0 council_tax: 1_500 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 2_500 household_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml new file mode 100644 index 0000000000..6b97a251d2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml @@ -0,0 +1,132 @@ +# Corporate SDLT is shareholding x (residential + non-residential corporate +# SDLT revenue) = shareholding x (£705m + £2,425m) = shareholding x £3,130m +# (gov.hmrc.stamp_duty.statistics, 2020 values held flat). A shareholding of +# 1e-6 gives £3,130 and 3e-6 gives £9,390. business_rates is set to 0 so the +# totals isolate SDLT. A £500,000 purchase that is not a first home pays +# £15,000 of SDLT (see expected_sdlt.yaml). +- name: Corporate SDLT enters household and government taxes once + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + corporate_sdlt: 3_130 + expected_sdlt: 0 + corporate_tax_incidence: 3_130 + household_tax: 3_130 + gov_tax: 3_130 + pre_budget_change_household_tax: 3_130 +- name: Household and corporate SDLT each enter the totals once + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 15_000 + corporate_sdlt: 3_130 + household_tax: 18_130 + gov_tax: 18_130 + pre_budget_change_household_tax: 18_130 +- name: Abolishing SDLT removes household and corporate SDLT from the totals + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.abolish: true + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 15_000 + expected_sdlt: 0 + corporate_sdlt: 0 + corporate_tax_incidence: 0 + household_tax: 0 + gov_tax: 0 + pre_budget_change_household_tax: 0 +- name: Two households, baseline - each household's SDLT counted once + period: 2026 + absolute_error_margin: 0.01 + input: + people: + buyer: + age: 40 + shareholder: + age: 60 + benunits: + benunit_1: + members: [buyer] + benunit_2: + members: [shareholder] + households: + household_1: + members: [buyer] + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + household_2: + members: [shareholder] + region: SCOTLAND + shareholding: 0.000003 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: [15_000, 0] + corporate_sdlt: [3_130, 9_390] + household_tax: [18_130, 9_390] + gov_tax: [18_130, 9_390] +- name: Two households, SDLT abolished - no SDLT left in either household's totals + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.abolish: true + people: + buyer: + age: 40 + shareholder: + age: 60 + benunits: + benunit_1: + members: [buyer] + benunit_2: + members: [shareholder] + households: + household_1: + members: [buyer] + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + household_2: + members: [shareholder] + region: SCOTLAND + shareholding: 0.000003 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: [0, 0] + corporate_sdlt: [0, 0] + household_tax: [0, 0] + gov_tax: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml new file mode 100644 index 0000000000..0261edc9fe --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml @@ -0,0 +1,68 @@ +# expected_sdlt is the household's own SDLT, with nothing added to it. +# Residential rates from 1 April 2025 (gov.uk/stamp-duty-land-tax/residential-property-rates): +# 0% to £125,000; 2% to £250,000; 5% to £925,000. A £500,000 purchase that is +# not a first home pays 2% x £125,000 + 5% x £250,000 = £15,000. +- name: No purchase and no corporate wealth means no SDLT in the tax totals (England) + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 0 + expected_sdlt: 0 + corporate_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: No SDLT in the tax totals of a household outside England and Northern Ireland (Scotland) + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: No SDLT in the tax totals of a household outside England and Northern Ireland (Wales) + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: Expected SDLT on a purchase equals the SDLT liability + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 15_000 + expected_sdlt: 15_000 + household_tax: 15_000 + gov_tax: 15_000 +- name: Abolishing SDLT zeroes expected SDLT but not the liability variable + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.stamp_duty.abolish: true + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 15_000 + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 diff --git a/policyengine_uk/tests/test_sdlt_counted_once_properties.py b/policyengine_uk/tests/test_sdlt_counted_once_properties.py new file mode 100644 index 0000000000..f9b970dbd8 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_counted_once_properties.py @@ -0,0 +1,185 @@ +"""Property-based tests: Stamp Duty Land Tax enters the tax totals once. + +SDLT reaches a household in two parts. ``expected_sdlt`` is the SDLT on the +household's own purchases and leases (``stamp_duty_land_tax``), and +``corporate_sdlt`` is its share, through ``shareholding``, of the SDLT that +corporations pay. ``household_tax``, ``gov_tax`` and +``pre_budget_change_household_tax`` each list both parts, so each part must +appear in them exactly once, and ``gov.hmrc.stamp_duty.abolish`` must remove +both. + +Each example builds a two-household population and simulates it three ways: + +- B: the baseline; +- A: with ``gov.hmrc.stamp_duty.abolish`` set; +- Z: the baseline with both corporate SDLT revenue statistics set to zero. + +The population varies the year, each household's region (inside and outside +England and Northern Ireland), purchases of main, additional and +non-residential property (or none), first-home status, earnings, corporate +wealth (zero or positive in either household) and positive weights. "Equal" +below means equal up to the float32 rounding of the totals (32 float32 ulps +of the largest value compared, per household); "exactly" means bit-equal. + +1. Own SDLT: ``expected_sdlt`` in B is exactly ``stamp_duty_land_tax``, so a + household with no purchase, or outside England and Northern Ireland, has + none. +2. Corporate SDLT once: for each total T, T(B) - T(Z) equals + ``corporate_sdlt`` in B. Counting it twice would make the gap twice that. +3. Abolition removes all of it once: T(B) - T(A) equals + ``stamp_duty_land_tax`` + ``corporate_sdlt`` in B, and T(Z) - T(A) equals + ``stamp_duty_land_tax`` in B, so no corporate SDLT is left after abolition. +4. Abolition zeroes both parts: ``expected_sdlt`` and ``corporate_sdlt`` are + exactly 0 in A, ``corporate_tax_incidence`` in A equals ``business_rates``, + and ``corporate_tax_incidence`` falls by ``corporate_sdlt`` in B. +5. Weighted totals: when some household has corporate-sector wealth, the + weighted sum of ``corporate_sdlt`` in B is the corporate SDLT revenue + statistic (within a relative 1e-6), and weighted ``gov_tax`` falls under + abolition by weighted ``stamp_duty_land_tax`` plus that revenue, once. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +ALWAYS = "2000-01-01.2100-12-31" +ABOLISH_SDLT = {"gov.hmrc.stamp_duty.abolish": {ALWAYS: True}} +NO_CORPORATE_SDLT = { + "gov.hmrc.stamp_duty.statistics.residential.corporate.revenue": {ALWAYS: 0}, + "gov.hmrc.stamp_duty.statistics.non_residential.corporate.revenue": {ALWAYS: 0}, +} +TOTALS = ["household_tax", "gov_tax", "pre_budget_change_household_tax"] +VARIABLES = TOTALS + [ + "stamp_duty_land_tax", + "expected_sdlt", + "corporate_sdlt", + "corporate_tax_incidence", + "business_rates", + "corporate_sector_wealth", + "household_weight", +] +REGIONS = ["LONDON", "NORTH_WEST", "NORTHERN_IRELAND", "SCOTLAND", "WALES"] +ULPS = 32 + +purchase = st.one_of(st.just(0.0), st.floats(40_000, 5_000_000)) + +household = st.fixed_dictionaries( + { + "region": st.sampled_from(REGIONS), + "main_residential_property_purchased": purchase, + "main_residential_property_purchased_is_first_home": st.booleans(), + "additional_residential_property_purchased": purchase, + "non_residential_property_purchased": purchase, + "corporate_wealth": st.one_of(st.just(0.0), st.floats(1_000, 10_000_000)), + "household_weight": st.floats(1, 10_000), + "employment_income": st.floats(0, 200_000), + } +) + + +def situation(households: list, year: int) -> dict: + people, benunits, hh = {}, {}, {} + for i, inputs in enumerate(households): + person, benunit = f"person_{i}", f"benunit_{i}" + people[person] = { + "age": {year: 40 + 20 * i}, + "employment_income": {year: inputs["employment_income"]}, + } + benunits[benunit] = {"members": [person]} + hh[f"household_{i}"] = { + "members": [person], + **{ + name: {year: value} + for name, value in inputs.items() + if name != "employment_income" + }, + } + return {"people": people, "benunits": benunits, "households": hh} + + +def simulate(households: list, year: int, reform=None) -> dict: + sim = Simulation(situation=situation(households, year), reform=reform) + return { + name: np.asarray(sim.calculate(name, year), dtype=np.float64) + for name in VARIABLES + } + + +def tolerance(*arrays) -> np.ndarray: + """Per-household rounding allowance for float32 totals.""" + magnitude = np.max(np.abs(np.stack(arrays)), axis=0) + return ULPS * np.spacing(magnitude.astype(np.float32)).astype(np.float64) + + +def corporate_sdlt_revenue(year: int) -> float: + statistics = system.parameters(str(year)).gov.hmrc.stamp_duty.statistics + return float( + statistics.residential.corporate.revenue + + statistics.non_residential.corporate.revenue + ) + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + households=st.lists(household, min_size=2, max_size=2), + year=st.sampled_from([2024, 2025, 2026, 2027]), +) +def test_sdlt_is_counted_once_and_abolition_removes_it(households, year): + b = simulate(households, year) + a = simulate(households, year, ABOLISH_SDLT) + z = simulate(households, year, NO_CORPORATE_SDLT) + + sdlt = b["stamp_duty_land_tax"] + corporate = b["corporate_sdlt"] + + # 1. Own SDLT, with nothing added. + np.testing.assert_array_equal(b["expected_sdlt"], sdlt) + + for total in TOTALS: + # 2. Corporate SDLT is in the baseline total once. + np.testing.assert_array_less( + np.abs(b[total] - z[total] - corporate), + tolerance(b[total], z[total], corporate) + 1e-9, + err_msg=f"{total}: corporate SDLT not counted exactly once", + ) + # 3. Abolition removes household and corporate SDLT, once. + np.testing.assert_array_less( + np.abs(b[total] - a[total] - (sdlt + corporate)), + tolerance(b[total], a[total], sdlt + corporate) + 1e-9, + err_msg=f"{total}: abolition did not remove all SDLT once", + ) + np.testing.assert_array_less( + np.abs(z[total] - a[total] - sdlt), + tolerance(z[total], a[total], sdlt) + 1e-9, + err_msg=f"{total}: corporate SDLT left after abolition", + ) + + # 4. Abolition zeroes both parts and the corporate incidence they feed. + np.testing.assert_array_equal(a["expected_sdlt"], 0) + np.testing.assert_array_equal(a["corporate_sdlt"], 0) + np.testing.assert_array_equal(a["corporate_tax_incidence"], a["business_rates"]) + np.testing.assert_array_less( + np.abs(b["corporate_tax_incidence"] - a["corporate_tax_incidence"] - corporate), + tolerance(b["corporate_tax_incidence"], corporate) + 1e-9, + ) + + # 5. Weighted, the corporate part is the revenue statistic, once. + weight = b["household_weight"] + if (b["corporate_sector_wealth"] * weight).sum() > 0: + revenue = corporate_sdlt_revenue(year) + assert np.isclose((corporate * weight).sum(), revenue, rtol=1e-6, atol=0) + removed = ((b["gov_tax"] - a["gov_tax"]) * weight).sum() + allowance = (tolerance(b["gov_tax"], a["gov_tax"]) * weight).sum() + assert abs(removed - ((sdlt * weight).sum() + revenue)) <= allowance + ( + 1e-6 * revenue + ) + else: + np.testing.assert_array_equal(corporate, 0) diff --git a/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py b/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py index 91e2b68485..9e1195d85e 100644 --- a/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py +++ b/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py @@ -3,14 +3,20 @@ class corporate_sdlt(Variable): label = "Stamp Duty (corporations)" - documentation = "Stamp Duty paid by corporations, incident on this household" + documentation = ( + "Stamp Duty paid by corporations, incident on this household. " + "Zero when Stamp Duty Land Tax is abolished." + ) entity = Household definition_period = YEAR value_type = float unit = GBP def formula(household, period, parameters): - sd = parameters(period).gov.hmrc.stamp_duty.statistics + stamp_duty = parameters(period).gov.hmrc.stamp_duty + if stamp_duty.abolish: + return 0 + sd = stamp_duty.statistics return household("shareholding", period) * ( sd.residential.corporate.revenue + sd.non_residential.corporate.revenue ) diff --git a/policyengine_uk/variables/gov/hmrc/expected_sdlt.py b/policyengine_uk/variables/gov/hmrc/expected_sdlt.py index f15450335b..f34677e092 100644 --- a/policyengine_uk/variables/gov/hmrc/expected_sdlt.py +++ b/policyengine_uk/variables/gov/hmrc/expected_sdlt.py @@ -3,7 +3,14 @@ class expected_sdlt(Variable): label = "Stamp Duty (expected)" - documentation = "Expected value of Stamp Duty Land Tax" + documentation = ( + "Expected annual Stamp Duty Land Tax on the household's own property " + "purchases and leases. Population datasets mark only the households " + "that buy property in the year as purchasers, so this is " + "stamp_duty_land_tax with no further scaling. SDLT paid by " + "corporations and incident on the household is corporate_sdlt, which " + "the tax totals count separately." + ) entity = Household definition_period = YEAR value_type = float @@ -12,7 +19,4 @@ class expected_sdlt(Variable): def formula(household, period, parameters): if parameters(period).gov.hmrc.stamp_duty.abolish: return 0 - return ( - household("stamp_duty_land_tax", period) - + parameters(period).gov.hmrc.stamp_duty.property_sale_rate - ) + household("corporate_sdlt", period) + return household("stamp_duty_land_tax", period) From 2b6738ba516df9391373a09bc35fb7164514f2f5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 12:13:54 -0400 Subject: [PATCH 2/7] Describe the imputed purchaser indicator accurately in expected_sdlt Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/hmrc/expected_sdlt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/expected_sdlt.py b/policyengine_uk/variables/gov/hmrc/expected_sdlt.py index f34677e092..1b294f6645 100644 --- a/policyengine_uk/variables/gov/hmrc/expected_sdlt.py +++ b/policyengine_uk/variables/gov/hmrc/expected_sdlt.py @@ -5,9 +5,9 @@ class expected_sdlt(Variable): label = "Stamp Duty (expected)" documentation = ( "Expected annual Stamp Duty Land Tax on the household's own property " - "purchases and leases. Population datasets mark only the households " - "that buy property in the year as purchasers, so this is " - "stamp_duty_land_tax with no further scaling. SDLT paid by " + "purchases and leases. The FRS-based datasets impute an annual " + "purchaser indicator (a seeded draw at the property purchase rate), " + "so this is stamp_duty_land_tax with no further scaling. SDLT paid by " "corporations and incident on the household is corporate_sdlt, which " "the tax totals count separately." ) From 02a3c1e34b778e32736ddbd6157cc739fd4618d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:43:48 -0400 Subject: [PATCH 3/7] Specify the tax-head partition and test the totals against it 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 --- changelog.d/fiscal-partition.added.md | 1 + policyengine_uk/data/fiscal_partition.yaml | 525 ++++++++++++++++++ .../gov/hmrc/stamp_duty/corporate_sdlt.yaml | 36 ++ .../tests/test_fiscal_partition.py | 507 +++++++++++++++++ .../test_sdlt_counted_once_properties.py | 2 +- 5 files changed, 1070 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fiscal-partition.added.md create mode 100644 policyengine_uk/data/fiscal_partition.yaml create mode 100644 policyengine_uk/tests/test_fiscal_partition.py diff --git a/changelog.d/fiscal-partition.added.md b/changelog.d/fiscal-partition.added.md new file mode 100644 index 0000000000..5cc874f4ce --- /dev/null +++ b/changelog.d/fiscal-partition.added.md @@ -0,0 +1 @@ +- Add `policyengine_uk/data/fiscal_partition.yaml`, a tax-head partition of `gov_tax`, `household_tax` and `pre_budget_change_household_tax` (one OBR receipts line per head, explicit exclusions and overlap guards), with tests that each total is the sum of its heads and counts each head once. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml new file mode 100644 index 0000000000..b04bd7b151 --- /dev/null +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -0,0 +1,525 @@ +# Fiscal partition: how the model's tax totals divide into tax heads. +# +# Each head is one receipt (an OBR current-receipts line, split by who pays +# where the model splits it) and names the one variable that measures the +# household's share of it. Heads are mutually exclusive: no head's value +# contains another head's value, and no variable is summed twice in a total. +# +# The heads were specified from the OBR receipts classification and from each +# variable's formula, not copied from the model's own lists +# (GOV_TAX_VARIABLES, HOUSEHOLD_TAX_VARIABLES, +# PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES). policyengine_uk/tests/ +# test_fiscal_partition.py checks the two against each other and checks the +# accounting properties below on simulated households. +# +# Consumers (tests, the per-PR aggregate impact report) read this file +# directly. Bump schema_version on any change to field meanings. +# +# Fields +# tax_heads[]: +# id one per head +# label sentence-case name +# variable the one model variable that measures the head +# entity household or person +# obr_line OBR current-receipts line the head belongs to, or +# not_an_obr_current_receipt +# report_group grouping for reports +# payer who bears it in the model: household (the household +# or its members pay it directly), employer, consumer +# incidence (allocated by consumption), corporate +# incidence (allocated by shareholding), or mixed +# totals the totals that include it +# sign nonnegative (at current-law parameter values, for +# non-negative inputs) or any (a change measure) +# zero_at_baseline expected: current-law parameters make it zero for +# every household (a reform-only head) +# reads other heads this head's formula reaches, with the +# route; reads that change eligibility, not inclusion +# exclusions[]: variables that must never be summed alongside the heads. +# kind: component (summed or read inside `head`), measure_within (an +# analytical decomposition already inside `head` through its inputs), +# composite (spans several heads), duplicate (same value as `head`), +# baseline_measure. +# model_double_counts[]: known violations still in the model (none). +# overlap_guards[]: the places double counting could occur, and the +# property the tests check for each. +schema_version: 1 + +totals: + gov_tax: + label: Government tax revenue in respect of the household + description: Receipts at their level, including employer-paid taxes and VAT. + dropped_when: + gov.contrib.abolish_council_tax: + - council_tax + - high_value_council_tax_surcharge + household_tax: + label: Taxes borne by the household + description: >- + Taxes subtracted from household net income. VAT and employer NI enter + only as reform-induced changes (vat_change and the employer NI response + incidence), because baseline VAT and employer NI are not deducted from + the incomes the model starts from. + dropped_when: + gov.contrib.abolish_council_tax: + - council_tax + - high_value_council_tax_surcharge + pre_budget_change_household_tax: + label: Taxes borne by the household, pre-budget-change definition + description: >- + A fixed earlier definition of household_tax used by + pre_budget_change_household_net_income. It omits heads added since + (alcohol, tobacco and vehicle excise duties, private school VAT, the + high value council tax surcharge, student loan repayments, and the + incidence-change heads). + dropped_when: + gov.contrib.abolish_council_tax: + - council_tax + +tax_heads: + # Income and earnings + - id: income_tax + label: Income tax + variable: income_tax + entity: person + obr_line: Income tax + report_group: income_tax + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: nics_individual + label: National Insurance (employee and self-employed) + variable: national_insurance + entity: person + obr_line: National insurance contributions + report_group: national_insurance + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: nics_employer + label: National Insurance (employer) + variable: ni_employer + entity: person + obr_line: National insurance contributions + report_group: national_insurance + payer: employer + totals: [gov_tax] + sign: nonnegative + - id: employer_nics_response_capital + label: Employer NI change borne by capital + variable: employer_ni_response_capital_incidence + entity: person + obr_line: National insurance contributions + report_group: national_insurance + payer: corporate incidence + totals: [household_tax] + sign: any + zero_at_baseline: expected + - id: employer_nics_response_consumer + label: Employer NI change borne by consumers + variable: employer_ni_response_consumer_incidence + entity: person + obr_line: National insurance contributions + report_group: national_insurance + payer: consumer incidence + totals: [household_tax] + sign: any + zero_at_baseline: expected + - id: capital_gains_tax + label: Capital gains tax + variable: capital_gains_tax + entity: person + obr_line: Capital gains tax + report_group: capital_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: student_loan_repayments + label: Student loan repayments + variable: student_loan_repayments + entity: person + obr_line: not_an_obr_current_receipt + obr_line_note: >- + A financial transaction: repayments reduce the student loan book, not + borrowing, so they are outside current receipts. The model counts them + in gov_tax and household_tax; reports should show them separately. + report_group: financial_transactions + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + + # Consumption + - id: vat + label: VAT + variable: vat + entity: household + obr_line: Value added tax + report_group: vat + payer: consumer incidence + totals: [gov_tax] + sign: nonnegative + - id: vat_change + label: Change in VAT from its baseline + variable: vat_change + entity: household + obr_line: Value added tax + report_group: vat + payer: consumer incidence + totals: [household_tax, pre_budget_change_household_tax] + sign: any + zero_at_baseline: expected + reads: + - head: vat + route: vat_change adds vat and subtracts baseline_vat + why: a change measure built from the level; the two are never in the same total + - id: private_school_vat + label: VAT on private school fees + variable: private_school_vat + entity: household + obr_line: Value added tax + report_group: vat + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + zero_at_baseline: expected + zero_at_baseline_note: >- + gov.contrib.labour.private_school_vat is 0 at current law in this + model, so the head is reform-only here. + reads: + - head: income_tax + route: private_school_vat -> attends_private_school -> income_tax + why: attendance is imputed from income; it decides who attends, not how much tax is counted + - head: nics_individual + route: private_school_vat -> attends_private_school -> (net income) -> national_insurance + why: as above + - head: council_tax + route: private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax + why: as above + - id: fuel_duty + label: Fuel duties + variable: fuel_duty + entity: household + obr_line: Fuel duties + report_group: excise_duties + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: alcohol_duty + label: Alcohol duties + variable: alcohol_duty + entity: household + obr_line: Alcohol duties + report_group: excise_duties + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + data_note: >- + Zero for every household on policyengine-uk-data 1.58.0 + (enhanced_frs_2024_25) in every year from 2025 to 2030, measured + 2026-10-11. + - id: tobacco_duty + label: Tobacco duties + variable: tobacco_duty + entity: household + obr_line: Tobacco duties + report_group: excise_duties + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + data_note: >- + Zero for every household on policyengine-uk-data 1.58.0 + (enhanced_frs_2024_25) in every year from 2025 to 2030, measured + 2026-10-11. + - id: vehicle_excise_duty + label: Vehicle excise duties + variable: vehicle_excise_duty + entity: household + obr_line: Vehicle excise duties + report_group: excise_duties + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + data_note: >- + Zero for every household on policyengine-uk-data 1.58.0 + (enhanced_frs_2024_25) in every year from 2025 to 2030, measured + 2026-10-11. + - id: tv_licence + label: TV licence fee + variable: tv_licence + entity: household + obr_line: Licence fee receipts + report_group: other_receipts + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + reads: + - head: income_tax + route: tv_licence -> tv_licence_discount -> pension_credit -> pension_credit_income -> income_tax + why: the over-75 discount depends on Pension Credit receipt, a means test on net income + - head: nics_individual + route: tv_licence -> tv_licence_discount -> pension_credit -> pension_credit_income -> national_insurance + why: as above + + # Property + - id: council_tax + label: Council tax + variable: council_tax + entity: household + obr_line: Council tax + report_group: local_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: high_value_council_tax_surcharge + label: High value council tax surcharge + variable: high_value_council_tax_surcharge + entity: household + obr_line: Council tax + report_group: local_taxes + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + - id: domestic_rates + label: Northern Ireland domestic rates + variable: domestic_rates + entity: household + obr_line: Council tax + obr_line_note: >- + Northern Ireland's domestic rates are its counterpart to council tax; + check the line against the OBR table in use before publishing a + line-by-line comparison. + report_group: local_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: business_rates + label: Business rates + variable: business_rates + entity: household + obr_line: Business rates + report_group: business_rates + payer: corporate incidence + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: sdlt_household + label: Stamp duty land tax on the household's own purchases + variable: expected_sdlt + entity: household + obr_line: Stamp duty land tax + report_group: property_transaction_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: sdlt_corporate + label: Stamp duty land tax paid by companies + variable: corporate_sdlt + entity: household + obr_line: Stamp duty land tax + report_group: property_transaction_taxes + payer: corporate incidence + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: land_transaction_tax + label: Land transaction tax (Wales) + variable: expected_ltt + entity: household + obr_line: Land transaction tax + report_group: property_transaction_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + data_note: >- + expected_ltt multiplies an already purchase-drawn liability by + property_sale_rate, discounting it twice (issue 2246). + - id: land_and_buildings_transaction_tax + label: Land and buildings transaction tax (Scotland) + variable: expected_lbtt + entity: household + obr_line: Land and buildings transaction tax + report_group: property_transaction_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + data_note: >- + expected_lbtt multiplies an already purchase-drawn liability by + property_sale_rate, discounting it twice (issue 2246). + + # Reform-only heads + - id: corporate_incident_tax_change + label: Unspecified tax change borne by capital + variable: corporate_incident_tax_revenue_change + entity: household + obr_line: not_an_obr_current_receipt + obr_line_note: A lump-sum revenue change allocated by shareholding. + report_group: incidence_changes + payer: corporate incidence + totals: [gov_tax, household_tax] + sign: any + zero_at_baseline: expected + - id: consumer_incident_tax_change + label: Unspecified tax change borne by consumers + variable: consumer_incident_tax_revenue_change + entity: household + obr_line: not_an_obr_current_receipt + obr_line_note: A lump-sum revenue change allocated by consumption. + report_group: incidence_changes + payer: consumer incidence + totals: [gov_tax, household_tax] + sign: any + zero_at_baseline: expected + - id: wealth_tax + label: Wealth tax + variable: wealth_tax + entity: household + obr_line: not_an_obr_current_receipt + obr_line_note: A hypothetical tax. + report_group: hypothetical_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + zero_at_baseline: expected + - id: non_primary_residence_wealth_tax + label: Wealth tax excluding the main residence + variable: non_primary_residence_wealth_tax + entity: household + obr_line: not_an_obr_current_receipt + obr_line_note: A hypothetical tax. + report_group: hypothetical_taxes + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + zero_at_baseline: expected + - id: land_value_tax + label: Land value tax + variable: LVT + entity: household + obr_line: not_an_obr_current_receipt + obr_line_note: A hypothetical tax. + report_group: hypothetical_taxes + payer: mixed + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + zero_at_baseline: expected + - id: carbon_tax + label: Carbon tax + variable: carbon_tax + entity: household + obr_line: not_an_obr_current_receipt + obr_line_note: A hypothetical tax. + report_group: hypothetical_taxes + payer: mixed + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + zero_at_baseline: expected + +exclusions: + # Property transaction taxes + - {variable: stamp_duty_land_tax, kind: component, head: sdlt_household, note: "Equal to expected_sdlt, but not zeroed by gov.hmrc.stamp_duty.abolish; report the head, not this."} + - {variable: sdlt_on_transactions, kind: component, head: sdlt_household} + - {variable: sdlt_on_rent, kind: component, head: sdlt_household} + - {variable: sdlt_on_residential_property_transactions, kind: component, head: sdlt_household} + - {variable: sdlt_on_non_residential_property_transactions, kind: component, head: sdlt_household} + - {variable: sdlt_on_residential_property_rent, kind: component, head: sdlt_household} + - {variable: sdlt_on_non_residential_property_rent, kind: component, head: sdlt_household} + - {variable: land_transaction_tax, kind: component, head: land_transaction_tax, note: "The liability before the property_sale_rate scaling (issue 2246)."} + - {variable: ltt_on_transactions, kind: component, head: land_transaction_tax} + - {variable: ltt_on_rent, kind: component, head: land_transaction_tax} + - {variable: ltt_on_residential_property_transactions, kind: component, head: land_transaction_tax} + - {variable: ltt_on_non_residential_property_transactions, kind: component, head: land_transaction_tax} + - {variable: ltt_on_residential_property_rent, kind: component, head: land_transaction_tax} + - {variable: ltt_on_non_residential_property_rent, kind: component, head: land_transaction_tax} + - {variable: land_and_buildings_transaction_tax, kind: component, head: land_and_buildings_transaction_tax, note: "The liability before the property_sale_rate scaling (issue 2246)."} + - {variable: lbtt_on_transactions, kind: component, head: land_and_buildings_transaction_tax} + - {variable: lbtt_on_rent, kind: component, head: land_and_buildings_transaction_tax} + - {variable: lbtt_on_residential_property_transactions, kind: component, head: land_and_buildings_transaction_tax} + - {variable: lbtt_on_non_residential_property_transactions, kind: component, head: land_and_buildings_transaction_tax} + - {variable: lbtt_on_residential_property_rent, kind: component, head: land_and_buildings_transaction_tax} + - {variable: lbtt_on_non_residential_property_rent, kind: component, head: land_and_buildings_transaction_tax} + - {variable: corporate_tax_incidence, kind: composite, spans: [sdlt_corporate, business_rates]} + - {variable: hbai_excluded_income, kind: composite, spans: [sdlt_corporate, business_rates], note: "Minus corporate_tax_incidence."} + - {variable: baseline_business_rates, kind: duplicate, head: business_rates, note: "Same formula and current-law parameters as business_rates."} + # Income tax and National Insurance + - {variable: income_tax_pre_charges, kind: component, head: income_tax} + - {variable: earned_income_tax, kind: component, head: income_tax} + - {variable: savings_income_tax, kind: component, head: income_tax} + - {variable: dividend_income_tax, kind: component, head: income_tax} + - {variable: property_income_tax, kind: component, head: income_tax} + - {variable: CB_HITC, kind: component, head: income_tax} + - {variable: personal_pension_contributions_tax, kind: component, head: income_tax} + - {variable: ni_employee, kind: measure_within, head: nics_individual, note: "Class 1 employee; national_insurance adds the classes directly."} + - {variable: ni_self_employed, kind: measure_within, head: nics_individual, note: "Classes 2 and 4; national_insurance adds the classes directly."} + - {variable: ni_class_1_employee, kind: component, head: nics_individual} + - {variable: ni_class_1_employee_primary, kind: component, head: nics_individual} + - {variable: ni_class_1_employee_additional, kind: component, head: nics_individual} + - {variable: ni_class_2, kind: component, head: nics_individual} + - {variable: ni_class_3, kind: component, head: nics_individual} + - {variable: ni_class_4, kind: component, head: nics_individual} + - {variable: ni_class_1_employer, kind: component, head: nics_employer} + - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to employment_income, so Class 1 already charges it."} + - {variable: salary_sacrifice_pension_ni_employer, kind: measure_within, head: nics_employer, note: "As above, for employer Class 1."} + - {variable: tax, kind: composite, spans: [income_tax, nics_individual]} + - {variable: benunit_tax, kind: composite, spans: [income_tax, nics_individual]} + - {variable: total_national_insurance, kind: composite, spans: [nics_individual, nics_employer]} + - {variable: student_loan_repayment, kind: component, head: student_loan_repayments} + # Consumption + - {variable: baseline_vat, kind: baseline_measure, head: vat_change, note: "Subtracted inside vat_change."} + - {variable: beer_duty, kind: component, head: alcohol_duty} + - {variable: cider_duty, kind: component, head: alcohol_duty} + - {variable: sparkling_cider_duty, kind: component, head: alcohol_duty} + - {variable: wine_duty, kind: component, head: alcohol_duty} + - {variable: spirits_duty, kind: component, head: alcohol_duty} + - {variable: other_fermented_duty, kind: component, head: alcohol_duty} + - {variable: car_vehicle_excise_duty, kind: component, head: vehicle_excise_duty} + # Council tax net of a benefit + - {variable: council_tax_less_benefit, kind: composite, spans: [council_tax], note: "Council tax minus Council Tax Reduction, a benefit; not a receipt."} + +model_double_counts: [] + +overlap_guards: + - id: sdlt_payer_split + kind: payer_split + heads: [sdlt_household, sdlt_corporate] + property: >- + Neither head reads the other. gov.hmrc.stamp_duty.abolish zeroes both, + so abolition lowers each total by exactly their sum. With a dataset, + weighted sdlt_corporate equals the corporate SDLT revenue statistics. + (Issue 2159: expected_sdlt used to add corporate_sdlt.) + - id: property_transaction_tax_geography + kind: geographic_exclusivity + heads: [sdlt_household, land_transaction_tax, land_and_buildings_transaction_tax] + property: At most one is non-zero for any household (England and Northern Ireland, Wales, Scotland). + - id: local_tax_geography + kind: geographic_exclusivity + heads: [council_tax, domestic_rates] + property: >- + No household has both. Both are inputs, so this is a property of the + data, checked on the certified dataset. + - id: vat_level_and_change + kind: level_and_change + heads: [vat, vat_change] + property: Never in the same total. vat_change reads vat. + - id: employer_nics_level_and_incidence + kind: level_and_change + heads: [nics_employer, employer_nics_response_capital, employer_nics_response_consumer] + property: The level is only in gov_tax; the response incidence is only in household_tax. + - id: corporate_incidence_allocations + kind: shared_allocation_key + heads: [business_rates, sdlt_corporate, corporate_incident_tax_change, carbon_tax, land_value_tax] + property: >- + Each allocates a different receipt by shareholding; none reads another. + corporate_tax_incidence sums two of them and is excluded. + - id: private_school_vat_and_vat + kind: possible_base_overlap + heads: [vat, private_school_vat] + property: >- + vat applies to imputed consumption; if that consumption includes school + fees, turning on private_school_vat would tax them twice. The head is + zero at baseline, so baseline totals are unaffected; check the + consumption imputation before costing a reform that sets it. + - id: eligibility_reads + kind: read_not_inclusion + heads: [tv_licence, private_school_vat] + property: >- + These heads reach other heads of the same total only through + eligibility or imputation (see each head's reads). Raising the other + head by a small amount leaves them unchanged on the test population. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml index 6b97a251d2..15ad990f35 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml @@ -130,3 +130,39 @@ corporate_sdlt: [0, 0] household_tax: [0, 0] gov_tax: [0, 0] +# Abolition removes SDLT and nothing else. Corporate SDLT reaches shareholders +# wherever they live; business rates, the other corporate tax allocated by +# shareholding, stays. +- name: A Welsh shareholder bears corporate SDLT and business rates, each once + period: 2026 + absolute_error_margin: 0.01 + input: + region: WALES + shareholding: 0.000001 + business_rates: 500 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + corporate_sdlt: 3_130 + corporate_tax_incidence: 3_630 + household_tax: 3_630 + gov_tax: 3_630 + pre_budget_change_household_tax: 3_630 +- name: Abolishing SDLT removes the Welsh shareholder's corporate SDLT and keeps business rates + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.abolish: true + region: WALES + shareholding: 0.000001 + business_rates: 500 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + corporate_sdlt: 0 + corporate_tax_incidence: 500 + household_tax: 500 + gov_tax: 500 + pre_budget_change_household_tax: 500 diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py new file mode 100644 index 0000000000..f64b8aefcb --- /dev/null +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -0,0 +1,507 @@ +"""The tax totals against an independently specified tax-head partition. + +``policyengine_uk/data/fiscal_partition.yaml`` divides UK tax receipts into +heads, one OBR current-receipts line (split by payer where the model splits +it) per head, and names the one variable that measures each. It was written +from the OBR classification and the variables' formulas, not from the +model's lists, so these tests compare two specifications: + +1. Schema: ids and variables are unique, every reference resolves, and no + variable is both a head and an exclusion. +2. Differential: for each total, the heads the partition assigns to it are + exactly the variables the model sums (``GOV_TAX_VARIABLES``, + ``HOUSEHOLD_TAX_VARIABLES``, ``PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES``), + each listed once. +3. Overlap guard (traced): within a total, no head's formula reaches another + head of that total unless the partition declares the read (an + eligibility or imputation route) under the head's ``reads``; every + declared read occurs. Component and baseline-measure exclusions are reached + from their head; composite, duplicate and measure-within exclusions are + reached from no head. +4. Coefficient one (perturbation): raising one head by delta, with every + other input unchanged, raises each total that includes it by exactly + delta per member and leaves every other head of those totals unchanged. + A head counted twice, as corporate SDLT was through ``expected_sdlt`` + (#2159), moves its totals by 2 x delta. +5. Identity (property-based): on random households, years 2025-2030, and + under SDLT abolition and council tax abolition, each total equals the sum + of its heads, less the heads ``dropped_when`` names. Heads marked + ``sign: nonnegative`` are non-negative and heads marked + ``zero_at_baseline: expected`` are zero at current law; at most one of + SDLT, LTT and LBTT is non-zero per household. + +"Equal" allows float32 rounding: 64 float32 ulps of the largest magnitude +compared, per household. +""" + +from collections import defaultdict +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +import policyengine_uk +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.variables.contrib.policyengine.pre_budget_change_household_tax import ( + PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES, +) +from policyengine_uk.variables.gov.gov_tax import GOV_TAX_VARIABLES +from policyengine_uk.variables.gov.hmrc.household_tax import HOUSEHOLD_TAX_VARIABLES + +PARTITION_PATH = ( + Path(policyengine_uk.__file__).parent / "data" / "fiscal_partition.yaml" +) +MODEL_LISTS = { + "gov_tax": GOV_TAX_VARIABLES, + "household_tax": HOUSEHOLD_TAX_VARIABLES, + "pre_budget_change_household_tax": PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES, +} +EXCLUSION_KINDS = { + "component", + "measure_within", + "composite", + "duplicate", + "baseline_measure", +} +ULPS = 64 +ALWAYS = "2000-01-01.2100-12-31" + + +@lru_cache(maxsize=None) +def partition() -> dict: + return yaml.safe_load(PARTITION_PATH.read_text()) + + +def heads() -> list: + return partition()["tax_heads"] + + +def head_by_id() -> dict: + return {head["id"]: head for head in heads()} + + +def heads_of(total: str) -> list: + return [head for head in heads() if total in head["totals"]] + + +def tolerance(*arrays) -> np.ndarray: + magnitude = np.max(np.abs(np.stack(arrays)), axis=0) + return ULPS * np.spacing(magnitude.astype(np.float32)).astype(np.float64) + + +# 1. Schema + + +def test_partition_schema(): + p = partition() + assert p["schema_version"] == 1 + totals = set(p["totals"]) + assert totals == set(MODEL_LISTS) + ids = [head["id"] for head in heads()] + variables = [head["variable"] for head in heads()] + assert len(ids) == len(set(ids)), "duplicate head id" + assert len(variables) == len(set(variables)), "a variable is in two heads" + for head in heads(): + variable = system.variables[head["variable"]] + assert head["entity"] == variable.entity.key, head["id"] + assert set(head["totals"]) <= totals, head["id"] + assert head["sign"] in {"nonnegative", "any"}, head["id"] + assert head.get("zero_at_baseline", "expected") == "expected", head["id"] + assert head["obr_line"], head["id"] + assert head["report_group"], head["id"] + for read in head.get("reads", []): + assert read["head"] in head_by_id(), (head["id"], read) + excluded = [x["variable"] for x in p["exclusions"]] + assert len(excluded) == len(set(excluded)), "duplicate exclusion" + assert not set(excluded) & set(variables), "a head variable is also excluded" + for x in p["exclusions"]: + assert x["variable"] in system.variables, x + assert x["kind"] in EXCLUSION_KINDS, x + if x["kind"] == "composite": + assert set(x["spans"]) <= set(ids), x + else: + assert x["head"] in ids, x + for total, spec in p["totals"].items(): + for switch, dropped in spec.get("dropped_when", {}).items(): + system.parameters.get_child(switch) + assert set(dropped) <= {h["variable"] for h in heads_of(total)} + for guard in p["overlap_guards"]: + assert set(guard["heads"]) <= set(ids), guard["id"] + assert p["model_double_counts"] == [] + + +# 2. Differential against the model's lists + + +@pytest.mark.parametrize("total", sorted(MODEL_LISTS)) +def test_partition_heads_are_the_variables_each_total_sums(total): + listed = MODEL_LISTS[total] + assert len(listed) == len(set(listed)), f"{total} lists a variable twice" + specified = {head["variable"] for head in heads_of(total)} + assert set(listed) == specified, ( + f"{total}: listed but not a head: {sorted(set(listed) - specified)}; " + f"a head of it but not listed: {sorted(specified - set(listed))}" + ) + excluded = {x["variable"] for x in partition()["exclusions"]} + assert not set(listed) & excluded + + +# Population for the traced and perturbation tests: every nation, buyers of +# property in each, shareholders, a self-employed person with gains and a +# student loan, a two-adult household, an owner liable for the high value +# council tax surcharge, and an over-75 pensioner (the TV licence route). +# shareholding is set directly: derived from corporate wealth, it would split +# all UK corporate taxes across these few households, and values in the +# billions leave float32 too coarse to see a 1,024 change. + +YEAR = 2028 +PEOPLE = { + "london_buyer": {"age": 45, "employment_income": 90_000}, + "welsh_buyer": {"age": 35, "employment_income": 40_000}, + "scottish_buyer": {"age": 50, "employment_income": 60_000}, + "ni_earner": {"age": 40, "employment_income": 30_000}, + "ni_partner": {"age": 38, "employment_income": 15_000}, + "self_employed": { + "age": 33, + "self_employment_income": 45_000, + "capital_gains": 25_000, + "student_loan_plan": "PLAN_2", + "employment_income": 10_000, + }, + "pensioner": {"age": 80}, +} +HOUSEHOLDS = { + "london": { + "members": ["london_buyer"], + "region": "LONDON", + "main_residential_property_purchased": 600_000, + "additional_residential_property_purchased": 300_000, + "non_residential_property_purchased": 200_000, + "shareholding": 1e-6, + "council_tax": 2_000, + "consumption": 40_000, + "petrol_litres": 900, + "tenure_type": "OWNED_WITH_MORTGAGE", + "main_residence_value": 3_000_000, + }, + "wales": { + "members": ["welsh_buyer"], + "region": "WALES", + "main_residential_property_purchased": 400_000, + "council_tax": 1_800, + "consumption": 25_000, + }, + "scotland": { + "members": ["scottish_buyer"], + "region": "SCOTLAND", + "main_residential_property_purchased": 450_000, + "shareholding": 1e-7, + "council_tax": 1_700, + "consumption": 30_000, + }, + "northern_ireland": { + "members": ["ni_earner", "ni_partner"], + "region": "NORTHERN_IRELAND", + "main_residential_property_purchased": 250_000, + "domestic_rates": 1_200, + "consumption": 28_000, + }, + "north_west": { + "members": ["self_employed"], + "region": "NORTH_WEST", + "shareholding": 4e-8, + "council_tax": 1_600, + "consumption": 20_000, + "petrol_litres": 1_200, + }, + "pensioner": { + "members": ["pensioner"], + "region": "NORTH_EAST", + "council_tax": 1_400, + "consumption": 12_000, + }, +} + + +def fixed_situation(year: int = YEAR) -> dict: + people = { + name: {key: {year: value} for key, value in inputs.items()} + for name, inputs in PEOPLE.items() + } + households, benunits = {}, {} + for name, inputs in HOUSEHOLDS.items(): + members = inputs["members"] + households[name] = { + "members": members, + **{key: {year: value} for key, value in inputs.items() if key != "members"}, + } + benunits[f"{name}_benunit"] = {"members": members} + return {"people": people, "benunits": benunits, "households": households} + + +def household_values(sim: Simulation, variable: str, year: int) -> np.ndarray: + """A variable's annual value per household (person values summed).""" + entity = system.variables[variable].entity.key + if entity == "household": + values = sim.calculate(variable, year) + else: + values = sim.map_result( + sim.calculate(variable, year), entity, "household", how="sum" + ) + return np.asarray(values, dtype=np.float64) + + +# 3. Overlap guard on the traced dependency graph + + +@lru_cache(maxsize=None) +def traced_reads() -> dict: + """Variable name -> names of the variables it reads, at current law.""" + sim = Simulation(situation=fixed_situation()) + sim.trace = True + for head in heads(): + sim.calculate(head["variable"], YEAR) + for total in MODEL_LISTS: + sim.calculate(total, YEAR) + children = defaultdict(set) + + def walk(node): + for child in node.children: + children[node.name].add(child.name) + walk(child) + + for tree in sim.tracer.trees: + walk(tree) + return dict(children) + + +def reached_from(variable: str) -> set: + children, seen, stack = traced_reads(), set(), [variable] + while stack: + for child in children.get(stack.pop(), ()): + if child not in seen: + seen.add(child) + stack.append(child) + return seen + + +@pytest.mark.parametrize("total", sorted(MODEL_LISTS)) +def test_no_head_reaches_another_head_of_its_total_undeclared(total): + by_variable = {head["variable"]: head for head in heads_of(total)} + for variable, head in by_variable.items(): + reached = reached_from(variable) & (set(by_variable) - {variable}) + declared = { + head_by_id()[read["head"]]["variable"] for read in head.get("reads", []) + } + undeclared = reached - declared + assert not undeclared, ( + f"{total}: {variable} reaches {sorted(undeclared)}, which {total} " + "also sums; that double counts unless it is an eligibility read " + "declared under the head's reads" + ) + + +def test_declared_reads_occur(): + for head in heads(): + for read in head.get("reads", []): + target = head_by_id()[read["head"]]["variable"] + assert target in reached_from(head["variable"]), ( + f"{head['id']} declares a read of {read['head']} that no " + "longer happens; remove it" + ) + + +def test_exclusions_sit_where_the_partition_says(): + head_variables = {head["variable"] for head in heads()} + for x in partition()["exclusions"]: + readers = {v for v in head_variables if x["variable"] in reached_from(v)} + if x["kind"] in ("component", "baseline_measure"): + owner = head_by_id()[x["head"]]["variable"] + assert owner in readers, f"{x['variable']} is not inside {owner}" + else: + assert not readers, ( + f"{x['variable']} ({x['kind']}) is read by heads {sorted(readers)}" + ) + + +# 4. Coefficient one + + +DELTA = 1_024.0 + + +@lru_cache(maxsize=None) +def baseline_values() -> dict: + sim = Simulation(situation=fixed_situation()) + names = [head["variable"] for head in heads()] + list(MODEL_LISTS) + return {name: household_values(sim, name, YEAR) for name in names} + + +@lru_cache(maxsize=None) +def baseline_entity_values(variable: str) -> dict: + """A head's baseline values at its own entity, by period.""" + sim = Simulation(situation=fixed_situation()) + if system.variables[variable].definition_period == "month": + periods = [f"{YEAR}-{month:02d}" for month in range(1, 13)] + else: + periods = [str(YEAR)] + return { + period: np.asarray(sim.calculate(variable, period), dtype=np.float64) + for period in periods + } + + +def raise_head(sim: Simulation, variable: str, delta: float) -> None: + """Set a head to its baseline value plus delta a year, per member.""" + values = baseline_entity_values(variable) + for period, base in values.items(): + sim.set_input(variable, period, base + delta / len(values)) + + +def members_per_household(sim: Simulation, variable: str) -> np.ndarray: + if system.variables[variable].entity.key == "household": + return np.ones(len(HOUSEHOLDS)) + return np.asarray(sim.populations["household"].nb_persons(), dtype=float) + + +@pytest.mark.parametrize("head_id", [head["id"] for head in heads()]) +def test_each_head_enters_its_totals_once(head_id): + head = head_by_id()[head_id] + variable = head["variable"] + base = baseline_values() + sim = Simulation(situation=fixed_situation()) + raise_head(sim, variable, DELTA) + after = {name: household_values(sim, name, YEAR) for name in base} + step = DELTA * members_per_household(sim, variable) + + own_tolerance = tolerance(after[variable], base[variable]) + np.testing.assert_array_less( + np.abs(after[variable] - base[variable] - step), own_tolerance + 1e-9 + ) + for total in head["totals"]: + moved = after[total] - base[total] + # Keep the population's magnitudes small enough that float32 can + # resolve the step; otherwise this test would pass vacuously. + assert (tolerance(after[total], base[total]) < DELTA / 8).all(), total + np.testing.assert_array_less( + np.abs(moved - step), + tolerance(after[total], base[total]) + 1e-9, + err_msg=f"{total} moved by {moved} when {variable} rose by {step}", + ) + for other in heads_of(total): + if other["id"] == head_id: + continue + name = other["variable"] + np.testing.assert_array_less( + np.abs(after[name] - base[name]), + tolerance(after[name], base[name]) + 1e-9, + err_msg=f"raising {variable} moved {name}, also in {total}", + ) + for total in MODEL_LISTS: + parts = sum(after[h["variable"]] for h in heads_of(total)) + np.testing.assert_array_less( + np.abs(after[total] - parts), + tolerance(after[total], parts) + 1e-9, + ) + + +# 5. Identity, signs and zero-at-baseline on random households + + +SCENARIOS = { + "baseline": None, + "sdlt_abolished": {"gov.hmrc.stamp_duty.abolish": {ALWAYS: True}}, + "council_tax_abolished": {"gov.contrib.abolish_council_tax": {ALWAYS: True}}, +} +REGIONS = ["LONDON", "NORTH_WEST", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +amount = lambda top: st.one_of(st.just(0.0), st.floats(1_000, top)) # noqa: E731 +random_household = st.fixed_dictionaries( + { + "region": st.sampled_from(REGIONS), + "age": st.integers(18, 90), + "employment_income": amount(250_000), + "self_employment_income": amount(100_000), + "capital_gains": amount(200_000), + "main_residential_property_purchased": amount(3_000_000), + "shareholding": st.one_of(st.just(0.0), st.floats(1e-9, 1e-5)), + "council_tax": amount(5_000), + "consumption": amount(80_000), + "main_residence_value": amount(5_000_000), + "owner": st.booleans(), + } +) +PERSON_INPUTS = {"age", "employment_income", "self_employment_income", "capital_gains"} + + +def random_situation(drawn: list, year: int) -> dict: + people, benunits, households = {}, {}, {} + for i, inputs in enumerate(drawn): + person = f"person_{i}" + people[person] = {key: {year: inputs[key]} for key in PERSON_INPUTS} + benunits[f"benunit_{i}"] = {"members": [person]} + household = { + key: {year: value} + for key, value in inputs.items() + if key not in PERSON_INPUTS and key != "owner" + } + household["tenure_type"] = { + year: "OWNED_OUTRIGHT" if inputs["owner"] else "RENT_PRIVATELY" + } + households[f"household_{i}"] = {"members": [person], **household} + return {"people": people, "benunits": benunits, "households": households} + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + drawn=st.lists(random_household, min_size=2, max_size=2), + year=st.integers(2025, 2030), +) +def test_each_total_is_the_sum_of_its_heads(drawn, year): + for scenario, reform in SCENARIOS.items(): + sim = Simulation(situation=random_situation(drawn, year), reform=reform) + values = {h["id"]: household_values(sim, h["variable"], year) for h in heads()} + for total, spec in partition()["totals"].items(): + dropped = set() + for switch, names in spec.get("dropped_when", {}).items(): + if reform and switch in reform: + dropped |= set(names) + parts = [ + values[h["id"]] for h in heads_of(total) if h["variable"] not in dropped + ] + value = household_values(sim, total, year) + parts_sum = np.sum(parts, axis=0) + np.testing.assert_array_less( + np.abs(value - parts_sum), + tolerance(value, np.sum(np.abs(parts), axis=0)) + 1e-9, + err_msg=f"{scenario}: {total} is not the sum of its heads", + ) + property_taxes = np.stack( + [ + values[i] != 0 + for i in ( + "sdlt_household", + "land_transaction_tax", + "land_and_buildings_transaction_tax", + ) + ] + ) + assert (property_taxes.sum(axis=0) <= 1).all(), scenario + if scenario == "baseline": + for head in heads(): + if head["sign"] == "nonnegative": + assert (values[head["id"]] >= 0).all(), head["id"] + if head.get("zero_at_baseline") == "expected": + np.testing.assert_array_equal(values[head["id"]], 0, head["id"]) + if scenario == "sdlt_abolished": + np.testing.assert_array_equal(values["sdlt_household"], 0) + np.testing.assert_array_equal(values["sdlt_corporate"], 0) diff --git a/policyengine_uk/tests/test_sdlt_counted_once_properties.py b/policyengine_uk/tests/test_sdlt_counted_once_properties.py index f9b970dbd8..1e43cad5b9 100644 --- a/policyengine_uk/tests/test_sdlt_counted_once_properties.py +++ b/policyengine_uk/tests/test_sdlt_counted_once_properties.py @@ -123,7 +123,7 @@ def corporate_sdlt_revenue(year: int) -> float: @settings( - max_examples=15, + max_examples=10, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow], From 41e970bae23f5b7df05d64ac46ec3b9889072402 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:50:59 -0400 Subject: [PATCH 4/7] Anchor tax heads to the EFO's Table 3.1 lines 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 --- policyengine_uk/data/fiscal_partition.yaml | 52 +++++++++++++++++-- .../tests/test_fiscal_partition.py | 18 +++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index b04bd7b151..84da2e60fd 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -23,6 +23,9 @@ # entity household or person # obr_line OBR current-receipts line the head belongs to, or # not_an_obr_current_receipt +# obr_table_3_1 the line of EFO Table 3.1 ("Public sector receipts +# as a share of GDP") that holds it, where the table's +# footnotes say so; null where they don't (see sources) # report_group grouping for reports # payer who bears it in the model: household (the household # or its members pay it directly), employer, consumer @@ -45,6 +48,20 @@ # property the tests check for each. schema_version: 1 +sources: + obr_table_3_1: >- + OBR, Economic and fiscal outlook, March 2026, Table 3.1. Its footnotes put + capital gains tax, inheritance tax, property transaction taxes and stamp + taxes on shares in "Capital taxes", and council tax in "PSNB-neutral + receipts". They do not place vehicle excise duty, the licence fee or + Northern Ireland domestic rates. Read 2026-10-11 from + https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + obr_line: >- + obr_line is a plain label for the receipt, one tax per line, at the level + of the EFO's tax-by-tax analysis; it carries no figures. Student loan + repayments are not a receipt in the EFO; only accrued interest on the + loans enters receipts (in "Interest and dividend receipts"). + totals: gov_tax: label: Government tax revenue in respect of the household @@ -83,6 +100,7 @@ tax_heads: variable: income_tax entity: person obr_line: Income tax + obr_table_3_1: Income tax report_group: income_tax payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -92,6 +110,7 @@ tax_heads: variable: national_insurance entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -101,6 +120,7 @@ tax_heads: variable: ni_employer entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: employer totals: [gov_tax] @@ -110,6 +130,7 @@ tax_heads: variable: employer_ni_response_capital_incidence entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: corporate incidence totals: [household_tax] @@ -120,6 +141,7 @@ tax_heads: variable: employer_ni_response_consumer_incidence entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: consumer incidence totals: [household_tax] @@ -130,6 +152,7 @@ tax_heads: variable: capital_gains_tax entity: person obr_line: Capital gains tax + obr_table_3_1: Capital taxes report_group: capital_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -139,6 +162,7 @@ tax_heads: variable: student_loan_repayments entity: person obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: >- A financial transaction: repayments reduce the student loan book, not borrowing, so they are outside current receipts. The model counts them @@ -154,6 +178,7 @@ tax_heads: variable: vat entity: household obr_line: Value added tax + obr_table_3_1: Value added tax report_group: vat payer: consumer incidence totals: [gov_tax] @@ -163,6 +188,7 @@ tax_heads: variable: vat_change entity: household obr_line: Value added tax + obr_table_3_1: Value added tax report_group: vat payer: consumer incidence totals: [household_tax, pre_budget_change_household_tax] @@ -177,6 +203,7 @@ tax_heads: variable: private_school_vat entity: household obr_line: Value added tax + obr_table_3_1: Value added tax report_group: vat payer: household totals: [gov_tax, household_tax] @@ -200,6 +227,7 @@ tax_heads: variable: fuel_duty entity: household obr_line: Fuel duties + obr_table_3_1: Fuel duties report_group: excise_duties payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -209,6 +237,7 @@ tax_heads: variable: alcohol_duty entity: household obr_line: Alcohol duties + obr_table_3_1: Alcohol and tobacco duties report_group: excise_duties payer: household totals: [gov_tax, household_tax] @@ -216,12 +245,13 @@ tax_heads: data_note: >- Zero for every household on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, measured - 2026-10-11. + 2026-10-11. Issue 1872. - id: tobacco_duty label: Tobacco duties variable: tobacco_duty entity: household obr_line: Tobacco duties + obr_table_3_1: Alcohol and tobacco duties report_group: excise_duties payer: household totals: [gov_tax, household_tax] @@ -229,12 +259,13 @@ tax_heads: data_note: >- Zero for every household on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, measured - 2026-10-11. + 2026-10-11. Issue 1873. - id: vehicle_excise_duty label: Vehicle excise duties variable: vehicle_excise_duty entity: household obr_line: Vehicle excise duties + obr_table_3_1: null report_group: excise_duties payer: household totals: [gov_tax, household_tax] @@ -242,12 +273,13 @@ tax_heads: data_note: >- Zero for every household on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, measured - 2026-10-11. + 2026-10-11. Issue 1874. - id: tv_licence label: TV licence fee variable: tv_licence entity: household obr_line: Licence fee receipts + obr_table_3_1: null report_group: other_receipts payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -266,6 +298,7 @@ tax_heads: variable: council_tax entity: household obr_line: Council tax + obr_table_3_1: PSNB-neutral receipts report_group: local_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -275,6 +308,7 @@ tax_heads: variable: high_value_council_tax_surcharge entity: household obr_line: Council tax + obr_table_3_1: PSNB-neutral receipts report_group: local_taxes payer: household totals: [gov_tax, household_tax] @@ -284,6 +318,7 @@ tax_heads: variable: domestic_rates entity: household obr_line: Council tax + obr_table_3_1: null obr_line_note: >- Northern Ireland's domestic rates are its counterpart to council tax; check the line against the OBR table in use before publishing a @@ -297,6 +332,7 @@ tax_heads: variable: business_rates entity: household obr_line: Business rates + obr_table_3_1: Business rates report_group: business_rates payer: corporate incidence totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -306,6 +342,7 @@ tax_heads: variable: expected_sdlt entity: household obr_line: Stamp duty land tax + obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -315,6 +352,7 @@ tax_heads: variable: corporate_sdlt entity: household obr_line: Stamp duty land tax + obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: corporate incidence totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -324,6 +362,7 @@ tax_heads: variable: expected_ltt entity: household obr_line: Land transaction tax + obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -336,6 +375,7 @@ tax_heads: variable: expected_lbtt entity: household obr_line: Land and buildings transaction tax + obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -350,6 +390,7 @@ tax_heads: variable: corporate_incident_tax_revenue_change entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A lump-sum revenue change allocated by shareholding. report_group: incidence_changes payer: corporate incidence @@ -361,6 +402,7 @@ tax_heads: variable: consumer_incident_tax_revenue_change entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A lump-sum revenue change allocated by consumption. report_group: incidence_changes payer: consumer incidence @@ -372,6 +414,7 @@ tax_heads: variable: wealth_tax entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: household @@ -383,6 +426,7 @@ tax_heads: variable: non_primary_residence_wealth_tax entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: household @@ -394,6 +438,7 @@ tax_heads: variable: LVT entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: mixed @@ -405,6 +450,7 @@ tax_heads: variable: carbon_tax entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: mixed diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index f64b8aefcb..8e7b0da100 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -68,6 +68,20 @@ "duplicate", "baseline_measure", } +OBR_TABLE_3_1_LINES = { + "Income tax", + "NICs", + "Value added tax", + "Onshore corporation tax", + "Capital taxes", + "Business rates", + "Fuel duties", + "Alcohol and tobacco duties", + "PSNB-neutral receipts", + "Other taxes", + "Interest and dividend receipts", + "Other receipts", +} ULPS = 64 ALWAYS = "2000-01-01.2100-12-31" @@ -113,6 +127,10 @@ def test_partition_schema(): assert head["sign"] in {"nonnegative", "any"}, head["id"] assert head.get("zero_at_baseline", "expected") == "expected", head["id"] assert head["obr_line"], head["id"] + table_line = head["obr_table_3_1"] + assert table_line is None or table_line in OBR_TABLE_3_1_LINES, head["id"] + if head["obr_line"] == "not_an_obr_current_receipt": + assert table_line is None and head["obr_line_note"], head["id"] assert head["report_group"], head["id"] for read in head.get("reads", []): assert read["head"] in head_by_id(), (head["id"], read) From 862f721fb9bf48131d8e665d0cd965899f3f9830 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:45:56 -0400 Subject: [PATCH 5/7] Let the partition file carry spending sections; note negative VAT 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 --- policyengine_uk/data/fiscal_partition.yaml | 7 +++++++ .../tests/test_fiscal_partition.py | 20 +++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 84da2e60fd..5eacbdde67 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -183,6 +183,13 @@ tax_heads: payer: consumer incidence totals: [gov_tax] sign: nonnegative + data_note: >- + Negative for 76 households on policyengine-uk-data 1.58.0 + (enhanced_frs_2024_25) in every year from 2025 to 2030, -£72m weighted + in 2026: full_rate_vat_consumption is negative there because the + dataset's consumption (44 households) or full_rate_vat_expenditure_rate + (32 households) is negative. policyengine-uk-data issue 565. Measured + 2026-10-11. - id: vat_change label: Change in VAT from its baseline variable: vat_change diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index 8e7b0da100..2988b43371 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -114,16 +114,23 @@ def tolerance(*arrays) -> np.ndarray: def test_partition_schema(): p = partition() assert p["schema_version"] == 1 - totals = set(p["totals"]) - assert totals == set(MODEL_LISTS) + # The tax totals are this file's subject; the spending totals and + # spending_heads (the aggregate impact report's) share the file and must + # not borrow tax heads or variables. + assert set(MODEL_LISTS) <= set(p["totals"]) + spending_heads = p.get("spending_heads", []) + spending_totals = {t for head in spending_heads for t in head["totals"]} + assert not spending_totals & set(MODEL_LISTS) ids = [head["id"] for head in heads()] variables = [head["variable"] for head in heads()] assert len(ids) == len(set(ids)), "duplicate head id" assert len(variables) == len(set(variables)), "a variable is in two heads" + assert not set(variables) & {h["variable"] for h in spending_heads} + assert not set(ids) & {h["id"] for h in spending_heads} for head in heads(): variable = system.variables[head["variable"]] assert head["entity"] == variable.entity.key, head["id"] - assert set(head["totals"]) <= totals, head["id"] + assert set(head["totals"]) <= set(MODEL_LISTS), head["id"] assert head["sign"] in {"nonnegative", "any"}, head["id"] assert head.get("zero_at_baseline", "expected") == "expected", head["id"] assert head["obr_line"], head["id"] @@ -144,8 +151,8 @@ def test_partition_schema(): assert set(x["spans"]) <= set(ids), x else: assert x["head"] in ids, x - for total, spec in p["totals"].items(): - for switch, dropped in spec.get("dropped_when", {}).items(): + for total in MODEL_LISTS: + for switch, dropped in p["totals"][total].get("dropped_when", {}).items(): system.parameters.get_child(switch) assert set(dropped) <= {h["variable"] for h in heads_of(total)} for guard in p["overlap_guards"]: @@ -488,7 +495,8 @@ def test_each_total_is_the_sum_of_its_heads(drawn, year): for scenario, reform in SCENARIOS.items(): sim = Simulation(situation=random_situation(drawn, year), reform=reform) values = {h["id"]: household_values(sim, h["variable"], year) for h in heads()} - for total, spec in partition()["totals"].items(): + for total in MODEL_LISTS: + spec = partition()["totals"][total] dropped = set() for switch, names in spec.get("dropped_when", {}).items(): if reform and switch in reform: From 3ada49f2300c2c17c1030d7b1e1385cd26493aea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 19:38:46 -0400 Subject: [PATCH 6/7] Reconcile the partition with a blind specification; abolish SDLT liability 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 --- changelog.d/corporate-sdlt-once.fixed.md | 2 +- changelog.d/fiscal-partition.added.md | 2 +- policyengine_uk/data/fiscal_partition.yaml | 140 +++++++++++++++--- .../gov/hmrc/stamp_duty/corporate_sdlt.yaml | 2 +- .../gov/hmrc/stamp_duty/expected_sdlt.yaml | 16 +- .../gov/hmrc/stamp_duty/sdlt_liable.yaml | 7 + .../tests/test_fiscal_partition.py | 54 ++++++- .../test_sdlt_counted_once_properties.py | 8 +- .../variables/gov/hmrc/sdlt_liable.py | 11 +- 9 files changed, 206 insertions(+), 36 deletions(-) diff --git a/changelog.d/corporate-sdlt-once.fixed.md b/changelog.d/corporate-sdlt-once.fixed.md index 5e090b8715..6a70074688 100644 --- a/changelog.d/corporate-sdlt-once.fixed.md +++ b/changelog.d/corporate-sdlt-once.fixed.md @@ -1 +1 @@ -- Count corporate SDLT once in `household_tax`, `gov_tax` and `pre_budget_change_household_tax`. `expected_sdlt` no longer adds `corporate_sdlt`, which those totals already list, and `gov.hmrc.stamp_duty.abolish` now zeroes `corporate_sdlt` as well as `expected_sdlt`, so abolishing SDLT removes all of it. `expected_sdlt` also no longer adds `property_sale_rate` (a 0.045 share) to every household as £0.045 of SDLT. +- Count corporate SDLT once in `household_tax`, `gov_tax` and `pre_budget_change_household_tax`. `expected_sdlt` no longer adds `corporate_sdlt`, which those totals already list. `gov.hmrc.stamp_duty.abolish` now zeroes `corporate_sdlt` and the `stamp_duty_land_tax` liability (no household is `sdlt_liable` once the tax is abolished) as well as `expected_sdlt`, so abolishing SDLT removes all of it. `expected_sdlt` also no longer adds `property_sale_rate` (a 0.045 share) to every household as £0.045 of SDLT. diff --git a/changelog.d/fiscal-partition.added.md b/changelog.d/fiscal-partition.added.md index 5cc874f4ce..c1c4809d45 100644 --- a/changelog.d/fiscal-partition.added.md +++ b/changelog.d/fiscal-partition.added.md @@ -1 +1 @@ -- Add `policyengine_uk/data/fiscal_partition.yaml`, a tax-head partition of `gov_tax`, `household_tax` and `pre_budget_change_household_tax` (one OBR receipts line per head, explicit exclusions and overlap guards), with tests that each total is the sum of its heads and counts each head once. +- Add `policyengine_uk/data/fiscal_partition.yaml`, a tax-head partition of `gov_tax`, `household_tax` and `pre_budget_change_household_tax`: one row of the OBR current receipts table per head, the rows the model does not cover, explicit exclusions and overlap guards. Tests check that each total is the sum of its heads and counts each head once. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 5eacbdde67..cb456d9ce3 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -1,8 +1,8 @@ # Fiscal partition: how the model's tax totals divide into tax heads. # -# Each head is one receipt (an OBR current-receipts line, split by who pays -# where the model splits it) and names the one variable that measures the -# household's share of it. Heads are mutually exclusive: no head's value +# Each head is one receipt (a row of the OBR's current receipts table, split +# by who pays or where it is charged when the model splits it) and names the +# one variable that measures the household's share of it. Heads are mutually exclusive: no head's value # contains another head's value, and no variable is summed twice in a total. # # The heads were specified from the OBR receipts classification and from each @@ -21,8 +21,9 @@ # label sentence-case name # variable the one model variable that measures the head # entity household or person -# obr_line OBR current-receipts line the head belongs to, or -# not_an_obr_current_receipt +# obr_line the row of OBR EFO November 2025 Table A.5 ("Current +# receipts") the head belongs to, or +# not_an_obr_current_receipt (see sources) # obr_table_3_1 the line of EFO Table 3.1 ("Public sector receipts # as a share of GDP") that holds it, where the table's # footnotes say so; null where they don't (see sources) @@ -42,8 +43,13 @@ # kind: component (summed or read inside `head`), measure_within (an # analytical decomposition already inside `head` through its inputs), # composite (spans several heads), duplicate (same value as `head`), -# baseline_measure. +# baseline_measure, assessment (a tax amount worked out for a means test, +# a ceiling or a record, not a payment), change_measure (a wage change +# that already sits inside the incomes a head taxes). # model_double_counts[]: known violations still in the model (none). +# uncovered_receipts[]: rows of Table A.5 the model has no household +# measure for. With the heads' obr_line values they account for every +# tax row of the table. # overlap_guards[]: the places double counting could occur, and the # property the tests check for each. schema_version: 1 @@ -57,10 +63,31 @@ sources: Northern Ireland domestic rates. Read 2026-10-11 from https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ obr_line: >- - obr_line is a plain label for the receipt, one tax per line, at the level - of the EFO's tax-by-tax analysis; it carries no figures. Student loan - repayments are not a receipt in the EFO; only accrued interest on the - loans enters receipts (in "Interest and dividend receipts"). + OBR, Economic and fiscal outlook, November 2025, Table A.5 ("Current + receipts"): its tax rows, from "Income tax" to "Other taxes". Footnote 3 + puts the high-value council tax surcharge in "Council tax"; footnote 4 + puts stamp duty land tax, the devolved property transaction taxes and + the annual tax on enveloped dwellings in "Property transaction taxes". + Read 2026-10-11 from + https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + council_tax: >- + OBR, "Council tax" (tax by tax, spend by spend): council tax receipts add + a forecast of Northern Irish domestic rates to Great Britain's, and are + net of discounts and reduction schemes. Read 2026-10-11 from + https://obr.uk/forecasts-in-depth/tax-by-tax-spend-by-spend/council-tax/ + student_loans: >- + Student loan repayments are not a row of Table A.5. In the March 2026 + EFO only the interest accrued on the loans enters receipts, in "Interest + and dividend receipts". + independent_specification: >- + A second specification was written from Table A.5 and the variables' + formulas by a reviewer who was not shown the model's lists or this file + (2026-10-11). It chose the same variable for every head here except + where noted on the head, and supplied the uncovered rows and several of + the exclusions and guards. It listed the change measures (vat_change, + the employer NI response incidence and the two unallocated tax changes) + as exclusions from a total of levels; here they are heads of the totals + that use them, zero at current law. totals: gov_tax: @@ -166,7 +193,8 @@ tax_heads: obr_line_note: >- A financial transaction: repayments reduce the student loan book, not borrowing, so they are outside current receipts. The model counts them - in gov_tax and household_tax; reports should show them separately. + in gov_tax and household_tax; reports should show them separately. The + independent specification left them out of both tax totals. report_group: financial_transactions payer: household totals: [gov_tax, household_tax] @@ -305,6 +333,12 @@ tax_heads: variable: council_tax entity: household obr_line: Council tax + obr_line_note: >- + Gross of Council Tax Reduction. The OBR's receipts are net of discounts + and reduction schemes; the model counts the reduction as spending + (council_tax_benefit) instead. Compare with the OBR row net of it. The + independent specification chose the net variable, + council_tax_less_benefit. obr_table_3_1: PSNB-neutral receipts report_group: local_taxes payer: household @@ -327,9 +361,8 @@ tax_heads: obr_line: Council tax obr_table_3_1: null obr_line_note: >- - Northern Ireland's domestic rates are its counterpart to council tax; - check the line against the OBR table in use before publishing a - line-by-line comparison. + The OBR adds Northern Irish domestic rates to its council tax receipts + (see sources). report_group: local_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -348,7 +381,7 @@ tax_heads: label: Stamp duty land tax on the household's own purchases variable: expected_sdlt entity: household - obr_line: Stamp duty land tax + obr_line: Property transaction taxes obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household @@ -358,7 +391,7 @@ tax_heads: label: Stamp duty land tax paid by companies variable: corporate_sdlt entity: household - obr_line: Stamp duty land tax + obr_line: Property transaction taxes obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: corporate incidence @@ -368,7 +401,7 @@ tax_heads: label: Land transaction tax (Wales) variable: expected_ltt entity: household - obr_line: Land transaction tax + obr_line: Property transaction taxes obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household @@ -376,12 +409,13 @@ tax_heads: sign: nonnegative data_note: >- expected_ltt multiplies an already purchase-drawn liability by - property_sale_rate, discounting it twice (issue 2246). + property_sale_rate, discounting it twice (issue 2246). The independent + specification chose land_transaction_tax for that reason. - id: land_and_buildings_transaction_tax label: Land and buildings transaction tax (Scotland) variable: expected_lbtt entity: household - obr_line: Land and buildings transaction tax + obr_line: Property transaction taxes obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household @@ -389,7 +423,8 @@ tax_heads: sign: nonnegative data_note: >- expected_lbtt multiplies an already purchase-drawn liability by - property_sale_rate, discounting it twice (issue 2246). + property_sale_rate, discounting it twice (issue 2246). The independent + specification chose land_and_buildings_transaction_tax for that reason. # Reform-only heads - id: corporate_incident_tax_change @@ -467,7 +502,7 @@ tax_heads: exclusions: # Property transaction taxes - - {variable: stamp_duty_land_tax, kind: component, head: sdlt_household, note: "Equal to expected_sdlt, but not zeroed by gov.hmrc.stamp_duty.abolish; report the head, not this."} + - {variable: stamp_duty_land_tax, kind: component, head: sdlt_household, note: "The liability expected_sdlt reads; zero under gov.hmrc.stamp_duty.abolish because sdlt_liable is then false."} - {variable: sdlt_on_transactions, kind: component, head: sdlt_household} - {variable: sdlt_on_rent, kind: component, head: sdlt_household} - {variable: sdlt_on_residential_property_transactions, kind: component, head: sdlt_household} @@ -499,6 +534,18 @@ exclusions: - {variable: property_income_tax, kind: component, head: income_tax} - {variable: CB_HITC, kind: component, head: income_tax} - {variable: personal_pension_contributions_tax, kind: component, head: income_tax} + - {variable: capped_mcad, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: married_couples_allowance_deduction, kind: component, head: income_tax, note: "Inside capped_mcad."} + - {variable: other_tax_credits, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: property_finance_cost_relief, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: basic_rate_earned_income_tax, kind: assessment, head: income_tax, note: "Earned income tax by band; earned_income_tax does not read it."} + - {variable: higher_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} + - {variable: add_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} + - {variable: tax_reported, kind: assessment, head: income_tax, note: "Tax as the survey reports it, an alternative to the modelled liability."} + - {variable: tax_modelling, kind: assessment, head: income_tax, note: "Modelled minus reported tax."} + - {variable: uc_income_tax_on_earnings, kind: assessment, head: income_tax, note: "Income tax on earnings as Universal Credit deducts it; no extra payment."} + - {variable: uc_minimum_income_floor_income_tax, kind: assessment, head: income_tax, note: "Tax on the notional minimum income floor earnings."} + - {variable: child_benefit_less_tax_charge, kind: composite, spans: [income_tax], note: "Child Benefit net of the High Income Child Benefit Charge, which income_tax already includes; never alongside gross Child Benefit and income_tax."} - {variable: ni_employee, kind: measure_within, head: nics_individual, note: "Class 1 employee; national_insurance adds the classes directly."} - {variable: ni_self_employed, kind: measure_within, head: nics_individual, note: "Classes 2 and 4; national_insurance adds the classes directly."} - {variable: ni_class_1_employee, kind: component, head: nics_individual} @@ -507,6 +554,12 @@ exclusions: - {variable: ni_class_2, kind: component, head: nics_individual} - {variable: ni_class_3, kind: component, head: nics_individual} - {variable: ni_class_4, kind: component, head: nics_individual} + - {variable: ni_class_4_main, kind: assessment, head: nics_individual, note: "Class 4 before the annual maximum."} + - {variable: ni_class_4_maximum, kind: assessment, head: nics_individual, note: "The annual ceiling on Class 4, not a payment."} + - {variable: uc_national_insurance_on_earnings, kind: assessment, head: nics_individual, note: "National Insurance on earnings as Universal Credit deducts it; no extra payment."} + - {variable: uc_minimum_income_floor_national_insurance, kind: assessment, head: nics_individual, note: "National Insurance on the notional minimum income floor earnings."} + - {variable: employer_ni_fixed_employer_cost_change, kind: change_measure, head: nics_employer, note: "The wage change from an employer NI reform; employment_income already adds it."} + - {variable: salary_sacrifice_broad_base_haircut, kind: change_measure, head: nics_employer, note: "A wage change employment_income already adds."} - {variable: ni_class_1_employer, kind: component, head: nics_employer} - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to employment_income, so Class 1 already charges it."} - {variable: salary_sacrifice_pension_ni_employer, kind: measure_within, head: nics_employer, note: "As above, for employer Class 1."} @@ -524,7 +577,7 @@ exclusions: - {variable: other_fermented_duty, kind: component, head: alcohol_duty} - {variable: car_vehicle_excise_duty, kind: component, head: vehicle_excise_duty} # Council tax net of a benefit - - {variable: council_tax_less_benefit, kind: composite, spans: [council_tax], note: "Council tax minus Council Tax Reduction, a benefit; not a receipt."} + - {variable: council_tax_less_benefit, kind: composite, spans: [council_tax], note: "Council tax net of Council Tax Reduction (the OBR's basis). The totals count gross council tax and credit the reduction as spending, so adding this would count council tax twice."} model_double_counts: [] @@ -569,6 +622,28 @@ overlap_guards: fees, turning on private_school_vat would tax them twice. The head is zero at baseline, so baseline totals are unaffected; check the consumption imputation before costing a reform that sets it. + - id: council_tax_gross_and_reduction + kind: gross_and_net + heads: [council_tax] + property: >- + The tax totals hold gross council tax, and the spending totals hold + Council Tax Reduction (council_tax_benefit). Never add + council_tax_less_benefit, and compare with the OBR row net of the + reduction. gov.contrib.abolish_council_tax drops council tax from the + tax totals and council_tax_benefit from the spending totals together. + - id: child_benefit_charge + kind: gross_and_net + heads: [income_tax] + property: >- + income_tax includes the High Income Child Benefit Charge, so benefits + must be counted gross of it; child_benefit_less_tax_charge is excluded. + - id: unallocated_budget_changes + kind: level_and_change + heads: [corporate_incident_tax_change, consumer_incident_tax_change] + property: >- + These allocate a lump-sum revenue change. A change in a tax that has + its own head must be made through that head's parameters, not entered + here as well. - id: eligibility_reads kind: read_not_inclusion heads: [tv_licence, private_school_vat] @@ -576,3 +651,24 @@ overlap_guards: These heads reach other heads of the same total only through eligibility or imputation (see each head's reads). Raising the other head by a small amount leaves them unchanged on the test population. + +uncovered_receipts: + # Rows of Table A.5 with no household measure in the model. + - {obr_line: Corporation tax} + - {obr_line: Petroleum revenue tax} + - {obr_line: VAT refunds, note: "A National Accounts adjustment, not a tax on households."} + - {obr_line: Inheritance tax} + - {obr_line: Stamp taxes on shares} + - {obr_line: Air passenger duty} + - {obr_line: Insurance premium tax} + - {obr_line: Climate change levy} + - {obr_line: Bank levy} + - {obr_line: Bank surcharge} + - {obr_line: Apprenticeship levy} + - {obr_line: Digital services tax} + - {obr_line: Other HMRC taxes} + - {obr_line: Environmental levies} + - {obr_line: Emissions Trading Scheme} + - {obr_line: Energy profits levy} + - {obr_line: Electricity generator levy} + - {obr_line: Other taxes} diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml index 15ad990f35..5d09e809a0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml @@ -50,7 +50,7 @@ council_tax: 0 household_owns_tv: false output: - stamp_duty_land_tax: 15_000 + stamp_duty_land_tax: 0 expected_sdlt: 0 corporate_sdlt: 0 corporate_tax_incidence: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml index 0261edc9fe..97acbc0cdf 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml @@ -51,7 +51,7 @@ expected_sdlt: 15_000 household_tax: 15_000 gov_tax: 15_000 -- name: Abolishing SDLT zeroes expected SDLT but not the liability variable +- name: Abolishing SDLT zeroes the liability and expected SDLT period: 2026 absolute_error_margin: 0 input: @@ -62,7 +62,21 @@ council_tax: 0 household_owns_tv: false output: + sdlt_liable: false + stamp_duty_land_tax: 0 + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: Abolishing SDLT zeroes expected SDLT even when the liability is entered directly + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.stamp_duty.abolish: true + region: LONDON stamp_duty_land_tax: 15_000 + council_tax: 0 + household_owns_tv: false + output: expected_sdlt: 0 household_tax: 0 gov_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml index ce809502ff..7ae38e1781 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml @@ -4,3 +4,10 @@ country: [ENGLAND, WALES, SCOTLAND, NORTHERN_IRELAND] output: sdlt_liable: [true, false, false, true] +- name: No household is liable once Stamp Duty is abolished + period: 2026 + input: + gov.hmrc.stamp_duty.abolish: true + country: [ENGLAND, WALES, SCOTLAND, NORTHERN_IRELAND] + output: + sdlt_liable: [false, false, false, false] diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index 2988b43371..92a6bbb805 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -7,7 +7,9 @@ model's lists, so these tests compare two specifications: 1. Schema: ids and variables are unique, every reference resolves, and no - variable is both a head and an exclusion. + variable is both a head and an exclusion. Every tax row of the OBR's + current receipts table (EFO November 2025, Table A.5) is either a head's + ``obr_line`` or listed under ``uncovered_receipts``, never both. 2. Differential: for each total, the heads the partition assigns to it are exactly the variables the model sums (``GOV_TAX_VARIABLES``, ``HOUSEHOLD_TAX_VARIABLES``, ``PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES``), @@ -17,7 +19,8 @@ eligibility or imputation route) under the head's ``reads``; every declared read occurs. Component and baseline-measure exclusions are reached from their head; composite, duplicate and measure-within exclusions are - reached from no head. + reached from no head. (Assessment and change-measure exclusions may be + read by means tests and incomes, so only the differential test binds them.) 4. Coefficient one (perturbation): raising one head by delta, with every other input unchanged, raises each total that includes it by exactly delta per member and leaves every other head of those totals unchanged. @@ -67,6 +70,41 @@ "composite", "duplicate", "baseline_measure", + "assessment", + "change_measure", +} +# The tax rows of OBR EFO November 2025 Table A.5, "Current receipts". +OBR_TABLE_A5_TAX_ROWS = { + "Income tax", + "National insurance contributions", + "Value added tax", + "Corporation tax", + "Petroleum revenue tax", + "Fuel duties", + "Business rates", + "Council tax", + "VAT refunds", + "Capital gains tax", + "Inheritance tax", + "Property transaction taxes", + "Stamp taxes on shares", + "Tobacco duties", + "Alcohol duties", + "Air passenger duty", + "Insurance premium tax", + "Climate change levy", + "Bank levy", + "Bank surcharge", + "Apprenticeship levy", + "Digital services tax", + "Other HMRC taxes", + "Vehicle excise duties", + "Licence fee receipts", + "Environmental levies", + "Emissions Trading Scheme", + "Energy profits levy", + "Electricity generator levy", + "Other taxes", } OBR_TABLE_3_1_LINES = { "Income tax", @@ -133,11 +171,12 @@ def test_partition_schema(): assert set(head["totals"]) <= set(MODEL_LISTS), head["id"] assert head["sign"] in {"nonnegative", "any"}, head["id"] assert head.get("zero_at_baseline", "expected") == "expected", head["id"] - assert head["obr_line"], head["id"] table_line = head["obr_table_3_1"] assert table_line is None or table_line in OBR_TABLE_3_1_LINES, head["id"] if head["obr_line"] == "not_an_obr_current_receipt": assert table_line is None and head["obr_line_note"], head["id"] + else: + assert head["obr_line"] in OBR_TABLE_A5_TAX_ROWS, head["id"] assert head["report_group"], head["id"] for read in head.get("reads", []): assert read["head"] in head_by_id(), (head["id"], read) @@ -158,6 +197,13 @@ def test_partition_schema(): for guard in p["overlap_guards"]: assert set(guard["heads"]) <= set(ids), guard["id"] assert p["model_double_counts"] == [] + # Every tax row of Table A.5 is either measured by a head or declared + # uncovered, never both. + covered = {head["obr_line"] for head in heads()} - {"not_an_obr_current_receipt"} + uncovered = [row["obr_line"] for row in p["uncovered_receipts"]] + assert len(uncovered) == len(set(uncovered)) + assert not covered & set(uncovered) + assert covered | set(uncovered) == OBR_TABLE_A5_TAX_ROWS # 2. Differential against the model's lists @@ -348,7 +394,7 @@ def test_exclusions_sit_where_the_partition_says(): if x["kind"] in ("component", "baseline_measure"): owner = head_by_id()[x["head"]]["variable"] assert owner in readers, f"{x['variable']} is not inside {owner}" - else: + elif x["kind"] in ("composite", "duplicate", "measure_within"): assert not readers, ( f"{x['variable']} ({x['kind']}) is read by heads {sorted(readers)}" ) diff --git a/policyengine_uk/tests/test_sdlt_counted_once_properties.py b/policyengine_uk/tests/test_sdlt_counted_once_properties.py index 1e43cad5b9..626f885ee2 100644 --- a/policyengine_uk/tests/test_sdlt_counted_once_properties.py +++ b/policyengine_uk/tests/test_sdlt_counted_once_properties.py @@ -29,9 +29,10 @@ 3. Abolition removes all of it once: T(B) - T(A) equals ``stamp_duty_land_tax`` + ``corporate_sdlt`` in B, and T(Z) - T(A) equals ``stamp_duty_land_tax`` in B, so no corporate SDLT is left after abolition. -4. Abolition zeroes both parts: ``expected_sdlt`` and ``corporate_sdlt`` are - exactly 0 in A, ``corporate_tax_incidence`` in A equals ``business_rates``, - and ``corporate_tax_incidence`` falls by ``corporate_sdlt`` in B. +4. Abolition zeroes both parts: ``stamp_duty_land_tax``, ``expected_sdlt`` + and ``corporate_sdlt`` are exactly 0 in A, ``corporate_tax_incidence`` in + A equals ``business_rates``, and ``corporate_tax_incidence`` falls by + ``corporate_sdlt`` in B. 5. Weighted totals: when some household has corporate-sector wealth, the weighted sum of ``corporate_sdlt`` in B is the corporate SDLT revenue statistic (within a relative 1e-6), and weighted ``gov_tax`` falls under @@ -163,6 +164,7 @@ def test_sdlt_is_counted_once_and_abolition_removes_it(households, year): ) # 4. Abolition zeroes both parts and the corporate incidence they feed. + np.testing.assert_array_equal(a["stamp_duty_land_tax"], 0) np.testing.assert_array_equal(a["expected_sdlt"], 0) np.testing.assert_array_equal(a["corporate_sdlt"], 0) np.testing.assert_array_equal(a["corporate_tax_incidence"], a["business_rates"]) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index e609d02667..2e5e7146a4 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -3,16 +3,21 @@ class sdlt_liable(Variable): label = "Liable for Stamp Duty" - documentation = "Whether the household is liable for Stamp Duty Land Tax" + documentation = ( + "Whether the household is liable for Stamp Duty Land Tax: it lives in " + "England or Northern Ireland and the tax has not been abolished." + ) entity = Household definition_period = YEAR value_type = bool unit = GBP - def formula(household, period): + def formula(household, period, parameters): country = household("country", period) countries = country.possible_values - return np.isin( + in_england_or_northern_ireland = np.isin( country.decode_to_str(), [countries.ENGLAND.name, countries.NORTHERN_IRELAND.name], ) + abolished = parameters(period).gov.hmrc.stamp_duty.abolish + return in_england_or_northern_ireland & (not abolished) From 8c1b54dd900bd8ed932f0dd7853f3383727e29b8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 22 Oct 2026 00:50:00 -0400 Subject: [PATCH 7/7] Trace the partition's reads as a situation and as data Main's #1905 gate stops attends_private_school before it reads income unless the simulation was built from data, so tracing a household situation no longer saw private_school_vat's declared reads of income tax, NI and council tax. Those reads still happen in every data-built simulation, which is what the partition describes. traced_reads now traces the same households twice, once with built_from_dataset set, and takes the union, so the undeclared-read guard and the exclusion check cover both paths. Format the measure test's pre-fix partition helper. Co-Authored-By: Claude Opus 5.5 --- .../test_aggregate_impact_measure.py | 8 ++---- .../tests/test_fiscal_partition.py | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py index 5b8c59011e..20636f0dad 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py @@ -580,16 +580,12 @@ def _corporate_sdlt_double_counted(): with open(default_partition_path()) as f: raw = yaml.safe_load(f) - totals = next(h for h in raw["tax_heads"] if h["id"] == "sdlt_corporate")[ - "totals" - ] + totals = next(h for h in raw["tax_heads"] if h["id"] == "sdlt_corporate")["totals"] raw["tax_heads"] = [h for h in raw["tax_heads"] if h["id"] != "sdlt_corporate"] def repoint(ids): return list( - dict.fromkeys( - "sdlt_household" if i == "sdlt_corporate" else i for i in ids - ) + dict.fromkeys("sdlt_household" if i == "sdlt_corporate" else i for i in ids) ) for exclusion in raw["exclusions"]: diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index 92a6bbb805..95c0ff53ca 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -332,13 +332,13 @@ def household_values(sim: Simulation, variable: str, year: int) -> np.ndarray: @lru_cache(maxsize=None) def traced_reads() -> dict: - """Variable name -> names of the variables it reads, at current law.""" - sim = Simulation(situation=fixed_situation()) - sim.trace = True - for head in heads(): - sim.calculate(head["variable"], YEAR) - for total in MODEL_LISTS: - sim.calculate(total, YEAR) + """Variable name -> names of the variables it reads, at current law. + + The union of two traces of the same households: as a situation, and as + if built from data. Some formulas read further only in a data-built + simulation (private school attendance is imputed from net income only + there, since #1905), and the guards cover both. + """ children = defaultdict(set) def walk(node): @@ -346,8 +346,17 @@ def walk(node): children[node.name].add(child.name) walk(child) - for tree in sim.tracer.trees: - walk(tree) + for built_from_dataset in (False, True): + sim = Simulation(situation=fixed_situation()) + # The flag utils.data_source.built_from_data reads. + sim.built_from_dataset = built_from_dataset + sim.trace = True + for head in heads(): + sim.calculate(head["variable"], YEAR) + for total in MODEL_LISTS: + sim.calculate(total, YEAR) + for tree in sim.tracer.trees: + walk(tree) return dict(children)