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..2056115ed0e --- /dev/null +++ b/changelog.d/fix-mt-medical-credit-allocation.fixed.md @@ -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. 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..90f778844e2 --- /dev/null +++ b/policyengine_us/tests/core/test_mt_filing_choice_invariants.py @@ -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] + ) 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..8a06070d396 --- /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. +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, + ) 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_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..987b1ae6979 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_dependent_medical_expense_head_share.yaml @@ -0,0 +1,236 @@ +- 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 + employment_income: 10_000 + long_term_capital_gains: 10_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + employment_income: 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 + employment_income: 20_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + employment_income: 10_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 + +- 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 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..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 @@ -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: @@ -54,3 +66,140 @@ 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 + +- 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 new file mode 100644 index 00000000000..4ead91cdda6 --- /dev/null +++ b/policyengine_us/tools/mt_filing_choice.py @@ -0,0 +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, 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/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..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,30 +20,31 @@ 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 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% " - "floors, the total deduction does not depend on the share and the " - "couple's tax is convex in it, lowest where the columns balance. " + "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. " "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 " - "equal split, so the result does not depend on which spouse is " - "labelled head. The tax is evaluated in closed form with the column " + "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. 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." ) 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". @@ -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 @@ -102,19 +104,55 @@ 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 + # 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((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) 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..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 @@ -1,10 +1,20 @@ 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 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 definition_period = YEAR defined_for = StateCode.MT @@ -12,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 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 e5362277e0f..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 @@ -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 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 - 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