From b04d4fbee15d827d97a78efdfe683355ba3d44ff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 17:45:01 -0400 Subject: [PATCH 1/3] Report Montana's state AGI for the filing path used, then federal AGI taxsim_state_agi summed mt_agi_indiv for Montana. That variable is defined only while Montana allows married couples to file separately on the same form, so Montana's state AGI was 0 from 2024; for 2021-2023 it floored each spouse's Montana AGI before adding, so a joint return was not pooled. - Generate mt_agi: the sum of mt_agi_indiv when the couple files separately on the same form, mt_agi_joint otherwise (2021-2023 Form 2, page 1, line 14), and list it in state_agis. - Move the federal-AGI state list out of taxsim_state_agi into the dated parameter gov.states.household.states_using_federal_agi, and add Montana from 2024, when Form 2 starts from federal AGI and has no Montana AGI line. TAXSIM reports federal AGI for Montana from 2024 too. Fixes #9929 Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-mt-taxsim-state-agi.fixed.md | 1 + .../gov/states/household/state_agis.yaml | 2 +- .../household/states_using_federal_agi.yaml | 32 +++ .../core/test_mt_state_agi_invariants.py | 254 ++++++++++++++++++ .../gov/states/mt/tax/income/mt_agi.yaml | 101 +++++++ .../states/tax/income/taxsim_state_agi.yaml | 193 +++++++++++++ .../tax/income/test_legacy_state_umbrellas.py | 29 ++ .../income/_generate_state_mfs_variables.py | 5 +- .../gov/states/tax/income/taxsim_state_agi.py | 23 +- 9 files changed, 626 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fix-mt-taxsim-state-agi.fixed.md create mode 100644 policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml create mode 100644 policyengine_us/tests/core/test_mt_state_agi_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml diff --git a/changelog.d/fix-mt-taxsim-state-agi.fixed.md b/changelog.d/fix-mt-taxsim-state-agi.fixed.md new file mode 100644 index 00000000000..949470eb3ca --- /dev/null +++ b/changelog.d/fix-mt-taxsim-state-agi.fixed.md @@ -0,0 +1 @@ +Report Montana's state adjusted gross income (taxsim_state_agi) as the Montana AGI of the filing path used through 2023, pooled on joint returns, and federal AGI from 2024, instead of 0 from 2024. diff --git a/policyengine_us/parameters/gov/states/household/state_agis.yaml b/policyengine_us/parameters/gov/states/household/state_agis.yaml index fec49cd3ba6..f5bd5f4c837 100644 --- a/policyengine_us/parameters/gov/states/household/state_agis.yaml +++ b/policyengine_us/parameters/gov/states/household/state_agis.yaml @@ -25,7 +25,7 @@ values: # Minnesota adopts federal AGI - mo_adjusted_gross_income # Missouri - ms_agi # Mississippi - - mt_agi_indiv # Montana + - mt_agi # Montana; federal AGI from 2024 (states_using_federal_agi) # North Carolina adopts federal AGI # North Dakota adopts federal AGI - ne_agi # Nebraska diff --git a/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml b/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml new file mode 100644 index 00000000000..a7cf5b38d93 --- /dev/null +++ b/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml @@ -0,0 +1,32 @@ +description: List of USPS state codes whose state adjusted gross income, as reported in TAXSIM's state AGI output, is federal adjusted gross income. +metadata: + unit: list + period: year + label: States reporting federal AGI as state AGI + reference: + - title: 2022 Montana Form 2, page 1, line 14 - Montana adjusted gross income (federal adjusted gross income plus Montana additions minus Montana subtractions) + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=1 + - title: 2024 Montana Form 2 Instructions, page i - Montana taxable income is now calculated from federal taxable income + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=5 + - title: 2024 Montana Form 2 Instructions, page 7 - Line 1, federal adjusted gross income + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15 +values: + 0000-01-01: + - CO + - MI + - MN + - NC + - ND + - NM + - SC + # From 2024 Montana Form 2 starts from federal adjusted gross income (line 1) + # and federal taxable income, and has no Montana adjusted gross income line. + 2024-01-01: + - CO + - MI + - MN + - MT + - NC + - ND + - NM + - SC diff --git a/policyengine_us/tests/core/test_mt_state_agi_invariants.py b/policyengine_us/tests/core/test_mt_state_agi_invariants.py new file mode 100644 index 00000000000..2ec4e09c50e --- /dev/null +++ b/policyengine_us/tests/core/test_mt_state_agi_invariants.py @@ -0,0 +1,254 @@ +"""Invariants of Montana's state adjusted gross income as TAXSIM reports it. + +Montana Form 2 for 2021-2023 reports Montana adjusted gross income on page 1, +line 14: federal adjusted gross income (line 11) plus Montana additions +(line 12) minus Montana subtractions (line 13). A joint return fills one +column for the couple; under filing status 2a (married filing separately on +the same form) each spouse fills their own column. From 2024 Form 2 starts +from federal adjusted gross income (line 1) and federal taxable income, and +has no Montana adjusted gross income line, so the state AGI that TAXSIM +reports is federal adjusted gross income. TAXSIM (taxsimtest build 2025Aug23) +reports exactly this: 80,000 for a 2022 couple with 100,000 of wages and a +20,000 Schedule C loss, 121,200 (Montana AGI) for a 2023 filer aged 70, and +100,000 (federal AGI) for a 2024 filer aged 70 whose Montana taxable income +nets a 5,500 subtraction. + +A seeded grid of single filers and couples (ages 40 and 70, wages, +self-employment gains and losses, pensions, Social Security and interest) +runs as one simulation per year from 2021 to 2027, and Hypothesis draws +further batches. For every tax unit: + +1. Filing path: `mt_agi` is the sum of each spouse's `mt_agi_indiv` when the + couple files separately on the same form, and `mt_agi_joint` otherwise. +2. Reporting: `taxsim_state_agi`, and its alias `state_agi`, equal `mt_agi` + through 2023 and federal adjusted gross income from 2024. +3. Pooling: through 2023, a unit on the joint path with no Montana additions + or subtractions and no Social Security reports max(0, federal AGI), so one + spouse's loss offsets the other spouse's income. +4. Bounds: `mt_agi` is never negative, and a unit outside Montana has no + `mt_agi`. + +Guards check that the grid reaches both filing paths, a joint-path couple +whose pooled AGI differs from the sum of the spouses' floored AGIs, and, from +2024, a filer whose Montana base differs from federal AGI, so properties 1-3 +are never vacuous. +""" + +from itertools import product + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +YEARS = range(2021, 2028) +# Restated from Form 2, independently of +# gov.states.household.states_using_federal_agi. +FIRST_FEDERAL_AGI_YEAR = 2024 + +PERSON_INPUTS = ( + "age", + "employment_income", + "self_employment_income", + "taxable_private_pension_income", + "social_security_retirement", + "taxable_interest_income", +) +TAX_UNIT_OUTPUTS = ( + "mt_agi", + "mt_agi_joint", + "mt_files_separately", + "taxsim_state_agi", + "state_agi", + "adjusted_gross_income", +) +PERSON_OUTPUTS = ( + "mt_agi_indiv", + "mt_additions", + "mt_subtractions", + "social_security", +) + + +def _person(age, wages=0, self_employment=0, pension=0, social_security=0, interest=0): + return dict( + age=age, + employment_income=float(wages), + self_employment_income=float(self_employment), + taxable_private_pension_income=float(pension), + social_security_retirement=float(social_security), + taxable_interest_income=float(interest), + ) + + +def _seeded_units(): + """Single filers and couples in Montana, plus one Idaho couple.""" + units = [] + for age, head_wages, spouse in product( + (40, 70), + (0, 30_000, 100_000), + (None, (0, -20_000), (0, 15_000), (60_000, 0), (60_000, -20_000)), + ): + # Retirees also get the income Montana adjusts: pensions, Social + # Security and interest. + retiree = dict(pension=10_000, social_security=12_000, interest=3_000) + extra = retiree if age == 70 else {} + head = _person(age, wages=head_wages, **extra) + if spouse is None: + units.append(("MT", head, None)) + continue + spouse_wages, spouse_self_employment = spouse + units.append( + ( + "MT", + head, + _person(age, spouse_wages, spouse_self_employment, **extra), + ) + ) + units.append(("ID", _person(40, 100_000), _person(40, 0, -20_000))) + return units + + +def _situation(units, year): + period = str(year) + people, tax_units, families, spm_units, marital_units, households = ( + {}, + {}, + {}, + {}, + {}, + {}, + ) + for i, (state, head, spouse) in enumerate(units): + members = [] + for role, inputs in (("head", head), ("spouse", spouse)): + if inputs is None: + continue + name = f"{role}_{i}" + people[name] = {k: {period: inputs[k]} for k in PERSON_INPUTS} + members.append(name) + tax_units[f"tax_unit_{i}"] = {"members": members} + families[f"family_{i}"] = {"members": members} + spm_units[f"spm_unit_{i}"] = {"members": members} + marital_units[f"marital_unit_{i}"] = {"members": members} + households[f"household_{i}"] = { + "members": members, + "state_code": {period: state}, + } + return dict( + people=people, + tax_units=tax_units, + families=families, + spm_units=spm_units, + marital_units=marital_units, + households=households, + ) + + +def _run(units, year): + simulation = Simulation(situation=_situation(units, year)) + run = {name: simulation.calculate(name, year) for name in TAX_UNIT_OUTPUTS} + for name in PERSON_OUTPUTS: + run[name] = simulation.map_result( + simulation.calculate(name, year), "person", "tax_unit" + ) + run["in_mt"] = np.array([state == "MT" for state, _, _ in units]) + return run + + +def _check(units, year): + """Assert properties 1-4 for every unit and return the run.""" + run = _run(units, year) + in_mt = run["in_mt"] + separate = run["mt_files_separately"] + mt_agi = run["mt_agi"] + + # 1. Filing path. + expected_mt_agi = np.where(separate, run["mt_agi_indiv"], run["mt_agi_joint"]) + np.testing.assert_allclose(mt_agi[in_mt], expected_mt_agi[in_mt], atol=TOLERANCE) + if year >= FIRST_FEDERAL_AGI_YEAR: + assert not separate.any() + assert (run["mt_agi_indiv"] == 0).all() + + # 2. Reporting. + reported = run["taxsim_state_agi"] + if year >= FIRST_FEDERAL_AGI_YEAR: + expected_reported = run["adjusted_gross_income"] + else: + expected_reported = mt_agi + np.testing.assert_allclose( + reported[in_mt], expected_reported[in_mt], atol=TOLERANCE + ) + np.testing.assert_allclose(run["state_agi"], reported, atol=TOLERANCE) + + # 3. Pooling. + unadjusted = ( + (run["mt_additions"] == 0) + & (run["mt_subtractions"] == 0) + & (run["social_security"] == 0) + ) + pooled = in_mt & ~separate & unadjusted + if year < FIRST_FEDERAL_AGI_YEAR: + np.testing.assert_allclose( + reported[pooled], + np.maximum(run["adjusted_gross_income"][pooled], 0), + atol=TOLERANCE, + ) + + # 4. Bounds. + assert (mt_agi >= 0).all() + assert (mt_agi[~in_mt] == 0).all() + return run + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_grid(year): + run = _check(_seeded_units(), year) + in_mt = run["in_mt"] + separate = run["mt_files_separately"] + if year < FIRST_FEDERAL_AGI_YEAR: + # Both filing paths are reached. + assert (in_mt & separate).any() + assert (in_mt & ~separate).any() + # Pooling matters for some joint-path couple: summing each spouse's + # floored Montana AGI, as taxsim_state_agi used to, overstates it. + floored_sum = run["mt_agi_indiv"] + joint_path = in_mt & ~separate + assert (floored_sum[joint_path] > run["mt_agi"][joint_path] + 1).any() + else: + # Federal AGI is not just the Montana base under another name. + assert ( + np.abs(run["mt_agi_joint"] - run["adjusted_gross_income"])[in_mt] > 1 + ).any() + + +amount = st.integers(0, 150_000) +gain_or_loss = st.integers(-40_000, 60_000) +people = st.builds( + _person, + age=st.sampled_from([25, 45, 64, 65, 80]), + wages=amount, + self_employment=gain_or_loss, + pension=st.sampled_from([0, 12_000]), + social_security=st.sampled_from([0, 18_000]), + interest=st.sampled_from([0, 2_500]), +) +units = st.tuples(st.sampled_from(["MT", "MT", "MT", "ID"]), people, st.none() | people) + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(units, min_size=10, max_size=30), st.sampled_from(list(YEARS))) +def test_drawn_batches(batch, year): + _check(batch, year) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml new file mode 100644 index 00000000000..0b81bb4e1e2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml @@ -0,0 +1,101 @@ +# Montana adjusted gross income of the filing path used (Form 2, page 1, +# line 14 for 2021-2023): the joint Montana AGI, or the sum of each spouse's +# Montana AGI when the couple files separately on the same form. + +- name: Separate filing on the same form adds each spouse's Montana AGI + period: 2022 + input: + people: + head: + mt_agi_indiv: 60_000 + spouse: + mt_agi_indiv: 30_000 + tax_units: + tax_unit: + members: [head, spouse] + mt_agi_joint: 85_000 + mt_files_separately: true + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_agi: 90_000 + +- name: Joint filing takes the joint Montana AGI + period: 2022 + input: + people: + head: + mt_agi_indiv: 60_000 + spouse: + mt_agi_indiv: 30_000 + tax_units: + tax_unit: + members: [head, spouse] + mt_agi_joint: 85_000 + mt_files_separately: false + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_agi: 85_000 + +- name: Joint return pools a spouse's loss with the other spouse's wages + period: 2022 + input: + people: + head: + age: 40 + employment_income: 100_000 + spouse: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_files_separately: false + mt_agi_indiv: [100_000, 0] + mt_agi: 80_000 + +- name: From 2024 every return takes the joint Montana base + period: 2024 + input: + people: + head: + age: 70 + employment_income: 100_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + mt_files_separately: false + mt_agi_joint: 94_500 + mt_agi: 94_500 + +- name: Zero outside Montana + period: 2022 + input: + people: + head: + age: 40 + employment_income: 100_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: ID + output: + mt_agi: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml new file mode 100644 index 00000000000..a7c7281a02f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml @@ -0,0 +1,193 @@ +# Montana. Through 2023 the state AGI is Form 2, page 1, line 14 (Montana +# adjusted gross income) of the filing path used: one column on a joint +# return, a column per spouse under status 2a. From 2024 Form 2 starts from +# federal adjusted gross income (line 1) and has no Montana AGI line, so the +# state AGI is federal AGI. Where noted, the expected value is what TAXSIM +# (taxsimtest build 2025Aug23) reports as state AGI (v32) for the same inputs. + +- name: Montana 2022 joint return pools a spouse's loss with the other spouse's wages + # TAXSIM v32: 80,000. + period: 2022 + input: + people: + head: + age: 40 + employment_income: 100_000 + spouse: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_files_separately: false + mt_agi_joint: 80_000 + taxsim_state_agi: 80_000 + state_agi: 80_000 + +- name: Montana 2023 joint return pools a spouse's loss with the other spouse's wages + # TAXSIM v32: 80,000. + period: 2023 + input: + people: + head: + age: 40 + employment_income: 100_000 + spouse: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_files_separately: false + taxsim_state_agi: 80_000 + +- name: Montana 2023 state AGI is Montana AGI, after the senior interest exemption + # TAXSIM v32: 121,200. Federal AGI is 122,000: wages 70,000, pension + # 30,000, interest 5,000 and 17,000 of the 20,000 of Social Security. + period: 2023 + input: + people: + head: + age: 70 + employment_income: 70_000 + taxable_private_pension_income: 30_000 + social_security_retirement: 20_000 + taxable_interest_income: 5_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + adjusted_gross_income: 122_000 + taxsim_state_agi: 121_200 + +- name: Montana 2022 separate filing on the same form adds each spouse's Montana AGI + period: 2022 + input: + people: + head: + mt_agi_indiv: 60_000 + spouse: + mt_agi_indiv: 30_000 + tax_units: + tax_unit: + members: [head, spouse] + mt_agi_joint: 85_000 + mt_files_separately: true + households: + household: + members: [head, spouse] + state_code: MT + output: + taxsim_state_agi: 90_000 + +- name: Montana 2022 joint filing reports the joint Montana AGI + period: 2022 + input: + people: + head: + mt_agi_indiv: 60_000 + spouse: + mt_agi_indiv: 30_000 + tax_units: + tax_unit: + members: [head, spouse] + mt_agi_joint: 85_000 + mt_files_separately: false + households: + household: + members: [head, spouse] + state_code: MT + output: + taxsim_state_agi: 85_000 + +- name: Montana 2024 state AGI is federal AGI + # TAXSIM v32: 100,000. + period: 2024 + input: + people: + head: + age: 40 + employment_income: 100_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + taxsim_state_agi: 100_000 + state_agi: 100_000 + +- name: Montana 2024 state AGI is federal AGI, not federal AGI less the senior subtraction + # TAXSIM v32: 100,000, while TAXSIM's Montana taxable income (77,950) nets + # the 5,500 subtraction for a taxpayer aged 65 or older. + period: 2024 + input: + people: + head: + age: 70 + employment_income: 100_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + adjusted_gross_income: 100_000 + mt_agi: 94_500 + taxsim_state_agi: 100_000 + +- name: Montana 2024 joint return reports the couple's federal AGI + # TAXSIM v32: 80,000. + period: 2024 + input: + people: + head: + age: 40 + employment_income: 100_000 + spouse: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + taxsim_state_agi: 80_000 + +- name: Montana 2026 state AGI is federal AGI + period: 2026 + input: + people: + head: + age: 40 + employment_income: 100_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MT + output: + taxsim_state_agi: 100_000 diff --git a/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py b/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py index aecab18704f..ada072437a2 100644 --- a/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py +++ b/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py @@ -106,6 +106,11 @@ def configured_component_vars(parameter_name: str, year: int) -> list[str]: ["ok_agi"], make_tax_unit_situation(year=2023, state="OK", wages=40_000.0), ), + ( + "taxsim_state_agi", + ["mt_agi"], + make_tax_unit_situation(year=2023, state="MT", wages=40_000.0), + ), ( "taxsim_state_taxable_income", ["ok_taxable_income"], @@ -146,6 +151,30 @@ def test_legacy_state_umbrellas_match_state_specific_components( assert actual == pytest.approx(expected, abs=0.01) +@pytest.mark.parametrize("year", [2021, 2022, 2023, 2024, 2025, 2026]) +def test_mt_state_agi_is_montana_agi_then_federal_agi(year): + # Through 2023 Montana's state AGI is Form 2 line 14, Montana AGI. From + # 2024 Form 2 starts from federal AGI (line 1), so Montana joins the + # states that report federal AGI. A filer aged 70 has a Montana + # subtraction from 2024, so the two measures differ. + federal_agi_states = configured_component_vars("states_using_federal_agi", year) + assert ("MT" in federal_agi_states) == (year >= 2024) + situation = make_tax_unit_situation( + year=year, state="MT", primary_age=70, wages=40_000.0 + ) + if year >= 2024: + expected_vars = ["adjusted_gross_income"] + assert calculate_sum(situation, year, ["mt_agi"]) < calculate_sum( + situation, year, expected_vars + ) + else: + expected_vars = configured_component_vars("state_agis", year) + expected = calculate_sum(situation, year, expected_vars) + assert expected > 0 + actual = calculate_sum(situation, year, ["taxsim_state_agi"]) + assert actual == pytest.approx(expected, abs=0.01) + + def test_state_ctc_matches_configured_component_sum(): # NY is in the 2023 state_ctcs block; MN is not (see the next test). situation = make_tax_unit_situation( diff --git a/policyengine_us/variables/gov/states/tax/income/_generate_state_mfs_variables.py b/policyengine_us/variables/gov/states/tax/income/_generate_state_mfs_variables.py index 5689a485855..1f5d0c83ce5 100644 --- a/policyengine_us/variables/gov/states/tax/income/_generate_state_mfs_variables.py +++ b/policyengine_us/variables/gov/states/tax/income/_generate_state_mfs_variables.py @@ -19,7 +19,7 @@ # Skip variables that already exist or aren't needed if ( # Skip existing AGI variables - (state_code in ["ky", "ms", "mt"] and var_code == "agi") + (state_code in ["ky", "ms"] and var_code == "agi") or # Skip all DC variables except taxable income (state_code == "dc" and var_code != "taxable_income") @@ -119,6 +119,9 @@ "mt_taxable_income_indiv", "mt_taxable_income_joint", ), + # 2021-2023 Form 2 line 14: one column on a joint return, + # a column per spouse under status 2a. + "agi": ("mt_agi_indiv", "mt_agi_joint"), }, }[state_code][var_code] diff --git a/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py b/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py index dcd31c2df2d..39119b835e1 100644 --- a/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py +++ b/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py @@ -5,21 +5,20 @@ class taxsim_state_agi(Variable): value_type = float entity = TaxUnit label = "State adjusted gross income" + documentation = ( + "State adjusted gross income as TAXSIM reports it in its state AGI " + "output. States listed in gov.states.household.states_using_federal_agi " + "report federal adjusted gross income; other states report the state " + "AGI variables in gov.states.household.state_agis." + ) unit = USD definition_period = YEAR def formula(tax_unit, period, parameters): - # States that adopt the federal AGI - # Based on comments in state_agis.yaml - FEDERAL_AGI_STATES = [ - "CO", # Colorado - "MI", # Michigan - "MN", # Minnesota - "NC", # North Carolina - "ND", # North Dakota - "NM", # New Mexico - "SC", # South Carolina - ] + # States that report federal AGI as state AGI + federal_agi_states = parameters( + period + ).gov.states.household.states_using_federal_agi # Get the current state state_code = tax_unit.household("state_code_str", period) @@ -47,7 +46,7 @@ def formula(tax_unit, period, parameters): state_specific_base = where(is_state, max_agi, state_specific_base) # Check if the state adopts federal AGI - uses_federal = np.isin(state_code, FEDERAL_AGI_STATES) + uses_federal = np.isin(state_code, federal_agi_states) # Get federal AGI federal_agi = tax_unit("adjusted_gross_income", period) From 5c46dd490c0f86401b32e2ab5cef1e1b6c83da3e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 19:01:11 -0400 Subject: [PATCH 2/3] Drop per-spouse mt_agi_indiv assertion that depends on the joint loss ALD split Co-Authored-By: Claude Opus 5.5 --- .../tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml index 0b81bb4e1e2..1dbe1b3f827 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi.yaml @@ -61,7 +61,6 @@ state_code: MT output: mt_files_separately: false - mt_agi_indiv: [100_000, 0] mt_agi: 80_000 - name: From 2024 every return takes the joint Montana base From 3b249d67fc83c31a7ddb59251c43a05cc26eeb69 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:08:49 -0400 Subject: [PATCH 3/3] Address MT AGI invariant and simulation reuse review --- .../household/states_using_federal_agi.yaml | 3 ++ .../core/test_mt_state_agi_invariants.py | 34 ++++++++++---- .../states/tax/income/taxsim_state_agi.yaml | 3 +- .../tax/income/test_legacy_state_umbrellas.py | 46 +++++++++++-------- .../gov/states/tax/income/taxsim_state_agi.py | 3 +- 5 files changed, 60 insertions(+), 29 deletions(-) diff --git a/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml b/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml index a7cf5b38d93..c78e5797e10 100644 --- a/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml +++ b/policyengine_us/parameters/gov/states/household/states_using_federal_agi.yaml @@ -11,6 +11,9 @@ metadata: - title: 2024 Montana Form 2 Instructions, page 7 - Line 1, federal adjusted gross income href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15 values: + # Carry forward the seven states from taxsim_state_agi's former + # FEDERAL_AGI_STATES constant, based on the comments in state_agis.yaml. + # The references above support Montana's addition from 2024. 0000-01-01: - CO - MI diff --git a/policyengine_us/tests/core/test_mt_state_agi_invariants.py b/policyengine_us/tests/core/test_mt_state_agi_invariants.py index 2ec4e09c50e..330b4e02af5 100644 --- a/policyengine_us/tests/core/test_mt_state_agi_invariants.py +++ b/policyengine_us/tests/core/test_mt_state_agi_invariants.py @@ -8,8 +8,9 @@ from federal adjusted gross income (line 1) and federal taxable income, and has no Montana adjusted gross income line, so the state AGI that TAXSIM reports is federal adjusted gross income. TAXSIM (taxsimtest build 2025Aug23) -reports exactly this: 80,000 for a 2022 couple with 100,000 of wages and a -20,000 Schedule C loss, 121,200 (Montana AGI) for a 2023 filer aged 70, and +reports these values rounded to whole dollars: 80,000 for a 2022 couple +with 100,000 of wages and a 20,000 Schedule C loss, 121,200 (Montana AGI) +for a 2023 filer aged 70, and 100,000 (federal AGI) for a 2024 filer aged 70 whose Montana taxable income nets a 5,500 subtraction. @@ -22,12 +23,19 @@ couple files separately on the same form, and `mt_agi_joint` otherwise. 2. Reporting: `taxsim_state_agi`, and its alias `state_agi`, equal `mt_agi` through 2023 and federal adjusted gross income from 2024. -3. Pooling: through 2023, a unit on the joint path with no Montana additions - or subtractions and no Social Security reports max(0, federal AGI), so one - spouse's loss offsets the other spouse's income. +3. Pooling: through 2023, a unit on the joint path with non-negative federal + AGI, no Montana additions or subtractions and no Social Security reports + federal AGI, so one spouse's loss offsets the other spouse's income. 4. Bounds: `mt_agi` is never negative, and a unit outside Montana has no `mt_agi`. +The non-negative bound is inherited from PE's existing `mt_agi_joint` and +`mt_agi_indiv`, not a TAXSIM requirement. An independent review probe for a +2023 single filer aged 40 with a 20,000 Schedule C loss and no other income +returned federal AGI -20,000 and TAXSIM v32 -19,999.99; PE's Montana AGI is +zero. Property 3 excludes negative federal AGI rather than endorsing that +existing discrepancy. Properties 1, 2 and 4 still exercise those units. + Guards check that the grid reaches both filing paths, a joint-path couple whose pooled AGI differs from the sum of the spouses' floored AGIs, and, from 2024, a filer whose Montana base differs from federal AGI, so properties 1-3 @@ -190,15 +198,15 @@ def _check(units, year): & (run["mt_subtractions"] == 0) & (run["social_security"] == 0) ) - pooled = in_mt & ~separate & unadjusted + pooled = in_mt & ~separate & unadjusted & (run["adjusted_gross_income"] >= 0) if year < FIRST_FEDERAL_AGI_YEAR: np.testing.assert_allclose( reported[pooled], - np.maximum(run["adjusted_gross_income"][pooled], 0), + run["adjusted_gross_income"][pooled], atol=TOLERANCE, ) - # 4. Bounds. + # 4. Inherited PE bounds, including negative federal AGI. assert (mt_agi >= 0).all() assert (mt_agi[~in_mt] == 0).all() return run @@ -209,6 +217,7 @@ def test_seeded_grid(year): run = _check(_seeded_units(), year) in_mt = run["in_mt"] separate = run["mt_files_separately"] + assert (in_mt & (run["adjusted_gross_income"] < 0)).any() if year < FIRST_FEDERAL_AGI_YEAR: # Both filing paths are reached. assert (in_mt & separate).any() @@ -216,7 +225,14 @@ def test_seeded_grid(year): # Pooling matters for some joint-path couple: summing each spouse's # floored Montana AGI, as taxsim_state_agi used to, overstates it. floored_sum = run["mt_agi_indiv"] - joint_path = in_mt & ~separate + joint_path = ( + in_mt + & ~separate + & (run["adjusted_gross_income"] >= 0) + & (run["mt_additions"] == 0) + & (run["mt_subtractions"] == 0) + & (run["social_security"] == 0) + ) assert (floored_sum[joint_path] > run["mt_agi"][joint_path] + 1).any() else: # Federal AGI is not just the Montana base under another name. diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml index a7c7281a02f..1cbbf97b167 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/income/taxsim_state_agi.yaml @@ -3,7 +3,8 @@ # return, a column per spouse under status 2a. From 2024 Form 2 starts from # federal adjusted gross income (line 1) and has no Montana AGI line, so the # state AGI is federal AGI. Where noted, the expected value is what TAXSIM -# (taxsimtest build 2025Aug23) reports as state AGI (v32) for the same inputs. +# (taxsimtest build 2025Aug23) reports as state AGI (v32) for the same inputs, +# rounded to whole dollars (the raw probe values include a one-cent offset). - name: Montana 2022 joint return pools a spouse's loss with the other spouse's wages # TAXSIM v32: 80,000. diff --git a/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py b/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py index ada072437a2..f6ece880e00 100644 --- a/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py +++ b/policyengine_us/tests/policy/baseline/gov/tax/income/test_legacy_state_umbrellas.py @@ -82,8 +82,7 @@ def make_tax_unit_situation( } -def calculate_sum(situation: dict, year: int, variables: list[str]) -> float: - simulation = Simulation(situation=situation) +def calculate_sum(simulation: Simulation, year: int, variables: list[str]) -> float: total = 0.0 for variable in variables: @@ -146,8 +145,9 @@ def configured_component_vars(parameter_name: str, year: int) -> list[str]: def test_legacy_state_umbrellas_match_state_specific_components( legacy_var, state_specific_vars, situation ): - expected = calculate_sum(situation, 2023, state_specific_vars) - actual = calculate_sum(situation, 2023, [legacy_var]) + simulation = Simulation(situation=situation) + expected = calculate_sum(simulation, 2023, state_specific_vars) + actual = calculate_sum(simulation, 2023, [legacy_var]) assert actual == pytest.approx(expected, abs=0.01) @@ -159,19 +159,23 @@ def test_mt_state_agi_is_montana_agi_then_federal_agi(year): # subtraction from 2024, so the two measures differ. federal_agi_states = configured_component_vars("states_using_federal_agi", year) assert ("MT" in federal_agi_states) == (year >= 2024) + state_agis = configured_component_vars("state_agis", year) + assert "mt_agi" in state_agis + assert "mt_agi_indiv" not in state_agis situation = make_tax_unit_situation( year=year, state="MT", primary_age=70, wages=40_000.0 ) + simulation = Simulation(situation=situation) if year >= 2024: expected_vars = ["adjusted_gross_income"] - assert calculate_sum(situation, year, ["mt_agi"]) < calculate_sum( - situation, year, expected_vars + assert calculate_sum(simulation, year, ["mt_agi"]) < calculate_sum( + simulation, year, expected_vars ) else: - expected_vars = configured_component_vars("state_agis", year) - expected = calculate_sum(situation, year, expected_vars) + expected_vars = ["mt_agi"] + expected = calculate_sum(simulation, year, expected_vars) assert expected > 0 - actual = calculate_sum(situation, year, ["taxsim_state_agi"]) + actual = calculate_sum(simulation, year, ["taxsim_state_agi"]) assert actual == pytest.approx(expected, abs=0.01) @@ -183,10 +187,11 @@ def test_state_ctc_matches_configured_component_sum(): wages=20_050.0, dependent_ages=(9, 7), ) + simulation = Simulation(situation=situation) expected = calculate_sum( - situation, 2023, configured_component_vars("state_ctcs", 2023) + simulation, 2023, configured_component_vars("state_ctcs", 2023) ) - actual = calculate_sum(situation, 2023, ["taxsim_state_ctc"]) + actual = calculate_sum(simulation, 2023, ["taxsim_state_ctc"]) assert expected > 0 assert actual == pytest.approx(expected, abs=0.01) @@ -202,12 +207,15 @@ def test_mn_combined_credit_is_state_eitc_not_state_ctc(year): wages=20_050.0, dependent_ages=(9, 7), ) - combined = calculate_sum(situation, year, ["mn_child_and_working_families_credits"]) + simulation = Simulation(situation=situation) + combined = calculate_sum( + simulation, year, ["mn_child_and_working_families_credits"] + ) assert combined > 0 - assert calculate_sum(situation, year, ["taxsim_state_eitc"]) == pytest.approx( + assert calculate_sum(simulation, year, ["taxsim_state_eitc"]) == pytest.approx( combined, abs=0.01 ) - assert calculate_sum(situation, year, ["taxsim_state_ctc"]) == 0 + assert calculate_sum(simulation, year, ["taxsim_state_ctc"]) == 0 def test_state_property_tax_credit_matches_configured_component_sum(): @@ -218,12 +226,13 @@ def test_state_property_tax_credit_matches_configured_component_sum(): wages=10_000.0, rent=12_000.0, ) + simulation = Simulation(situation=situation) expected = calculate_sum( - situation, + simulation, 2023, configured_component_vars("state_property_tax_credits", 2023), ) - actual = calculate_sum(situation, 2023, ["taxsim_state_property_tax_credit"]) + actual = calculate_sum(simulation, 2023, ["taxsim_state_property_tax_credit"]) assert actual == pytest.approx(expected, abs=0.01) @@ -249,6 +258,7 @@ def test_state_property_tax_credit_matches_configured_component_sum(): def test_decomposed_credit_components_preserve_combined_credit_total( component_vars, combined_var, situation ): - combined_total = calculate_sum(situation, 2023, [combined_var]) - decomposed_total = calculate_sum(situation, 2023, component_vars) + simulation = Simulation(situation=situation) + combined_total = calculate_sum(simulation, 2023, [combined_var]) + decomposed_total = calculate_sum(simulation, 2023, component_vars) assert decomposed_total == pytest.approx(combined_total, abs=0.01) diff --git a/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py b/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py index 39119b835e1..9c9d46d866c 100644 --- a/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py +++ b/policyengine_us/variables/gov/states/tax/income/taxsim_state_agi.py @@ -9,7 +9,8 @@ class taxsim_state_agi(Variable): "State adjusted gross income as TAXSIM reports it in its state AGI " "output. States listed in gov.states.household.states_using_federal_agi " "report federal adjusted gross income; other states report the state " - "AGI variables in gov.states.household.state_agis." + "AGI variables in gov.states.household.state_agis, except Arkansas " + "and Delaware, which report the larger of their separate and joint AGIs." ) unit = USD definition_period = YEAR