From bd93ff48892e34ce305856b346c55c1857f35295 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:03:29 -0500 Subject: [PATCH 1/9] Minimize Montana tax at capital gain credit boundaries Co-Authored-By: Claude Opus 5.5 --- .../fix-mt-medical-credit-allocation.fixed.md | 1 + .../core/test_mt_filing_choice_invariants.py | 90 +++++++++++ ...test_mt_medical_credit_split_properties.py | 153 ++++++++++++++++++ .../mt_income_tax_before_2021_rebate.yaml | 78 +++++++++ .../mt/tax/income/mt_files_separately.yaml | 53 ++++++ .../income/mt_medical_credit_allocation.yaml | 73 +++++++++ policyengine_us/tools/mt_filing_choice.py | 8 + ...mt_dependent_medical_expense_head_share.py | 48 +++++- .../tax/income/filing/mt_files_separately.py | 10 +- .../mt_income_tax_before_2021_rebate.py | 8 +- ...come_tax_before_refundable_credits_unit.py | 3 +- 11 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fix-mt-medical-credit-allocation.fixed.md create mode 100644 policyengine_us/tests/core/test_mt_filing_choice_invariants.py create mode 100644 policyengine_us/tests/core/test_mt_medical_credit_split_properties.py create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_income_tax_before_2021_rebate.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml create mode 100644 policyengine_us/tools/mt_filing_choice.py diff --git a/changelog.d/fix-mt-medical-credit-allocation.fixed.md b/changelog.d/fix-mt-medical-credit-allocation.fixed.md new file mode 100644 index 00000000000..575134f13be --- /dev/null +++ b/changelog.d/fix-mt-medical-credit-allocation.fixed.md @@ -0,0 +1 @@ +Minimized Montana's separate-column tax when a capital gain credit exhausts one column's liability, and kept joint filing when separate filing saves no more than one cent. diff --git a/policyengine_us/tests/core/test_mt_filing_choice_invariants.py b/policyengine_us/tests/core/test_mt_filing_choice_invariants.py new file mode 100644 index 00000000000..f9abb9e54b6 --- /dev/null +++ b/policyengine_us/tests/core/test_mt_filing_choice_invariants.py @@ -0,0 +1,90 @@ +"""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 when the savings do not exceed one cent. +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 + + +@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, + ) diff --git a/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py b/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py new file mode 100644 index 00000000000..af2a6620286 --- /dev/null +++ b/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py @@ -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. +For each generated couple, the chosen combined line 20 cannot exceed any grid +share, including when one or both capital gain credits exhaust a column's tax. +Swapping columns and adding dependent bills cannot raise the combined tax. +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): + # One cent covers the tie convention; the rest covers float32 income + # and medical-share arithmetic at the generated income limits. + tolerance = 0.03 + 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, + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_income_tax_before_2021_rebate.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_income_tax_before_2021_rebate.yaml new file mode 100644 index 00000000000..f452771839e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/rebate/mt_income_tax_before_2021_rebate.yaml @@ -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 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml index 59688877ab9..80168c9c46e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml @@ -54,3 +54,56 @@ state_code: MT output: mt_files_separately: false + +- name: Spouses file jointly when both paths have the same tax + period: 2022 + input: + state_code: MT + mt_income_tax_indiv: 100 + mt_income_tax_joint: 100 + output: + mt_files_separately: false + mt_income_tax: 100 + +- name: Spouses keep joint filing when separate filing saves less than one cent + period: 2022 + input: + state_code: MT + mt_income_tax_indiv: 99.991 + mt_income_tax_joint: 100 + output: + # 100 - 99.991 = 0.009, within the one-cent election tolerance. + mt_files_separately: false + mt_income_tax: 100 + +- name: Spouses keep joint filing when separate filing saves one cent + period: 2022 + input: + state_code: MT + mt_income_tax_indiv: 99.99 + mt_income_tax_joint: 100 + output: + # The one-cent boundary follows the same joint-filing tie preference. + mt_files_separately: false + mt_income_tax: 100 + +- name: Spouses file separately when the saving exceeds one cent + period: 2022 + input: + state_code: MT + mt_income_tax_indiv: 99.989 + mt_income_tax_joint: 100 + output: + # 100 - 99.989 = 0.011, beyond the one-cent election tolerance. + mt_files_separately: true + mt_income_tax: 99.989 + +- name: Spouses file jointly when the joint path saves less than one cent + period: 2022 + input: + state_code: MT + mt_income_tax_indiv: 100.009 + mt_income_tax_joint: 100 + output: + mt_files_separately: false + mt_income_tax: 100 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml new file mode 100644 index 00000000000..9b7d3e6876f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml @@ -0,0 +1,73 @@ +- name: Spouses move dependent bills until the head uses the capital gain credit + # TY2022: each spouse has AGI 20,000 and personal exemption 2,710. + # The child's exemption is 2,710; each medical floor is 1,500. + # Head credit: 10,000 x 2% = 200. Tax at 8,900 is 176, so the + # head can retain 8,900 + (200 - 176) / 4% = 9,500 of taxable income. + # Head bills: 20,000 - 2,710 - 2,710 + 1,500 - 9,500 = 6,580. + # Share: 6,580 / 25,000 = 0.2632. Spouse taxable income: + # 20,000 - 2,710 - (18,420 - 1,500) = 370; tax = 370 x 1% = 3.70. + # At an equal split, each taxable income is 4,935; the spouse owes + # 33 + (4,935 - 3,300) x 2% = 65.70, exceeding the minimum by 62. + period: 2022 + absolute_error_margin: 0.005 + input: + people: + head: + age: 40 + mt_agi_indiv: 20_000 + long_term_capital_gains: 10_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + mt_agi_indiv: 20_000 + mt_non_medical_itemized_deductions_indiv: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 25_000 + tax_units: + tax_unit: + members: [head, spouse, child] + mt_agi_joint: 40_000 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.2632 + mt_taxable_income_indiv: [9_500, 370, 0] + mt_income_tax_before_refundable_credits_indiv: [0, 3.70, 0] + mt_tax_unit_itemizes: true + +- name: Spouses move dependent bills until the spouse uses the capital gain credit + # Swap the credit recipient in the preceding computation. + period: 2022 + absolute_error_margin: 0.005 + input: + people: + head: + age: 40 + mt_agi_indiv: 20_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + mt_agi_indiv: 20_000 + long_term_capital_gains: 10_000 + mt_non_medical_itemized_deductions_indiv: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 25_000 + tax_units: + tax_unit: + members: [head, spouse, child] + mt_agi_joint: 40_000 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.7368 + mt_taxable_income_indiv: [370, 9_500, 0] + mt_income_tax_before_refundable_credits_indiv: [3.70, 0, 0] + mt_tax_unit_itemizes: true diff --git a/policyengine_us/tools/mt_filing_choice.py b/policyengine_us/tools/mt_filing_choice.py new file mode 100644 index 00000000000..b5938035064 --- /dev/null +++ b/policyengine_us/tools/mt_filing_choice.py @@ -0,0 +1,8 @@ +"""Compare Montana joint and separate filing elections.""" + +TAX_TOLERANCE = 0.01 + + +def mt_separate_filing_is_cheaper(separate_tax, joint_tax): + """Keep joint filing unless separate filing saves more than one cent.""" + return separate_tax < joint_tax - TAX_TOLERANCE diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py index 4a8b4c51087..25687e9c300 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py @@ -24,18 +24,18 @@ class mt_dependent_medical_expense_head_share(Variable): "the couple divide them in any proportion, so the model picks the " "share that gives the lowest combined tax before refundable credits " "(Form 2 line 20 in both columns), holding every other itemized " - "deduction fixed. It compares four candidates: the equal split " + "deduction fixed. It compares the equal split " "(spouse_allocation_rate), all to the head, all to the spouse, and " "the share at which the two columns' incomes before the dependent " - "exemptions are equal. While both columns are above their 7.5% " + "exemptions are equal, and the shares where each column exhausts " + "its capital gain credit. While both columns are above their 7.5% " "floors, the total deduction does not depend on the share and the " "couple's tax is convex in it, lowest where the columns balance. " "Where a column is below its floor, bills moved into it are wasted " "and the tax is monotone in the share, so the shares at which a " - "floor starts to bind never beat all to one spouse. The set reaches " - "the minimum unless a column's non-refundable credit (the capital " - "gain credit) holds its line 20 at zero, which can move the minimum " - "away from the balance point. Ties go to the share closest to the " + "floor starts to bind never beat all to one spouse. A column's " + "capital gain credit can move the minimum away from balance to " + "the point where the column exhausts its credit. Ties go to the share closest to the " "equal split, so the result does not depend on which spouse is " "labelled head. The tax is evaluated in closed form with the column " "variables' own formulas (policyengine_us.tools.mt_separate_return), " @@ -106,6 +106,42 @@ def couple_tax(head_share): np.full(tax_unit.count, 1.0), balance, ] + if not p.main.capital_gains.in_effect: + # A credit can hold the higher column's tax at zero. Add the + # shares where each column exhausts its credit. + schedule = p.main.separate + thresholds = np.asarray(schedule.thresholds, dtype=float) + tax_at_thresholds = schedule.calc(thresholds) + credits = person("mt_non_refundable_credits", period) + credit_income = np.interp(credits, tax_at_thresholds, thresholds) + credit_income += ( + max_(credits - tax_at_thresholds[-1], 0) / schedule.rates[-1] + ) + dependent_exemptions = tax_unit.sum( + person("mt_dependent_exemptions_person", period) + ) + balanced_income = max_( + (tax_unit.sum(level * head_or_spouse) - pool - dependent_exemptions) + / 2, + 0, + ) + # Dependent exemptions first reduce the higher column. A credit + # threshold above balanced taxable income needs the full + # dependent exemption added back. + required_income = credit_income + where( + credit_income > tax_unit.project(balanced_income), + tax_unit.project(dependent_exemptions), + 0, + ) + for column, is_head in [(head, True), (spouse, False)]: + allocation = ( + tax_unit.sum((level - required_income) * column) / safe_pool + ) + candidate = np.clip(allocation if is_head else 1 - allocation, 0, 1) + # Ignore zero credits to preserve the existing candidates + # when neither spouse receives a credit. + has_credit = tax_unit.sum(credits * column) > 0 + candidates.append(where(has_credit, candidate, default)) best_share = default best_tax = couple_tax(default) for candidate in candidates: diff --git a/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py b/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py index b948206e31a..75582e28df7 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py @@ -1,10 +1,18 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_filing_choice import mt_separate_filing_is_cheaper class mt_files_separately(Variable): value_type = bool entity = TaxUnit label = "married couple files separately on Montana tax return" + documentation = ( + "Married taxpayers may choose joint or separate filing on Montana Form 2. " + "PolicyEngine chooses separate filing when its combined tax falls " + "below the joint tax by more than one cent. PolicyEngine treats " + "savings of at most one cent as ties and keeps joint filing." + ) + reference = "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=11" unit = USD definition_period = YEAR defined_for = StateCode.MT @@ -14,5 +22,5 @@ def formula(tax_unit, period, parameters): if p.married_filing_separately_on_same_return_allowed: itax_indiv = add(tax_unit, period, ["mt_income_tax_indiv"]) itax_joint = tax_unit("mt_income_tax_joint", period) - return itax_indiv < itax_joint + return mt_separate_filing_is_cheaper(itax_indiv, itax_joint) return False diff --git a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py index e5362277e0f..dec1a1bed1c 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_filing_choice import mt_separate_filing_is_cheaper class mt_income_tax_before_2021_rebate(Variable): @@ -67,9 +68,12 @@ def formula(person, period, parameters): where=claimants > 0, ) # The couple files separately when doing so lowers the rebate-free - # line 20, mirroring mt_income_tax_before_refundable_credits_unit. + # line 20 by more than one cent, mirroring + # mt_income_tax_before_refundable_credits_unit. p = parameters(period).gov.states.mt.tax.income separate_allowed = p.married_filing_separately_on_same_return_allowed - cheaper_separately = person.tax_unit.sum(indiv) < joint + cheaper_separately = mt_separate_filing_is_cheaper( + person.tax_unit.sum(indiv), joint + ) files_separately = separate_allowed & cheaper_separately return head_or_spouse * where(files_separately, indiv, joint_share) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_refundable_credits_unit.py b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_refundable_credits_unit.py index b8b5f859be4..a37871699d2 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_refundable_credits_unit.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_refundable_credits_unit.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_filing_choice import mt_separate_filing_is_cheaper class mt_income_tax_before_refundable_credits_unit(Variable): @@ -14,5 +15,5 @@ def formula(tax_unit, period, parameters): indiv = add(tax_unit, period, ["mt_income_tax_before_refundable_credits_indiv"]) joint = tax_unit("mt_income_tax_before_refundable_credits_joint", period) if p.married_filing_separately_on_same_return_allowed: - return min_(indiv, joint) + return where(mt_separate_filing_is_cheaper(indiv, joint), indiv, joint) return joint From 0ee259d8d3c374806c1907ca3adb6ce66ba9d21f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:10:15 -0500 Subject: [PATCH 2/9] Test Montana medical allocation boundaries and defaults Co-Authored-By: Claude Opus 5.5 --- ..._dependent_medical_expense_head_share.yaml | 199 ++++++++++++++++++ .../income/mt_medical_credit_allocation.yaml | 73 ------- 2 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml delete mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml new file mode 100644 index 00000000000..94e5f0d4ed1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml @@ -0,0 +1,199 @@ +- name: Spouses move dependent bills until the head uses the capital gain credit + # TY2022: each spouse has AGI 20,000 and personal exemption 2,710. + # The child's exemption is 2,710; each medical floor is 1,500. + # Head credit: 10,000 x 2% = 200. Tax at 8,900 is 176, so the + # head can retain 8,900 + (200 - 176) / 4% = 9,500 of taxable income. + # Head bills: 20,000 - 2,710 - 2,710 + 1,500 - 9,500 = 6,580. + # Share: 6,580 / 25,000 = 0.2632. Spouse taxable income: + # 20,000 - 2,710 - (18,420 - 1,500) = 370; tax = 370 x 1% = 3.70. + # At an equal split, each taxable income is 4,935; the spouse owes + # 33 + (4,935 - 3,300) x 2% = 65.70, exceeding the minimum by 62. + period: 2022 + absolute_error_margin: 0.005 + input: + people: + head: + age: 40 + mt_agi_indiv: 20_000 + long_term_capital_gains: 10_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + mt_agi_indiv: 20_000 + mt_non_medical_itemized_deductions_indiv: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 25_000 + tax_units: + tax_unit: + members: [head, spouse, child] + mt_agi_joint: 40_000 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.2632 + mt_taxable_income_indiv: [9_500, 370, 0] + mt_income_tax_before_refundable_credits_indiv: [0, 3.70, 0] + mt_tax_unit_itemizes: true + +- name: Spouses move dependent bills until the spouse uses the capital gain credit + # Swap the credit recipient in the preceding computation. + period: 2022 + absolute_error_margin: 0.005 + input: + people: + head: + age: 40 + mt_agi_indiv: 20_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + mt_agi_indiv: 20_000 + long_term_capital_gains: 10_000 + mt_non_medical_itemized_deductions_indiv: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 25_000 + tax_units: + tax_unit: + members: [head, spouse, child] + mt_agi_joint: 40_000 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.7368 + mt_taxable_income_indiv: [370, 9_500, 0] + mt_income_tax_before_refundable_credits_indiv: [3.70, 0, 0] + mt_tax_unit_itemizes: true + +# Moving 2.50 of bills across the optimum changes the head tax by 0.10 +# and the spouse tax by 0.025, until the head tax reaches zero. +- name: A forced share of 0.2631 brackets the head credit exhaustion + period: 2022 + absolute_error_margin: 0.005 + input: + people: + head: + age: 40 + mt_agi_indiv: 20000 + long_term_capital_gains: 10000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + mt_agi_indiv: 20000 + mt_non_medical_itemized_deductions_indiv: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 25000 + tax_units: + tax_unit: + members: + - head + - spouse + - child + mt_agi_joint: 40000 + mt_dependent_medical_expense_head_share: 0.2631 + households: + household: + members: + - head + - spouse + - child + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.2631 + mt_taxable_income_indiv: + - 9502.5 + - 367.5 + - 0 + mt_income_tax_before_refundable_credits_indiv: + - 0.1 + - 3.675 + - 0 + mt_tax_unit_itemizes: true + +# Moving 2.50 of bills across the optimum changes the head tax by 0.10 +# and the spouse tax by 0.025, until the head tax reaches zero. +- name: A forced share of 0.2633 brackets the head credit exhaustion + period: 2022 + absolute_error_margin: 0.005 + input: + people: + head: + age: 40 + mt_agi_indiv: 20000 + long_term_capital_gains: 10000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + mt_agi_indiv: 20000 + mt_non_medical_itemized_deductions_indiv: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 25000 + tax_units: + tax_unit: + members: + - head + - spouse + - child + mt_agi_joint: 40000 + mt_dependent_medical_expense_head_share: 0.2633 + households: + household: + members: + - head + - spouse + - child + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.2633 + mt_taxable_income_indiv: + - 9497.5 + - 372.5 + - 0 + mt_income_tax_before_refundable_credits_indiv: + - 0 + - 3.725 + - 0 + mt_tax_unit_itemizes: true + +- name: The head share stays at its default after separate filing ends + period: 2024 + input: + state_code: MT + age: 40 + output: + mt_dependent_medical_expense_head_share: 0.5 +- name: The head share stays at its default without dependent bills + period: 2022 + input: + people: + head: + age: 40 + mt_agi_indiv: 20000 + long_term_capital_gains: 10000 + spouse: + age: 40 + mt_agi_indiv: 20000 + tax_units: + unit: + members: + - head + - spouse + households: + household: + members: + - head + - spouse + state_code: MT + output: + mt_dependent_medical_expense_head_share: 0.5 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml deleted file mode 100644 index 9b7d3e6876f..00000000000 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_medical_credit_allocation.yaml +++ /dev/null @@ -1,73 +0,0 @@ -- name: Spouses move dependent bills until the head uses the capital gain credit - # TY2022: each spouse has AGI 20,000 and personal exemption 2,710. - # The child's exemption is 2,710; each medical floor is 1,500. - # Head credit: 10,000 x 2% = 200. Tax at 8,900 is 176, so the - # head can retain 8,900 + (200 - 176) / 4% = 9,500 of taxable income. - # Head bills: 20,000 - 2,710 - 2,710 + 1,500 - 9,500 = 6,580. - # Share: 6,580 / 25,000 = 0.2632. Spouse taxable income: - # 20,000 - 2,710 - (18,420 - 1,500) = 370; tax = 370 x 1% = 3.70. - # At an equal split, each taxable income is 4,935; the spouse owes - # 33 + (4,935 - 3,300) x 2% = 65.70, exceeding the minimum by 62. - period: 2022 - absolute_error_margin: 0.005 - input: - people: - head: - age: 40 - mt_agi_indiv: 20_000 - long_term_capital_gains: 10_000 - mt_non_medical_itemized_deductions_indiv: 0 - spouse: - age: 40 - mt_agi_indiv: 20_000 - mt_non_medical_itemized_deductions_indiv: 0 - child: - age: 10 - is_tax_unit_dependent: true - other_medical_expenses: 25_000 - tax_units: - tax_unit: - members: [head, spouse, child] - mt_agi_joint: 40_000 - households: - household: - members: [head, spouse, child] - state_code: MT - output: - mt_dependent_medical_expense_head_share: 0.2632 - mt_taxable_income_indiv: [9_500, 370, 0] - mt_income_tax_before_refundable_credits_indiv: [0, 3.70, 0] - mt_tax_unit_itemizes: true - -- name: Spouses move dependent bills until the spouse uses the capital gain credit - # Swap the credit recipient in the preceding computation. - period: 2022 - absolute_error_margin: 0.005 - input: - people: - head: - age: 40 - mt_agi_indiv: 20_000 - mt_non_medical_itemized_deductions_indiv: 0 - spouse: - age: 40 - mt_agi_indiv: 20_000 - long_term_capital_gains: 10_000 - mt_non_medical_itemized_deductions_indiv: 0 - child: - age: 10 - is_tax_unit_dependent: true - other_medical_expenses: 25_000 - tax_units: - tax_unit: - members: [head, spouse, child] - mt_agi_joint: 40_000 - households: - household: - members: [head, spouse, child] - state_code: MT - output: - mt_dependent_medical_expense_head_share: 0.7368 - mt_taxable_income_indiv: [370, 9_500, 0] - mt_income_tax_before_refundable_credits_indiv: [3.70, 0, 0] - mt_tax_unit_itemizes: true From f053b6c9065436ed90edd0b2778399889c893b96 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:16:29 -0500 Subject: [PATCH 3/9] Preserve Montana allocation choices without capital gain credits Co-Authored-By: Claude Opus 5.5 --- .../mt_dependent_medical_expense_head_share.yaml | 8 ++++---- .../mt_dependent_medical_expense_head_share.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml index 94e5f0d4ed1..3b72a881968 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml @@ -14,12 +14,12 @@ people: head: age: 40 - mt_agi_indiv: 20_000 + employment_income: 10_000 long_term_capital_gains: 10_000 mt_non_medical_itemized_deductions_indiv: 0 spouse: age: 40 - mt_agi_indiv: 20_000 + employment_income: 20_000 mt_non_medical_itemized_deductions_indiv: 0 child: age: 10 @@ -47,11 +47,11 @@ people: head: age: 40 - mt_agi_indiv: 20_000 + employment_income: 20_000 mt_non_medical_itemized_deductions_indiv: 0 spouse: age: 40 - mt_agi_indiv: 20_000 + employment_income: 10_000 long_term_capital_gains: 10_000 mt_non_medical_itemized_deductions_indiv: 0 child: diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py index 25687e9c300..8745a324299 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py @@ -102,9 +102,9 @@ def couple_tax(head_share): gap = tax_unit.sum(level * head) - tax_unit.sum(level * spouse) balance = np.clip(0.5 + gap / (2 * safe_pool), 0, 1) candidates = [ - np.full(tax_unit.count, 0.0), - np.full(tax_unit.count, 1.0), - balance, + (np.full(tax_unit.count, 0.0), True), + (np.full(tax_unit.count, 1.0), True), + (balance, True), ] if not p.main.capital_gains.in_effect: # A credit can hold the higher column's tax at zero. Add the @@ -141,16 +141,16 @@ def couple_tax(head_share): # Ignore zero credits to preserve the existing candidates # when neither spouse receives a credit. has_credit = tax_unit.sum(credits * column) > 0 - candidates.append(where(has_credit, candidate, default)) + candidates.append((candidate, has_credit)) best_share = default best_tax = couple_tax(default) - for candidate in candidates: + for candidate, eligible in candidates: tax = couple_tax(candidate) lower = tax < best_tax - TAX_TOLERANCE tied_and_closer = (np.abs(tax - best_tax) <= TAX_TOLERANCE) & ( np.abs(candidate - equal_split) < np.abs(best_share - equal_split) ) - take = lower | tied_and_closer + take = eligible & (lower | tied_and_closer) best_share = where(take, candidate, best_share) best_tax = where(take, tax, best_tax) married = tax_unit("tax_unit_married", period) From 63892990286b581b0ad4a74061c4eff40a9366e0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:19:43 -0500 Subject: [PATCH 4/9] Clarify Montana allocation comparison tolerances Co-Authored-By: Claude Opus 5.5 --- .../test_mt_medical_credit_split_properties.py | 6 +++--- .../mt_dependent_medical_expense_head_share.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py b/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py index af2a6620286..3c410ada780 100644 --- a/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py +++ b/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py @@ -109,9 +109,9 @@ def _check(cases, year): 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): - # One cent covers the tie convention; the rest covers float32 income - # and medical-share arithmetic at the generated income limits. - tolerance = 0.03 + # 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() diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py index 8745a324299..5c980a7f2ed 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py @@ -29,15 +29,16 @@ class mt_dependent_medical_expense_head_share(Variable): "the share at which the two columns' incomes before the dependent " "exemptions are equal, and the shares where each column exhausts " "its capital gain credit. While both columns are above their 7.5% " - "floors, the total deduction does not depend on the share and the " - "couple's tax is convex in it, lowest where the columns balance. " + "floors, the total deduction does not depend on the share. Without " + "binding credits, the couple's tax is convex in the share and " + "lowest where the columns balance. " "Where a column is below its floor, bills moved into it are wasted " "and the tax is monotone in the share, so the shares at which a " "floor starts to bind never beat all to one spouse. A column's " "capital gain credit can move the minimum away from balance to " "the point where the column exhausts its credit. Ties go to the share closest to the " - "equal split, so the result does not depend on which spouse is " - "labelled head. The tax is evaluated in closed form with the column " + "equal split. The comparisons allow numerical tolerance. " + "The tax is evaluated in closed form with the column " "variables' own formulas (policyengine_us.tools.mt_separate_return), " "not with simulation branches. Unmarried filers and couples without " "dependents' expenses get the equal split, which has no effect." @@ -80,8 +81,9 @@ def couple_tax(head_share): # The share at which the two columns' incomes before the dependent # exemptions are equal. While both columns are above their 7.5% # floors, each extra dollar of bills is deductible in either column, - # so the total deduction is fixed and the couple's tax is convex in - # the share, lowest where the columns balance. There a column's + # so the total deduction is fixed. Without binding credits, the + # couple's tax is convex in the share, lowest where columns balance. + # There a column's # income is level - share x pool, where level is its income with line # 1a at its floor (room is how far line 1a is below the floor); the # spouse's uses 1 - share. Where a column is below its floor, bills From 02375ef7ef5d4cb7b3ce5465d8c48d7501875fa9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 01:36:23 -0500 Subject: [PATCH 5/9] Check Montana filing tolerance for unmarried filers Co-Authored-By: Claude Opus 5.5 --- .../states/mt/tax/income/mt_files_separately.yaml | 12 ++++++++++++ .../mt_dependent_medical_expense_head_share.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml index 80168c9c46e..38d1933e613 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml @@ -1,3 +1,15 @@ +- name: An unmarried filer keeps the joint path when rounding lowers the individual tax + # The 0.009 difference falls below the one-cent comparison tolerance. + period: 2022 + input: + state_code: MT + age: 40 + mt_income_tax_indiv: 99.991 + mt_income_tax_joint: 100 + output: + mt_files_separately: false + mt_income_tax: 100 + - name: Joint over individual income period: 2021 input: diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py index 5c980a7f2ed..30f99e21ff5 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py @@ -44,7 +44,7 @@ class mt_dependent_medical_expense_head_share(Variable): "dependents' expenses get the equal split, which has no effect." ) reference = ( - # PDF pages 11, 32-33: "Allocation of deductions" and line 1. + # PDF pages 11, 32-33, 43: deduction allocation, line 1, capital gain credit. "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=11", # ARM 42.15.524(3)(b): on the same form, a deduction not clearly # attributable to one spouse "may be divided as provided on the form". From fce0a03eafc0e75fdef4a07a43a8ab4a4671983a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 02:25:43 -0500 Subject: [PATCH 6/9] Allow float32 roundoff when comparing Montana filing taxes Co-Authored-By: Claude Opus 5.5 --- .../core/test_mt_filing_choice_invariants.py | 33 +++++++- .../mt/tax/income/mt_files_separately.yaml | 84 +++++++++++++++++++ policyengine_us/tools/mt_filing_choice.py | 20 ++++- .../tax/income/filing/mt_files_separately.py | 12 ++- .../mt_income_tax_before_2021_rebate.py | 2 +- 5 files changed, 142 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/core/test_mt_filing_choice_invariants.py b/policyengine_us/tests/core/test_mt_filing_choice_invariants.py index f9abb9e54b6..90f778844e2 100644 --- a/policyengine_us/tests/core/test_mt_filing_choice_invariants.py +++ b/policyengine_us/tests/core/test_mt_filing_choice_invariants.py @@ -1,7 +1,7 @@ """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 when the savings do not exceed one cent. +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. """ @@ -10,6 +10,7 @@ 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) @@ -88,3 +89,33 @@ def test_filing_ties_and_rebate_base_agree(columns, year): 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] + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml index 38d1933e613..70390f306a5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_files_separately.yaml @@ -119,3 +119,87 @@ output: mt_files_separately: false mt_income_tax: 100 + +- name: Spouses keep joint filing when column rounding changes a one-cent tie + period: 2022 + input: + people: + head: + mt_income_tax_indiv: 0.375 + spouse: + mt_income_tax_indiv: 0.115 + tax_units: + tax_unit: + members: [head, spouse] + mt_income_tax_joint: 0.5 + households: + household: + members: [head, spouse] + state_code: MT + output: + # 0.5 - (0.375 + 0.115) = 0.01; preserve the nominal tie after float32 inputs. + mt_files_separately: false + mt_income_tax: 0.5 + +- name: Spouses keep joint filing when both paths round a one-cent tie upward + period: 2022 + input: + people: + head: + mt_income_tax_indiv: 69.96 + spouse: + mt_income_tax_indiv: 81.77 + tax_units: + tax_unit: + members: [head, spouse] + mt_income_tax_joint: 151.74 + households: + household: + members: [head, spouse] + state_code: MT + output: + # 151.74 - (69.96 + 81.77) = 0.01; both totals carry float32 rounding. + mt_files_separately: false + mt_income_tax: 151.74 + +- name: Spouses file separately when two columns save more than the roundoff tolerance + period: 2022 + input: + people: + head: + mt_income_tax_indiv: 0.375 + spouse: + mt_income_tax_indiv: 0.114 + tax_units: + tax_unit: + members: [head, spouse] + mt_income_tax_joint: 0.5 + households: + household: + members: [head, spouse] + state_code: MT + output: + # 0.5 - (0.375 + 0.114) = 0.011, above one cent plus float32 roundoff. + mt_files_separately: true + mt_income_tax: 0.489 + +- name: Spouses keep joint filing when refundable credits offset the columns at a one-cent tie + period: 2022 + input: + people: + head: + mt_income_tax_indiv: 69.95 + spouse: + mt_income_tax_indiv: -69.96 + tax_units: + tax_unit: + members: [head, spouse] + mt_income_tax_joint: 0 + households: + household: + members: [head, spouse] + state_code: MT + output: + # 0 - (69.95 - 69.96) = 0.01; count both columns' rounding exposure. + mt_files_separately: false + mt_income_tax: 0 diff --git a/policyengine_us/tools/mt_filing_choice.py b/policyengine_us/tools/mt_filing_choice.py index b5938035064..4ead91cdda6 100644 --- a/policyengine_us/tools/mt_filing_choice.py +++ b/policyengine_us/tools/mt_filing_choice.py @@ -1,8 +1,22 @@ """Compare Montana joint and separate filing elections.""" +import numpy as np + TAX_TOLERANCE = 0.01 -def mt_separate_filing_is_cheaper(separate_tax, joint_tax): - """Keep joint filing unless separate filing saves more than one cent.""" - return separate_tax < joint_tax - TAX_TOLERANCE +def mt_separate_filing_is_cheaper(separate_tax, joint_tax, separate_tax_magnitude=None): + """Require savings above one cent plus float32 roundoff.""" + separate = np.asarray(separate_tax, dtype=np.float64) + joint = np.asarray(joint_tax, dtype=np.float64) + magnitude = ( + np.abs(separate) + if separate_tax_magnitude is None + else np.asarray(separate_tax_magnitude, dtype=np.float64) + ) + # Core stores each column and the joint tax in float32, then sums the + # separate columns in float64. Allow their float32 input rounding before + # comparing the savings in float64. After refundable credits, pass the + # sum of absolute columns so offsetting taxes do not hide rounding. + roundoff = np.finfo(np.float32).eps * np.maximum(magnitude, np.abs(joint)) + return joint - separate > TAX_TOLERANCE + roundoff diff --git a/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py b/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py index 75582e28df7..7c0d809cbf5 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/filing/mt_files_separately.py @@ -9,8 +9,10 @@ class mt_files_separately(Variable): documentation = ( "Married taxpayers may choose joint or separate filing on Montana Form 2. " "PolicyEngine chooses separate filing when its combined tax falls " - "below the joint tax by more than one cent. PolicyEngine treats " - "savings of at most one cent as ties and keeps joint filing." + "below the joint tax by more than one cent plus a float32 roundoff " + "allowance. The allowance equals float32 precision times the larger " + "of the separate columns' summed absolute taxes and the absolute " + "joint tax. PolicyEngine keeps joint filing within that tolerance." ) reference = "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=11" unit = USD @@ -20,7 +22,9 @@ class mt_files_separately(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.mt.tax.income if p.married_filing_separately_on_same_return_allowed: - itax_indiv = add(tax_unit, period, ["mt_income_tax_indiv"]) + columns = tax_unit.members("mt_income_tax_indiv", period) + itax_indiv = tax_unit.sum(columns) itax_joint = tax_unit("mt_income_tax_joint", period) - return mt_separate_filing_is_cheaper(itax_indiv, itax_joint) + magnitude = tax_unit.sum(np.abs(columns)) + return mt_separate_filing_is_cheaper(itax_indiv, itax_joint, magnitude) return False diff --git a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py index dec1a1bed1c..40012709630 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_income_tax_before_2021_rebate.py @@ -68,7 +68,7 @@ def formula(person, period, parameters): where=claimants > 0, ) # The couple files separately when doing so lowers the rebate-free - # line 20 by more than one cent, mirroring + # line 20 by more than one cent plus float32 roundoff, mirroring # mt_income_tax_before_refundable_credits_unit. p = parameters(period).gov.states.mt.tax.income separate_allowed = p.married_filing_separately_on_same_return_allowed From b500a9efc9ae19beda29195bfa26a29200e6597a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 02:28:26 -0500 Subject: [PATCH 7/9] Describe the rounding allowance in Montana filing elections Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-mt-medical-credit-allocation.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-mt-medical-credit-allocation.fixed.md b/changelog.d/fix-mt-medical-credit-allocation.fixed.md index 575134f13be..2056115ed0e 100644 --- a/changelog.d/fix-mt-medical-credit-allocation.fixed.md +++ b/changelog.d/fix-mt-medical-credit-allocation.fixed.md @@ -1 +1 @@ -Minimized Montana's separate-column tax when a capital gain credit exhausts one column's liability, and kept joint filing when separate filing saves no more than one cent. +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. From a496a936187b718972c54ba2ddb712d0f487b510 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 02:34:46 -0500 Subject: [PATCH 8/9] Clarify the Montana medical split comparison and test range Co-Authored-By: Claude Opus 5.5 --- .../core/test_mt_medical_credit_split_properties.py | 6 +++--- .../medical/mt_dependent_medical_expense_head_share.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py b/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py index 3c410ada780..8a06070d396 100644 --- a/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py +++ b/policyengine_us/tests/core/test_mt_medical_credit_split_properties.py @@ -1,9 +1,9 @@ """Compare Montana's chosen medical split with an independent share grid. Form 2 permits spouses to divide jointly paid dependent bills in any proportion. -For each generated couple, the chosen combined line 20 cannot exceed any grid -share, including when one or both capital gain credits exhaust a column's tax. -Swapping columns and adding dependent bills cannot raise the combined tax. +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. """ diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py index 30f99e21ff5..b397ccdeefd 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py @@ -20,15 +20,15 @@ class mt_dependent_medical_expense_head_share(Variable): "The share of the dependents' other medical expenses (Itemized " "Deductions Schedule line 1a) placed in the head's column when spouses " "file separately on the same form; the spouse's column takes the " - "rest. For bills paid from accounts both spouses own, the form lets " - "the couple divide them in any proportion, so the model picks the " - "share that gives the lowest combined tax before refundable credits " + "rest. Spouses may divide bills paid from their jointly owned " + "accounts in any proportion. The model picks the share with the " + "lowest combined tax before refundable credits " "(Form 2 line 20 in both columns), holding every other itemized " "deduction fixed. It compares the equal split " "(spouse_allocation_rate), all to the head, all to the spouse, and " "the share at which the two columns' incomes before the dependent " - "exemptions are equal, and the shares where each column exhausts " - "its capital gain credit. While both columns are above their 7.5% " + "exemptions are equal. It also compares the shares where each " + "column exhausts its capital gain credit. While both columns are above their 7.5% " "floors, the total deduction does not depend on the share. Without " "binding credits, the couple's tax is convex in the share and " "lowest where the columns balance. " From 5c0126eb3c6604e08d7eec6e3e7d708044dbcf4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 12 Nov 2026 02:47:15 -0500 Subject: [PATCH 9/9] Check the Montana medical allocation when both credits are zero Co-Authored-By: Claude Opus 5.5 --- ..._dependent_medical_expense_head_share.yaml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml index 3b72a881968..987b1ae6979 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml @@ -197,3 +197,40 @@ state_code: MT output: mt_dependent_medical_expense_head_share: 0.5 + +- name: Zero credits retain the former balancing candidate when dependent exemptions cover both columns + period: 2022 + absolute_error_margin: 0.000001 + input: + people: + head: + age: 40 + is_tax_unit_head: true + mt_agi_indiv: 30_000 + mt_non_medical_itemized_deductions_indiv: 0 + mt_non_refundable_credits: 0 + spouse: + age: 40 + is_tax_unit_spouse: true + mt_agi_indiv: 10_000 + mt_non_medical_itemized_deductions_indiv: 0 + mt_non_refundable_credits: 0 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 36_000 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: MT + output: + # Levels are 30,000 - 2,710 + 2,250 = 29,540 and + # 10,000 - 2,710 + 750 = 8,040. The former balancing share is + # 0.5 + (29,540 - 8,040) / 72,000 = 0.7986111111. + # Each pre-dependent income is 790, so the child's 2,710 exemption + # leaves both columns at zero. A zero-credit kink at 0.7766666667 + # also gives zero tax but must not replace the former candidate. + mt_dependent_medical_expense_head_share: 0.7986111111