From 27bfd4878ffea76f82aa2f3661b0c5f4c2f64b22 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 15:55:33 -0400 Subject: [PATCH 1/7] Derive abolish-tax reform household totals from the baseline totals The abolish-federal-income-tax and abolish-payroll-tax reforms replaced household_tax_before_refundable_credits with a formula that summed a separate parameter list, gov.household.household_tax_before_refundable_credits, less the abolished tax. That list had drifted from the baseline adds lists: it lacked id_pbf, the CA, IN, NC, OK and PA use taxes, and every local tax except New York City's, and it held pa_income_tax, which is after refundable credits. Under either reform, household tax and net income were wrong for those households. Each reform now redefines the total as the system's current adds list less its own component, so the lists cannot drift and the two reforms combine. The parameter list and the unread gov.household.household_refundable_credits are removed. Co-Authored-By: Claude Opus 5.5 --- .../fix-reform-household-tax-drift.fixed.md | 1 + .../fix-reform-household-tax-drift.removed.md | 1 + .../household_refundable_credits.yaml | 55 --- ...usehold_tax_before_refundable_credits.yaml | 57 --- .../federal/abolish_federal_income_tax.py | 43 +- .../reforms/federal/abolish_payroll_tax.py | 23 +- .../reforms/federal/household_totals.py | 27 ++ .../federal/abolish_federal_income_tax.yaml | 95 +++++ .../contrib/federal/abolish_payroll_tax.yaml | 69 +++- .../test_abolish_tax_household_totals.py | 391 ++++++++++++++++++ 10 files changed, 600 insertions(+), 162 deletions(-) create mode 100644 changelog.d/fix-reform-household-tax-drift.fixed.md create mode 100644 changelog.d/fix-reform-household-tax-drift.removed.md delete mode 100644 policyengine_us/parameters/gov/household/household_refundable_credits.yaml delete mode 100644 policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml create mode 100644 policyengine_us/reforms/federal/household_totals.py create mode 100644 policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py diff --git a/changelog.d/fix-reform-household-tax-drift.fixed.md b/changelog.d/fix-reform-household-tax-drift.fixed.md new file mode 100644 index 00000000000..f44032e7cbe --- /dev/null +++ b/changelog.d/fix-reform-household-tax-drift.fixed.md @@ -0,0 +1 @@ +The abolish-federal-income-tax and abolish-payroll-tax reforms now build their household totals from the baseline totals less the abolished tax. Under either reform, household tax and net income again include the Idaho permanent building fund tax, the California, Indiana, North Carolina, Oklahoma and Pennsylvania use taxes, and every local income and occupational tax (only New York City's was counted); Pennsylvania refundable credits are no longer counted twice; and the two reforms combine when both are applied. diff --git a/changelog.d/fix-reform-household-tax-drift.removed.md b/changelog.d/fix-reform-household-tax-drift.removed.md new file mode 100644 index 00000000000..3d51a98af51 --- /dev/null +++ b/changelog.d/fix-reform-household-tax-drift.removed.md @@ -0,0 +1 @@ +Removed the parameters `gov.household.household_tax_before_refundable_credits` and `gov.household.household_refundable_credits`. The first was read only by the abolish-federal-income-tax and abolish-payroll-tax reforms, which now use the baseline totals; nothing read the second. diff --git a/policyengine_us/parameters/gov/household/household_refundable_credits.yaml b/policyengine_us/parameters/gov/household/household_refundable_credits.yaml deleted file mode 100644 index 5938f3e3b76..00000000000 --- a/policyengine_us/parameters/gov/household/household_refundable_credits.yaml +++ /dev/null @@ -1,55 +0,0 @@ -description: All household refundable credits. -values: - 0000-01-01: - - income_tax_refundable_credits # Federal. - - al_refundable_credits # Alabama. - - ar_refundable_credits # Arkansas. - - az_refundable_credits # Arizona. - - ca_refundable_credits # California. - - co_refundable_credits # Colorado - - ct_refundable_credits # Connecticut. - - dc_refundable_credits # District of Columbia. - - de_refundable_credits # Delaware. - - ga_refundable_credits # Georgia. - - hi_refundable_credits # Hawaii. - - ia_refundable_credits # Iowa. - - id_refundable_credits # Idaho. - - il_refundable_credits # Illinois. - - in_refundable_credits # Indiana. - - ks_refundable_credits # Kansas. - - ky_refundable_credits # Kentucky. - - la_refundable_credits # Louisiana. - - ma_refundable_credits # Massachusetts. - - me_refundable_credits # Maine. - - md_refundable_credits # Maryland. - - mi_refundable_credits # Michigan. - - mn_refundable_credits # Minnesota. - - mo_refundable_credits # Missouri. - - ms_refundable_credits # Mississippi. - - mt_refundable_credits # Montana. - # Skip NC which has no refundable credits. - - nd_refundable_credits # North Dakota. - - ne_refundable_credits # Nebraska. - - nh_refundable_credits # New Hampshire. - - nm_refundable_credits # New Mexico. - - nj_refundable_credits # New Jersey. - - ny_refundable_credits # New York. - - oh_refundable_credits # Ohio. - - ok_refundable_credits # Oklahoma. - - or_refundable_credits # Oregon. - - pa_refundable_tax_credits # Pennsylvania. - - ri_refundable_credits # Rhode Island. - - sc_refundable_credits # South Carolina. - - wa_refundable_credits # Washington. - - nyc_refundable_credits # New York City. - - ut_refundable_credits # Utah. - - va_refundable_credits # Virginia. - - vt_refundable_credits # Vermont. - - wi_refundable_credits # Wisconsin. - - wv_refundable_credits # West Virginia. - - -metadata: - unit: list - period: year - label: Household refundable credits diff --git a/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml b/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml deleted file mode 100644 index 656d4b40bf3..00000000000 --- a/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml +++ /dev/null @@ -1,57 +0,0 @@ -description: The sum of total tax before refundable credits -values: - 0000-01-01: - - al_income_tax_before_refundable_credits - - ar_income_tax_before_refundable_credits - - az_income_tax_before_refundable_credits - - ca_income_tax_before_refundable_credits - - co_income_tax_before_refundable_credits - - ct_income_tax_before_refundable_credits - - dc_income_tax_before_refundable_credits - - de_income_tax_before_refundable_credits - - employee_payroll_tax - - flat_tax - - ga_income_tax_before_refundable_credits - - hi_income_tax_before_refundable_credits - - ia_income_tax_before_refundable_credits - - id_income_tax_before_refundable_credits - - income_tax_before_refundable_credits - - il_total_tax - - in_income_tax_before_refundable_credits - - ks_income_tax_before_refundable_credits - - ky_income_tax_before_refundable_credits - - la_income_tax_before_refundable_credits - - me_income_tax_before_refundable_credits - - ma_income_tax_before_refundable_credits - - md_income_tax_before_refundable_credits - - mi_income_tax_before_refundable_credits - - mn_income_tax_before_refundable_credits - - mo_income_tax_before_refundable_credits - - ms_income_tax_before_credits_unit - - mt_income_tax_before_refundable_credits_unit - - nc_income_tax # NC has no refundable credits. - - nd_income_tax_before_refundable_credits - - ne_income_tax_before_refundable_credits - - nh_income_tax_before_refundable_credits - - nj_income_tax_before_refundable_credits - - nm_income_tax_before_refundable_credits - - ny_income_tax_before_refundable_credits - - oh_income_tax_before_refundable_credits - - or_income_tax_before_refundable_credits - - ok_income_tax_before_refundable_credits - - pa_income_tax # PA has no refundable credits. - - ri_income_tax_before_refundable_credits - - self_employment_tax - - sc_income_tax_before_refundable_credits - - wa_income_tax_before_refundable_credits - - nyc_income_tax_before_refundable_credits - - ut_income_tax_before_refundable_credits - - va_income_tax_before_refundable_credits - - vt_income_tax_before_refundable_credits - - wi_income_tax_before_refundable_credits - - wv_income_tax_before_refundable_credits - -metadata: - unit: list - period: year - label: Total tax before refundable credits diff --git a/policyengine_us/reforms/federal/abolish_federal_income_tax.py b/policyengine_us/reforms/federal/abolish_federal_income_tax.py index d2cfd51036b..14a276fe190 100644 --- a/policyengine_us/reforms/federal/abolish_federal_income_tax.py +++ b/policyengine_us/reforms/federal/abolish_federal_income_tax.py @@ -1,41 +1,20 @@ from policyengine_us.model_api import * +from policyengine_us.reforms.federal.household_totals import exclude_from_total def create_abolish_federal_income_tax() -> Reform: - class household_tax_before_refundable_credits(Variable): - value_type = float - entity = Household - label = "total tax before refundable credits" - documentation = "Total tax liability before refundable credits." - unit = USD - definition_period = YEAR - - def formula(household, period, parameters): - p = parameters(period) - added_components = p.gov.household.household_tax_before_refundable_credits - added_components = [ - c - for c in added_components - if c != "income_tax_before_refundable_credits" - ] - return add(household, period, added_components) - - class household_refundable_tax_credits(Variable): - value_type = float - entity = Household - label = "refundable tax credits" - definition_period = YEAR - unit = USD - - adds = [ - "household_refundable_state_tax_credits", - "household_refundable_local_tax_credits", - ] - class reform(Reform): def apply(self): - self.update_variable(household_tax_before_refundable_credits) - self.update_variable(household_refundable_tax_credits) + exclude_from_total( + self, + "household_tax_before_refundable_credits", + "income_tax_before_refundable_credits", + ) + exclude_from_total( + self, + "household_refundable_tax_credits", + "income_tax_refundable_credits", + ) return reform diff --git a/policyengine_us/reforms/federal/abolish_payroll_tax.py b/policyengine_us/reforms/federal/abolish_payroll_tax.py index ab817e009de..a809ad58c71 100644 --- a/policyengine_us/reforms/federal/abolish_payroll_tax.py +++ b/policyengine_us/reforms/federal/abolish_payroll_tax.py @@ -1,26 +1,15 @@ from policyengine_us.model_api import * +from policyengine_us.reforms.federal.household_totals import exclude_from_total def create_abolish_payroll_tax() -> Reform: - class household_tax_before_refundable_credits(Variable): - value_type = float - entity = Household - label = "total tax before refundable credits" - documentation = "Total tax liability before refundable credits." - unit = USD - definition_period = YEAR - - def formula(household, period, parameters): - p = parameters(period) - added_components = p.gov.household.household_tax_before_refundable_credits - added_components = [ - c for c in added_components if c != "employee_payroll_tax" - ] - return add(household, period, added_components) - class reform(Reform): def apply(self): - self.update_variable(household_tax_before_refundable_credits) + exclude_from_total( + self, + "household_tax_before_refundable_credits", + "employee_payroll_tax", + ) return reform diff --git a/policyengine_us/reforms/federal/household_totals.py b/policyengine_us/reforms/federal/household_totals.py new file mode 100644 index 00000000000..88e7825d949 --- /dev/null +++ b/policyengine_us/reforms/federal/household_totals.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +def exclude_from_total(system, total: str, component: str) -> None: + """Redefine the ``adds`` variable ``total`` without ``component``. + + The remaining components are read from the definition of ``total`` that + ``system`` holds when the reform is applied, not from a second list. A + component added to the baseline total therefore reaches the reformed + total too, and two reforms that each exclude a component of the same + total combine, where a fixed list would let the later one overwrite the + earlier one. + + Excluding a component that is already absent changes nothing. + ``CountryTaxBenefitSystem`` applies a reform more than once, so this must + hold. + """ + current = system.get_variable(total, check_existence=True) + if not isinstance(current.adds, list): + raise ValueError( + f"{total} must list its components in `adds` for a reform to " + f"exclude {component} from it." + ) + # A new list: `current.adds` belongs to the variable being replaced. + remaining = [name for name in current.adds if name != component] + # `update_variable` inherits every attribute the new class leaves out. + system.update_variable(type(total, (Variable,), {"adds": remaining})) diff --git a/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml b/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml index c9b93b42b24..7eb5efa06ef 100644 --- a/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml +++ b/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml @@ -73,3 +73,98 @@ household_refundable_local_tax_credits: 200 household_refundable_tax_credits: 300 household_tax: -300 + +# The reform's household totals are the baseline totals less the federal +# member, so every other baseline component stays in them. Each case below +# pins the other components and leaves one of them to its own formula or input. +- name: Abolishing federal income tax keeps the Idaho permanent building fund tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true + state_code: ID + employment_income: 60_000 + employee_payroll_tax: 4_590 + income_tax_before_refundable_credits: 5_000 + income_tax_refundable_credits: 800 + id_income_tax_before_refundable_credits: 2_000 + id_refundable_credits: 100 + household_market_income: 60_000 + household_benefits: 0 + output: + id_pbf: 10 + household_state_tax_before_refundable_credits: 2_010 + household_tax_before_refundable_credits: 6_600 # 4,590 + 2,000 + 10 + household_refundable_tax_credits: 100 + household_net_income: 53_500 # 60,000 + 100 - 6,600 + +- name: Abolishing federal income tax keeps California use tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true + state_code: CA + income_tax_before_refundable_credits: 1_200 + ca_income_tax_before_refundable_credits: 1_000 + ca_use_tax: 20 + output: + state_use_tax: 20 + household_state_tax_before_refundable_credits: 1_020 + household_tax_before_refundable_credits: 1_020 + +- name: Abolishing federal income tax keeps the Wilmington earned income tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true + state_code: DE + in_wilmington: true + employment_income: 40_000 + employee_payroll_tax: 3_060 + income_tax_before_refundable_credits: 2_000 + de_income_tax_before_refundable_credits: 1_500 + output: + de_wilmington_earned_income_tax: 500 # 40,000 * 1.25% + local_income_tax_before_refundable_credits: 500 + household_tax_before_refundable_credits: 5_060 # 3,060 + 1,500 + 500 + +- name: Abolishing federal income tax counts Pennsylvania refundable credits once + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true + state_code: PA + income_tax_before_refundable_credits: 1_200 + income_tax_refundable_credits: 800 + pa_income_tax_after_forgiveness: 100 + pa_use_tax: 20 + pa_refundable_tax_credits: 5 + household_market_income: 1_000 + household_benefits: 0 + output: + pa_income_tax: 95 + household_tax_before_refundable_credits: 120 # 100 + 20, before the credit + household_refundable_tax_credits: 5 + household_tax: 115 + household_net_income: 885 # 1,000 + 5 - 120 + +- name: Abolishing federal income and payroll taxes together removes both + period: 2025 + reforms: + - policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax + - policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true + gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true + state_code: CA + income_tax_before_refundable_credits: 1_200 + income_tax_refundable_credits: 800 + employee_payroll_tax: 600 + self_employment_tax: 300 + ca_income_tax_before_refundable_credits: 1_000 + ca_use_tax: 20 + ca_refundable_credits: 50 + output: + household_tax_before_refundable_credits: 1_320 # 300 + 1,000 + 20 + household_refundable_tax_credits: 50 + household_tax: 1_270 diff --git a/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml b/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml index 79c6de97654..a1bc979c2da 100644 --- a/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml +++ b/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml @@ -1,4 +1,4 @@ -- name: Abolish federal income tax reform +- name: Abolish payroll tax reform period: 2022 reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax input: @@ -19,3 +19,70 @@ output: household_state_tax_before_refundable_credits: 1_000 household_tax_before_refundable_credits: 1_600 + +# The reform's household total is the baseline total less employee payroll +# tax, so every other baseline component stays in it. Each case below pins the +# other components and leaves one of them to its own formula or input. +- name: Abolishing payroll tax keeps the Idaho permanent building fund tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true + state_code: ID + employment_income: 60_000 + employee_payroll_tax: 4_590 + income_tax_before_refundable_credits: 5_000 + income_tax_refundable_credits: 800 + id_income_tax_before_refundable_credits: 2_000 + id_refundable_credits: 100 + household_market_income: 60_000 + household_benefits: 0 + output: + id_pbf: 10 + household_state_tax_before_refundable_credits: 2_010 + household_tax_before_refundable_credits: 7_010 # 5,000 + 2,000 + 10 + household_refundable_tax_credits: 900 + household_net_income: 53_890 # 60,000 + 900 - 7,010 + +- name: Abolishing payroll tax keeps North Carolina use tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true + state_code: NC + employee_payroll_tax: 600 + nc_income_tax: 1_000 + nc_use_tax: 20 + output: + state_use_tax: 20 + household_state_tax_before_refundable_credits: 1_020 + household_tax_before_refundable_credits: 1_020 + +- name: Abolishing payroll tax keeps the Jefferson County occupational tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true + state_code: KY + county_str: JEFFERSON_COUNTY_KY + employment_income: 50_000 + employee_payroll_tax: 3_825 + income_tax_before_refundable_credits: 3_000 + ky_income_tax_before_refundable_credits: 1_500 + output: + ky_jefferson_occupational_tax: 1_100 # 50,000 * 2.20% + local_occupational_tax: 1_100 + household_tax_before_refundable_credits: 5_600 # 3,000 + 1,500 + 1,100 + +- name: Abolishing payroll tax keeps Maryland county income tax + period: 2025 + reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax + input: + gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true + state_code: MD + employee_payroll_tax: 600 + md_income_tax_before_refundable_credits: 1_000 + md_local_income_tax_before_credits: 400 + output: + local_income_tax_before_refundable_credits: 400 + household_tax_before_refundable_credits: 1_400 diff --git a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py new file mode 100644 index 00000000000..c820f1aad0e --- /dev/null +++ b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py @@ -0,0 +1,391 @@ +"""Hold the abolish-tax reforms' household totals to the baseline totals. + +``abolish_federal_income_tax`` and ``abolish_payroll_tax`` redefine +``household_tax_before_refundable_credits`` (and, for the income tax, +``household_refundable_tax_credits``) without the abolished federal component. +Every other baseline component must stay in them: the Idaho permanent building +fund tax, state use taxes, local income and occupational taxes, and each +state's income tax before, not after, its refundable credits. + +YAML cannot state these properties, because each compares a reformed total +with the baseline total rather than with a fixed number: + +1. Structure, with no simulation: each reformed ``adds`` list is the baseline + list less the abolished component, however the reforms are ordered or + repeated, and the baseline lists are left as they were. +2. The same for any list, by Hypothesis draws on ``exclude_from_total``. +3. Values: for 17 households in 15 states, with both reforms applied, each + reformed total equals the baseline total less the abolished components, + and net income rises by exactly the abolished taxes less the federal + refundable credits that go with them. +""" + +from collections import Counter +from types import SimpleNamespace + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from policyengine_core.taxbenefitsystems import TaxBenefitSystem + +from policyengine_us import Simulation +from policyengine_us.entities import entities +from policyengine_us.model_api import * +from policyengine_us.reforms.federal.abolish_federal_income_tax import ( + abolish_federal_income_tax, + create_abolish_federal_income_tax_reform, +) +from policyengine_us.reforms.federal.abolish_payroll_tax import ( + abolish_payroll_tax, + create_abolish_payroll_tax_reform, +) +from policyengine_us.reforms.federal.household_totals import exclude_from_total +from policyengine_us.system import system + +TAX = "household_tax_before_refundable_credits" +CREDITS = "household_refundable_tax_credits" +TOTALS = (TAX, CREDITS) +FEDERAL_INCOME_TAX = "income_tax_before_refundable_credits" +FEDERAL_REFUNDABLE_CREDITS = "income_tax_refundable_credits" +PAYROLL_TAX = "employee_payroll_tax" + +# reform -> {total: the component the reform excludes from it} +EXCLUSIONS = { + abolish_federal_income_tax: { + TAX: FEDERAL_INCOME_TAX, + CREDITS: FEDERAL_REFUNDABLE_CREDITS, + }, + abolish_payroll_tax: {TAX: PAYROLL_TAX}, +} +REFORM_IDS = { + abolish_federal_income_tax: "abolish_federal_income_tax", + abolish_payroll_tax: "abolish_payroll_tax", +} + + +def baseline_adds(total): + return list(system.variables[total].adds) + + +def reformed_adds(*reforms): + """Apply reforms to a system that holds only the baseline household totals. + + The reforms read and replace those two variables and nothing else, so this + needs neither a clone of the full system nor its parameter tree. + """ + bare = TaxBenefitSystem(entities) + bare.variables = {total: system.variables[total] for total in TOTALS} + for reform in reforms: + reform.apply(bare) + return {total: list(bare.variables[total].adds) for total in TOTALS} + + +def expected_adds(*reforms): + excluded = {total: set() for total in TOTALS} + for reform in reforms: + for total, component in EXCLUSIONS[reform].items(): + excluded[total].add(component) + return { + total: [c for c in baseline_adds(total) if c not in excluded[total]] + for total in TOTALS + } + + +@pytest.mark.parametrize( + "total, component", + [ + (total, component) + for exclusions in EXCLUSIONS.values() + for total, component in exclusions.items() + ], +) +def test_abolished_component_is_in_the_baseline_total(total, component): + # Excluding an absent component is a silent no-op, so a renamed component + # must fail here. + assert baseline_adds(total).count(component) == 1 + + +@pytest.mark.parametrize("reform", EXCLUSIONS, ids=REFORM_IDS.get) +def test_reformed_total_is_baseline_total_less_abolished_component(reform): + assert reformed_adds(reform) == expected_adds(reform) + + +@pytest.mark.parametrize( + "reforms", + [ + (abolish_federal_income_tax, abolish_payroll_tax), + (abolish_payroll_tax, abolish_federal_income_tax), + ], + ids=["income_tax_then_payroll_tax", "payroll_tax_then_income_tax"], +) +def test_reforms_combine_in_either_order(reforms): + assert reformed_adds(*reforms) == expected_adds(*reforms) + + +@pytest.mark.parametrize("reform", EXCLUSIONS, ids=REFORM_IDS.get) +def test_applying_a_reform_again_changes_nothing(reform): + # CountryTaxBenefitSystem applies a reform more than once. + assert reformed_adds(reform, reform, reform) == reformed_adds(reform) + + +@pytest.mark.parametrize( + "factory, flag, reform", + [ + ( + create_abolish_federal_income_tax_reform, + "abolish_federal_income_tax", + abolish_federal_income_tax, + ), + (create_abolish_payroll_tax_reform, "abolish_payroll_tax", abolish_payroll_tax), + ], + ids=["abolish_federal_income_tax", "abolish_payroll_tax"], +) +def test_parameter_switches_on_the_same_reform(factory, flag, reform): + # reforms/reforms.py calls each factory with (parameters, period), so + # setting the parameter applies the reform without the module-level object. + def parameters_with(value): + flat_tax = SimpleNamespace(**{flag: value}) + ubi_center = SimpleNamespace(flat_tax=flat_tax) + gov = SimpleNamespace(contrib=SimpleNamespace(ubi_center=ubi_center)) + return lambda period: SimpleNamespace(gov=gov) + + assert factory(parameters_with(False), "2025-01-01") is None + switched_on = factory(parameters_with(True), "2025-01-01") + assert reformed_adds(switched_on) == expected_adds(reform) + + +def test_reforms_leave_the_baseline_totals_unchanged(): + before = {total: baseline_adds(total) for total in TOTALS} + variables = {total: system.variables[total] for total in TOTALS} + reformed_adds(abolish_federal_income_tax, abolish_payroll_tax) + assert {total: baseline_adds(total) for total in TOTALS} == before + assert all(system.variables[total] is variables[total] for total in TOTALS) + + +def test_exclude_from_total_rejects_a_total_without_an_adds_list(): + class total_with_a_formula(Variable): + value_type = float + entity = Household + label = "total with a formula" + definition_period = YEAR + + def formula(household, period, parameters): + return 0 + + bare = TaxBenefitSystem(entities) + bare.add_variable(total_with_a_formula) + with pytest.raises(ValueError, match="total_with_a_formula"): + exclude_from_total(bare, "total_with_a_formula", "component") + + +COMPONENT_NAMES = st.sampled_from(["a", "b", "c", "d", "e"]) + + +def system_with_total(adds): + bare = TaxBenefitSystem(entities) + bare.add_variable( + type( + "total", + (Variable,), + dict( + value_type=float, + entity=Household, + label="total", + definition_period=YEAR, + adds=adds, + ), + ) + ) + return bare + + +@given( + adds=st.lists(COMPONENT_NAMES, max_size=8), + first=COMPONENT_NAMES, + second=COMPONENT_NAMES, +) +@settings(max_examples=200, deadline=None) +def test_exclude_from_total_properties(adds, first, second): + original = list(adds) + bare = system_with_total(adds) + + exclude_from_total(bare, "total", first) + once = list(bare.variables["total"].adds) + # Only the excluded component goes, and the rest keep their order. + assert first not in once + assert Counter(once) == Counter(c for c in original if c != first) + assert once == [c for c in original if c in once] + # The list handed in belongs to the replaced variable and is not edited. + assert adds == original + + exclude_from_total(bare, "total", first) + assert bare.variables["total"].adds == once + + exclude_from_total(bare, "total", second) + other_order = system_with_total(list(original)) + exclude_from_total(other_order, "total", second) + exclude_from_total(other_order, "total", first) + assert bare.variables["total"].adds == other_order.variables["total"].adds + + +YEAR_OF_POPULATION = 2025 + +# name: (state, household inputs, person inputs, earnings). One adult each. +POPULATION = { + "ID permanent building fund tax": ("ID", {}, {}, 60_000), + "CA use tax": ("CA", {}, {}, 60_000), + "IN county tax": ("IN", {"county_str": "BOONE_COUNTY_IN"}, {}, 60_000), + "NC use tax": ("NC", {}, {}, 60_000), + "OK use tax": ("OK", {}, {}, 60_000), + "PA use tax and Philadelphia wage tax": ( + "PA", + {"county_str": "PHILADELPHIA_COUNTY_PA"}, + { + "pa_philadelphia_wage_tax_taxable_wages": 60_000, + "pa_philadelphia_wage_tax_resident": True, + }, + 60_000, + ), + "PA refundable credits": ("PA", {}, {}, 12_000), + "IL use tax": ("IL", {}, {}, 60_000), + "MD county tax": ("MD", {"county_str": "MONTGOMERY_COUNTY_MD"}, {}, 60_000), + "DE Wilmington earned income tax": ("DE", {"in_wilmington": True}, {}, 60_000), + "KY Jefferson County occupational tax": ( + "KY", + {"county_str": "JEFFERSON_COUNTY_KY"}, + {}, + 60_000, + ), + "MO Kansas City earnings tax": ( + "MO", + {}, + {"mo_kansas_city_earnings_tax_taxable_earnings": 60_000}, + 60_000, + ), + "NY Yonkers surcharge": ("NY", {"in_yonkers": True}, {}, 60_000), + "OR Multnomah County tax": ( + "OR", + {"in_multnomah_county_or": True}, + {}, + 200_000, + ), + "CO Denver occupational privilege tax": ( + "CO", + {}, + {"co_denver_employee_occupational_privilege_tax_months": 12}, + 60_000, + ), + "NY New York City income tax": ("NY", {"in_nyc": True}, {}, 60_000), + "TX no state or local income tax": ("TX", {}, {}, 60_000), +} + +# Baseline components that a separate list of components has dropped or +# miscounted before. Each must be nonzero for some household, or the value +# check below would pass without testing it. +DRIFT_PRONE_COMPONENTS = [ + "id_pbf", + "state_use_tax", + "local_income_tax_before_refundable_credits", + "local_occupational_tax", + "pa_refundable_tax_credits", +] + +CALCULATED = [ + TAX, + CREDITS, + "household_net_income", + FEDERAL_INCOME_TAX, + FEDERAL_REFUNDABLE_CREDITS, + PAYROLL_TAX, +] + +# float32 totals near $200,000 carry about a cent of rounding each. +TOLERANCE = 0.05 + + +def population_situation(): + period = YEAR_OF_POPULATION + situation = { + entity: {} + for entity in ( + "people", + "households", + "tax_units", + "spm_units", + "families", + "marital_units", + ) + } + for index, (state, household_inputs, person_inputs, earnings) in enumerate( + POPULATION.values() + ): + person = f"person_{index}" + situation["people"][person] = { + "age": {period: 35}, + "employment_income": {period: earnings}, + **{name: {period: value} for name, value in person_inputs.items()}, + } + situation["households"][f"household_{index}"] = { + "members": [person], + "state_code": {period: state}, + **{name: {period: value} for name, value in household_inputs.items()}, + } + for entity in ("tax_units", "spm_units", "families", "marital_units"): + situation[entity][f"{entity}_{index}"] = {"members": [person]} + return situation + + +def household_values(simulation, variables): + return { + variable: np.asarray( + simulation.calculate(variable, YEAR_OF_POPULATION, map_to="household"), + dtype=float, + ) + for variable in variables + } + + +@pytest.fixture(scope="module") +def baseline(): + simulation = Simulation(situation=population_situation()) + return household_values(simulation, CALCULATED + DRIFT_PRONE_COMPONENTS) + + +@pytest.fixture(scope="module") +def both_reforms(): + # The module-level reform objects leave the abolish parameters false, so + # each component keeps its baseline value and only the totals can differ. + # One model build covers both reforms; the structural tests above hold + # each reform's own lists. + simulation = Simulation( + situation=population_situation(), + reform=(abolish_federal_income_tax, abolish_payroll_tax), + ) + return household_values(simulation, CALCULATED) + + +def assert_close(actual, expected): + np.testing.assert_allclose(actual, expected, rtol=0, atol=TOLERANCE) + + +def test_population_exercises_the_drift_prone_components(baseline): + for component in DRIFT_PRONE_COMPONENTS: + assert baseline[component].max() > 0, component + not_nyc = np.array(["New York City" not in name for name in POPULATION]) + assert baseline["local_income_tax_before_refundable_credits"][not_nyc].max() > 0 + for abolished in (FEDERAL_INCOME_TAX, FEDERAL_REFUNDABLE_CREDITS, PAYROLL_TAX): + assert baseline[abolished].max() > 0, abolished + + +def test_abolishing_both_taxes_removes_only_those_taxes(baseline, both_reforms): + abolished_tax = baseline[FEDERAL_INCOME_TAX] + baseline[PAYROLL_TAX] + assert_close(both_reforms[TAX], baseline[TAX] - abolished_tax) + assert_close( + both_reforms[CREDITS], + baseline[CREDITS] - baseline[FEDERAL_REFUNDABLE_CREDITS], + ) + assert_close( + both_reforms["household_net_income"] - baseline["household_net_income"], + abolished_tax - baseline[FEDERAL_REFUNDABLE_CREDITS], + ) From c5304901e930c69145c8926a9e8557eefe5cfcb4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 15:56:36 -0400 Subject: [PATCH 2/7] Drop the combined-reform YAML case The Python value test already applies both reforms to 17 households, and the YAML case would add one more reform combination for the runner to hold. Co-Authored-By: Claude Opus 5.5 --- .../federal/abolish_federal_income_tax.yaml | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml b/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml index 7eb5efa06ef..33f5bad62a4 100644 --- a/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml +++ b/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml @@ -147,24 +147,3 @@ household_refundable_tax_credits: 5 household_tax: 115 household_net_income: 885 # 1,000 + 5 - 120 - -- name: Abolishing federal income and payroll taxes together removes both - period: 2025 - reforms: - - policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax - - policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax - input: - gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true - gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true - state_code: CA - income_tax_before_refundable_credits: 1_200 - income_tax_refundable_credits: 800 - employee_payroll_tax: 600 - self_employment_tax: 300 - ca_income_tax_before_refundable_credits: 1_000 - ca_use_tax: 20 - ca_refundable_credits: 50 - output: - household_tax_before_refundable_credits: 1_320 # 300 + 1,000 + 20 - household_refundable_tax_credits: 50 - household_tax: 1_270 From ebb3e07dd64585ef2c3500c818fe96c2ae24491f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 16:04:57 -0400 Subject: [PATCH 3/7] Compare against baseline lists copied at import A reform that edited the default system's lists in place would also have moved the lists the structural tests compared with, so the check that the default system is unchanged could pass after an earlier test had already applied the edit. Co-Authored-By: Claude Opus 5.5 --- .../federal/test_abolish_tax_household_totals.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py index c820f1aad0e..3e6d6030fc9 100644 --- a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py +++ b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py @@ -64,8 +64,14 @@ } +# Copied at import, before any test applies a reform, so a reform that edited +# the default system's lists in place could not also move what they are +# compared with. +BASELINE_ADDS = {total: tuple(system.variables[total].adds) for total in TOTALS} + + def baseline_adds(total): - return list(system.variables[total].adds) + return list(BASELINE_ADDS[total]) def reformed_adds(*reforms): @@ -155,12 +161,12 @@ def parameters_with(value): assert reformed_adds(switched_on) == expected_adds(reform) -def test_reforms_leave_the_baseline_totals_unchanged(): - before = {total: baseline_adds(total) for total in TOTALS} +def test_reforms_leave_the_default_system_unchanged(): variables = {total: system.variables[total] for total in TOTALS} reformed_adds(abolish_federal_income_tax, abolish_payroll_tax) - assert {total: baseline_adds(total) for total in TOTALS} == before - assert all(system.variables[total] is variables[total] for total in TOTALS) + for total in TOTALS: + assert system.variables[total] is variables[total] + assert system.variables[total].adds == baseline_adds(total) def test_exclude_from_total_rejects_a_total_without_an_adds_list(): From b53c2d61e632f28f441055fdff3f96399a854db5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 16:09:19 -0400 Subject: [PATCH 4/7] Refuse to exclude a component from a total computed by a formula The replacement variable inherits any formula of the variable it replaces, and the formula is computed in place of adds, so the exclusion would be silently ineffective. Co-Authored-By: Claude Opus 5.5 --- .../reforms/federal/household_totals.py | 6 +++-- .../test_abolish_tax_household_totals.py | 22 +++++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/policyengine_us/reforms/federal/household_totals.py b/policyengine_us/reforms/federal/household_totals.py index 88e7825d949..ba66c62459e 100644 --- a/policyengine_us/reforms/federal/household_totals.py +++ b/policyengine_us/reforms/federal/household_totals.py @@ -16,9 +16,11 @@ def exclude_from_total(system, total: str, component: str) -> None: hold. """ current = system.get_variable(total, check_existence=True) - if not isinstance(current.adds, list): + # The replacement inherits any formula of `current`, and a formula is + # computed in place of `adds`, so editing the list would then do nothing. + if current.formulas or not isinstance(current.adds, list): raise ValueError( - f"{total} must list its components in `adds` for a reform to " + f"{total} must be an `adds` list with no formula for a reform to " f"exclude {component} from it." ) # A new list: `current.adds` belongs to the variable being replaced. diff --git a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py index 3e6d6030fc9..30204677369 100644 --- a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py +++ b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py @@ -169,20 +169,28 @@ def test_reforms_leave_the_default_system_unchanged(): assert system.variables[total].adds == baseline_adds(total) -def test_exclude_from_total_rejects_a_total_without_an_adds_list(): - class total_with_a_formula(Variable): +def test_exclude_from_total_rejects_a_total_computed_by_a_formula(): + class total(Variable): value_type = float entity = Household - label = "total with a formula" + label = "total" definition_period = YEAR def formula(household, period, parameters): return 0 - bare = TaxBenefitSystem(entities) - bare.add_variable(total_with_a_formula) - with pytest.raises(ValueError, match="total_with_a_formula"): - exclude_from_total(bare, "total_with_a_formula", "component") + without_adds = TaxBenefitSystem(entities) + without_adds.add_variable(total) + with pytest.raises(ValueError, match="total must be an `adds` list"): + exclude_from_total(without_adds, "total", "a") + + # A formula that replaced an `adds` variable inherits the list, but the + # formula is what is computed. + with_inherited_adds = system_with_total(["a", "b"]) + with_inherited_adds.update_variable(total) + assert with_inherited_adds.variables["total"].adds == ["a", "b"] + with pytest.raises(ValueError, match="total must be an `adds` list"): + exclude_from_total(with_inherited_adds, "total", "a") COMPONENT_NAMES = st.sampled_from(["a", "b", "c", "d", "e"]) From 46be35ab661ddaf27579536626889b575adae198 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 17:09:34 -0400 Subject: [PATCH 5/7] Clone the current total instead of building a new class A new class passed to update_variable drops attributes that core does not inherit from the replaced variable, among them defined_for, is_neutralized and a custom set_input. Cloning the current definition and replacing only its adds list keeps them, as TaxBenefitSystem.neutralize_variable does, and adds no baseline_variable link when a reform is applied again. The value test now checks that each household has a nonzero amount of the tax it is there to exercise, not only that each component is nonzero somewhere in the population. Co-Authored-By: Claude Opus 5.5 --- .../reforms/federal/household_totals.py | 35 ++--- .../test_abolish_tax_household_totals.py | 127 +++++++++++++----- 2 files changed, 114 insertions(+), 48 deletions(-) diff --git a/policyengine_us/reforms/federal/household_totals.py b/policyengine_us/reforms/federal/household_totals.py index ba66c62459e..806e1289f61 100644 --- a/policyengine_us/reforms/federal/household_totals.py +++ b/policyengine_us/reforms/federal/household_totals.py @@ -1,29 +1,30 @@ -from policyengine_us.model_api import * - - def exclude_from_total(system, total: str, component: str) -> None: """Redefine the ``adds`` variable ``total`` without ``component``. - The remaining components are read from the definition of ``total`` that - ``system`` holds when the reform is applied, not from a second list. A - component added to the baseline total therefore reaches the reformed - total too, and two reforms that each exclude a component of the same - total combine, where a fixed list would let the later one overwrite the - earlier one. + The replacement is a clone of the definition of ``total`` that ``system`` + holds when the reform is applied, with ``component`` taken out of its + ``adds`` list. There is no second list: a component added to the baseline + total reaches the reformed total too, and two reforms that each exclude a + component of the same total combine, where a fixed list would let the + later one overwrite the earlier one. - Excluding a component that is already absent changes nothing. - ``CountryTaxBenefitSystem`` applies a reform more than once, so this must - hold. + Excluding a component that is already absent leaves the definition as it + was. ``CountryTaxBenefitSystem`` applies a reform more than once, so this + must hold. """ current = system.get_variable(total, check_existence=True) - # The replacement inherits any formula of `current`, and a formula is - # computed in place of `adds`, so editing the list would then do nothing. + # A formula is computed in place of `adds`, so editing the list of a + # total that has one would do nothing. if current.formulas or not isinstance(current.adds, list): raise ValueError( f"{total} must be an `adds` list with no formula for a reform to " f"exclude {component} from it." ) + # A clone, as in `TaxBenefitSystem.neutralize_variable`, keeps every other + # attribute. A new class passed to `update_variable` would not: it drops + # `defined_for`, for one. + replacement = current.clone() # A new list: `current.adds` belongs to the variable being replaced. - remaining = [name for name in current.adds if name != component] - # `update_variable` inherits every attribute the new class leaves out. - system.update_variable(type(total, (Variable,), {"adds": remaining})) + replacement.adds = [name for name in current.adds if name != component] + system.variables[total] = replacement + system.data_modified = True diff --git a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py index 30204677369..b78013ba9dc 100644 --- a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py +++ b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py @@ -169,6 +169,35 @@ def test_reforms_leave_the_default_system_unchanged(): assert system.variables[total].adds == baseline_adds(total) +def test_exclude_from_total_changes_nothing_but_adds(): + class total(Variable): + value_type = float + entity = Household + label = "total" + documentation = "A total." + unit = USD + definition_period = YEAR + defined_for = "eligible" + adds = ["a", "b"] + subtracts = ["c"] + + bare = TaxBenefitSystem(entities) + bare.add_variable(total) + bare.neutralize_variable("total") + before = dict(vars(bare.variables["total"])) + + exclude_from_total(bare, "total", "a") + after = dict(vars(bare.variables["total"])) + + assert before.pop("adds") == ["a", "b"] + assert after.pop("adds") == ["b"] + assert after == before + # Two attributes a new class passed to `update_variable` would reset. + assert after["defined_for"] == "eligible" + assert after["is_neutralized"] + assert bare.data_modified + + def test_exclude_from_total_rejects_a_total_computed_by_a_formula(): class total(Variable): value_type = float @@ -245,13 +274,20 @@ def test_exclude_from_total_properties(adds, first, second): YEAR_OF_POPULATION = 2025 -# name: (state, household inputs, person inputs, earnings). One adult each. +# name: (state, household inputs, person inputs, earnings, the tax or credit the +# household is here to exercise). One adult each. POPULATION = { - "ID permanent building fund tax": ("ID", {}, {}, 60_000), - "CA use tax": ("CA", {}, {}, 60_000), - "IN county tax": ("IN", {"county_str": "BOONE_COUNTY_IN"}, {}, 60_000), - "NC use tax": ("NC", {}, {}, 60_000), - "OK use tax": ("OK", {}, {}, 60_000), + "ID permanent building fund tax": ("ID", {}, {}, 60_000, "id_pbf"), + "CA use tax": ("CA", {}, {}, 60_000, "ca_use_tax"), + "IN county tax": ( + "IN", + {"county_str": "BOONE_COUNTY_IN"}, + {}, + 60_000, + "in_county_tax", + ), + "NC use tax": ("NC", {}, {}, 60_000, "nc_use_tax"), + "OK use tax": ("OK", {}, {}, 60_000, "ok_use_tax"), "PA use tax and Philadelphia wage tax": ( "PA", {"county_str": "PHILADELPHIA_COUNTY_PA"}, @@ -260,50 +296,77 @@ def test_exclude_from_total_properties(adds, first, second): "pa_philadelphia_wage_tax_resident": True, }, 60_000, + "pa_philadelphia_wage_tax", + ), + "PA use tax and refundable credits": ( + "PA", + {}, + {}, + 12_000, + "pa_refundable_tax_credits", + ), + "IL use tax": ("IL", {}, {}, 60_000, "il_use_tax"), + "MD county tax": ( + "MD", + {"county_str": "MONTGOMERY_COUNTY_MD"}, + {}, + 60_000, + "md_local_income_tax_before_refundable_credits", + ), + "DE Wilmington earned income tax": ( + "DE", + {"in_wilmington": True}, + {}, + 60_000, + "de_wilmington_earned_income_tax", ), - "PA refundable credits": ("PA", {}, {}, 12_000), - "IL use tax": ("IL", {}, {}, 60_000), - "MD county tax": ("MD", {"county_str": "MONTGOMERY_COUNTY_MD"}, {}, 60_000), - "DE Wilmington earned income tax": ("DE", {"in_wilmington": True}, {}, 60_000), "KY Jefferson County occupational tax": ( "KY", {"county_str": "JEFFERSON_COUNTY_KY"}, {}, 60_000, + "ky_jefferson_occupational_tax", ), "MO Kansas City earnings tax": ( "MO", {}, {"mo_kansas_city_earnings_tax_taxable_earnings": 60_000}, 60_000, + "mo_kansas_city_earnings_tax", + ), + "NY Yonkers surcharge": ( + "NY", + {"in_yonkers": True}, + {}, + 60_000, + "ny_yonkers_income_tax", ), - "NY Yonkers surcharge": ("NY", {"in_yonkers": True}, {}, 60_000), "OR Multnomah County tax": ( "OR", {"in_multnomah_county_or": True}, {}, 200_000, + "or_multnomah_pfa_tax", ), "CO Denver occupational privilege tax": ( "CO", {}, {"co_denver_employee_occupational_privilege_tax_months": 12}, 60_000, + "co_denver_employee_occupational_privilege_tax", + ), + "NY New York City income tax": ( + "NY", + {"in_nyc": True}, + {}, + 60_000, + "nyc_income_tax_before_refundable_credits", ), - "NY New York City income tax": ("NY", {"in_nyc": True}, {}, 60_000), - "TX no state or local income tax": ("TX", {}, {}, 60_000), + "TX no state or local income tax": ("TX", {}, {}, 60_000, None), } - -# Baseline components that a separate list of components has dropped or -# miscounted before. Each must be nonzero for some household, or the value -# check below would pass without testing it. -DRIFT_PRONE_COMPONENTS = [ - "id_pbf", - "state_use_tax", - "local_income_tax_before_refundable_credits", - "local_occupational_tax", - "pa_refundable_tax_credits", -] +EXERCISED = sorted( + {case[4] for case in POPULATION.values() if case[4]} | {"pa_use_tax"} +) CALCULATED = [ TAX, @@ -331,7 +394,7 @@ def population_situation(): "marital_units", ) } - for index, (state, household_inputs, person_inputs, earnings) in enumerate( + for index, (state, household_inputs, person_inputs, earnings, _) in enumerate( POPULATION.values() ): person = f"person_{index}" @@ -363,7 +426,7 @@ def household_values(simulation, variables): @pytest.fixture(scope="module") def baseline(): simulation = Simulation(situation=population_situation()) - return household_values(simulation, CALCULATED + DRIFT_PRONE_COMPONENTS) + return household_values(simulation, CALCULATED + EXERCISED) @pytest.fixture(scope="module") @@ -383,11 +446,13 @@ def assert_close(actual, expected): np.testing.assert_allclose(actual, expected, rtol=0, atol=TOLERANCE) -def test_population_exercises_the_drift_prone_components(baseline): - for component in DRIFT_PRONE_COMPONENTS: - assert baseline[component].max() > 0, component - not_nyc = np.array(["New York City" not in name for name in POPULATION]) - assert baseline["local_income_tax_before_refundable_credits"][not_nyc].max() > 0 +def test_each_household_exercises_its_tax(baseline): + # Otherwise the value check below could pass without testing a component. + for index, (name, case) in enumerate(POPULATION.items()): + if case[4]: + assert baseline[case[4]][index] > 0, name + pennsylvania = [i for i, case in enumerate(POPULATION.values()) if case[0] == "PA"] + assert baseline["pa_use_tax"][pennsylvania].min() > 0 for abolished in (FEDERAL_INCOME_TAX, FEDERAL_REFUNDABLE_CREDITS, PAYROLL_TAX): assert baseline[abolished].max() > 0, abolished From 32a284ff08f52e01e5c801ccd2300a3d028464a4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 17:35:33 -0400 Subject: [PATCH 6/7] Clear data_modified before checking that the exclusion sets it Adding and neutralizing the test variable already set the flag, so the assertion passed whether or not exclude_from_total set it. Co-Authored-By: Claude Opus 5.5 --- .../contrib/federal/test_abolish_tax_household_totals.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py index b78013ba9dc..d2817f41b23 100644 --- a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py +++ b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py @@ -185,6 +185,9 @@ class total(Variable): bare.add_variable(total) bare.neutralize_variable("total") before = dict(vars(bare.variables["total"])) + # Adding and neutralizing the variable set the flag; clear it so the + # assertion below sees the helper set it. + bare.data_modified = False exclude_from_total(bare, "total", "a") after = dict(vars(bare.variables["total"])) From b2c68b56e53f87bb23d0af8c614c9d33de0763e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:07:17 -0400 Subject: [PATCH 7/7] Remove the abolish-tax structural reforms Both reforms replaced the household tax totals with copies built from a separate list. With either flag on, the baseline formulas already return 0 for the abolished tax, so dropping it from the total did nothing; the copy's only effect was the drift it introduced. The structural reforms were also switched on only if the flag was set at the start instant (2024-01-01). The one effect the parameters lacked, leaving federal refundable credits out of household net income when the federal income tax is abolished, is now a per-year check in household_refundable_tax_credits, which no reform replaces. The YAML cases set only the parameters, and a structural test keeps reforms from redefining any household tax total. Co-Authored-By: Claude Opus 5.5 --- .../fix-reform-household-tax-drift.fixed.md | 2 +- .../fix-reform-household-tax-drift.removed.md | 2 +- policyengine_us/reforms/federal/__init__.py | 4 - .../federal/abolish_federal_income_tax.py | 36 -- .../reforms/federal/abolish_payroll_tax.py | 29 -- .../reforms/federal/household_totals.py | 30 -- policyengine_us/reforms/reforms.py | 8 - .../federal/abolish_federal_income_tax.yaml | 37 +- .../contrib/federal/abolish_payroll_tax.yaml | 37 +- .../test_abolish_tax_household_totals.py | 473 ------------------ .../tests/test_aggregate_list_integrity.py | 36 ++ .../household_refundable_tax_credits.py | 22 +- 12 files changed, 92 insertions(+), 624 deletions(-) delete mode 100644 policyengine_us/reforms/federal/__init__.py delete mode 100644 policyengine_us/reforms/federal/abolish_federal_income_tax.py delete mode 100644 policyengine_us/reforms/federal/abolish_payroll_tax.py delete mode 100644 policyengine_us/reforms/federal/household_totals.py delete mode 100644 policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py diff --git a/changelog.d/fix-reform-household-tax-drift.fixed.md b/changelog.d/fix-reform-household-tax-drift.fixed.md index f44032e7cbe..9bf0f555f9a 100644 --- a/changelog.d/fix-reform-household-tax-drift.fixed.md +++ b/changelog.d/fix-reform-household-tax-drift.fixed.md @@ -1 +1 @@ -The abolish-federal-income-tax and abolish-payroll-tax reforms now build their household totals from the baseline totals less the abolished tax. Under either reform, household tax and net income again include the Idaho permanent building fund tax, the California, Indiana, North Carolina, Oklahoma and Pennsylvania use taxes, and every local income and occupational tax (only New York City's was counted); Pennsylvania refundable credits are no longer counted twice; and the two reforms combine when both are applied. +With the abolish-federal-income-tax or abolish-payroll-tax parameter on, household tax and net income now count every other tax: the Idaho permanent building fund tax, the California, Indiana, North Carolina, Oklahoma and Pennsylvania use taxes, and every local income and occupational tax (only New York City's was counted). Pennsylvania refundable credits are no longer counted twice, and federal refundable credits leave household net income in every year the federal income tax is abolished, including when the parameter is first set after 2024. diff --git a/changelog.d/fix-reform-household-tax-drift.removed.md b/changelog.d/fix-reform-household-tax-drift.removed.md index 3d51a98af51..3837245e7fe 100644 --- a/changelog.d/fix-reform-household-tax-drift.removed.md +++ b/changelog.d/fix-reform-household-tax-drift.removed.md @@ -1 +1 @@ -Removed the parameters `gov.household.household_tax_before_refundable_credits` and `gov.household.household_refundable_credits`. The first was read only by the abolish-federal-income-tax and abolish-payroll-tax reforms, which now use the baseline totals; nothing read the second. +Removed the abolish-federal-income-tax and abolish-payroll-tax structural reforms (`policyengine_us.reforms.federal`). Their parameters, `gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax` and `abolish_payroll_tax`, still abolish the taxes through the baseline formulas. Also removed the parameters `gov.household.household_tax_before_refundable_credits`, which only those reforms read, and `gov.household.household_refundable_credits`, which nothing read. diff --git a/policyengine_us/reforms/federal/__init__.py b/policyengine_us/reforms/federal/__init__.py deleted file mode 100644 index a19e1941be5..00000000000 --- a/policyengine_us/reforms/federal/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .abolish_federal_income_tax import ( - create_abolish_federal_income_tax_reform, -) -from .abolish_payroll_tax import create_abolish_payroll_tax_reform diff --git a/policyengine_us/reforms/federal/abolish_federal_income_tax.py b/policyengine_us/reforms/federal/abolish_federal_income_tax.py deleted file mode 100644 index 14a276fe190..00000000000 --- a/policyengine_us/reforms/federal/abolish_federal_income_tax.py +++ /dev/null @@ -1,36 +0,0 @@ -from policyengine_us.model_api import * -from policyengine_us.reforms.federal.household_totals import exclude_from_total - - -def create_abolish_federal_income_tax() -> Reform: - class reform(Reform): - def apply(self): - exclude_from_total( - self, - "household_tax_before_refundable_credits", - "income_tax_before_refundable_credits", - ) - exclude_from_total( - self, - "household_refundable_tax_credits", - "income_tax_refundable_credits", - ) - - return reform - - -def create_abolish_federal_income_tax_reform(parameters, period, bypass: bool = False): - if bypass: - return create_abolish_federal_income_tax() - - p = parameters(period).gov.contrib.ubi_center.flat_tax - - if p.abolish_federal_income_tax: - return create_abolish_federal_income_tax() - else: - return None - - -abolish_federal_income_tax = create_abolish_federal_income_tax_reform( - None, None, bypass=True -) diff --git a/policyengine_us/reforms/federal/abolish_payroll_tax.py b/policyengine_us/reforms/federal/abolish_payroll_tax.py deleted file mode 100644 index a809ad58c71..00000000000 --- a/policyengine_us/reforms/federal/abolish_payroll_tax.py +++ /dev/null @@ -1,29 +0,0 @@ -from policyengine_us.model_api import * -from policyengine_us.reforms.federal.household_totals import exclude_from_total - - -def create_abolish_payroll_tax() -> Reform: - class reform(Reform): - def apply(self): - exclude_from_total( - self, - "household_tax_before_refundable_credits", - "employee_payroll_tax", - ) - - return reform - - -def create_abolish_payroll_tax_reform(parameters, period, bypass: bool = False): - if bypass: - return create_abolish_payroll_tax() - - p = parameters(period).gov.contrib.ubi_center.flat_tax - - if p.abolish_payroll_tax: - return create_abolish_payroll_tax() - else: - return None - - -abolish_payroll_tax = create_abolish_payroll_tax_reform(None, None, bypass=True) diff --git a/policyengine_us/reforms/federal/household_totals.py b/policyengine_us/reforms/federal/household_totals.py deleted file mode 100644 index 806e1289f61..00000000000 --- a/policyengine_us/reforms/federal/household_totals.py +++ /dev/null @@ -1,30 +0,0 @@ -def exclude_from_total(system, total: str, component: str) -> None: - """Redefine the ``adds`` variable ``total`` without ``component``. - - The replacement is a clone of the definition of ``total`` that ``system`` - holds when the reform is applied, with ``component`` taken out of its - ``adds`` list. There is no second list: a component added to the baseline - total reaches the reformed total too, and two reforms that each exclude a - component of the same total combine, where a fixed list would let the - later one overwrite the earlier one. - - Excluding a component that is already absent leaves the definition as it - was. ``CountryTaxBenefitSystem`` applies a reform more than once, so this - must hold. - """ - current = system.get_variable(total, check_existence=True) - # A formula is computed in place of `adds`, so editing the list of a - # total that has one would do nothing. - if current.formulas or not isinstance(current.adds, list): - raise ValueError( - f"{total} must be an `adds` list with no formula for a reform to " - f"exclude {component} from it." - ) - # A clone, as in `TaxBenefitSystem.neutralize_variable`, keeps every other - # attribute. A new class passed to `update_variable` would not: it drops - # `defined_for`, for one. - replacement = current.clone() - # A new list: `current.adds` belongs to the variable being replaced. - replacement.adds = [name for name in current.adds if name != component] - system.variables[total] = replacement - system.data_modified = True diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 46b0754e164..c5161d99244 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -12,8 +12,6 @@ create_increase_taxable_earnings_for_social_security_reform, ) from .congress.wyden_smith import create_ctc_expansion_reform -from .federal import create_abolish_federal_income_tax_reform -from .federal import create_abolish_payroll_tax_reform from .biden.budget_2025 import ( create_medicare_and_investment_tax_increase_reform, ) @@ -381,10 +379,6 @@ def create_structural_reforms_from_parameters(parameters, period): ) ctc_expansion = create_ctc_expansion_reform(parameters, period) - abolish_federal_income_tax = create_abolish_federal_income_tax_reform( - parameters, period - ) - abolish_payroll_tax = create_abolish_payroll_tax_reform(parameters, period) capital_gains_tax_increase = create_capital_gains_tax_increase_reform( parameters, period ) @@ -605,8 +599,6 @@ def create_structural_reforms_from_parameters(parameters, period): remove_head_of_household, increase_taxable_earnings_for_social_security_reform, ctc_expansion, - abolish_federal_income_tax, - abolish_payroll_tax, medicare_and_investment_tax_increase, capital_gains_tax_increase, halve_joint_eitc_phase_out_rate, diff --git a/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml b/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml index 33f5bad62a4..ab110441d04 100644 --- a/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml +++ b/policyengine_us/tests/policy/contrib/federal/abolish_federal_income_tax.yaml @@ -1,23 +1,26 @@ -- name: Abolish federal income tax reform +# With gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax on, +# income_tax_before_refundable_credits is zero and household refundable tax +# credits leave out federal refundable credits. Every other component of the +# household totals stays. Most cases pin the other components and leave one +# to its own formula, with federal income tax left to its formula. + +- name: Abolishing federal income tax drops federal tax and refundable credits period: 2022 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true - income_tax_before_refundable_credits: 1_200 income_tax_refundable_credits: 800 co_income_tax_before_refundable_credits: 1_000 hi_refundable_credits: 600 output: + income_tax_before_refundable_credits: 0 household_refundable_tax_credits: 600 household_state_tax_before_refundable_credits: 1_000 household_tax_before_refundable_credits: 1_000 -- name: Multiple state refundable credits +- name: Abolishing federal income tax keeps several state refundable credits period: 2022 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true - income_tax_before_refundable_credits: 1_200 income_tax_refundable_credits: 800 co_income_tax_before_refundable_credits: 1_000 hi_refundable_credits: 600 @@ -27,7 +30,7 @@ household_state_tax_before_refundable_credits: 1_000 household_tax_before_refundable_credits: 1_000 -- name: No reform +- name: Federal income tax and refundable credits count when not abolished period: 2022 input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: false @@ -42,7 +45,6 @@ - name: Abolishing federal income tax preserves Montgomery County refundable EITC period: 2024 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true age: 30 @@ -59,7 +61,6 @@ - name: Abolishing federal income tax includes NYC refundable credits exactly once period: 2024 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true age: 30 @@ -74,24 +75,20 @@ household_refundable_tax_credits: 300 household_tax: -300 -# The reform's household totals are the baseline totals less the federal -# member, so every other baseline component stays in them. Each case below -# pins the other components and leaves one of them to its own formula or input. - name: Abolishing federal income tax keeps the Idaho permanent building fund tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true state_code: ID employment_income: 60_000 employee_payroll_tax: 4_590 - income_tax_before_refundable_credits: 5_000 income_tax_refundable_credits: 800 id_income_tax_before_refundable_credits: 2_000 id_refundable_credits: 100 household_market_income: 60_000 household_benefits: 0 output: + income_tax_before_refundable_credits: 0 id_pbf: 10 household_state_tax_before_refundable_credits: 2_010 household_tax_before_refundable_credits: 6_600 # 4,590 + 2,000 + 10 @@ -100,41 +97,39 @@ - name: Abolishing federal income tax keeps California use tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true state_code: CA - income_tax_before_refundable_credits: 1_200 + employment_income: 60_000 + employee_payroll_tax: 600 ca_income_tax_before_refundable_credits: 1_000 ca_use_tax: 20 output: + income_tax_before_refundable_credits: 0 state_use_tax: 20 household_state_tax_before_refundable_credits: 1_020 - household_tax_before_refundable_credits: 1_020 + household_tax_before_refundable_credits: 1_620 # 600 + 1,000 + 20 - name: Abolishing federal income tax keeps the Wilmington earned income tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true state_code: DE in_wilmington: true employment_income: 40_000 employee_payroll_tax: 3_060 - income_tax_before_refundable_credits: 2_000 de_income_tax_before_refundable_credits: 1_500 output: + income_tax_before_refundable_credits: 0 de_wilmington_earned_income_tax: 500 # 40,000 * 1.25% local_income_tax_before_refundable_credits: 500 household_tax_before_refundable_credits: 5_060 # 3,060 + 1,500 + 500 - name: Abolishing federal income tax counts Pennsylvania refundable credits once period: 2025 - reforms: policyengine_us.reforms.federal.abolish_federal_income_tax.abolish_federal_income_tax input: gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: true state_code: PA - income_tax_before_refundable_credits: 1_200 income_tax_refundable_credits: 800 pa_income_tax_after_forgiveness: 100 pa_use_tax: 20 diff --git a/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml b/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml index a1bc979c2da..857ba88e072 100644 --- a/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml +++ b/policyengine_us/tests/policy/contrib/federal/abolish_payroll_tax.yaml @@ -1,16 +1,24 @@ -- name: Abolish payroll tax reform +# With gov.contrib.ubi_center.flat_tax.abolish_payroll_tax on, +# employee_payroll_tax is zero and every other component of the household +# total stays. Each case below leaves payroll tax to its formula on earnings, +# pins federal income tax, and leaves one other component to its own formula +# or input. + +- name: Abolishing payroll tax drops it from the household total period: 2022 - reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax input: gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true + state_code: CA + employment_income: 10_000 + income_tax_before_refundable_credits: 0 ca_income_tax_before_refundable_credits: 1_000 - employee_payroll_tax: 600 + ca_use_tax: 0 output: + employee_payroll_tax: 0 household_state_tax_before_refundable_credits: 1_000 household_tax_before_refundable_credits: 1_000 - -- name: No reform +- name: Payroll tax counts when not abolished period: 2022 input: gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: false @@ -20,17 +28,12 @@ household_state_tax_before_refundable_credits: 1_000 household_tax_before_refundable_credits: 1_600 -# The reform's household total is the baseline total less employee payroll -# tax, so every other baseline component stays in it. Each case below pins the -# other components and leaves one of them to its own formula or input. - name: Abolishing payroll tax keeps the Idaho permanent building fund tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax input: gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true state_code: ID employment_income: 60_000 - employee_payroll_tax: 4_590 income_tax_before_refundable_credits: 5_000 income_tax_refundable_credits: 800 id_income_tax_before_refundable_credits: 2_000 @@ -38,6 +41,7 @@ household_market_income: 60_000 household_benefits: 0 output: + employee_payroll_tax: 0 id_pbf: 10 household_state_tax_before_refundable_credits: 2_010 household_tax_before_refundable_credits: 7_010 # 5,000 + 2,000 + 10 @@ -46,43 +50,44 @@ - name: Abolishing payroll tax keeps North Carolina use tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax input: gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true state_code: NC - employee_payroll_tax: 600 + employment_income: 60_000 + income_tax_before_refundable_credits: 0 nc_income_tax: 1_000 nc_use_tax: 20 output: + employee_payroll_tax: 0 state_use_tax: 20 household_state_tax_before_refundable_credits: 1_020 household_tax_before_refundable_credits: 1_020 - name: Abolishing payroll tax keeps the Jefferson County occupational tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax input: gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true state_code: KY county_str: JEFFERSON_COUNTY_KY employment_income: 50_000 - employee_payroll_tax: 3_825 income_tax_before_refundable_credits: 3_000 ky_income_tax_before_refundable_credits: 1_500 output: + employee_payroll_tax: 0 ky_jefferson_occupational_tax: 1_100 # 50,000 * 2.20% local_occupational_tax: 1_100 household_tax_before_refundable_credits: 5_600 # 3,000 + 1,500 + 1,100 - name: Abolishing payroll tax keeps Maryland county income tax period: 2025 - reforms: policyengine_us.reforms.federal.abolish_payroll_tax.abolish_payroll_tax input: gov.contrib.ubi_center.flat_tax.abolish_payroll_tax: true state_code: MD - employee_payroll_tax: 600 + employment_income: 60_000 + income_tax_before_refundable_credits: 0 md_income_tax_before_refundable_credits: 1_000 md_local_income_tax_before_credits: 400 output: + employee_payroll_tax: 0 local_income_tax_before_refundable_credits: 400 household_tax_before_refundable_credits: 1_400 diff --git a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py b/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py deleted file mode 100644 index d2817f41b23..00000000000 --- a/policyengine_us/tests/policy/contrib/federal/test_abolish_tax_household_totals.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Hold the abolish-tax reforms' household totals to the baseline totals. - -``abolish_federal_income_tax`` and ``abolish_payroll_tax`` redefine -``household_tax_before_refundable_credits`` (and, for the income tax, -``household_refundable_tax_credits``) without the abolished federal component. -Every other baseline component must stay in them: the Idaho permanent building -fund tax, state use taxes, local income and occupational taxes, and each -state's income tax before, not after, its refundable credits. - -YAML cannot state these properties, because each compares a reformed total -with the baseline total rather than with a fixed number: - -1. Structure, with no simulation: each reformed ``adds`` list is the baseline - list less the abolished component, however the reforms are ordered or - repeated, and the baseline lists are left as they were. -2. The same for any list, by Hypothesis draws on ``exclude_from_total``. -3. Values: for 17 households in 15 states, with both reforms applied, each - reformed total equals the baseline total less the abolished components, - and net income rises by exactly the abolished taxes less the federal - refundable credits that go with them. -""" - -from collections import Counter -from types import SimpleNamespace - -import numpy as np -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st -from policyengine_core.taxbenefitsystems import TaxBenefitSystem - -from policyengine_us import Simulation -from policyengine_us.entities import entities -from policyengine_us.model_api import * -from policyengine_us.reforms.federal.abolish_federal_income_tax import ( - abolish_federal_income_tax, - create_abolish_federal_income_tax_reform, -) -from policyengine_us.reforms.federal.abolish_payroll_tax import ( - abolish_payroll_tax, - create_abolish_payroll_tax_reform, -) -from policyengine_us.reforms.federal.household_totals import exclude_from_total -from policyengine_us.system import system - -TAX = "household_tax_before_refundable_credits" -CREDITS = "household_refundable_tax_credits" -TOTALS = (TAX, CREDITS) -FEDERAL_INCOME_TAX = "income_tax_before_refundable_credits" -FEDERAL_REFUNDABLE_CREDITS = "income_tax_refundable_credits" -PAYROLL_TAX = "employee_payroll_tax" - -# reform -> {total: the component the reform excludes from it} -EXCLUSIONS = { - abolish_federal_income_tax: { - TAX: FEDERAL_INCOME_TAX, - CREDITS: FEDERAL_REFUNDABLE_CREDITS, - }, - abolish_payroll_tax: {TAX: PAYROLL_TAX}, -} -REFORM_IDS = { - abolish_federal_income_tax: "abolish_federal_income_tax", - abolish_payroll_tax: "abolish_payroll_tax", -} - - -# Copied at import, before any test applies a reform, so a reform that edited -# the default system's lists in place could not also move what they are -# compared with. -BASELINE_ADDS = {total: tuple(system.variables[total].adds) for total in TOTALS} - - -def baseline_adds(total): - return list(BASELINE_ADDS[total]) - - -def reformed_adds(*reforms): - """Apply reforms to a system that holds only the baseline household totals. - - The reforms read and replace those two variables and nothing else, so this - needs neither a clone of the full system nor its parameter tree. - """ - bare = TaxBenefitSystem(entities) - bare.variables = {total: system.variables[total] for total in TOTALS} - for reform in reforms: - reform.apply(bare) - return {total: list(bare.variables[total].adds) for total in TOTALS} - - -def expected_adds(*reforms): - excluded = {total: set() for total in TOTALS} - for reform in reforms: - for total, component in EXCLUSIONS[reform].items(): - excluded[total].add(component) - return { - total: [c for c in baseline_adds(total) if c not in excluded[total]] - for total in TOTALS - } - - -@pytest.mark.parametrize( - "total, component", - [ - (total, component) - for exclusions in EXCLUSIONS.values() - for total, component in exclusions.items() - ], -) -def test_abolished_component_is_in_the_baseline_total(total, component): - # Excluding an absent component is a silent no-op, so a renamed component - # must fail here. - assert baseline_adds(total).count(component) == 1 - - -@pytest.mark.parametrize("reform", EXCLUSIONS, ids=REFORM_IDS.get) -def test_reformed_total_is_baseline_total_less_abolished_component(reform): - assert reformed_adds(reform) == expected_adds(reform) - - -@pytest.mark.parametrize( - "reforms", - [ - (abolish_federal_income_tax, abolish_payroll_tax), - (abolish_payroll_tax, abolish_federal_income_tax), - ], - ids=["income_tax_then_payroll_tax", "payroll_tax_then_income_tax"], -) -def test_reforms_combine_in_either_order(reforms): - assert reformed_adds(*reforms) == expected_adds(*reforms) - - -@pytest.mark.parametrize("reform", EXCLUSIONS, ids=REFORM_IDS.get) -def test_applying_a_reform_again_changes_nothing(reform): - # CountryTaxBenefitSystem applies a reform more than once. - assert reformed_adds(reform, reform, reform) == reformed_adds(reform) - - -@pytest.mark.parametrize( - "factory, flag, reform", - [ - ( - create_abolish_federal_income_tax_reform, - "abolish_federal_income_tax", - abolish_federal_income_tax, - ), - (create_abolish_payroll_tax_reform, "abolish_payroll_tax", abolish_payroll_tax), - ], - ids=["abolish_federal_income_tax", "abolish_payroll_tax"], -) -def test_parameter_switches_on_the_same_reform(factory, flag, reform): - # reforms/reforms.py calls each factory with (parameters, period), so - # setting the parameter applies the reform without the module-level object. - def parameters_with(value): - flat_tax = SimpleNamespace(**{flag: value}) - ubi_center = SimpleNamespace(flat_tax=flat_tax) - gov = SimpleNamespace(contrib=SimpleNamespace(ubi_center=ubi_center)) - return lambda period: SimpleNamespace(gov=gov) - - assert factory(parameters_with(False), "2025-01-01") is None - switched_on = factory(parameters_with(True), "2025-01-01") - assert reformed_adds(switched_on) == expected_adds(reform) - - -def test_reforms_leave_the_default_system_unchanged(): - variables = {total: system.variables[total] for total in TOTALS} - reformed_adds(abolish_federal_income_tax, abolish_payroll_tax) - for total in TOTALS: - assert system.variables[total] is variables[total] - assert system.variables[total].adds == baseline_adds(total) - - -def test_exclude_from_total_changes_nothing_but_adds(): - class total(Variable): - value_type = float - entity = Household - label = "total" - documentation = "A total." - unit = USD - definition_period = YEAR - defined_for = "eligible" - adds = ["a", "b"] - subtracts = ["c"] - - bare = TaxBenefitSystem(entities) - bare.add_variable(total) - bare.neutralize_variable("total") - before = dict(vars(bare.variables["total"])) - # Adding and neutralizing the variable set the flag; clear it so the - # assertion below sees the helper set it. - bare.data_modified = False - - exclude_from_total(bare, "total", "a") - after = dict(vars(bare.variables["total"])) - - assert before.pop("adds") == ["a", "b"] - assert after.pop("adds") == ["b"] - assert after == before - # Two attributes a new class passed to `update_variable` would reset. - assert after["defined_for"] == "eligible" - assert after["is_neutralized"] - assert bare.data_modified - - -def test_exclude_from_total_rejects_a_total_computed_by_a_formula(): - class total(Variable): - value_type = float - entity = Household - label = "total" - definition_period = YEAR - - def formula(household, period, parameters): - return 0 - - without_adds = TaxBenefitSystem(entities) - without_adds.add_variable(total) - with pytest.raises(ValueError, match="total must be an `adds` list"): - exclude_from_total(without_adds, "total", "a") - - # A formula that replaced an `adds` variable inherits the list, but the - # formula is what is computed. - with_inherited_adds = system_with_total(["a", "b"]) - with_inherited_adds.update_variable(total) - assert with_inherited_adds.variables["total"].adds == ["a", "b"] - with pytest.raises(ValueError, match="total must be an `adds` list"): - exclude_from_total(with_inherited_adds, "total", "a") - - -COMPONENT_NAMES = st.sampled_from(["a", "b", "c", "d", "e"]) - - -def system_with_total(adds): - bare = TaxBenefitSystem(entities) - bare.add_variable( - type( - "total", - (Variable,), - dict( - value_type=float, - entity=Household, - label="total", - definition_period=YEAR, - adds=adds, - ), - ) - ) - return bare - - -@given( - adds=st.lists(COMPONENT_NAMES, max_size=8), - first=COMPONENT_NAMES, - second=COMPONENT_NAMES, -) -@settings(max_examples=200, deadline=None) -def test_exclude_from_total_properties(adds, first, second): - original = list(adds) - bare = system_with_total(adds) - - exclude_from_total(bare, "total", first) - once = list(bare.variables["total"].adds) - # Only the excluded component goes, and the rest keep their order. - assert first not in once - assert Counter(once) == Counter(c for c in original if c != first) - assert once == [c for c in original if c in once] - # The list handed in belongs to the replaced variable and is not edited. - assert adds == original - - exclude_from_total(bare, "total", first) - assert bare.variables["total"].adds == once - - exclude_from_total(bare, "total", second) - other_order = system_with_total(list(original)) - exclude_from_total(other_order, "total", second) - exclude_from_total(other_order, "total", first) - assert bare.variables["total"].adds == other_order.variables["total"].adds - - -YEAR_OF_POPULATION = 2025 - -# name: (state, household inputs, person inputs, earnings, the tax or credit the -# household is here to exercise). One adult each. -POPULATION = { - "ID permanent building fund tax": ("ID", {}, {}, 60_000, "id_pbf"), - "CA use tax": ("CA", {}, {}, 60_000, "ca_use_tax"), - "IN county tax": ( - "IN", - {"county_str": "BOONE_COUNTY_IN"}, - {}, - 60_000, - "in_county_tax", - ), - "NC use tax": ("NC", {}, {}, 60_000, "nc_use_tax"), - "OK use tax": ("OK", {}, {}, 60_000, "ok_use_tax"), - "PA use tax and Philadelphia wage tax": ( - "PA", - {"county_str": "PHILADELPHIA_COUNTY_PA"}, - { - "pa_philadelphia_wage_tax_taxable_wages": 60_000, - "pa_philadelphia_wage_tax_resident": True, - }, - 60_000, - "pa_philadelphia_wage_tax", - ), - "PA use tax and refundable credits": ( - "PA", - {}, - {}, - 12_000, - "pa_refundable_tax_credits", - ), - "IL use tax": ("IL", {}, {}, 60_000, "il_use_tax"), - "MD county tax": ( - "MD", - {"county_str": "MONTGOMERY_COUNTY_MD"}, - {}, - 60_000, - "md_local_income_tax_before_refundable_credits", - ), - "DE Wilmington earned income tax": ( - "DE", - {"in_wilmington": True}, - {}, - 60_000, - "de_wilmington_earned_income_tax", - ), - "KY Jefferson County occupational tax": ( - "KY", - {"county_str": "JEFFERSON_COUNTY_KY"}, - {}, - 60_000, - "ky_jefferson_occupational_tax", - ), - "MO Kansas City earnings tax": ( - "MO", - {}, - {"mo_kansas_city_earnings_tax_taxable_earnings": 60_000}, - 60_000, - "mo_kansas_city_earnings_tax", - ), - "NY Yonkers surcharge": ( - "NY", - {"in_yonkers": True}, - {}, - 60_000, - "ny_yonkers_income_tax", - ), - "OR Multnomah County tax": ( - "OR", - {"in_multnomah_county_or": True}, - {}, - 200_000, - "or_multnomah_pfa_tax", - ), - "CO Denver occupational privilege tax": ( - "CO", - {}, - {"co_denver_employee_occupational_privilege_tax_months": 12}, - 60_000, - "co_denver_employee_occupational_privilege_tax", - ), - "NY New York City income tax": ( - "NY", - {"in_nyc": True}, - {}, - 60_000, - "nyc_income_tax_before_refundable_credits", - ), - "TX no state or local income tax": ("TX", {}, {}, 60_000, None), -} -EXERCISED = sorted( - {case[4] for case in POPULATION.values() if case[4]} | {"pa_use_tax"} -) - -CALCULATED = [ - TAX, - CREDITS, - "household_net_income", - FEDERAL_INCOME_TAX, - FEDERAL_REFUNDABLE_CREDITS, - PAYROLL_TAX, -] - -# float32 totals near $200,000 carry about a cent of rounding each. -TOLERANCE = 0.05 - - -def population_situation(): - period = YEAR_OF_POPULATION - situation = { - entity: {} - for entity in ( - "people", - "households", - "tax_units", - "spm_units", - "families", - "marital_units", - ) - } - for index, (state, household_inputs, person_inputs, earnings, _) in enumerate( - POPULATION.values() - ): - person = f"person_{index}" - situation["people"][person] = { - "age": {period: 35}, - "employment_income": {period: earnings}, - **{name: {period: value} for name, value in person_inputs.items()}, - } - situation["households"][f"household_{index}"] = { - "members": [person], - "state_code": {period: state}, - **{name: {period: value} for name, value in household_inputs.items()}, - } - for entity in ("tax_units", "spm_units", "families", "marital_units"): - situation[entity][f"{entity}_{index}"] = {"members": [person]} - return situation - - -def household_values(simulation, variables): - return { - variable: np.asarray( - simulation.calculate(variable, YEAR_OF_POPULATION, map_to="household"), - dtype=float, - ) - for variable in variables - } - - -@pytest.fixture(scope="module") -def baseline(): - simulation = Simulation(situation=population_situation()) - return household_values(simulation, CALCULATED + EXERCISED) - - -@pytest.fixture(scope="module") -def both_reforms(): - # The module-level reform objects leave the abolish parameters false, so - # each component keeps its baseline value and only the totals can differ. - # One model build covers both reforms; the structural tests above hold - # each reform's own lists. - simulation = Simulation( - situation=population_situation(), - reform=(abolish_federal_income_tax, abolish_payroll_tax), - ) - return household_values(simulation, CALCULATED) - - -def assert_close(actual, expected): - np.testing.assert_allclose(actual, expected, rtol=0, atol=TOLERANCE) - - -def test_each_household_exercises_its_tax(baseline): - # Otherwise the value check below could pass without testing a component. - for index, (name, case) in enumerate(POPULATION.items()): - if case[4]: - assert baseline[case[4]][index] > 0, name - pennsylvania = [i for i, case in enumerate(POPULATION.values()) if case[0] == "PA"] - assert baseline["pa_use_tax"][pennsylvania].min() > 0 - for abolished in (FEDERAL_INCOME_TAX, FEDERAL_REFUNDABLE_CREDITS, PAYROLL_TAX): - assert baseline[abolished].max() > 0, abolished - - -def test_abolishing_both_taxes_removes_only_those_taxes(baseline, both_reforms): - abolished_tax = baseline[FEDERAL_INCOME_TAX] + baseline[PAYROLL_TAX] - assert_close(both_reforms[TAX], baseline[TAX] - abolished_tax) - assert_close( - both_reforms[CREDITS], - baseline[CREDITS] - baseline[FEDERAL_REFUNDABLE_CREDITS], - ) - assert_close( - both_reforms["household_net_income"] - baseline["household_net_income"], - abolished_tax - baseline[FEDERAL_REFUNDABLE_CREDITS], - ) diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index 0f8fface8ac..a61c3dad40a 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -14,6 +14,11 @@ hardcoded list, so a contributed program added to the baseline aggregates is silently dropped under those reforms unless it is added to every copy. That shipped with the Trump dividend, which #9430 added to the baseline lists only. + +The last test keeps reforms from copying the household tax totals at all. The +abolish-federal-income-tax and abolish-payroll-tax reforms once did, from a +separate list that lost the Idaho permanent building fund tax, five states' +use taxes and every local tax except New York City's. """ import ast @@ -225,3 +230,34 @@ def test_reform_aggregates_keep_baseline_contrib_entries( "here too, or record a deliberate omission in " "ALLOWED_REFORM_OMISSIONS in this test." ) + + +# --- Household tax totals ------------------------------------------------- + +HOUSEHOLD_TAX_TOTALS = { + "household_tax_before_refundable_credits", + "household_state_tax_before_refundable_credits", + "household_refundable_tax_credits", + "household_refundable_state_tax_credits", + "household_refundable_local_tax_credits", + "household_tax", + "household_net_income", +} + + +def test_no_reform_redefines_a_household_tax_total(): + """Change a component or the baseline formula instead of copying a total. + + A copy keeps the components it was written with, so it misses any that + the baseline total gains later. + """ + redefinitions = [] + for source in sorted((PACKAGE / "reforms").rglob("*.py")): + tree = ast.parse(source.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name in HOUSEHOLD_TAX_TOTALS: + redefinitions.append(f"{source.relative_to(PACKAGE)}::{node.name}") + assert not redefinitions, ( + "Reforms redefine household tax totals, which then drift from the " + f"baseline: {redefinitions}" + ) diff --git a/policyengine_us/variables/household/income/household/household_refundable_tax_credits.py b/policyengine_us/variables/household/income/household/household_refundable_tax_credits.py index b8a07fac41e..0ddedb14bb2 100644 --- a/policyengine_us/variables/household/income/household/household_refundable_tax_credits.py +++ b/policyengine_us/variables/household/income/household/household_refundable_tax_credits.py @@ -7,8 +7,20 @@ class household_refundable_tax_credits(Variable): label = "refundable tax credits" definition_period = YEAR unit = USD - adds = [ - "income_tax_refundable_credits", - "household_refundable_state_tax_credits", - "household_refundable_local_tax_credits", - ] + + def formula(household, period, parameters): + credits = add( + household, + period, + [ + "household_refundable_state_tax_credits", + "household_refundable_local_tax_credits", + ], + ) + # `income_tax` nets federal refundable credits and is zero when the + # federal income tax is abolished, so the credits go too. + if parameters( + period + ).gov.contrib.ubi_center.flat_tax.abolish_federal_income_tax: + return credits + return credits + add(household, period, ["income_tax_refundable_credits"])