Skip to content
Draft
1 change: 1 addition & 0 deletions changelog.d/fix-mt-medical-credit-allocation.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Minimize Montana's separate-column tax when a capital gain credit exhausts one column's liability, and keep joint filing when separate savings stay within one cent plus float32 roundoff.
121 changes: 121 additions & 0 deletions policyengine_us/tests/core/test_mt_filing_choice_invariants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Keep Montana filing choices stable within one cent.

Spouses may choose a joint return or separate returns on the same form.
PolicyEngine keeps the joint path within one cent plus float32 roundoff.
The rebate base repeats this election before refundable credits, so both
comparisons must make the same choice when credits are zero.
"""

import numpy as np
from hypothesis import given, settings, strategies as st

from policyengine_us import Simulation
from policyengine_us.tools.mt_filing_choice import mt_separate_filing_is_cheaper


@settings(max_examples=4, deadline=None)
@given(
columns=st.lists(
st.tuples(st.integers(0, 20_000), st.integers(0, 20_000)),
min_size=1,
max_size=4,
),
year=st.sampled_from([2021, 2022, 2023, 2024]),
)
def test_filing_ties_and_rebate_base_agree(columns, year):
people, tax_units, households = {}, {}, {}
expected_separate, expected_tax, expected_rebate_base = [], [], []
# These savings surround one cent and have exact float32 representations.
savings_grid = [-0.015625, -0.0078125, 0, 0.0078125, 0.015625]
for i, (head_quarters, spouse_quarters) in enumerate(columns):
for j, savings in enumerate(savings_grid):
head_tax, spouse_tax = head_quarters / 4, spouse_quarters / 4
separate_tax = head_tax + spouse_tax
joint_tax = max(0, separate_tax + savings)
separate = year < 2024 and savings == 0.015625
# Swapping the columns must preserve the election and total tax.
for swapped in [False, True]:
key = f"{i}_{j}_{int(swapped)}"
head, spouse = f"head_{key}", f"spouse_{key}"
first, second = (
(spouse_tax, head_tax) if swapped else (head_tax, spouse_tax)
)
people[head] = {
"is_tax_unit_head": {year: True},
"mt_income_tax_indiv": {year: first},
"mt_income_tax_before_refundable_credits_indiv": {year: first},
}
people[spouse] = {
"is_tax_unit_spouse": {year: True},
"mt_income_tax_indiv": {year: second},
"mt_income_tax_before_refundable_credits_indiv": {year: second},
}
tax_units[key] = {
"members": [head, spouse],
"mt_income_tax_joint": {year: joint_tax},
"mt_income_tax_before_refundable_credits_joint": {year: joint_tax},
}
households[key] = {
"members": [head, spouse],
"state_code": {year: "MT"},
}
expected_separate.append(separate)
expected_tax.append(separate_tax if separate else joint_tax)
expected_rebate_base.extend(
[first, second] if separate else [joint_tax / 2, joint_tax / 2]
)
simulation = Simulation(
situation={
"people": people,
"tax_units": tax_units,
"households": households,
}
)
np.testing.assert_array_equal(
simulation.calculate("mt_files_separately", year), expected_separate
)
np.testing.assert_allclose(
simulation.calculate("mt_income_tax", year), expected_tax, atol=0, rtol=0
)
np.testing.assert_allclose(
simulation.calculate("mt_income_tax_before_refundable_credits_unit", year),
expected_tax,
atol=0,
rtol=0,
)
np.testing.assert_allclose(
simulation.calculate("mt_income_tax_before_2021_rebate", year),
expected_rebate_base,
atol=0,
rtol=0,
)


@settings(max_examples=100, deadline=None, derandomize=True)
@given(
head_cents=st.integers(-10_000_000, 10_000_000),
spouse_cents=st.integers(-10_000_000, 10_000_000),
)
def test_nominal_one_cent_ties_keep_joint_filing(head_cents, spouse_cents):
columns = np.array([head_cents / 100, spouse_cents / 100], dtype=np.float32)
separate = columns.astype(np.float64).sum()
joint = np.float32((head_cents + spouse_cents + 1) / 100)
magnitude = np.abs(columns.astype(np.float64)).sum()
assert not mt_separate_filing_is_cheaper(separate, joint, magnitude)


@settings(max_examples=100, deadline=None, derandomize=True)
@given(
head_cents=st.integers(0, 200_000),
spouse_cents=st.integers(0, 200_000),
)
def test_resolvable_savings_on_both_sides_of_one_cent(head_cents, spouse_cents):
columns = np.array([head_cents / 100, spouse_cents / 100], dtype=np.float32)
separate = columns.astype(np.float64).sum()
nominal = (head_cents + spouse_cents) / 100
joint = np.array(
[nominal + saving for saving in [0.009, 0.01, 0.011]], dtype=np.float32
)
np.testing.assert_array_equal(
mt_separate_filing_is_cheaper(separate, joint), [False, False, True]
)
153 changes: 153 additions & 0 deletions policyengine_us/tests/core/test_mt_medical_credit_split_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Compare Montana's chosen medical split with an independent share grid.

Form 2 permits spouses to divide jointly paid dependent bills in any proportion.
Within the tested input ranges, the chooser stays within a numerical tolerance
of an independent grid, including when capital gain credits exhaust a column's
tax. Swapping columns and adding dependent bills use the same tolerance.
The oracle uses the Form 2 schedules and the model's dependent-exemption
allocation convention, without calling the closed-form tax helpers.
"""

import numpy as np
import pytest
from hypothesis import given, settings, strategies as st

from policyengine_us import Simulation


# Form 2 instructions: 2021 p. 15, 2022 p. 16, 2023 p. 18.
THRESHOLDS = {
2021: [0, 3_100, 5_500, 8_400, 11_400, 14_600, 18_800],
2022: [0, 3_300, 5_800, 8_900, 12_000, 15_400, 19_800],
2023: [0, 3_600, 6_300, 9_700, 13_000, 16_800, 21_600],
}
EXEMPTION = {2021: 2_580, 2022: 2_710, 2023: 2_960}


@st.composite
def couples(draw):
agi = draw(st.lists(st.integers(0, 80_000), min_size=2, max_size=2))
return {
"agi": agi,
"gains": [draw(st.integers(0, amount)) for amount in agi],
"own": draw(st.lists(st.integers(0, 12_000), min_size=2, max_size=2)),
"other": draw(st.lists(st.integers(0, 8_000), min_size=2, max_size=2)),
"premiums": draw(st.lists(st.integers(0, 6_000), min_size=2, max_size=2)),
"children": draw(st.integers(1, 3)),
"bills": draw(st.integers(1, 50_000)),
}


def _situation(cases, year):
people, units, households = {}, {}, {}
for i, case in enumerate(cases):
members = []
for j in range(2):
name = f"u{i}_filer{j}"
members.append(name)
people[name] = {
"age": {year: 40},
"is_tax_unit_head": {year: j == 0},
"is_tax_unit_spouse": {year: j == 1},
"mt_agi_indiv": {year: case["agi"][j]},
"long_term_capital_gains": {year: case["gains"][j]},
"other_medical_expenses": {year: case["own"][j]},
"mt_non_medical_itemized_deductions_indiv": {year: case["other"][j]},
"mt_eligible_medical_insurance_premiums": {year: case["premiums"][j]},
}
for j in range(case["children"]):
name = f"u{i}_child{j}"
members.append(name)
people[name] = {
"age": {year: 10},
"is_tax_unit_dependent": {year: True},
"other_medical_expenses": {year: case["bills"] if j == 0 else 0},
}
units[f"u{i}"] = {"members": members, "mt_tax_unit_itemizes": {year: True}}
households[f"u{i}"] = {"members": members, "state_code": {year: "MT"}}
return {"people": people, "tax_units": units, "households": households}


def _grid_tax(case, year, shares):
shares = np.array([shares, 1 - shares]).T
deduction = (
np.maximum(
np.array(case["own"])
+ shares * case["bills"]
- 0.075 * np.array(case["agi"]),
0,
)
+ case["other"]
+ np.array(case["premiums"])
)
pre = np.maximum(np.array(case["agi"]) - EXEMPTION[year] - deduction, 0)
dependent = EXEMPTION[year] * case["children"]
balanced = np.maximum((pre.sum(axis=1) - dependent) / 2, 0)
taxable = np.minimum(pre, np.maximum(pre - dependent, balanced[:, None]))
rates = np.array(
[0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.069 if year == 2021 else 0.0675]
)
tax = (
np.maximum(taxable[:, :, None] - THRESHOLDS[year], 0)
* np.diff(rates, prepend=0)
).sum(axis=2)
return np.maximum(tax - 0.02 * np.array(case["gains"]), 0).sum(axis=1)


def _check(cases, year):
variants = []
for case in cases:
swapped = {
key: value[::-1] if isinstance(value, list) else value
for key, value in case.items()
}
variants.extend([case, swapped, {**case, "bills": case["bills"] + 1_000}])
sim = Simulation(situation=_situation(variants, year))
assert sim.calculate("tax_unit_married", year).all()
share = sim.calculate("mt_dependent_medical_expense_head_share", year)
assert np.all((share >= 0) & (share <= 1))
columns = sim.calculate("mt_income_tax_before_refundable_credits_indiv", year)
tax = np.bincount(sim.populations["tax_unit"].members_entity_id, weights=columns)
for i, case in enumerate(cases):
# Successive ties can each favor a closer share within one cent.
# Allow those ties and float32 income and medical-share arithmetic.
tolerance = 0.05
at_chosen_share = _grid_tax(case, year, np.array([share[3 * i]]))[0]
assert abs(tax[3 * i] - at_chosen_share) <= tolerance
expected = _grid_tax(case, year, np.linspace(0, 1, 1_001)).min()
assert tax[3 * i] <= expected + tolerance
assert abs(tax[3 * i] - tax[3 * i + 1]) <= tolerance
assert tax[3 * i + 2] <= tax[3 * i] + tolerance


@settings(max_examples=6, deadline=None, derandomize=True)
@given(st.sampled_from(list(THRESHOLDS)), st.lists(couples(), min_size=4, max_size=12))
def test_credit_aware_medical_split(year, cases):
_check(cases, year)


@pytest.mark.parametrize("year", THRESHOLDS)
def test_credit_exhaustion_witness(year):
_check(
[
{
"agi": [20_000, 20_000],
"gains": [10_000, 0],
"own": [0, 0],
"other": [0, 0],
"premiums": [0, 0],
"children": 1,
"bills": 25_000,
},
{
"agi": [100_000, 100_000],
"gains": [100_000, 0],
"own": [0, 0],
"other": [0, 0],
"premiums": [0, 0],
"children": 1,
"bills": 150_000,
},
],
year,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
- name: Spouses share the joint rebate base when the separate saving falls below one cent
period: 2021
input:
people:
head:
is_tax_unit_head: true
mt_income_tax_before_refundable_credits_indiv: 40
spouse:
is_tax_unit_spouse: true
mt_income_tax_before_refundable_credits_indiv: 59.991
tax_units:
tax_unit:
members: [head, spouse]
mt_income_tax_before_refundable_credits_joint: 100
households:
household:
members: [head, spouse]
state_code: MT
output:
# 100 - (40 + 59.991) = 0.009, so each spouse takes 100 / 2.
mt_income_tax_before_2021_rebate: [50, 50]
mt_income_tax_before_refundable_credits_unit: 100

- name: Spouses keep their own rebate bases when the separate saving exceeds one cent
period: 2021
input:
people:
head:
is_tax_unit_head: true
mt_income_tax_before_refundable_credits_indiv: 40
spouse:
is_tax_unit_spouse: true
mt_income_tax_before_refundable_credits_indiv: 59.989
tax_units:
tax_unit:
members: [head, spouse]
mt_income_tax_before_refundable_credits_joint: 100
households:
household:
members: [head, spouse]
state_code: MT
output:
# 100 - (40 + 59.989) = 0.011, so each spouse keeps their own line 20.
mt_income_tax_before_2021_rebate: [40, 59.989]
mt_income_tax_before_refundable_credits_unit: 99.989

- name: Spouses apply the joint rebate cap when their filing taxes differ by less than one cent
period: 2021
absolute_error_margin: 0.0001
input:
people:
head:
age: 40
is_tax_unit_head: true
mt_income_tax_before_refundable_credits_indiv: 2_000
spouse:
age: 40
is_tax_unit_spouse: true
mt_income_tax_before_refundable_credits_indiv: 0
tax_units:
tax_unit:
members: [head, spouse]
filing_status: JOINT
mt_income_tax_before_refundable_credits_joint: 2_000.009
households:
household:
members: [head, spouse]
state_code: MT
output:
# Joint filing takes the near tie: 2,000.009 - 2,000 = 0.009.
# Each rebate base = 2,000.009 / 2 = 1,000.0045, below the 1,250 cap.
# A separate election would cap the rebates at 1,250 + 0 = 1,250.
mt_income_tax_before_2021_rebate: [1_000.0045, 1_000.0045]
mt_income_tax_rebate: [1_000.0045, 1_000.0045]
mt_files_separately: false
mt_income_tax_before_refundable_credits_unit: 2_000.009
# The joint rebate covers its full line 20: 2,000.009 - 2,000.009 = 0.
mt_income_tax: 0
Loading