From 94fb45c70056569888a5a2803c161e32471c502c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 27 Oct 2026 09:05:10 -0700 Subject: [PATCH 1/2] Count Montana's pension exemption in the state AGI invariant PolicyEngine/policyengine-us#10392 subtracts Montana's 2021-2023 partial pension, annuity, and IRA income exemption outside mt_subtractions: mt_agi_joint takes mt_pension_exemption_joint once, and mt_agi_indiv takes each spouse's mt_pension_exemption_indiv. Property 3 treated a pension-only filer as unadjusted and expected federal AGI, so a 2021 single filer aged 25 with a 12,000 pension failed with 7,600 against 12,000. - Property 3 now expects max(federal AGI - mt_pension_exemption_joint, 0) on the pooled path through 2023, and the docstring states it. - The pooling guard reuses property 3's mask and requires no pension exemption on either path, so its gap is the pooling alone. - A new guard requires, through 2023, a pooled unit with a nonzero pension exemption; the seeded grid gains a pension-only single filer for it. - The shrunk failing draw is an explicit @example on test_drawn_batches. Co-Authored-By: Claude Opus 5.5 --- ...t-agi-invariant-pension-exemption.fixed.md | 1 + .../core/test_mt_state_agi_invariants.py | 65 ++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) create mode 100644 changelog.d/mt-agi-invariant-pension-exemption.fixed.md diff --git a/changelog.d/mt-agi-invariant-pension-exemption.fixed.md b/changelog.d/mt-agi-invariant-pension-exemption.fixed.md new file mode 100644 index 00000000000..70badea4224 --- /dev/null +++ b/changelog.d/mt-agi-invariant-pension-exemption.fixed.md @@ -0,0 +1 @@ +Count Montana's partial pension, annuity, and IRA income exemption in the Montana state AGI invariant test. 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 330b4e02af5..2bf096d845f 100644 --- a/policyengine_us/tests/core/test_mt_state_agi_invariants.py +++ b/policyengine_us/tests/core/test_mt_state_agi_invariants.py @@ -14,10 +14,21 @@ 100,000 (federal AGI) for a 2024 filer aged 70 whose Montana taxable income nets a 5,500 subtraction. +For 2021-2023 Montana subtracts a partial pension, annuity, and IRA income +exemption (MCA 15-30-2110(2)(c); Form 2 Subtractions Schedule line 35 for +2021, line 34 for 2022-2023): the smaller of each taxpayer's pension income +and the cap (4,400 for 2021), reduced by 2 for each dollar of federal AGI +above the threshold (36,700 for 2021). PE subtracts it outside +`mt_subtractions`: `mt_agi_joint` takes the spouses' combined amount, reduced +once by the joint federal AGI (`mt_pension_exemption_joint`), and +`mt_agi_indiv` takes each spouse's own amount, reduced by that spouse's +federal AGI (`mt_pension_exemption_indiv`). + 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: +self-employment gains and losses, pensions, Social Security and interest, +and a single filer whose only income is a pension) 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. @@ -25,7 +36,9 @@ through 2023 and federal adjusted gross income from 2024. 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. + federal AGI less the joint pension exemption, floored at zero: + `max(federal AGI - mt_pension_exemption_joint, 0)`. So one spouse's loss + offsets the other spouse's income, and the exemption is taken once. 4. Bounds: `mt_agi` is never negative, and a unit outside Montana has no `mt_agi`. @@ -37,16 +50,17 @@ 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 -are never vacuous. +with no pension exemption whose pooled AGI differs from the sum of the +spouses' floored AGIs, a unit on the property 3 path with a nonzero pension +exemption through 2023, 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 HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_us import Simulation @@ -68,6 +82,7 @@ TAX_UNIT_OUTPUTS = ( "mt_agi", "mt_agi_joint", + "mt_pension_exemption_joint", "mt_files_separately", "taxsim_state_agi", "state_agi", @@ -75,6 +90,7 @@ ) PERSON_OUTPUTS = ( "mt_agi_indiv", + "mt_pension_exemption_indiv", "mt_additions", "mt_subtractions", "social_security", @@ -116,6 +132,9 @@ def _seeded_units(): _person(age, spouse_wages, spouse_self_employment, **extra), ) ) + # A pension below the phase-out and no other income: property 3 nets the + # whole exemption (4,400 of 12,000 for 2021). + units.append(("MT", _person(40, pension=12_000), None)) units.append(("ID", _person(40, 100_000), _person(40, 0, -20_000))) return units @@ -192,17 +211,22 @@ def _check(units, year): ) np.testing.assert_allclose(run["state_agi"], reported, atol=TOLERANCE) - # 3. Pooling. + # 3. Pooling. The pension exemption is not in mt_subtractions, so these + # units can still claim it. unadjusted = ( (run["mt_additions"] == 0) & (run["mt_subtractions"] == 0) & (run["social_security"] == 0) ) pooled = in_mt & ~separate & unadjusted & (run["adjusted_gross_income"] >= 0) + run["pooled"] = pooled if year < FIRST_FEDERAL_AGI_YEAR: + expected_pooled = np.maximum( + run["adjusted_gross_income"] - run["mt_pension_exemption_joint"], 0 + ) np.testing.assert_allclose( reported[pooled], - run["adjusted_gross_income"][pooled], + expected_pooled[pooled], atol=TOLERANCE, ) @@ -224,16 +248,18 @@ def test_seeded_grid(year): 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. + # The pension exemption is reduced by each spouse's own AGI on the + # separate path and by the joint AGI on the joint path, so the couple + # has none on either and the gap is the pooling alone. floored_sum = run["mt_agi_indiv"] joint_path = ( - in_mt - & ~separate - & (run["adjusted_gross_income"] >= 0) - & (run["mt_additions"] == 0) - & (run["mt_subtractions"] == 0) - & (run["social_security"] == 0) + run["pooled"] + & (run["mt_pension_exemption_joint"] == 0) + & (run["mt_pension_exemption_indiv"] == 0) ) assert (floored_sum[joint_path] > run["mt_agi"][joint_path] + 1).any() + # Property 3 nets a nonzero pension exemption for some unit. + assert (run["pooled"] & (run["mt_pension_exemption_joint"] > 0)).any() else: # Federal AGI is not just the Montana base under another name. assert ( @@ -266,5 +292,12 @@ def test_seeded_grid(year): @settings(**SETTINGS) @given(st.lists(units, min_size=10, max_size=30), st.sampled_from(list(YEARS))) +# The shrunk draw from a CI failure just after the pension exemption was +# added: a 2021 single filer aged 25 whose only income is a 12,000 pension +# has Montana AGI 7,600, not federal AGI 12,000. +@example( + batch=[("MT", _person(25), None)] * 9 + [("MT", _person(25, pension=12_000), None)], + year=2021, +) def test_drawn_batches(batch, year): _check(batch, year) From fc8bdad5778c605661d41efc5cc0c55b9deacf94 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 05:30:32 -0400 Subject: [PATCH 2/2] Say why the pooling property keeps pension-exemption units The merged docstring kept #10496's "so it is checked separately", which described excluding those units. Say instead that the no-subtractions condition does not exclude them, and that their expected value nets the exemption. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/core/test_mt_state_agi_invariants.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 15e03ee54d3..87735a5a465 100644 --- a/policyengine_us/tests/core/test_mt_state_agi_invariants.py +++ b/policyengine_us/tests/core/test_mt_state_agi_invariants.py @@ -40,9 +40,9 @@ `max(federal AGI - mt_pension_exemption_joint, 0)`. So one spouse's loss offsets the other spouse's income, and the exemption is taken once. As #10496 explained, the exemption is outside `mt_subtractions` - (`mt_agi_indiv` and `mt_agi_joint` subtract it), so it is checked - separately: units that claim it stay in this property, and their expected - value nets it. + (`mt_agi_indiv` and `mt_agi_joint` subtract it), so the condition of no + subtractions does not exclude units that claim it: they stay in this + property, and their expected value nets it. 4. Bounds: `mt_agi` is never negative, and a unit outside Montana has no `mt_agi`.