diff --git a/changelog.d/corporate-sdlt-once.fixed.md b/changelog.d/corporate-sdlt-once.fixed.md new file mode 100644 index 0000000000..6a70074688 --- /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. `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 new file mode 100644 index 0000000000..c1c4809d45 --- /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 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 new file mode 100644 index 0000000000..cb456d9ce3 --- /dev/null +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -0,0 +1,674 @@ +# Fiscal partition: how the model's tax totals divide into tax heads. +# +# 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 +# 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 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) +# 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, 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 + +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, 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: + 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 + obr_table_3_1: 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 + obr_table_3_1: NICs + 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 + obr_table_3_1: NICs + 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 + obr_table_3_1: NICs + 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 + obr_table_3_1: NICs + 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 + obr_table_3_1: Capital taxes + 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_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 + 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] + sign: nonnegative + + # Consumption + - id: vat + label: VAT + 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] + 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 + 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] + 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 + obr_table_3_1: 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 + obr_table_3_1: 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 + obr_table_3_1: Alcohol and 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. 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] + 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. 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] + 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. 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] + 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 + 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 + 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 + obr_table_3_1: PSNB-neutral receipts + 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_table_3_1: null + obr_line_note: >- + 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] + sign: nonnegative + - id: business_rates + label: Business rates + 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] + sign: nonnegative + - id: sdlt_household + label: Stamp duty land tax on the household's own purchases + variable: expected_sdlt + entity: household + obr_line: Property transaction taxes + obr_table_3_1: Capital taxes + 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: Property transaction taxes + obr_table_3_1: Capital taxes + 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: Property transaction taxes + obr_table_3_1: Capital taxes + 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). 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: Property transaction taxes + obr_table_3_1: Capital taxes + 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). The independent + specification chose land_and_buildings_transaction_tax for that reason. + + # 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_table_3_1: null + 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_table_3_1: null + 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_table_3_1: null + 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_table_3_1: null + 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_table_3_1: null + 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_table_3_1: null + 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: "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} + - {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: 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} + - {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_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."} + - {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 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: [] + +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: 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] + 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. + +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/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..5d09e809a0 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml @@ -0,0 +1,168 @@ +# 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: 0 + 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] +# 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/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..97acbc0cdf --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml @@ -0,0 +1,82 @@ +# 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 the liability and expected SDLT + 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: + 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 new file mode 100644 index 0000000000..92a6bbb805 --- /dev/null +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -0,0 +1,579 @@ +"""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. 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``), + 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. (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. + 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", + "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", + "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" + + +@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 + # 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"]) <= set(MODEL_LISTS), head["id"] + assert head["sign"] in {"nonnegative", "any"}, head["id"] + assert head.get("zero_at_baseline", "expected") == "expected", 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) + 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 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"]: + 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 + + +@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}" + elif x["kind"] in ("composite", "duplicate", "measure_within"): + 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 in MODEL_LISTS: + spec = partition()["totals"][total] + 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 new file mode 100644 index 0000000000..626f885ee2 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_counted_once_properties.py @@ -0,0 +1,187 @@ +"""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: ``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 + 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=10, + 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["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"]) + 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..1b294f6645 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. 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." + ) 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) 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)