From 5f08c244cfbfa2230d9e812315d04b9b7a4a6ca1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:45:56 -0400 Subject: [PATCH 1/6] 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 84da2e60f..21bc4b992 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, as low as -1.02) 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 8e7b0da10..2988b4337 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 4f48f66da616463812b61a44a70d97328fc20d7c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:28:14 -0400 Subject: [PATCH 2/6] Charge VAT on private school fees at current law Finance Act 2025 s.47 takes private school education, vocational training and closely related board and lodging out of the VATA 1994 Sch 9 Group 6 exemption, with effect for supplies on or after 1 January 2025 (s.49). The new gov.hmrc.vat.private_school_fees_exempt parameter records the exemption; when it is off the fees bear the standard rate. The contributed reform rate now raises the rate instead of adding to it, so setting it never charges the fees twice. private_school_vat moves to variables/gov/hmrc. Household taxes include it as a change from baseline (private_school_vat_change), as they do VAT; government tax revenue includes the level. Fixes #2318 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2318.fixed.md | 1 + .../contrib/labour/private_school_vat.yaml | 5 +- .../hmrc/vat/private_school_fees_exempt.yaml | 25 ++ .../contrib/labour/private_school_vat.yaml | 65 ---- .../baseline/gov/hmrc/excise_integration.yaml | 1 + .../baseline/gov/hmrc/private_school_vat.yaml | 281 ++++++++++++++++++ .../contrib/labour/private_school_vat.py | 27 -- .../gov/hmrc/baseline_private_school_vat.py | 18 ++ .../variables/gov/hmrc/household_tax.py | 2 +- .../variables/gov/hmrc/private_school_vat.py | 48 +++ .../gov/hmrc/private_school_vat_change.py | 16 + 11 files changed, 394 insertions(+), 95 deletions(-) create mode 100644 changelog.d/2318.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml delete mode 100644 policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml delete mode 100644 policyengine_uk/variables/contrib/labour/private_school_vat.py create mode 100644 policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py create mode 100644 policyengine_uk/variables/gov/hmrc/private_school_vat.py create mode 100644 policyengine_uk/variables/gov/hmrc/private_school_vat_change.py diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md new file mode 100644 index 000000000..208f45eaf --- /dev/null +++ b/changelog.d/2318.fixed.md @@ -0,0 +1 @@ +- Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), with the contributed reform rate able to raise the rate but never charge the fees twice. Household taxes now include private school VAT as a change from baseline, as they do VAT. diff --git a/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml b/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml index 499a7ef84..3689b05b7 100644 --- a/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml +++ b/policyengine_uk/parameters/gov/contrib/labour/private_school_vat.yaml @@ -1,6 +1,7 @@ -description: VAT rate applied to private schools. +description: Contributed reform rate of VAT on private school fees. The fees bear VAT at the higher of this rate and the statutory rate (the standard rate from 1 January 2025, when the exemption ended), so the reform can raise the rate but never charges VAT on the fees twice. values: 2000-01-01: 0 metadata: unit: /1 - label: Private school VAT rate \ No newline at end of file + period: year + label: Private school VAT rate (reform) diff --git a/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml new file mode 100644 index 000000000..fd4087b02 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml @@ -0,0 +1,25 @@ +description: Education, vocational training and closely related board and lodging supplied by a private school are exempt from VAT if this is true. From 1 January 2025 they are not exempt, so the fees bear VAT at the standard rate. +values: + 1994-09-01: + value: true + metadata: + reference: + - title: Value Added Tax Act 1994, Schedule 9, Group 6, item 1 and note 1(a) (as enacted) + href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/9/enacted + - title: Value Added Tax Act 1994, section 101(1) (commencement on 1 September 1994) + href: https://www.legislation.gov.uk/ukpga/1994/23/section/101/enacted + 2025-01-01: + value: false + metadata: + reference: + - title: Finance Act 2025, section 47 (removal of exemption for private school fees; inserts Value Added Tax Act 1994, Schedule 9, Part 3) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/47/enacted + - title: Finance Act 2025, section 49 (effect for supplies on or after 1 January 2025) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/49/enacted +metadata: + unit: bool + period: year + label: Private school fees exempt from VAT + reference: + - title: Value Added Tax Act 1994, Schedule 9 (exemptions) + href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/9 diff --git a/policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml b/policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml deleted file mode 100644 index 43346f7cd..000000000 --- a/policyengine_uk/tests/policy/baseline/contrib/labour/private_school_vat.yaml +++ /dev/null @@ -1,65 +0,0 @@ -- name: Returns 0 when no children - period: 2024 - input: - # Set private school VAT rate to 25% to ensure correct testing - gov.contrib.labour.private_school_vat: 0.25 - people: - adult: - age: 25 - households: - household: - members: [ - adult, - ] - output: - private_school_vat: 0 - -- name: Returns correctly with one child - period: 2024 - input: - # Set private school VAT rate to 25% to ensure correct testing - gov.contrib.labour.private_school_vat: 0.25 - # Override other default settings for easier testing - gov.simulation.private_school_vat.private_school_vat_basis: 1 - gov.simulation.private_school_vat.private_school_fees: 10_000 - people: - adult: - age: 25 - child: - age: 10 - attends_private_school: True - households: - household: - members: [ - adult, - child - ] - output: - private_school_vat: 2_500 - -- name: Returns correct value with 2+ children - period: 2024 - input: - # Set private school VAT rate to 25% to ensure correct testing - gov.contrib.labour.private_school_vat: 0.25 - # Override other default settings for easier testing - gov.simulation.private_school_vat.private_school_vat_basis: 1 - gov.simulation.private_school_vat.private_school_fees: 10_000 - people: - adult: - age: 25 - child1: - age: 10 - attends_private_school: True - child2: - age: 12 - attends_private_school: True - households: - household: - members: [ - adult, - child1, - child2 - ] - output: - private_school_vat: 5_000 \ No newline at end of file diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml index 5e7a2a2a4..40b8974c7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/excise_integration.yaml @@ -22,6 +22,7 @@ ni_employer: 0 non_primary_residence_wealth_tax: 0 private_school_vat: 0 + private_school_vat_change: 0 student_loan_repayments: 0 tv_licence: 0 vat: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml new file mode 100644 index 000000000..cd7957eff --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml @@ -0,0 +1,281 @@ +# VAT on private school fees. VATA 1994 Sch 9 Group 6 exempted education by +# an eligible body, which included registered independent schools. Finance Act +# 2025 s.47 inserts Sch 9 Part 3, which removes private school education, +# vocational training and closely related board and lodging from the +# exemption, for supplies on or after 1 January 2025 (s.49), so the fees bear +# VAT at the standard rate. The tests fix the mean fee and the share of fees +# that bears VAT so the expected values are exact. + +- name: No pupils, no VAT + period: 2025 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 25 + households: + household: + members: [adult] + output: + private_school_vat: 0 + +- name: Fees were exempt in 2024 + period: 2024 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 0 + +- name: Standard rate on one pupil's fees from 2025 + period: 2025 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + # 10,000 x 1 x 20% + private_school_vat: 2_000 + +- name: Two pupils, three quarters of fees bearing VAT + period: 2026 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 0.75 + people: + adult: + age: 40 + child1: + age: 10 + attends_private_school: true + child2: + age: 12 + attends_private_school: true + households: + household: + members: [adult, child1, child2] + output: + # 2 x 10,000 x 0.75 x 20% + private_school_vat: 3_000 + +- name: Only pupils count + period: 2025 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child1: + age: 10 + attends_private_school: true + child2: + age: 7 + attends_private_school: false + households: + household: + members: [adult, child1, child2] + output: + private_school_vat: 2_000 + +- name: The reform rate at the standard rate does not charge the fees twice + period: 2025 + input: + gov.contrib.labour.private_school_vat: 0.2 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + # max(20%, 20%), not 20% + 20% + private_school_vat: 2_000 + +- name: A reform rate below the standard rate leaves the statutory charge + period: 2025 + input: + gov.contrib.labour.private_school_vat: 0.1 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 2_000 + +- name: A reform rate above the standard rate replaces it + period: 2025 + input: + gov.contrib.labour.private_school_vat: 0.25 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + # max(20%, 25%) = 25% + private_school_vat: 2_500 + +- name: The reform rate applies while the fees are exempt + period: 2024 + input: + gov.contrib.labour.private_school_vat: 0.2 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 2_000 + +- name: Restoring the exemption removes the charge + period: 2025 + input: + gov.hmrc.vat.private_school_fees_exempt: true + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 0 + +- name: The fees follow a change in the standard rate + period: 2025 + input: + gov.hmrc.vat.standard_rate: 0.22 + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat: 2_200 + +- name: No change from baseline at current law + period: 2026 + input: + people: + adult: + age: 40 + child: + age: 10 + attends_private_school: true + households: + household: + members: [adult, child] + output: + private_school_vat_change: 0 + +- name: Household taxes include the change, government taxes the level + period: 2025 + absolute_error_margin: 0.01 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + people: + adult: + age: 40 + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + child: + age: 10 + attends_private_school: true + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + households: + household: + members: [adult, child] + # Every other head of the two totals is zero. The change is its + # current-law value (tested above); the baseline parameters keep the + # unmodified mean fee, so it is set rather than computed here. + LVT: 0 + alcohol_duty: 0 + business_rates: 0 + carbon_tax: 0 + consumer_incident_tax_revenue_change: 0 + corporate_incident_tax_revenue_change: 0 + corporate_sdlt: 0 + council_tax: 0 + domestic_rates: 0 + expected_lbtt: 0 + expected_ltt: 0 + expected_sdlt: 0 + fuel_duty: 0 + high_value_council_tax_surcharge: 0 + non_primary_residence_wealth_tax: 0 + private_school_vat_change: 0 + tobacco_duty: 0 + tv_licence: 0 + vat: 0 + vat_change: 0 + vehicle_excise_duty: 0 + wealth_tax: 0 + output: + private_school_vat: 2_000 + gov_tax: 2_000 + household_tax: 0 diff --git a/policyengine_uk/variables/contrib/labour/private_school_vat.py b/policyengine_uk/variables/contrib/labour/private_school_vat.py deleted file mode 100644 index d6e7823ee..000000000 --- a/policyengine_uk/variables/contrib/labour/private_school_vat.py +++ /dev/null @@ -1,27 +0,0 @@ -from policyengine_uk.model_api import * - - -class private_school_vat(Variable): - label = "private school VAT" - entity = Household - definition_period = YEAR - value_type = float - unit = "currency-GBP" - - def formula(household, period, parameters): - num_private_school_pupils = add(household, period, ["attends_private_school"]) - - ps_vat_params = parameters(period).gov.simulation.private_school_vat - private_school_vat_basis = ps_vat_params.private_school_vat_basis - avg_yearly_private_school_cost = ps_vat_params.private_school_fees - - private_school_vat_rate = parameters( - period - ).gov.contrib.labour.private_school_vat - - return ( - num_private_school_pupils - * avg_yearly_private_school_cost - * private_school_vat_rate - * private_school_vat_basis - ) diff --git a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py new file mode 100644 index 000000000..5c49c2f1a --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.hmrc.private_school_vat import ( + private_school_vat_liability, +) + + +class baseline_private_school_vat(Variable): + label = "baseline private school VAT" + documentation = "private_school_vat under baseline parameters." + entity = Household + definition_period = YEAR + value_type = float + unit = "currency-GBP" + + def formula(household, period, parameters): + return private_school_vat_liability( + household, period, parameters(period).baseline.gov + ) diff --git a/policyengine_uk/variables/gov/hmrc/household_tax.py b/policyengine_uk/variables/gov/hmrc/household_tax.py index 3404b8ade..09a0bea0e 100644 --- a/policyengine_uk/variables/gov/hmrc/household_tax.py +++ b/policyengine_uk/variables/gov/hmrc/household_tax.py @@ -23,7 +23,7 @@ "carbon_tax", "vat_change", "capital_gains_tax", - "private_school_vat", + "private_school_vat_change", "corporate_incident_tax_revenue_change", "consumer_incident_tax_revenue_change", "employer_ni_response_capital_incidence", diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat.py b/policyengine_uk/variables/gov/hmrc/private_school_vat.py new file mode 100644 index 000000000..c2eeee972 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * + + +def private_school_vat_rate(gov): + """VAT rate on private school fees under the parameters in `gov`. + + Finance Act 2025 s.47 takes education, vocational training and closely + related board and lodging supplied by a private school out of the VAT + Act 1994 Sch 9 Group 6 exemption (new Sch 9 Part 3), with effect for + supplies on or after 1 January 2025 (s.49), so the fees bear the + standard rate. The contributed reform rate can raise the rate on the + fees but is never charged on top of the statutory rate: the fees bear + VAT once, at the higher of the two. + """ + vat = gov.hmrc.vat + statutory_rate = 0 if vat.private_school_fees_exempt else vat.standard_rate + return max_(statutory_rate, gov.contrib.labour.private_school_vat) + + +def private_school_vat_liability(household, period, gov): + pupils = add(household, period, ["attends_private_school"]) + p = gov.simulation.private_school_vat + return ( + pupils + * p.private_school_fees + * p.private_school_vat_basis + * private_school_vat_rate(gov) + ) + + +class private_school_vat(Variable): + label = "private school VAT" + documentation = ( + "VAT on the household's private school fees: pupils times the mean " + "fee times the share of fees that bears VAT net of the schools' " + "input tax, at the standard rate from 1 January 2025." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "currency-GBP" + reference = [ + "https://www.legislation.gov.uk/ukpga/2025/8/section/47/enacted", + "https://www.legislation.gov.uk/ukpga/2025/8/section/49/enacted", + ] + + def formula(household, period, parameters): + return private_school_vat_liability(household, period, parameters(period).gov) diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat_change.py b/policyengine_uk/variables/gov/hmrc/private_school_vat_change.py new file mode 100644 index 000000000..475ec65fc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat_change.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class private_school_vat_change(Variable): + label = "change in private school VAT" + documentation = ( + "Change in private school VAT from its baseline. Household taxes " + "include VAT only as a change, so this is the head they include; " + "government tax revenue includes the level." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "currency-GBP" + adds = ["private_school_vat"] + subtracts = ["baseline_private_school_vat"] From 7742c8e65be84ac40966023b095461738d30c4bd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:55:47 -0400 Subject: [PATCH 3/6] Partition private school VAT into a level and a change head private_school_vat is now non-zero at current law, so it leaves the zero-at-baseline heads and stays in gov_tax only; household_tax sums the new private_school_vat_change head, which is zero at baseline, with baseline_private_school_vat as its baseline measure. A level-and-change guard mirrors vat and vat_change. The vat overlap guard becomes a disjointness property: vat's full-rate share was trained when the fees were exempt, and neither head reads the other's inputs or parameters. Adds Hypothesis properties: private_school_vat matches the statute computed independently, a reform rate at or below the statutory rate never taxes the fees twice, the head is monotone in both rates, vat and private_school_vat are disjoint, and the level, baseline and change agree. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 68 ++++-- .../test_private_school_vat_properties.py | 225 ++++++++++++++++++ 2 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 policyengine_uk/tests/test_private_school_vat_properties.py diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 21bc4b992..b64c239f8 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -74,9 +74,9 @@ totals: 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. + only as reform-induced changes (vat_change, private_school_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 @@ -205,6 +205,11 @@ tax_heads: - 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 + # Standard-rated from 1 January 2025: Finance Act 2025 s.47 inserts VATA + # 1994 Sch 9 Part 3, taking private school education, vocational training + # and closely related board and lodging out of the Group 6 exemption, for + # supplies on or after that date (s.49). Before it the head is zero unless + # the contributed reform rate is set. - id: private_school_vat label: VAT on private school fees variable: private_school_vat @@ -213,12 +218,8 @@ tax_heads: obr_table_3_1: Value added tax report_group: vat payer: household - totals: [gov_tax, household_tax] + totals: [gov_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 @@ -229,6 +230,30 @@ tax_heads: - head: council_tax route: private_school_vat -> attends_private_school -> household_benefits -> council_tax_benefit -> council_tax why: as above + - id: private_school_vat_change + label: Change in private school VAT from its baseline + variable: private_school_vat_change + entity: household + obr_line: Value added tax + obr_table_3_1: Value added tax + report_group: vat + payer: household + totals: [household_tax] + sign: any + zero_at_baseline: expected + reads: + - head: private_school_vat + route: private_school_vat_change adds private_school_vat and subtracts baseline_private_school_vat + why: a change measure built from the level; the two are never in the same total + - head: income_tax + route: private_school_vat_change -> 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_change -> private_school_vat -> attends_private_school -> (net income) -> national_insurance + why: as above + - head: council_tax + route: private_school_vat_change -> 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 @@ -516,6 +541,7 @@ exclusions: - {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: baseline_private_school_vat, kind: baseline_measure, head: private_school_vat_change, note: "Subtracted inside private_school_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} @@ -561,17 +587,31 @@ overlap_guards: 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_level_and_change + kind: level_and_change + heads: [private_school_vat, private_school_vat_change] + property: >- + Never in the same total: the level is in gov_tax and the change in + household_tax, as for vat and vat_change. private_school_vat_change + reads private_school_vat. - id: private_school_vat_and_vat - kind: possible_base_overlap + kind: disjoint_bases 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. + Neither head reads the other or the other's inputs and parameters + (tested in test_private_school_vat_properties.py). vat applies one + household full-rate share to all imputed consumption, education + included. The share (full_rate_vat_expenditure_rate) is trained on ETB + VAT paid in the financial year ending 2024, when private school fees + were exempt (policyengine-uk-data datasets/imputations/vat.py), and is + the same in every year, so vat never charges the fees at the standard + rate. private_school_vat charges them once, at the higher of the + statutory and contributed reform rates. Outside the model, + policyengine-uk-data calibrates weighted vat to the OBR VAT receipts + line, which includes VAT on private school fees from 2025-26. - id: eligibility_reads kind: read_not_inclusion - heads: [tv_licence, private_school_vat] + heads: [tv_licence, private_school_vat, private_school_vat_change] property: >- These heads reach other heads of the same total only through eligibility or imputation (see each head's reads). Raising the other diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py new file mode 100644 index 000000000..414b2645c --- /dev/null +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -0,0 +1,225 @@ +"""VAT on private school fees is charged once, at the statutory or reform rate. + +VATA 1994 Sch 9 Group 6 exempted education supplied by an eligible body, +which included registered independent schools. Finance Act 2025 s.47 +inserts Sch 9 Part 3, which takes private school education, vocational +training and closely related board and lodging out of the exemption, for +supplies on or after 1 January 2025 (s.49). The fees then bear the standard +rate. gov.contrib.labour.private_school_vat is a contributed reform rate. + +Invariants, for any household with up to three pupils, any year from 2024 +to 2030, any mean fee, share of fees bearing VAT, standard rate and reform +rate, with the exemption at current law, restored or removed: + +- differential: private_school_vat equals pupils x fee x share x the + higher of the statutory rate (0 while the fees are exempt, the standard + rate otherwise) and the reform rate, computed here from the statute and + not from the model's formula; +- once: a reform rate at or below the statutory rate leaves + private_school_vat unchanged, so the fees are never taxed twice; +- monotone: private_school_vat never falls when the reform rate or the + standard rate rises; +- disjoint: vat (VAT on imputed consumption) is the same whatever the + private school parameters, and private_school_vat is the same whatever the + household's consumption, including education consumption; +- level and change: private_school_vat_change equals private_school_vat + minus baseline_private_school_vat; baseline_private_school_vat equals + private_school_vat in a simulation without the reform; and the change is + zero at current law. + +Every household in an example is built in one simulation per parameter +set, so the comparisons hold attendance fixed. +""" + +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" +CONTRIB = "gov.contrib.labour.private_school_vat" +EXEMPT = "gov.hmrc.vat.private_school_fees_exempt" +STANDARD = "gov.hmrc.vat.standard_rate" +FEES = "gov.simulation.private_school_vat.private_school_fees" +BASIS = "gov.simulation.private_school_vat.private_school_vat_basis" +# Model values are float32. +RTOL = 1e-6 + +household = st.fixed_dictionaries( + { + "pupils": st.integers(0, 3), + "other_children": st.integers(0, 2), + "consumption": st.one_of(st.just(0.0), st.floats(1_000, 100_000)), + "education_share": st.floats(0, 0.5), + } +) +parameter_set = st.fixed_dictionaries( + { + "year": st.integers(2024, 2030), + "fees": st.floats(1_000, 50_000), + "basis": st.floats(0, 1), + "standard_rate": st.one_of(st.none(), st.sampled_from([0.15, 0.2, 0.25])), + "exempt": st.one_of(st.none(), st.booleans()), + "contrib": st.one_of(st.just(0.0), st.floats(0, 0.4)), + } +) + + +def situation(households: list, year: int) -> dict: + people, benunits, hhs = {}, {}, {} + for i, h in enumerate(households): + members = [f"adult_{i}"] + people[f"adult_{i}"] = {"age": {year: 45}} + for kind, count in (("pupil", h["pupils"]), ("child", h["other_children"])): + for j in range(count): + name = f"{kind}_{i}_{j}" + people[name] = { + "age": {year: 6 + j}, + "attends_private_school": {year: kind == "pupil"}, + } + members.append(name) + benunits[f"benunit_{i}"] = {"members": members} + education = h["consumption"] * h["education_share"] + hhs[f"household_{i}"] = { + "members": members, + "education_consumption": {year: education}, + "food_and_non_alcoholic_beverages_consumption": { + year: h["consumption"] - education + }, + } + return {"people": people, "benunits": benunits, "households": hhs} + + +def reform_for(p: dict) -> dict: + reform = { + FEES: {ALWAYS: p["fees"]}, + BASIS: {ALWAYS: p["basis"]}, + CONTRIB: {ALWAYS: p["contrib"]}, + } + if p["standard_rate"] is not None: + reform[STANDARD] = {ALWAYS: p["standard_rate"]} + if p["exempt"] is not None: + reform[EXEMPT] = {ALWAYS: p["exempt"]} + return reform + + +def statutory_rate(p: dict) -> float: + """The statute's rate on the fees, independently of the model's formula.""" + year = p["year"] + exempt = p["exempt"] + if exempt is None: + # Exempt until the s.49 commencement on 1 January 2025. + exempt = year < 2025 + if exempt: + return 0.0 + if p["standard_rate"] is not None: + return p["standard_rate"] + return float(system.parameters.gov.hmrc.vat.standard_rate(f"{year}-01-01")) + + +def calc(households, p, variable, **overrides): + sim = Simulation( + situation=situation(households, p["year"]), + reform=reform_for({**p, **overrides}), + ) + return np.asarray(sim.calculate(variable, p["year"]), dtype=float) + + +def assert_close(actual, expected, message=""): + np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=1e-2, err_msg=message) + + +SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) +def test_private_school_vat_matches_the_statute(households, p): + pupils = np.array([h["pupils"] for h in households], dtype=float) + rate = max(statutory_rate(p), p["contrib"]) + expected = pupils * p["fees"] * p["basis"] * rate + assert_close(calc(households, p, "private_school_vat"), expected) + + +@SETTINGS +@given( + households=st.lists(household, min_size=1, max_size=3), + p=parameter_set, + fraction=st.floats(0, 1), +) +def test_a_reform_rate_at_or_below_the_statute_never_taxes_twice( + households, p, fraction +): + without_reform = calc(households, p, "private_school_vat", contrib=0.0) + at_or_below = calc( + households, p, "private_school_vat", contrib=fraction * statutory_rate(p) + ) + assert_close(at_or_below, without_reform) + + +@SETTINGS +@given( + households=st.lists(household, min_size=1, max_size=3), + p=parameter_set, + rise=st.floats(0, 0.2), +) +def test_private_school_vat_never_falls_as_a_rate_rises(households, p, rise): + before = calc(households, p, "private_school_vat") + higher_contrib = calc( + households, p, "private_school_vat", contrib=p["contrib"] + rise + ) + standard = p["standard_rate"] or statutory_rate({**p, "exempt": False}) + higher_standard = calc( + households, p, "private_school_vat", standard_rate=standard + rise + ) + tolerance = RTOL * np.abs(before) + 1e-2 + assert (higher_contrib >= before - tolerance).all() + assert (higher_standard >= before - tolerance).all() + + +@SETTINGS +@given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) +def test_vat_and_private_school_vat_are_disjoint(households, p): + vat = calc(households, p, "vat") + for overrides in ( + {"exempt": True, "contrib": 0.0}, + {"exempt": False, "contrib": 0.35}, + ): + assert_close(calc(households, p, "vat", **overrides), vat, str(overrides)) + no_consumption = [ + {**h, "consumption": 0.0, "education_share": 0.0} for h in households + ] + assert_close( + calc(no_consumption, p, "private_school_vat"), + calc(households, p, "private_school_vat"), + ) + + +@SETTINGS +@given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) +def test_level_and_change(households, p): + sim = Simulation(situation=situation(households, p["year"]), reform=reform_for(p)) + level, baseline, change = ( + np.asarray(sim.calculate(v, p["year"]), dtype=float) + for v in ( + "private_school_vat", + "baseline_private_school_vat", + "private_school_vat_change", + ) + ) + assert_close(change, level - baseline) + current_law = Simulation(situation=situation(households, p["year"])) + assert_close( + baseline, + np.asarray(current_law.calculate("private_school_vat", p["year"]), dtype=float), + ) + np.testing.assert_array_equal( + current_law.calculate("private_school_vat_change", p["year"]), 0 + ) From 7f1b1451c86f063eeda3eaf0c502f70198e755e8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:50:11 -0400 Subject: [PATCH 4/6] Hold assumptions fixed in the private school VAT baseline baseline_private_school_vat now applies baseline policy (the exemption, the standard rate and the reform rate) to this simulation's attendance, mean fee and share of fees bearing VAT, so a reform that changes only those assumptions (such as disabling economic assumptions, which moves the fee uprating) is no longer scored as a household tax change. YAML cases now compute the change and include reforms that raise and remove it; a grid test checks the statutory rate exhaustively across the commencement, the exemption, reform rates and standard rates. The exemption parameter starts when VAT did (Finance Act 1972 Sch 5 Group 6), so no earlier lookup reads as taxable. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/vat/private_school_fees_exempt.yaml | 6 +- .../baseline/gov/hmrc/private_school_vat.yaml | 127 +++++++++++++++++- .../test_private_school_vat_properties.py | 68 ++++++++-- .../gov/hmrc/baseline_private_school_vat.py | 12 +- .../variables/gov/hmrc/private_school_vat.py | 22 ++- 5 files changed, 211 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml index fd4087b02..7a462f771 100644 --- a/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml +++ b/policyengine_uk/parameters/gov/hmrc/vat/private_school_fees_exempt.yaml @@ -1,13 +1,13 @@ description: Education, vocational training and closely related board and lodging supplied by a private school are exempt from VAT if this is true. From 1 January 2025 they are not exempt, so the fees bear VAT at the standard rate. values: - 1994-09-01: + 1973-04-01: value: true metadata: reference: + - title: Finance Act 1972, Schedule 5, Group 6, item 1 (as enacted) + href: https://www.legislation.gov.uk/ukpga/1972/41/schedule/5/enacted - title: Value Added Tax Act 1994, Schedule 9, Group 6, item 1 and note 1(a) (as enacted) href: https://www.legislation.gov.uk/ukpga/1994/23/schedule/9/enacted - - title: Value Added Tax Act 1994, section 101(1) (commencement on 1 September 1994) - href: https://www.legislation.gov.uk/ukpga/1994/23/section/101/enacted 2025-01-01: value: false metadata: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml index cd7957eff..2f04cbe4b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/private_school_vat.yaml @@ -250,9 +250,7 @@ households: household: members: [adult, child] - # Every other head of the two totals is zero. The change is its - # current-law value (tested above); the baseline parameters keep the - # unmodified mean fee, so it is set rather than computed here. + # Every other head of the two totals is zero. LVT: 0 alcohol_duty: 0 business_rates: 0 @@ -268,7 +266,6 @@ fuel_duty: 0 high_value_council_tax_surcharge: 0 non_primary_residence_wealth_tax: 0 - private_school_vat_change: 0 tobacco_duty: 0 tv_licence: 0 vat: 0 @@ -276,6 +273,128 @@ vehicle_excise_duty: 0 wealth_tax: 0 output: + # The baseline applies baseline policy to this simulation's fee and share, so changing those assumptions is not a tax change. private_school_vat: 2_000 + baseline_private_school_vat: 2_000 + private_school_vat_change: 0 gov_tax: 2_000 household_tax: 0 + +- name: A higher reform rate is a household tax rise + period: 2025 + absolute_error_margin: 0.01 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + gov.contrib.labour.private_school_vat: 0.25 + people: + adult: + age: 40 + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + child: + age: 10 + attends_private_school: true + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + households: + household: + members: [adult, child] + # Every other head of the two totals is zero. + LVT: 0 + alcohol_duty: 0 + business_rates: 0 + carbon_tax: 0 + consumer_incident_tax_revenue_change: 0 + corporate_incident_tax_revenue_change: 0 + corporate_sdlt: 0 + council_tax: 0 + domestic_rates: 0 + expected_lbtt: 0 + expected_ltt: 0 + expected_sdlt: 0 + fuel_duty: 0 + high_value_council_tax_surcharge: 0 + non_primary_residence_wealth_tax: 0 + tobacco_duty: 0 + tv_licence: 0 + vat: 0 + vat_change: 0 + vehicle_excise_duty: 0 + wealth_tax: 0 + output: + # 10,000 x (25% - 20%) + private_school_vat: 2_500 + baseline_private_school_vat: 2_000 + private_school_vat_change: 500 + gov_tax: 2_500 + household_tax: 500 + +- name: Restoring the exemption is a household tax cut + period: 2025 + absolute_error_margin: 0.01 + input: + gov.simulation.private_school_vat.private_school_fees: 10_000 + gov.simulation.private_school_vat.private_school_vat_basis: 1 + gov.hmrc.vat.private_school_fees_exempt: true + people: + adult: + age: 40 + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + child: + age: 10 + attends_private_school: true + capital_gains_tax: 0 + employer_ni_response_capital_incidence: 0 + employer_ni_response_consumer_incidence: 0 + income_tax: 0 + national_insurance: 0 + ni_employer: 0 + student_loan_repayments: 0 + households: + household: + members: [adult, child] + # Every other head of the two totals is zero. + LVT: 0 + alcohol_duty: 0 + business_rates: 0 + carbon_tax: 0 + consumer_incident_tax_revenue_change: 0 + corporate_incident_tax_revenue_change: 0 + corporate_sdlt: 0 + council_tax: 0 + domestic_rates: 0 + expected_lbtt: 0 + expected_ltt: 0 + expected_sdlt: 0 + fuel_duty: 0 + high_value_council_tax_surcharge: 0 + non_primary_residence_wealth_tax: 0 + tobacco_duty: 0 + tv_licence: 0 + vat: 0 + vat_change: 0 + vehicle_excise_duty: 0 + wealth_tax: 0 + output: + private_school_vat: 0 + baseline_private_school_vat: 2_000 + private_school_vat_change: -2_000 + gov_tax: 0 + household_tax: -2_000 diff --git a/policyengine_uk/tests/test_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index 414b2645c..2689503ce 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -19,19 +19,30 @@ private_school_vat unchanged, so the fees are never taxed twice; - monotone: private_school_vat never falls when the reform rate or the standard rate rises; -- disjoint: vat (VAT on imputed consumption) is the same whatever the +- separate inputs: vat (VAT on imputed consumption) is the same whatever the private school parameters, and private_school_vat is the same whatever the - household's consumption, including education consumption; + household's consumption, including education consumption. This shows + neither head reads the other's parameters or inputs; it does not show the + bases are separate, since vat's base includes education consumption (see + the private_school_vat_and_vat guard in data/fiscal_partition.yaml); - level and change: private_school_vat_change equals private_school_vat minus baseline_private_school_vat; baseline_private_school_vat equals - private_school_vat in a simulation without the reform; and the change is - zero at current law. + private_school_vat under baseline policy with this simulation's mean fee + and share of fees bearing VAT; so a reform that changes only those + assumptions changes nothing, and the change is zero at current law. + +A grid test checks the differential exhaustively over years either side of +the commencement, every setting of the exemption, reform rates below, at +and above the standard rate, and two standard rates. Every household in an example is built in one simulation per parameter set, so the comparisons hold attendance fixed. """ import numpy as np +import itertools + +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -186,7 +197,7 @@ def test_private_school_vat_never_falls_as_a_rate_rises(households, p, rise): @SETTINGS @given(households=st.lists(household, min_size=1, max_size=3), p=parameter_set) -def test_vat_and_private_school_vat_are_disjoint(households, p): +def test_vat_and_private_school_vat_read_separate_inputs(households, p): vat = calc(households, p, "vat") for overrides in ( {"exempt": True, "contrib": 0.0}, @@ -215,11 +226,50 @@ def test_level_and_change(households, p): ) ) assert_close(change, level - baseline) - current_law = Simulation(situation=situation(households, p["year"])) + # Baseline policy with this simulation's assumptions. + assumptions_only = Simulation( + situation=situation(households, p["year"]), + reform={FEES: {ALWAYS: p["fees"]}, BASIS: {ALWAYS: p["basis"]}}, + ) assert_close( baseline, - np.asarray(current_law.calculate("private_school_vat", p["year"]), dtype=float), + np.asarray( + assumptions_only.calculate("private_school_vat", p["year"]), dtype=float + ), ) - np.testing.assert_array_equal( - current_law.calculate("private_school_vat_change", p["year"]), 0 + for current_policy in ( + assumptions_only, + Simulation(situation=situation(households, p["year"])), + ): + np.testing.assert_array_equal( + current_policy.calculate("private_school_vat_change", p["year"]), 0 + ) + + +GRID_HOUSEHOLDS = [ + {"pupils": n, "other_children": 1, "consumption": 20_000.0, "education_share": 0.2} + for n in (0, 1, 3) +] +GRID = list( + itertools.product( + (2024, 2025), + (None, True, False), + (0.0, 0.1, 0.2, 0.3), + (None, 0.25), ) +) + + +@pytest.mark.parametrize("year, exempt, contrib, standard_rate", GRID) +def test_private_school_vat_grid(year, exempt, contrib, standard_rate): + p = { + "year": year, + "fees": 12_000.0, + "basis": 0.75, + "standard_rate": standard_rate, + "exempt": exempt, + "contrib": contrib, + } + pupils = np.array([h["pupils"] for h in GRID_HOUSEHOLDS], dtype=float) + expected = pupils * 12_000 * 0.75 * max(statutory_rate(p), contrib) + assert_close(calc(GRID_HOUSEHOLDS, p, "private_school_vat"), expected, str(p)) diff --git a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py index 5c49c2f1a..f52f45802 100644 --- a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -6,7 +6,12 @@ class baseline_private_school_vat(Variable): label = "baseline private school VAT" - documentation = "private_school_vat under baseline parameters." + documentation = ( + "private_school_vat under baseline policy (the exemption, the standard " + "rate and the reform rate), with this simulation's attendance, mean " + "fee and share of fees bearing VAT. Holding the assumptions fixed " + "means private_school_vat_change measures policy alone." + ) entity = Household definition_period = YEAR value_type = float @@ -14,5 +19,8 @@ class baseline_private_school_vat(Variable): def formula(household, period, parameters): return private_school_vat_liability( - household, period, parameters(period).baseline.gov + household, + period, + parameters(period).baseline.gov, + parameters(period).gov.simulation.private_school_vat, ) diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat.py b/policyengine_uk/variables/gov/hmrc/private_school_vat.py index c2eeee972..1250588b5 100644 --- a/policyengine_uk/variables/gov/hmrc/private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -17,14 +17,21 @@ def private_school_vat_rate(gov): return max_(statutory_rate, gov.contrib.labour.private_school_vat) -def private_school_vat_liability(household, period, gov): +def private_school_vat_liability(household, period, policy, assumptions): + """VAT on the household's fees. + + `policy` (a `gov` parameter node) sets the rate; `assumptions` + (gov.simulation.private_school_vat) sets the mean fee and the share of + fees that bears VAT net of the schools' input tax. The two are separate + so that baseline_private_school_vat can apply baseline policy to this + simulation's assumptions. + """ pupils = add(household, period, ["attends_private_school"]) - p = gov.simulation.private_school_vat return ( pupils - * p.private_school_fees - * p.private_school_vat_basis - * private_school_vat_rate(gov) + * assumptions.private_school_fees + * assumptions.private_school_vat_basis + * private_school_vat_rate(policy) ) @@ -45,4 +52,7 @@ class private_school_vat(Variable): ] def formula(household, period, parameters): - return private_school_vat_liability(household, period, parameters(period).gov) + gov = parameters(period).gov + return private_school_vat_liability( + household, period, gov, gov.simulation.private_school_vat + ) From ccbab58ad1a6f46d9f41b01c91adef5542b3c5ba Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:56:17 -0400 Subject: [PATCH 5/6] Describe the private school VAT overlap with vat accurately The vat guard becomes overlapping_base: the heads read separate inputs, but vat applies one household share to all consumption including education, so a fee-paying household's fees are charged at its blended share, and in a household calculation education consumption entered as an input is charged in both heads. On calibrated data the overlap is in aggregate, because policyengine-uk-data targets vat at total OBR VAT receipts (policyengine-uk-data#567). The partition header lists the guard kinds and says data overlaps are recorded under their guards. The identity property now draws households with a child at private school and a scenario restoring the exemption, and checks the head is positive exactly where a pupil's fees are charged. The changelog says contributed reforms at 20% now score nothing from 2025. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2318.fixed.md | 2 +- policyengine_uk/data/fiscal_partition.yaml | 41 ++++++++++++------- .../tests/test_fiscal_partition.py | 29 ++++++++++--- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/changelog.d/2318.fixed.md b/changelog.d/2318.fixed.md index 208f45eaf..0646412b4 100644 --- a/changelog.d/2318.fixed.md +++ b/changelog.d/2318.fixed.md @@ -1 +1 @@ -- Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), with the contributed reform rate able to raise the rate but never charge the fees twice. Household taxes now include private school VAT as a change from baseline, as they do VAT. +- Charge VAT at the standard rate on private school fees from 1 January 2025 (Finance Act 2025 ss.47 and 49), through the new `gov.hmrc.vat.private_school_fees_exempt` parameter. The contributed rate `gov.contrib.labour.private_school_vat` now raises the rate rather than adding to it, so reforms that set it to 20% score nothing from 2025. Household taxes include private school VAT as a change from baseline (`private_school_vat_change`), as they do VAT; government tax revenue includes the level. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index b64c239f8..2335ff791 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -43,9 +43,14 @@ # 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). +# model_double_counts[]: known violations still in the model's formulas +# (none). An overlap that comes from the data, not the formulas, is +# recorded under the guard it belongs to. # overlap_guards[]: the places double counting could occur, and the -# property the tests check for each. +# property the tests check for each. kind: payer_split, +# geographic_exclusivity, level_and_change, shared_allocation_key, +# overlapping_base (the heads read separate inputs but their bases are +# not separated household by household), read_not_inclusion. schema_version: 1 sources: @@ -595,20 +600,28 @@ overlap_guards: household_tax, as for vat and vat_change. private_school_vat_change reads private_school_vat. - id: private_school_vat_and_vat - kind: disjoint_bases + kind: overlapping_base heads: [vat, private_school_vat] property: >- - Neither head reads the other or the other's inputs and parameters - (tested in test_private_school_vat_properties.py). vat applies one - household full-rate share to all imputed consumption, education - included. The share (full_rate_vat_expenditure_rate) is trained on ETB - VAT paid in the financial year ending 2024, when private school fees - were exempt (policyengine-uk-data datasets/imputations/vat.py), and is - the same in every year, so vat never charges the fees at the standard - rate. private_school_vat charges them once, at the higher of the - statutory and contributed reform rates. Outside the model, - policyengine-uk-data calibrates weighted vat to the OBR VAT receipts - line, which includes VAT on private school fees from 2025-26. + The two heads read separate inputs and parameters (tested in + test_private_school_vat_properties.py), but their bases are not + separated household by household. vat applies one household full-rate + share (full_rate_vat_expenditure_rate) to all imputed consumption, + education included. On the microdata the share is trained on ETB VAT + paid in the financial year ending 2024, when private school fees were + exempt, and predicted from household composition and income only + (policyengine-uk-data datasets/imputations/vat.py): in aggregate it + prices the fees as exempt, and a fee-paying household's fees are + charged at its blended share. In a household calculation with the + default share of 0.5, education consumption entered as an input is + charged in vat as well as in private_school_vat, where attendance is + an input. private_school_vat charges the fees once, at the higher of + the statutory and contributed reform rates. On calibrated data the two + overlap in aggregate: policyengine-uk-data calibrates weighted vat to + the OBR VAT receipts line, which includes VAT on private school fees + from 2025-26 (costed at 1,505m pounds for 2025-26), so gov_tax counts + that VAT in both heads until the target nets it out + (policyengine-uk-data issue 567). - id: eligibility_reads kind: read_not_inclusion heads: [tv_licence, private_school_vat, private_school_vat_change] diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index 2988b4337..a48f19651 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -23,9 +23,10 @@ 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 +5. Identity (property-based): on random households, some with a child at + private school, years 2025-2030, and under SDLT abolition, council tax + abolition and a restored private school VAT exemption, 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. @@ -442,6 +443,9 @@ def test_each_head_enters_its_totals_once(head_id): "baseline": None, "sdlt_abolished": {"gov.hmrc.stamp_duty.abolish": {ALWAYS: True}}, "council_tax_abolished": {"gov.contrib.abolish_council_tax": {ALWAYS: True}}, + "private_school_exemption_restored": { + "gov.hmrc.vat.private_school_fees_exempt": {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 @@ -458,6 +462,8 @@ def test_each_head_enters_its_totals_once(head_id): "consumption": amount(80_000), "main_residence_value": amount(5_000_000), "owner": st.booleans(), + # A child at private school, so private school VAT is non-zero. + "pupil": st.booleans(), } ) PERSON_INPUTS = {"age", "employment_income", "self_employment_income", "capital_gains"} @@ -468,16 +474,23 @@ def random_situation(drawn: list, year: int) -> dict: 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]} + members = [person] + if inputs["pupil"]: + people[f"child_{i}"] = { + "age": {year: 10}, + "attends_private_school": {year: True}, + } + members.append(f"child_{i}") + benunits[f"benunit_{i}"] = {"members": members} household = { key: {year: value} for key, value in inputs.items() - if key not in PERSON_INPUTS and key != "owner" + if key not in PERSON_INPUTS and key not in ("owner", "pupil") } household["tenure_type"] = { year: "OWNED_OUTRIGHT" if inputs["owner"] else "RENT_PRIVATELY" } - households[f"household_{i}"] = {"members": [person], **household} + households[f"household_{i}"] = {"members": members, **household} return {"people": people, "benunits": benunits, "households": households} @@ -528,6 +541,10 @@ def test_each_total_is_the_sum_of_its_heads(drawn, year): 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"]) + # Exercise the head: it is positive wherever a pupil's fees are + # charged at current law. + pupils = np.array([h["pupil"] for h in drawn]) + np.testing.assert_array_equal(values["private_school_vat"] > 0, pupils) if scenario == "sdlt_abolished": np.testing.assert_array_equal(values["sdlt_household"], 0) np.testing.assert_array_equal(values["sdlt_corporate"], 0) From 22be892e5d581b507cbc0308c6393d7e8a06bd30 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:17:25 -0400 Subject: [PATCH 6/6] Make the private school checks non-vacuous and note the simplifications From the second independent review: - The identity property always includes a household with a pupil, and under a restored exemption checks the head is zero and the change is minus the baseline charge exactly where a pupil's fees bore it. - The grid's reference uses the statutory 20 per cent, not the model's parameter. - The eligibility-reads guard says its check is vacuous for the private school heads on situations; the header says formula overlaps between heads with unseparated bases are recorded under their guards. - private_school_vat notes that every imputed pupil is charged (nursery classes excepted by law, 18-year-olds chargeable), and the baseline notes it reads 1 January values where the level reads 30 April. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 11 +++++-- .../tests/test_fiscal_partition.py | 33 ++++++++++++++++++- .../test_private_school_vat_properties.py | 4 +-- .../gov/hmrc/baseline_private_school_vat.py | 4 +++ .../variables/gov/hmrc/private_school_vat.py | 6 ++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 2335ff791..d390430e1 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -43,9 +43,10 @@ # 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's formulas -# (none). An overlap that comes from the data, not the formulas, is -# recorded under the guard it belongs to. +# model_double_counts[]: heads whose formulas count the same receipt +# twice (none). Where two heads read separate inputs but their bases are +# not separated, the overlap, in the formulas or in the data, is +# recorded under its guard. # overlap_guards[]: the places double counting could occur, and the # property the tests check for each. kind: payer_split, # geographic_exclusivity, level_and_change, shared_allocation_key, @@ -629,3 +630,7 @@ 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. + For the private school heads that check is vacuous: attendance is + imputed only on a dataset (on a situation it is zero or an input), so + a tax change moves them only through re-imputed attendance on the + microdata (policyengine-uk-data issue 568). diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py index a48f19651..be3d2a461 100644 --- a/policyengine_uk/tests/test_fiscal_partition.py +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -42,7 +42,7 @@ import numpy as np import pytest import yaml -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st import policyengine_uk @@ -494,6 +494,24 @@ def random_situation(drawn: list, year: int) -> dict: return {"people": people, "benunits": benunits, "households": households} +# Always one household with a pupil, so the private school checks are never +# vacuous. +PUPIL_HOUSEHOLD = { + "region": "LONDON", + "age": 45, + "employment_income": 120_000.0, + "self_employment_income": 0.0, + "capital_gains": 0.0, + "main_residential_property_purchased": 0.0, + "shareholding": 0.0, + "council_tax": 2_500.0, + "consumption": 50_000.0, + "main_residence_value": 900_000.0, + "owner": True, + "pupil": True, +} + + @settings( max_examples=10, deadline=None, @@ -504,6 +522,7 @@ def random_situation(drawn: list, year: int) -> dict: drawn=st.lists(random_household, min_size=2, max_size=2), year=st.integers(2025, 2030), ) +@example(drawn=[PUPIL_HOUSEHOLD, {**PUPIL_HOUSEHOLD, "pupil": False}], year=2026) 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) @@ -545,6 +564,18 @@ def test_each_total_is_the_sum_of_its_heads(drawn, year): # charged at current law. pupils = np.array([h["pupil"] for h in drawn]) np.testing.assert_array_equal(values["private_school_vat"] > 0, pupils) + if scenario == "private_school_exemption_restored": + # Restoring the exemption removes the charge, and household taxes + # fall by the baseline charge wherever a pupil's fees bore it. + pupils = np.array([h["pupil"] for h in drawn]) + np.testing.assert_array_equal(values["private_school_vat"], 0) + change = values["private_school_vat_change"] + np.testing.assert_array_equal(change < 0, pupils) + np.testing.assert_allclose( + change, + -household_values(sim, "baseline_private_school_vat", year), + rtol=1e-6, + ) 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_private_school_vat_properties.py b/policyengine_uk/tests/test_private_school_vat_properties.py index 2689503ce..78b69b8b1 100644 --- a/policyengine_uk/tests/test_private_school_vat_properties.py +++ b/policyengine_uk/tests/test_private_school_vat_properties.py @@ -47,7 +47,6 @@ from hypothesis import strategies as st from policyengine_uk import Simulation -from policyengine_uk.system import system ALWAYS = "2000-01-01.2100-12-31" CONTRIB = "gov.contrib.labour.private_school_vat" @@ -127,7 +126,8 @@ def statutory_rate(p: dict) -> float: return 0.0 if p["standard_rate"] is not None: return p["standard_rate"] - return float(system.parameters.gov.hmrc.vat.standard_rate(f"{year}-01-01")) + # VATA 1994 s.2(1): 20 per cent since 4 January 2011. + return 0.2 def calc(households, p, variable, **overrides): diff --git a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py index f52f45802..edf16a99d 100644 --- a/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/baseline_private_school_vat.py @@ -18,6 +18,10 @@ class baseline_private_school_vat(Variable): unit = "currency-GBP" def formula(household, period, parameters): + # parameters.baseline is cloned before the fiscal-year conversion, so + # it reads the exemption and rates at 1 January where the level reads + # them at 30 April. They agree while none changes between those + # dates; baseline_vat has the same property. return private_school_vat_liability( household, period, diff --git a/policyengine_uk/variables/gov/hmrc/private_school_vat.py b/policyengine_uk/variables/gov/hmrc/private_school_vat.py index 1250588b5..c1d28dceb 100644 --- a/policyengine_uk/variables/gov/hmrc/private_school_vat.py +++ b/policyengine_uk/variables/gov/hmrc/private_school_vat.py @@ -25,6 +25,12 @@ def private_school_vat_liability(household, period, policy, assumptions): fees that bears VAT net of the schools' input tax. The two are separate so that baseline_private_school_vat can apply baseline policy to this simulation's assumptions. + + Every imputed pupil is charged. The law excepts education in a nursery + class (Sch 9 Part 3 item 1(b)) and charges 18-year-olds in sixth forms, + but attends_private_school imputes pupils over all under-18s, calibrated + to the total pupil count; policyengine-uk-data issue 568 moves the + imputation to school ages. """ pupils = add(household, period, ["attends_private_school"]) return (