From d60afaacca1a8d9e515a7a514de529eb68bf7cd0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 09:49:31 -0400 Subject: [PATCH 01/14] Count dependents' medical bills in Montana's separate-filing deduction Montana's 2021-2023 separate-filing medical deduction read each spouse's own other_medical_expenses, so a dependent's bills (recorded on the person cared for) reached neither spouse. Add mt_medical_and_dental_expenses_indiv, Itemized Deductions Schedule line 1a in each column: the spouse's own expenses plus an equal share of the dependents' (spouse_allocation_rate, the model's convention for itemized deductions not clearly attributable to one spouse; ARM 42.15.524(3)(b) and the Form 2 instructions let the couple divide them in any proportion). An unmarried filer's column holds all of them. Co-Authored-By: Claude Opus 5.5 --- ...ix-mt-separate-medical-dependents.fixed.md | 1 + .../mt_medical_and_dental_expenses_indiv.py | 50 +++++++++++++++++++ .../mt_medical_expense_deduction_indiv.py | 21 ++++---- 3 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fix-mt-separate-medical-dependents.fixed.md create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py diff --git a/changelog.d/fix-mt-separate-medical-dependents.fixed.md b/changelog.d/fix-mt-separate-medical-dependents.fixed.md new file mode 100644 index 00000000000..a96ec48d60e --- /dev/null +++ b/changelog.d/fix-mt-separate-medical-dependents.fixed.md @@ -0,0 +1 @@ +Count dependents' other medical expenses in Montana's 2021-2023 separate-filing medical deduction, dividing them equally between spouses filing separately on the same form; an unmarried filer's column holds all of them. diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py new file mode 100644 index 00000000000..c8ca2ceb3c0 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py @@ -0,0 +1,50 @@ +from policyengine_us.model_api import * + + +class mt_medical_and_dental_expenses_indiv(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Montana medical and dental expenses in each column when married couples are filing separately" + documentation = ( + "Itemized Deductions Schedule line 1a in a filer's column: medical " + "and dental expenses other than insurance premiums. Other medical " + "expenses are recorded on the person cared for, including amounts " + "other household members paid on that person's behalf, so the " + "inputs do not say which spouse paid a dependent's bills. Each " + "spouse's own expenses stay in that spouse's column. A dependent's " + "expenses are divided equally between spouses filing separately on " + "the same form, the model's convention for itemized deductions not " + "clearly attributable to one spouse; the form lets the couple divide " + "them in any proportion. An unmarried filer's column holds every " + "dependent's expenses. The head's and spouse's columns together hold " + "every member's other medical expenses, as the joint return's line 1 " + "does." + ) + reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + # PDF pages 11, 32. Page 11, "Allocation of deductions": payments + # from accounts owned by both spouses for expenses not attributable + # specifically to one spouse "can be allocated to either spouse in + # any proportional amount". Page 32: line 1 instructions. + "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)(a)-(b), in effect 2010-2024 (repealed effective + # Jan. 25, 2025): a deduction clearly attributable to one spouse is + # claimed only by that spouse; on the same form, one not clearly + # attributable to one spouse "may be divided as provided on the form". + "https://web.archive.org/web/20201030190413/http://www.mtrules.org/gateway/ruleno.asp?RN=42.15.524", + # MCA 2021 § 15-30-2131(1)(a) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + ) + unit = USD + defined_for = "mt_married_filing_separately_on_same_return_eligible" + + def formula(person, period, parameters): + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + expense = person("other_medical_expenses", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + dependent_expense = person.tax_unit.sum(expense * ~head_or_spouse) + married = person.tax_unit("tax_unit_married", period) + head = person("is_tax_unit_head", period) + share = where(married, head_or_spouse * p.spouse_allocation_rate, head) + return head_or_spouse * expense + share * dependent_expense diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py index 3237224cb20..eca0ee4e7ec 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py @@ -21,21 +21,22 @@ class mt_medical_expense_deduction_indiv(Variable): documentation = ( "Each spouse's medical deduction when filing separately on the same " "form. While the Itemized Deductions Schedule applies (2021-2023), " - "line 1 floors the spouse's medical and dental expenses other than " - "premiums at 7.5% of their Montana AGI, and lines 2 and 3 add their " - "medical and long-term care insurance premiums in full, net of their " - "self-employed health insurance deduction. If a reform allows " - "separate filing in a later year, the premiums net of that deduction " - "stay under the floor with the other expenses. Premium inputs use " - "payer attribution: report all premiums paid by a person on that " - "person, including family coverage regardless of the covered " - "beneficiary." + "line 1 floors the medical and dental expenses other than premiums " + "in the spouse's column (mt_medical_and_dental_expenses_indiv: their " + "own expenses and their share of the dependents') at 7.5% of their " + "Montana AGI, and lines 2 and 3 add their medical and long-term care " + "insurance premiums in full, net of their self-employed health " + "insurance deduction. If a reform allows separate filing in a later " + "year, the premiums net of that deduction stay under the floor with " + "the other expenses. Premium inputs use payer attribution: report " + "all premiums paid by a person on that person, including family " + "coverage regardless of the covered beneficiary." ) def formula(person, period, parameters): p = parameters(period).gov.states.mt.tax.income.deductions.itemized floor = parameters(period).gov.irs.deductions.itemized.medical.floor - expense = person("other_medical_expenses", period) + expense = person("mt_medical_and_dental_expenses_indiv", period) # Law does not define Montana AGI as the cap. # Tax form points to page 1, line 14, which is Montana AGI. medical_floor = floor * person("mt_agi_indiv", period) From 01fd6e5c6a571dc2d5e41fbf29044c18012df9f2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 10:35:40 -0400 Subject: [PATCH 02/14] Test Montana's line 1a division of dependents' medical expenses YAML cases fail on main: a couple with a child's expenses under both head/spouse labellings, a head of household, and the 2024 reform branch. The invariants test states the line 1a closed form and conservation (the head's and spouse's columns sum to every member's other medical expenses), uses it in the separate-variant accounting identity, and adds a head-of-household differential against the joint variant. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_and_dental_expenses_indiv.yaml | 97 ++++++++++++++ .../mt_medical_expense_deduction_indiv.yaml | 124 ++++++++++++++++++ ...mt_medical_premium_deduction_invariants.py | 64 ++++++++- 3 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml new file mode 100644 index 00000000000..436f8b4684f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml @@ -0,0 +1,97 @@ +- name: Spouses filing separately on the same form each take half of every dependent's expenses + # Two tax units with the same people label the first adult head and then + # spouse; the columns do not depend on the label. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + a_first: + age: 50 + is_tax_unit_head: true + a_second: + age: 48 + is_tax_unit_spouse: true + other_medical_expenses: 700 + a_child1: + age: 17 + is_tax_unit_dependent: true + other_medical_expenses: 3_000 + a_child2: + age: 12 + is_tax_unit_dependent: true + other_medical_expenses: 1_000 + b_first: + age: 50 + is_tax_unit_spouse: true + b_second: + age: 48 + is_tax_unit_head: true + other_medical_expenses: 700 + b_child1: + age: 17 + is_tax_unit_dependent: true + other_medical_expenses: 3_000 + b_child2: + age: 12 + is_tax_unit_dependent: true + other_medical_expenses: 1_000 + tax_units: + a: + members: [a_first, a_second, a_child1, a_child2] + b: + members: [b_second, b_first, b_child1, b_child2] + households: + a: + members: [a_first, a_second, a_child1, a_child2] + state_code: MT + b: + members: [b_second, b_first, b_child1, b_child2] + state_code: MT + output: + # The dependents' 4,000 is divided equally: 2,000 to each column. The + # columns hold 2,000 + 2,700 = 4,700, every member's expenses. + mt_medical_and_dental_expenses_indiv: [2_000, 2_700, 0, 0, 2_000, 2_700, 0, 0] + +- name: An unmarried filer's column holds the dependent's expenses + period: 2022 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + other_medical_expenses: 100 + child: + age: 6 + is_tax_unit_dependent: true + other_medical_expenses: 900 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MT + output: + mt_medical_and_dental_expenses_indiv: [1_000, 0] + +- name: No column amount once Montana ends separate filing on the same form + period: 2024 + input: + people: + parent: + age: 35 + other_medical_expenses: 100 + child: + age: 6 + is_tax_unit_dependent: true + other_medical_expenses: 900 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: MT + output: + mt_married_filing_separately_on_same_return_eligible: [false, false] + mt_medical_and_dental_expenses_indiv: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml index c3cc0f15446..9d3542e1e52 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml @@ -170,3 +170,127 @@ # give 0 + 4,000 + 1,000 = 5,000. mt_married_filing_separately_on_same_return_eligible: true mt_medical_expense_deduction_indiv: 1_500 + +- name: A dependent's medical expenses are divided equally between spouses filing separately on the same form + # Other medical expenses sit on the person cared for, so the child's 6,000 + # is in neither spouse's own input. Two tax units with the same people label + # the earner head and then spouse; each column's amount does not depend on + # the label. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + a_earner: + age: 45 + is_tax_unit_head: true + other_medical_expenses: 1_000 + mt_agi_indiv: 60_000 + a_partner: + age: 45 + is_tax_unit_spouse: true + other_medical_expenses: 500 + mt_agi_indiv: 20_000 + a_child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 6_000 + b_earner: + age: 45 + is_tax_unit_spouse: true + other_medical_expenses: 1_000 + mt_agi_indiv: 60_000 + b_partner: + age: 45 + is_tax_unit_head: true + other_medical_expenses: 500 + mt_agi_indiv: 20_000 + b_child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 6_000 + tax_units: + a: + members: [a_earner, a_partner, a_child] + b: + members: [b_partner, b_earner, b_child] + households: + a: + members: [a_earner, a_partner, a_child] + state_code: MT + b: + members: [b_partner, b_earner, b_child] + state_code: MT + output: + # Line 1a: earner 1,000 + 6,000 / 2 = 4,000; partner 500 + 3,000 = 3,500. + mt_medical_and_dental_expenses_indiv: [4_000, 3_500, 0, 4_000, 3_500, 0] + # Earner: max(0, 4,000 - 7.5% x 60,000) = max(0, 4,000 - 4,500) = 0. + # Partner: 3,500 - 7.5% x 20,000 = 2,000. Leaving out the child's + # expenses would give max(0, 500 - 1,500) = 0. + mt_medical_expense_deduction_indiv: [0, 2_000, 0, 0, 2_000, 0] + +- name: An unmarried filer's column holds every dependent's medical expenses + period: 2023 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_tax_unit_head: true + other_medical_expenses: 500 + mt_agi_indiv: 30_000 + child1: + age: 8 + is_tax_unit_dependent: true + other_medical_expenses: 2_000 + child2: + age: 5 + is_tax_unit_dependent: true + other_medical_expenses: 1_500 + tax_units: + tax_unit: + members: [parent, child1, child2] + mt_agi_joint: 30_000 + households: + household: + members: [parent, child1, child2] + state_code: MT + output: + mt_medical_and_dental_expenses_indiv: [4_000, 0, 0] + # Line 1: (500 + 2,000 + 1,500) - 7.5% x 30,000 = 4,000 - 2,250 = 1,750, + # as on the single-column return. Leaving out the children's expenses + # would give max(0, 500 - 2,250) = 0. + mt_medical_expense_deduction_indiv: [1_750, 0, 0] + mt_medical_expense_deduction_joint: [1_750, 0, 0] + +- name: A reform that allows separate filing in 2024 also divides a dependent's expenses + period: 2024 + reforms: policyengine_us.tests.policy.baseline.gov.states.mt.tax.income.enable_mt_mfss_2024_reform.enable_mt_mfss_2024_reform + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + health_insurance_premiums: 2_000 + mt_agi_indiv: 40_000 + person2: + age: 45 + is_tax_unit_spouse: true + mt_agi_indiv: 10_000 + person3: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 4_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + mt_medical_and_dental_expenses_indiv: [2_000, 2_000, 0] + # Premiums stay under the floor with the other expenses from 2024. + # Head: (2,000 + 2,000) - 7.5% x 40,000 = 1,000. + # Spouse: 2,000 - 7.5% x 10,000 = 1,250. + mt_medical_expense_deduction_indiv: [1_000, 1_250, 0] diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index a5a90d8dceb..7e062bfc82a 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -37,9 +37,9 @@ 1. Accounting (2021-2023): the joint deduction is max(0, other expenses - 7.5% x Montana AGI) + lines 2 and 3, held by the - head. The separate variant is max(0, own other expenses - 7.5% x own - Montana AGI) + own lines 2 and 3 for the head and spouse; dependents hold - nothing. + head. The separate variant is max(0, line 1a in the column (property 10) + - 7.5% x own Montana AGI) + own lines 2 and 3 for the head and spouse; + dependents hold nothing. 2. Premium conservation against closed forms. Per-person helpers: line 2 + line 3 = max(0, premiums + long-term care premiums - own deduction), and line 2 = premiums - min(premiums, own @@ -65,6 +65,17 @@ exclusion of all premiums the self-employed deduction took (medical first), and the long-term care premiums still inside gives the same Montana deduction as the computed path. +10. Line 1a on separate returns (2021-2023): other medical expenses sit on + the person cared for, so a dependent's are not clearly attributable to + one spouse. Each spouse's column holds their own other expenses plus + half of the dependents' (the model's equal division; the form allows any + proportion); an unmarried head's column holds all of the dependents'. + Conservation: the head's and spouse's columns sum to every member's + other medical expenses, the joint return's line 1 base. Dependents hold + nothing. +11. Differential (2021-2023): for heads of household whose deduction is + recorded per person, the joint and separate variants agree, dependents' + expenses included. """ import numpy as np @@ -79,6 +90,9 @@ FORM_FLOOR = 0.075 # Itemized Deductions Schedule line 1c STATE_YEARS = [2021, 2022, 2023] FEDERAL_YEAR = 2024 +# Each spouse's share of a dependent's other medical expenses on separate +# returns: the model's equal division (property 10). +SPOUSE_SHARE = 0.5 YEARS = STATE_YEARS + [FEDERAL_YEAR] MODES = ["derived", "person", "unit"] VARIANTS = ["base", "raised", "no_pre_tax"] @@ -300,6 +314,7 @@ def _situation(units, year, mode, unit_inputs=None): PERSON_VARIABLES = [ "other_medical_expenses", + "mt_medical_and_dental_expenses_indiv", "long_term_health_insurance_premiums", "medical_expense_health_insurance_premiums", "self_employed_health_insurance_ald_person", @@ -360,6 +375,7 @@ def _check(units, year, mode): deduction, run["medical_expense_deduction"], atol=TOLERANCE ) assert (indiv == 0).all() + assert (run["mt_medical_and_dental_expenses_indiv"] == 0).all() return None premium = run["medical_expense_health_insurance_premiums"] @@ -409,11 +425,26 @@ def _check(units, year, mode): full = run["mt_premium_deduction_joint"] np.testing.assert_allclose(full, expected_joint_premiums, atol=TOLERANCE) + # 10. Line 1a in each separate column, from the inputs' roles. + unit = run["unit"] + married = _unit_sum(run, role == "spouse") > 0 + share = np.where(married[unit], filer * SPOUSE_SHARE, role == "head") + dependent_other = _unit_sum(run, ~filer * other) + expected_line_1a = filer * other + share * dependent_other[unit] + line_1a = run["mt_medical_and_dental_expenses_indiv"] + np.testing.assert_allclose(line_1a, expected_line_1a, atol=TOLERANCE) + np.testing.assert_allclose( + _unit_sum(run, line_1a), _unit_sum(run, other), atol=TOLERANCE + ) + assert (line_1a[~filer] == 0).all() + # 1. Accounting, joint and separate. floored = np.maximum(_unit_sum(run, other) - FORM_FLOOR * run["mt_agi_joint"], 0) np.testing.assert_allclose(deduction, floored + full, atol=TOLERANCE) expected_indiv = filer * ( - np.maximum(other - FORM_FLOOR * run["mt_agi_indiv"], 0) + line_2 + line_3 + np.maximum(expected_line_1a - FORM_FLOOR * run["mt_agi_indiv"], 0) + + line_2 + + line_3 ) np.testing.assert_allclose(indiv, expected_indiv, atol=TOLERANCE) @@ -459,6 +490,16 @@ def _check(units, year, mode): np.testing.assert_allclose( deduction[single], _unit_sum(run, indiv)[single], atol=TOLERANCE ) + + # 11. So do heads of household, whose columns hold their dependents' + # expenses. + if mode != "unit": + head_of_household = run["filing_status"] == "HEAD_OF_HOUSEHOLD" + np.testing.assert_allclose( + deduction[head_of_household], + _unit_sum(run, indiv)[head_of_household], + atol=TOLERANCE, + ) return run, floored, full @@ -521,6 +562,21 @@ def test_seeded_population(year, mode): assert np.any((full > 0) & (floored > 0)) assert np.any(run["filing_status"] == "SINGLE") assert np.any(run["filing_status"] == "JOINT") + # Properties 10 and 11 bind: dependents' expenses raise the separate + # line 1 of some spouse, some married head and some head of household. + role = run["role"] + filer = role != "dependent" + unit = run["unit"] + agi_floor_indiv = FORM_FLOOR * run["mt_agi_indiv"] + own_line_1 = filer * np.maximum(run["other_medical_expenses"] - agi_floor_indiv, 0) + line_1 = filer * np.maximum( + run["mt_medical_and_dental_expenses_indiv"] - agi_floor_indiv, 0 + ) + raised = line_1 > own_line_1 + 1 + head_of_household = (run["filing_status"] == "HEAD_OF_HOUSEHOLD")[unit] + assert np.any(raised & (role == "spouse")) + assert np.any(raised & (role == "head") & ~head_of_household) + assert np.any(raised & (role == "head") & head_of_household) deduction = run["self_employed_health_insurance_ald_person"] premiums = run["medical_expense_health_insurance_premiums"] long_term_care = run["long_term_health_insurance_premiums"] From b352a51acb1bd523756ec2f4acedd75a75d5372d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 14:39:15 -0400 Subject: [PATCH 03/14] Address review r1: pool scope, references and a non-dependent case Document that line 1a divides every member other than the head and spouse (so the columns hold every member's expenses, as the joint line 1 does) and test it with a member who is neither spouse nor tax unit dependent. Cite the 2022 instructions' pages 11 and 32-33, state ARM 42.15.524(3)'s federal-return precondition, and name the charitable split the equal division follows. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_and_dental_expenses_indiv.yaml | 27 +++++++++++++++++++ .../mt_medical_and_dental_expenses_indiv.py | 23 ++++++++-------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml index 436f8b4684f..0672624f86d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml @@ -95,3 +95,30 @@ output: mt_married_filing_separately_on_same_return_eligible: [false, false] mt_medical_and_dental_expenses_indiv: [0, 0] + +- name: A member who is neither spouse nor a tax unit dependent is divided too + # The joint return's line 1 counts every member's other medical expenses, + # so the columns divide every member other than the head and spouse. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 40 + is_tax_unit_spouse: true + relative: + age: 70 + is_tax_unit_dependent: false + other_medical_expenses: 1_000 + tax_units: + tax_unit: + members: [head, spouse, relative] + households: + household: + members: [head, spouse, relative] + state_code: MT + output: + mt_medical_and_dental_expenses_indiv: [500, 500, 0] diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py index c8ca2ceb3c0..43db4d2020d 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py @@ -14,24 +14,25 @@ class mt_medical_and_dental_expenses_indiv(Variable): "inputs do not say which spouse paid a dependent's bills. Each " "spouse's own expenses stay in that spouse's column. A dependent's " "expenses are divided equally between spouses filing separately on " - "the same form, the model's convention for itemized deductions not " - "clearly attributable to one spouse; the form lets the couple divide " - "them in any proportion. An unmarried filer's column holds every " - "dependent's expenses. The head's and spouse's columns together hold " - "every member's other medical expenses, as the joint return's line 1 " - "does." + "the same form (spouse_allocation_rate, the split the model uses for " + "the charitable deduction); the form lets the couple divide them in " + "any proportion. An unmarried filer's column holds every " + "dependent's expenses. The pool is every member other than the head " + "and spouse, so the head's and spouse's columns together hold every " + "member's other medical expenses, as the joint return's line 1 does." ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", - # PDF pages 11, 32. Page 11, "Allocation of deductions": payments + # PDF pages 11, 32-33. Page 11, "Allocation of deductions": payments # from accounts owned by both spouses for expenses not attributable # specifically to one spouse "can be allocated to either spouse in - # any proportional amount". Page 32: line 1 instructions. + # any proportional amount". Pages 32-33: line 1 instructions. "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)(a)-(b), in effect 2010-2024 (repealed effective - # Jan. 25, 2025): a deduction clearly attributable to one spouse is - # claimed only by that spouse; on the same form, one not clearly - # attributable to one spouse "may be divided as provided on the form". + # Jan. 25, 2025), for spouses who filed a joint federal return or + # none: a deduction clearly attributable to one spouse is claimed + # only by that spouse; on the same form, one not clearly attributable + # to one spouse "may be divided as provided on the form". "https://web.archive.org/web/20201030190413/http://www.mtrules.org/gateway/ruleno.asp?RN=42.15.524", # MCA 2021 § 15-30-2131(1)(a) "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", From 65036f16159b613a3e3f3ab92650cd927c5bd5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:26:00 +0000 Subject: [PATCH 04/14] Share the Montana status 2a column formulas as functions The split of dependents' medical bills and the itemize election need the couple's tax under other deductions, without simulation branches. Moving the column formulas (dependent exemption allocation, rate schedule, capital gains tax, medical deduction) into tools/mt_separate_return.py lets the variables and those choices run the same code. No change in results. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tools/mt_separate_return.py | 199 ++++++++++++++++++ ...al_gains_tax_applicable_threshold_indiv.py | 27 +-- .../mt_capital_gains_tax_indiv.py | 65 +----- .../mt_regular_income_tax_indiv.py | 30 +-- .../mt_taxable_income_indiv.py | 39 +--- 5 files changed, 218 insertions(+), 142 deletions(-) create mode 100644 policyengine_us/tools/mt_separate_return.py diff --git a/policyengine_us/tools/mt_separate_return.py b/policyengine_us/tools/mt_separate_return.py new file mode 100644 index 00000000000..00d6a4fe485 --- /dev/null +++ b/policyengine_us/tools/mt_separate_return.py @@ -0,0 +1,199 @@ +"""Montana separate filing on the same form (status 2a), column by column. + +The functions below are the formulas of the status 2a column variables, taking +the amount a step depends on as an argument. The variables call them, and so +do the tax unit's choices that must compare the couple's tax under different +deductions without simulation branches: the split of a dependent's medical +bills (mt_dependent_medical_expense_head_share) and the itemize election +(mt_tax_unit_itemizes). Because both paths run the same code, the tax a choice +compares is the tax the columns then compute. +""" + +import numpy as np + +from policyengine_core.model_api import max_, min_, select, where + + +def mt_medical_deduction(person, period, parameters, line_1a): + """Each column's medical deduction given its line 1a amount. + + While the Itemized Deductions Schedule applies (2021-2023), line 1 floors + the medical and dental expenses other than premiums (line 1a) at 7.5% of + the column's Montana AGI, and lines 2 and 3 add the medical and long-term + care insurance premiums in full. In a later year (a reform allowing + separate filing) the premiums stay under the floor with the other + expenses. Zero for members other than the head and spouse. + """ + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + floor = parameters(period).gov.irs.deductions.itemized.medical.floor + medical_floor = floor * person("mt_agi_indiv", period) + premiums = person("mt_eligible_medical_insurance_premiums", period) + if p.state_specific_deduction_applies: + long_term_care_premiums = person( + "mt_eligible_long_term_care_insurance_premiums", period + ) + deduction = ( + max_(0, line_1a - medical_floor) + premiums + long_term_care_premiums + ) + else: + deduction = max_(0, premiums + line_1a - medical_floor) + return person("is_tax_unit_head_or_spouse", period) * deduction + + +def mt_medical_floor_room(person, period, parameters): + """How much more line 1a each column can take before its floor binds. + + The 7.5% floor less what is already under it in the column: the column's + own other medical expenses, and from 2024 its premiums too. Negative when + the column is already above its floor. + """ + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + floor = parameters(period).gov.irs.deductions.itemized.medical.floor + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + under_floor = head_or_spouse * person("other_medical_expenses", period) + if not p.state_specific_deduction_applies: + under_floor = under_floor + person( + "mt_eligible_medical_insurance_premiums", period + ) + return floor * person("mt_agi_indiv", period) - under_floor + + +def mt_allocate_dependent_exemptions(person, period, pre_dependent_exemption_income): + """Taxable income in each column after the dependent exemptions. + + The dependent exemptions are allocated optimally between the head and + spouse: first to the column with the larger income, up to the difference + between the two, and the rest halved. + """ + dependent_exemptions = person.tax_unit.sum( + person("mt_dependent_exemptions_person", period) + ) + head = person("is_tax_unit_head", period) + spouse = person("is_tax_unit_spouse", period) + head_income = person.tax_unit.sum(pre_dependent_exemption_income * head) + spouse_income = person.tax_unit.sum(pre_dependent_exemption_income * spouse) + # Cap the exemption amount initially at the difference between the head + # and spouse income, and allocate it to the spouse with the larger income. + income_difference = np.abs(head_income - spouse_income) + capped = min_(income_difference, dependent_exemptions) + larger = where(head_income > spouse_income, head, spouse) + # The remaining exemption amount is halved between the head and spouse. + halved_remaining = (dependent_exemptions - capped) / 2 + return max_(pre_dependent_exemption_income - capped * larger - halved_remaining, 0) + + +def _filing_status(person, period): + filing_status = person.tax_unit( + "state_filing_status_if_married_filing_separately_on_same_return", period + ) + return filing_status, filing_status.possible_values + + +def _net_long_term_capital_gain(person, period): + # Montana Form 2 line 2 uses the federal net long-term capital gain + # amount, which is limited by any short-term capital losses. + ltcg = person("long_term_capital_gains", period) + stcg = person("short_term_capital_gains", period) + return max_(min_(ltcg, ltcg + stcg), 0) + + +def mt_regular_tax(person, period, parameters, taxable_income): + """Each column's tax on taxable income from the rate schedule.""" + p = parameters(period).gov.states.mt.tax.income.main + if p.capital_gains.in_effect: + taxable_income = max_( + taxable_income - _net_long_term_capital_gain(person, period), 0 + ) + filing_status, status = _filing_status(person, period) + return select( + [ + filing_status == status.SINGLE, + filing_status == status.HEAD_OF_HOUSEHOLD, + filing_status == status.SEPARATE, + filing_status == status.SURVIVING_SPOUSE, + ], + [ + p.single.calc(taxable_income), + p.head_of_household.calc(taxable_income), + p.separate.calc(taxable_income), + p.surviving_spouse.calc(taxable_income), + ], + ) + + +def mt_capital_gains_threshold(person, period, parameters, taxable_income): + """The part of the lower capital gains rate band left after ordinary income.""" + p = parameters(period).gov.states.mt.tax.income.main.capital_gains + capital_gains = _net_long_term_capital_gain(person, period) + filing_status, status = _filing_status(person, period) + non_qualified_income = max_(taxable_income - capital_gains, 0) + rate_threshold = select( + [ + filing_status == status.SINGLE, + filing_status == status.SEPARATE, + filing_status == status.HEAD_OF_HOUSEHOLD, + filing_status == status.SURVIVING_SPOUSE, + ], + [ + p.rates.single.thresholds[-1], + p.rates.separate.thresholds[-1], + p.rates.head_of_household.thresholds[-1], + p.rates.surviving_spouse.thresholds[-1], + ], + ) + return max_(rate_threshold - non_qualified_income, 0) + + +def mt_capital_gains_tax(person, period, parameters, applicable_threshold): + """Each column's tax on net long-term capital gains (from 2024).""" + p = parameters(period).gov.states.mt.tax.income.main.capital_gains + if not p.in_effect: + return 0 + capital_gains = _net_long_term_capital_gain(person, period) + filing_status, status = _filing_status(person, period) + statuses = [ + filing_status == status.SINGLE, + filing_status == status.SEPARATE, + filing_status == status.SURVIVING_SPOUSE, + filing_status == status.HEAD_OF_HOUSEHOLD, + ] + schedules = [ + p.rates.single, + p.rates.separate, + p.rates.surviving_spouse, + p.rates.head_of_household, + ] + lower_rate = select(statuses, [s.amounts[0] for s in schedules]) + higher_rate = select(statuses, [s.amounts[-1] for s in schedules]) + below = min_(applicable_threshold, capital_gains) + above = max_(capital_gains - applicable_threshold, 0) + return max_(below * lower_rate + above * higher_rate, 0) + + +def mt_separate_tax_before_refundable_credits(tax_unit, period, parameters, deductions): + """The couple's combined status 2a tax before refundable credits. + + deductions is each person's deduction (the head's and spouse's columns; + zero for other members). This follows the column variables from + mt_pre_dependent_exemption_taxable_income_indiv to + mt_income_tax_before_refundable_credits_indiv (Form 2 line 20 in each + column) and sums the columns. Refundable credits do not depend on the + deductions, except the 2021 income tax rebate, which is capped at line + 20 and so is left out of the comparison. + """ + person = tax_unit.members + agi = person("mt_agi_indiv", period) + exemptions = person("mt_personal_exemptions_indiv", period) + pre_dependent_exemption_income = max_(agi - exemptions - deductions, 0) + taxable_income = mt_allocate_dependent_exemptions( + person, period, pre_dependent_exemption_income + ) + regular_tax = mt_regular_tax(person, period, parameters, taxable_income) + threshold = mt_capital_gains_threshold(person, period, parameters, taxable_income) + capital_gains_tax = mt_capital_gains_tax(person, period, parameters, threshold) + non_refundable_credits = person("mt_non_refundable_credits", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + line_20 = head_or_spouse * max_( + regular_tax + capital_gains_tax - non_refundable_credits, 0 + ) + return tax_unit.sum(line_20) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_applicable_threshold_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_applicable_threshold_indiv.py index 1d9edfb6543..7b20059f3c8 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_applicable_threshold_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_applicable_threshold_indiv.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import mt_capital_gains_threshold class mt_capital_gains_tax_applicable_threshold_indiv(Variable): @@ -11,29 +12,5 @@ class mt_capital_gains_tax_applicable_threshold_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - p = parameters(period).gov.states.mt.tax.income.main.capital_gains - ltcg = person("long_term_capital_gains", period) - stcg = person("short_term_capital_gains", period) - capital_gains = max_(min_(ltcg, ltcg + stcg), 0) taxable_income = person("mt_taxable_income_indiv", period) - filing_status = person.tax_unit( - "state_filing_status_if_married_filing_separately_on_same_return", - period, - ) - status = filing_status.possible_values - non_qualified_income = max_(taxable_income - capital_gains, 0) - rate_threshold = select( - [ - filing_status == status.SINGLE, - filing_status == status.SEPARATE, - filing_status == status.HEAD_OF_HOUSEHOLD, - filing_status == status.SURVIVING_SPOUSE, - ], - [ - p.rates.single.thresholds[-1], - p.rates.separate.thresholds[-1], - p.rates.head_of_household.thresholds[-1], - p.rates.surviving_spouse.thresholds[-1], - ], - ) - return max_(rate_threshold - non_qualified_income, 0) + return mt_capital_gains_threshold(person, period, parameters, taxable_income) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_indiv.py index 9b19609c8c6..1dfb302a59e 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/capital_gains/mt_capital_gains_tax_indiv.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import mt_capital_gains_tax class mt_capital_gains_tax_indiv(Variable): @@ -13,60 +14,10 @@ class mt_capital_gains_tax_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - p = parameters(period).gov.states.mt.tax.income.main.capital_gains - # the tax for capital gains comes into effect after 2024 - if p.in_effect: - ltcg = person("long_term_capital_gains", period) - stcg = person("short_term_capital_gains", period) - net_cg = ltcg + stcg - capital_gains = max_(min_(ltcg, net_cg), 0) - filing_status = person.tax_unit( - "state_filing_status_if_married_filing_separately_on_same_return", - period, - ) - applicable_threshold = person( - "mt_capital_gains_tax_applicable_threshold_indiv", period - ) - status = filing_status.possible_values - - lower_rate = select( - [ - filing_status == status.SINGLE, - filing_status == status.SEPARATE, - filing_status == status.SURVIVING_SPOUSE, - filing_status == status.HEAD_OF_HOUSEHOLD, - ], - [ - p.rates.single.amounts[0], - p.rates.separate.amounts[0], - p.rates.surviving_spouse.amounts[0], - p.rates.head_of_household.amounts[0], - ], - ) - higher_rate = select( - [ - filing_status == status.SINGLE, - filing_status == status.SEPARATE, - filing_status == status.SURVIVING_SPOUSE, - filing_status == status.HEAD_OF_HOUSEHOLD, - ], - [ - p.rates.single.amounts[-1], - p.rates.separate.amounts[-1], - p.rates.surviving_spouse.amounts[-1], - p.rates.head_of_household.amounts[-1], - ], - ) - # Calculate taxes - capital_gains_below_threshold = min_(applicable_threshold, capital_gains) - capital_gains_above_threshold = max_( - capital_gains - applicable_threshold, 0 - ) - - lower_capital_gains_tax = capital_gains_below_threshold * lower_rate - higher_capital_gains_tax = capital_gains_above_threshold * higher_rate - - return max_(lower_capital_gains_tax + higher_capital_gains_tax, 0) - # Only apply tax if capital gains are positive - - return 0 + # The tax on capital gains comes into effect in 2024. + if not parameters(period).gov.states.mt.tax.income.main.capital_gains.in_effect: + return 0 + applicable_threshold = person( + "mt_capital_gains_tax_applicable_threshold_indiv", period + ) + return mt_capital_gains_tax(person, period, parameters, applicable_threshold) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_regular_income_tax_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_regular_income_tax_indiv.py index abdebdf6e12..fd94ee08684 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_regular_income_tax_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_regular_income_tax_indiv.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import mt_regular_tax class mt_regular_income_tax_indiv(Variable): @@ -10,32 +11,5 @@ class mt_regular_income_tax_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - p = parameters(period).gov.states.mt.tax.income.main taxable_income = person("mt_taxable_income_indiv", period) - filing_status = person.tax_unit( - "state_filing_status_if_married_filing_separately_on_same_return", - period, - ) - if p.capital_gains.in_effect: - ltcg = person("long_term_capital_gains", period) - stcg = person("short_term_capital_gains", period) - net_cg = ltcg + stcg - # Montana Form 2 line 2 uses the federal net long-term capital gain - # amount, which is limited by any short-term capital losses. - cg_to_subtract = max_(min_(ltcg, net_cg), 0) - taxable_income = max_(taxable_income - cg_to_subtract, 0) - status = filing_status.possible_values - return select( - [ - filing_status == status.SINGLE, - filing_status == status.HEAD_OF_HOUSEHOLD, - filing_status == status.SEPARATE, - filing_status == status.SURVIVING_SPOUSE, - ], - [ - p.single.calc(taxable_income), - p.head_of_household.calc(taxable_income), - p.separate.calc(taxable_income), - p.surviving_spouse.calc(taxable_income), - ], - ) + return mt_regular_tax(person, period, parameters, taxable_income) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_taxable_income_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_taxable_income_indiv.py index 4bdc14bf29b..0ec79430286 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_taxable_income_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/tax_calculation/mt_taxable_income_indiv.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import ( + mt_allocate_dependent_exemptions, +) class mt_taxable_income_indiv(Variable): @@ -17,36 +20,8 @@ def formula(person, period, parameters): pre_dependent_exemption_income = person( "mt_pre_dependent_exemption_taxable_income_indiv", period ) - dependent_exemptions = add( - person.tax_unit, period, ["mt_dependent_exemptions_person"] - ) - # The dependent exemptions are allocated optimally between the head and spouse - head = person("is_tax_unit_head", period) - spouse = person("is_tax_unit_spouse", period) - head_income = person.tax_unit.sum(pre_dependent_exemption_income * head) - spouse_income = person.tax_unit.sum(pre_dependent_exemption_income * spouse) - # Calculate the difference between the head and spouse income - income_difference = np.abs(head_income - spouse_income) - # Cap the exemption amount initially at the difference between the head and spouse income - exemptions_capped_at_income_difference = min_( - income_difference, dependent_exemptions - ) - head_income_exceeds_spouse_income = head_income > spouse_income - # The capped exemptions are allocated to the spouse with the larger income - spouse_with_larger_income = where( - head_income_exceeds_spouse_income, head, spouse - ) - exemptions_capped_at_income_difference_allocated_to_spouse_with_larger_income = ( - exemptions_capped_at_income_difference * spouse_with_larger_income - ) - # The remaining exemption amount is halved and allocated between the head and spouse - remaining_exemptions = ( - dependent_exemptions - exemptions_capped_at_income_difference - ) - halved_remaining_exemptions = remaining_exemptions / 2 - return max_( - pre_dependent_exemption_income - - exemptions_capped_at_income_difference_allocated_to_spouse_with_larger_income - - halved_remaining_exemptions, - 0, + # The dependent exemptions are allocated optimally between the head + # and spouse (mt_allocate_dependent_exemptions). + return mt_allocate_dependent_exemptions( + person, period, pre_dependent_exemption_income ) From 3dfd4a7333d61825cd56507a1776cabfc16673ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:26:00 +0000 Subject: [PATCH 05/14] Split dependents' medical bills to minimize the couple's Montana tax Under status 2a the couple may divide jointly paid bills in any proportion. mt_dependent_medical_expense_head_share evaluates the couple's line 20 in closed form at the equal split, all to either spouse, and the two shares where a column's floor starts to bind, and takes the lowest, with ties going to the equal split. Every dependent bill is treated as jointly paid, since the model has no payer data. The non-medical itemized deductions become their own variable so the choice can hold them fixed. Property tests check the chosen tax against every candidate, the standard deduction and the old result, label swaps, and more bills. Co-Authored-By: Claude Opus 5.5 --- ...ix-mt-separate-medical-dependents.fixed.md | 2 +- .../mt_medical_and_dental_expenses_indiv.yaml | 7 +- .../mt_medical_expense_deduction_indiv.yaml | 49 +++- ...t_mt_dependent_medical_split_invariants.py | 257 ++++++++++++++++++ ...mt_medical_premium_deduction_invariants.py | 22 +- .../general/mt_itemized_deductions_indiv.py | 36 +-- ...t_non_medical_itemized_deductions_indiv.py | 56 ++++ ...mt_dependent_medical_expense_head_share.py | 99 +++++++ .../mt_medical_and_dental_expenses_indiv.py | 24 +- .../mt_medical_expense_deduction_indiv.py | 25 +- 10 files changed, 498 insertions(+), 79 deletions(-) create mode 100644 policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_non_medical_itemized_deductions_indiv.py create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py diff --git a/changelog.d/fix-mt-separate-medical-dependents.fixed.md b/changelog.d/fix-mt-separate-medical-dependents.fixed.md index a96ec48d60e..736e9e0b6ff 100644 --- a/changelog.d/fix-mt-separate-medical-dependents.fixed.md +++ b/changelog.d/fix-mt-separate-medical-dependents.fixed.md @@ -1 +1 @@ -Count dependents' other medical expenses in Montana's 2021-2023 separate-filing medical deduction, dividing them equally between spouses filing separately on the same form; an unmarried filer's column holds all of them. +Count dependents' other medical expenses in Montana's 2021-2023 separate-filing medical deduction, allocating them between spouses filing separately on the same form to minimize the couple's tax; an unmarried filer's column holds all of them. The separate path now chooses between itemizing and the standard deduction by the couple's tax rather than by summed deductions. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml index 0672624f86d..253996f6cde 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_and_dental_expenses_indiv.yaml @@ -1,6 +1,8 @@ -- name: Spouses filing separately on the same form each take half of every dependent's expenses +- name: With no income, every split gives the same tax, so the tie goes to the equal split # Two tax units with the same people label the first adult head and then - # spouse; the columns do not depend on the label. + # spouse; the columns do not depend on the label. The couple has no income, + # so the combined tax is zero at every candidate share and the tie goes to + # the share closest to the equal split. period: 2021 absolute_error_margin: 0.01 input: @@ -48,6 +50,7 @@ members: [b_second, b_first, b_child1, b_child2] state_code: MT output: + mt_dependent_medical_expense_head_share: [0.5, 0.5] # The dependents' 4,000 is divided equally: 2,000 to each column. The # columns hold 2,000 + 2,700 = 4,700, every member's expenses. mt_medical_and_dental_expenses_indiv: [2_000, 2_700, 0, 0, 2_000, 2_700, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml index 9d3542e1e52..e7b4e6885e3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml @@ -171,11 +171,12 @@ mt_married_filing_separately_on_same_return_eligible: true mt_medical_expense_deduction_indiv: 1_500 -- name: A dependent's medical expenses are divided equally between spouses filing separately on the same form +- name: A dependent's medical expenses go to the column where they lower the couple's tax most # Other medical expenses sit on the person cared for, so the child's 6,000 # is in neither spouse's own input. Two tax units with the same people label # the earner head and then spouse; each column's amount does not depend on - # the label. + # the label. The couple may divide the child's bills in any proportion, and + # the model takes the candidate share with the lowest combined tax. period: 2022 absolute_error_margin: 0.01 input: @@ -221,12 +222,27 @@ members: [b_partner, b_earner, b_child] state_code: MT output: - # Line 1a: earner 1,000 + 6,000 / 2 = 4,000; partner 500 + 3,000 = 3,500. - mt_medical_and_dental_expenses_indiv: [4_000, 3_500, 0, 4_000, 3_500, 0] - # Earner: max(0, 4,000 - 7.5% x 60,000) = max(0, 4,000 - 4,500) = 0. - # Partner: 3,500 - 7.5% x 20,000 = 2,000. Leaving out the child's - # expenses would give max(0, 500 - 1,500) = 0. - mt_medical_expense_deduction_indiv: [0, 2_000, 0, 0, 2_000, 0] + # Floors: earner 7.5% x 60,000 = 4,500; partner 7.5% x 20,000 = 1,500. + # Taxable income before the medical deduction (2022 exemptions of 2,710 + # each; the child's goes to the earner, the larger income): earner + # 60,000 - 5,420 = 54,580, at the 6.75% rate; partner 20,000 - 2,710 = + # 17,290, in the 6% bracket from 15,400 and 5% from 12,000. + # Tax saved by the medical deduction at each candidate share to the + # earner: + # all to the partner: partner 500 + 6,000 - 1,500 = 5,000, taking + # 17,290 to 12,290: 1,890 x 6% + 3,110 x 5% = 268.90; + # all to the earner: earner 1,000 + 6,000 - 4,500 = 2,500 x 6.75% = + # 168.75; + # equal split: partner 3,500 - 1,500 = 2,000: 1,890 x 6% + 110 x 5% = + # 118.90; earner 4,000 is under the floor; + # the floors' kinks (shares 3,500 / 6,000 and 1 - 1,000 / 6,000): 90.00 + # and 101.25. + # So all of the child's bills go to the partner's column. + mt_dependent_medical_expense_head_share: [0, 1] + mt_medical_and_dental_expenses_indiv: [1_000, 6_500, 0, 1_000, 6_500, 0] + # Earner: max(0, 1,000 - 4,500) = 0. Partner: 6,500 - 1,500 = 5,000. + # Leaving out the child's expenses would give max(0, 500 - 1,500) = 0. + mt_medical_expense_deduction_indiv: [0, 5_000, 0, 0, 5_000, 0] - name: An unmarried filer's column holds every dependent's medical expenses period: 2023 @@ -262,7 +278,7 @@ mt_medical_expense_deduction_indiv: [1_750, 0, 0] mt_medical_expense_deduction_joint: [1_750, 0, 0] -- name: A reform that allows separate filing in 2024 also divides a dependent's expenses +- name: A reform that allows separate filing in 2024 also allocates a dependent's expenses period: 2024 reforms: policyengine_us.tests.policy.baseline.gov.states.mt.tax.income.enable_mt_mfss_2024_reform.enable_mt_mfss_2024_reform absolute_error_margin: 0.01 @@ -289,8 +305,15 @@ members: [person1, person2, person3] state_code: MT output: - mt_medical_and_dental_expenses_indiv: [2_000, 2_000, 0] # Premiums stay under the floor with the other expenses from 2024. - # Head: (2,000 + 2,000) - 7.5% x 40,000 = 1,000. - # Spouse: 2,000 - 7.5% x 10,000 = 1,250. - mt_medical_expense_deduction_indiv: [1_000, 1_250, 0] + # Floors: head 7.5% x 40,000 = 3,000; spouse 7.5% x 10,000 = 750. 2024 + # separate rates: 4.7% to 20,500 and 5.9% above, so the head's column is + # taxed at 5.9% and the spouse's at 4.7%. Tax saved at each candidate: + # all to the head: (2,000 + 4,000) - 3,000 = 3,000 x 5.9% = 177.00; + # all to the spouse: 4,000 - 750 = 3,250 x 4.7% = 152.75; + # equal split: 1,000 x 5.9% + 1,250 x 4.7% = 117.75; + # the floors' kinks (shares 1,000 / 4,000 and 1 - 750 / 4,000): + # 2,250 x 4.7% = 105.75 and 2,250 x 5.9% = 132.75. + mt_dependent_medical_expense_head_share: 1 + mt_medical_and_dental_expenses_indiv: [4_000, 0, 0] + mt_medical_expense_deduction_indiv: [3_000, 0, 0] diff --git a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py new file mode 100644 index 00000000000..97852dd3021 --- /dev/null +++ b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py @@ -0,0 +1,257 @@ +"""Invariants for Montana's tax-minimizing split of dependents' medical bills. + +On a 2021-2023 return where spouses file separately on the same form (status +2a), the couple may divide bills paid from a joint account that are not +attributable to one spouse in any proportion, and may elect the standard +deduction (both spouses or neither). The model treats every dependent's bill +as jointly paid, takes the head's share of those bills +(mt_dependent_medical_expense_head_share) from a candidate set to minimize the +couple's combined tax before refundable credits (Form 2 line 20 in both +columns), and picks the election (mt_tax_unit_itemizes) by the same tax. + +For every generated couple: + +1. The chosen tax is at most the tax at every candidate share when itemizing: + all to the head, all to the spouse, the equal split, and the shares at + which each column's 7.5% floor starts to bind (computed here from the + inputs, not read from the model). +2. The chosen tax is at most the tax with the standard deduction in both + columns. +3. The couple's Montana tax (mt_income_tax) is at most its tax before this + change, which counted no dependent bills and itemized when the summed + itemized deductions exceeded the summed standard deductions. +4. Swapping which spouse is labelled head leaves the couple's tax unchanged. +5. More dependent bills never raise the couple's tax. + +Properties 3 to 5 use mt_income_tax in 2022 and 2023. In 2021 they use line +20, because the 2021 income tax rebate is capped at line 20 in each column +and is left out of the choice. + +Why Python rather than YAML: the properties compare generated couples with +their own variants (other shares, the other election, swapped labels, more +bills). The YAML cases pin worked examples. + +Montana AGI and the other itemized deductions are inputs, so the federal +return does not enter. Core treats a formula variable supplied for some tax +units as supplied for all of them, so the forced shares and elections run in +a second simulation in which every tax unit has them. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +# Dollars; the model computes in float32 and incomes reach $150,000. +TOLERANCE = 0.05 +FORM_FLOOR = 0.075 # Itemized Deductions Schedule line 1c +YEARS = [2021, 2022, 2023] +EQUAL_SPLIT = 0.5 + +money = st.integers(0, 150_000).map(float) +bills = st.one_of(st.just(0.0), st.integers(1, 40_000).map(float)) +small = st.one_of(st.just(0.0), st.integers(1, 12_000).map(float)) + + +@st.composite +def couples(draw): + return { + "agi": [draw(money), draw(money)], + "own_medical": [draw(small), draw(small)], + "other_itemized": [draw(small), draw(small)], + "dependent_bills": [draw(bills) for _ in range(draw(st.integers(1, 2)))], + "more_bills": draw(st.integers(1, 10_000).map(float)), + } + + +def _kinks(couple, pool): + """The head shares at which each column's floor starts to bind.""" + room = [FORM_FLOOR * couple["agi"][i] - couple["own_medical"][i] for i in range(2)] + if pool <= 0: + return EQUAL_SPLIT, EQUAL_SPLIT + head = min(max(room[0] / pool, 0.0), 1.0) + spouse = min(max(1 - room[1] / pool, 0.0), 1.0) + return head, spouse + + +def _situation(entries, year): + """entries: (tag, couple, swapped, bills, unit inputs) per tax unit.""" + people, tax_units, households, marital_units = {}, {}, {}, {} + for tag, couple, swapped, dependent_bills, unit_inputs in entries: + filers = [] + for i in (1, 0) if swapped else (0, 1): + name = f"{tag}_filer{i}" + people[name] = { + "age": {year: 40}, + "is_tax_unit_head": {year: i == (1 if swapped else 0)}, + "is_tax_unit_spouse": {year: i == (0 if swapped else 1)}, + "mt_agi_indiv": {year: couple["agi"][i]}, + "other_medical_expenses": {year: couple["own_medical"][i]}, + "mt_non_medical_itemized_deductions_indiv": { + year: couple["other_itemized"][i] + }, + } + filers.append(name) + members = list(filers) + for j, amount in enumerate(dependent_bills): + name = f"{tag}_child{j}" + people[name] = { + "age": {year: 10}, + "is_tax_unit_dependent": {year: True}, + "other_medical_expenses": {year: amount}, + } + members.append(name) + marital_units[f"{name}_alone"] = {"members": [name]} + marital_units[f"{tag}_couple"] = {"members": filers} + unit = { + "members": members, + "mt_agi_joint": {year: sum(couple["agi"])}, + } + unit.update({k: {year: v} for k, v in unit_inputs.items()}) + tax_units[tag] = unit + households[tag] = {"members": members, "state_code": {year: "MT"}} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households, + } + + +def _taxes(entries, year): + sim = Simulation(situation=_situation(entries, year)) + unit = sim.populations["tax_unit"].members_entity_id + n = len(entries) + + def unit_sum(name): + values = np.asarray(sim.calculate(name, year), dtype=float) + return np.bincount(unit, weights=values, minlength=n) + + out = { + "line_20": unit_sum("mt_income_tax_before_refundable_credits_indiv"), + "itemized": unit_sum("mt_itemized_deductions_indiv"), + "standard": unit_sum("mt_standard_deduction_indiv"), + } + for name in ["mt_income_tax", "mt_dependent_medical_expense_head_share"]: + out[name] = np.asarray(sim.calculate(name, year), dtype=float) + out["couple_tax"] = out["line_20"] if year == 2021 else out["mt_income_tax"] + return out + + +def _check(couples_drawn, year): + n = len(couples_drawn) + # First simulation: the model chooses. Variants: as drawn, labels + # swapped, more bills, and no dependent bills (for the old election). + free = [] + for i, c in enumerate(couples_drawn): + more = [c["dependent_bills"][0] + c["more_bills"]] + c["dependent_bills"][1:] + free += [ + (f"u{i}", c, False, c["dependent_bills"], {}), + (f"s{i}", c, True, c["dependent_bills"], {}), + (f"m{i}", c, False, more, {}), + (f"z{i}", c, False, [0.0] * len(c["dependent_bills"]), {}), + ] + chosen = _taxes(free, year) + pick = lambda k: chosen["couple_tax"][k::4] # noqa: E731 + as_drawn, swapped, more_bills, no_bills = (pick(k) for k in range(4)) + old_itemizes = chosen["itemized"][3::4] > chosen["standard"][3::4] + share = chosen["mt_dependent_medical_expense_head_share"][0::4] + assert ((share >= 0) & (share <= 1)).all() + + # Second simulation: every tax unit has a forced share and election. + forced, labels = [], [] + for i, c in enumerate(couples_drawn): + pool = sum(c["dependent_bills"]) + head_kink, spouse_kink = _kinks(c, pool) + for label, s in [ + ("equal", EQUAL_SPLIT), + ("head", 1.0), + ("spouse", 0.0), + ("head_kink", head_kink), + ("spouse_kink", spouse_kink), + ]: + inputs = { + "mt_dependent_medical_expense_head_share": s, + "mt_tax_unit_itemizes": True, + } + forced.append((f"{label}{i}", c, False, c["dependent_bills"], inputs)) + labels.append(label) + standard = { + "mt_dependent_medical_expense_head_share": EQUAL_SPLIT, + "mt_tax_unit_itemizes": False, + } + forced.append((f"standard{i}", c, False, c["dependent_bills"], standard)) + labels.append("standard") + old = { + "mt_dependent_medical_expense_head_share": EQUAL_SPLIT, + "mt_tax_unit_itemizes": bool(old_itemizes[i]), + } + zero = [0.0] * len(c["dependent_bills"]) + forced.append((f"old{i}", c, False, zero, old)) + labels.append("old") + fixed = _taxes(forced, year) + per = len(labels) // n + labels = np.array(labels[:per]) + fixed_line_20 = fixed["line_20"].reshape(n, per) + fixed_tax = fixed["couple_tax"].reshape(n, per) + + # 1 and 2. The chosen line 20 is at most every candidate's and the + # standard deduction's. + candidates = labels != "old" + assert ( + chosen["line_20"][0::4] <= fixed_line_20[:, candidates].min(axis=1) + TOLERANCE + ).all() + # 3. At most the tax before this change. + assert (as_drawn <= fixed_tax[:, labels == "old"][:, 0] + TOLERANCE).all() + # 4. Label swap. + np.testing.assert_allclose(as_drawn, swapped, atol=TOLERANCE) + # 5. More bills never raise the tax. + assert (more_bills <= as_drawn + TOLERANCE).all() + # The free choice with no dependent bills is the old return's tax or less. + assert (no_bills <= fixed_tax[:, labels == "old"][:, 0] + TOLERANCE).all() + + +SETTINGS = dict( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given( + year=st.sampled_from(YEARS), + couples_drawn=st.lists(couples(), min_size=1, max_size=6), +) +def test_mt_dependent_medical_split_invariants(year, couples_drawn): + _check(couples_drawn, year) + + +SEED = 20261102 + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + rng = np.random.default_rng(SEED + year) + drawn = [] + for _ in range(40): + drawn.append( + { + "agi": [float(rng.integers(0, 150_001)) for _ in range(2)], + "own_medical": [ + float(rng.choice([0, rng.integers(1, 12_001)])) for _ in range(2) + ], + "other_itemized": [ + float(rng.choice([0, rng.integers(1, 12_001)])) for _ in range(2) + ], + "dependent_bills": [ + float(rng.choice([0, rng.integers(1, 40_001)])) + for _ in range(int(rng.integers(1, 3))) + ], + "more_bills": float(rng.integers(1, 10_001)), + } + ) + _check(drawn, year) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 7e062bfc82a..becf57e178c 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -68,8 +68,11 @@ 10. Line 1a on separate returns (2021-2023): other medical expenses sit on the person cared for, so a dependent's are not clearly attributable to one spouse. Each spouse's column holds their own other expenses plus - half of the dependents' (the model's equal division; the form allows any - proportion); an unmarried head's column holds all of the dependents'. + their share of the dependents': the head's share is + mt_dependent_medical_expense_head_share (between 0 and 1, chosen to + minimize the couple's tax; its own invariants are in + test_mt_dependent_medical_split_invariants.py) and the spouse's the rest; + an unmarried head's column holds all of the dependents'. Conservation: the head's and spouse's columns sum to every member's other medical expenses, the joint return's line 1 base. Dependents hold nothing. @@ -90,9 +93,6 @@ FORM_FLOOR = 0.075 # Itemized Deductions Schedule line 1c STATE_YEARS = [2021, 2022, 2023] FEDERAL_YEAR = 2024 -# Each spouse's share of a dependent's other medical expenses on separate -# returns: the model's equal division (property 10). -SPOUSE_SHARE = 0.5 YEARS = STATE_YEARS + [FEDERAL_YEAR] MODES = ["derived", "person", "unit"] VARIANTS = ["base", "raised", "no_pre_tax"] @@ -326,6 +326,7 @@ def _situation(units, year, mode, unit_inputs=None): ] UNIT_VARIABLES = [ "mt_agi_joint", + "mt_dependent_medical_expense_head_share", "self_employed_health_insurance_ald", "mt_premium_deduction_joint", "itemized_medical_expenses", @@ -428,7 +429,16 @@ def _check(units, year, mode): # 10. Line 1a in each separate column, from the inputs' roles. unit = run["unit"] married = _unit_sum(run, role == "spouse") > 0 - share = np.where(married[unit], filer * SPOUSE_SHARE, role == "head") + head_share = run["mt_dependent_medical_expense_head_share"][unit] + assert ((head_share >= 0) & (head_share <= 1)).all() + spouse_share = 1 - head_share + share = np.where( + married[unit], + np.where( + role == "head", head_share, np.where(role == "spouse", spouse_share, 0) + ), + role == "head", + ) dependent_other = _unit_sum(run, ~filer * other) expected_line_1a = filer * other + share * dependent_other[unit] line_1a = run["mt_medical_and_dental_expenses_indiv"] diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py index b690c0195d8..20ffc3f69d9 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py @@ -24,34 +24,10 @@ class mt_itemized_deductions_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - p = parameters(period).gov.states.mt.tax.income.deductions.itemized - # Since we only compute the federal charitable deduction at the tax unit level, - # we will split the value between each spouse - charitable_deduction = ( - person.tax_unit("charitable_deduction", period) * p.spouse_allocation_rate + # The medical deduction is kept apart because the split of a + # dependent's medical bills is chosen by comparing the couple's tax + # with every other itemized deduction held fixed + # (mt_dependent_medical_expense_head_share). + return person("mt_non_medical_itemized_deductions_indiv", period) + person( + "mt_medical_expense_deduction_indiv", period ) - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - # The interest deduction is the sum of mortagage and investment interest expenses - investment_interest = person("investment_interest_expense", period) - # Montana allows the mortgage interest federal law allows, so interest - # on acquisition debt above the IRC 163(h)(3) caps is not deductible - # (MCA 15-30-2131(1)(a); Form 2 instructions, itemized line 9). - mortgage_interest = person("deductible_mortgage_interest", period) - # Apply the federal phase-out first, then allocate the allowed - # premiums according to each spouse's payments on both residences. - mortgage_insurance = person( - "deductible_mortgage_insurance_premiums_indiv", period - ) - interest_ded = investment_interest + mortgage_interest + mortgage_insurance - other_deductions = add( - person, - period, - [ - "mt_misc_deductions", - "mt_child_dependent_care_expense_deduction", - "mt_medical_expense_deduction_indiv", - "mt_salt_deduction", - "mt_federal_income_tax_deduction_indiv", - ], - ) - return head_or_spouse * (interest_ded + charitable_deduction + other_deductions) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_non_medical_itemized_deductions_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_non_medical_itemized_deductions_indiv.py new file mode 100644 index 00000000000..613cd3fc838 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_non_medical_itemized_deductions_indiv.py @@ -0,0 +1,56 @@ +from policyengine_us.model_api import * + + +class mt_non_medical_itemized_deductions_indiv(Variable): + value_type = float + entity = Person + label = "Montana itemized deductions other than medical expenses when married couples are filing separately" + unit = USD + definition_period = YEAR + reference = ( + "https://revenuefiles.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2021_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", + # 2021 Form 2 line 9 includes qualified mortgage insurance premiums + # allowed on the federal return, after the federal AGI phase-out. + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", + # MT Code § 15-30-2131 (2022) (1) + "https://mca.legmt.gov/bills/2019/mca/title_0150/chapter_0300/part_0210/section_0010/0150-0300-0210-0010.html", + # MCA 15-30-2101: "Internal Revenue Code" means the IRC as amended + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=38", + # 2023 Form 2 instructions, itemized deductions line 9: home mortgage + # interest "allowed by federal law", limited to the first $750,000 of + # acquisition debt incurred after Dec. 15, 2017 + ) + defined_for = "mt_married_filing_separately_on_same_return_eligible" + + def formula(person, period, parameters): + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + # Since we only compute the federal charitable deduction at the tax unit level, + # we will split the value between each spouse + charitable_deduction = ( + person.tax_unit("charitable_deduction", period) * p.spouse_allocation_rate + ) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + # The interest deduction is the sum of mortagage and investment interest expenses + investment_interest = person("investment_interest_expense", period) + # Montana allows the mortgage interest federal law allows, so interest + # on acquisition debt above the IRC 163(h)(3) caps is not deductible + # (MCA 15-30-2131(1)(a); Form 2 instructions, itemized line 9). + mortgage_interest = person("deductible_mortgage_interest", period) + # Apply the federal phase-out first, then allocate the allowed + # premiums according to each spouse's payments on both residences. + mortgage_insurance = person( + "deductible_mortgage_insurance_premiums_indiv", period + ) + interest_ded = investment_interest + mortgage_interest + mortgage_insurance + other_deductions = add( + person, + period, + [ + "mt_misc_deductions", + "mt_child_dependent_care_expense_deduction", + "mt_salt_deduction", + "mt_federal_income_tax_deduction_indiv", + ], + ) + return head_or_spouse * (interest_ded + charitable_deduction + other_deductions) 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 new file mode 100644 index 00000000000..6b9c92d1fa7 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_dependent_medical_expense_head_share.py @@ -0,0 +1,99 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import ( + mt_medical_deduction, + mt_medical_floor_room, + mt_separate_tax_before_refundable_credits, +) + +# Two allocations whose taxes differ by no more than this are treated as equal, +# so float32 noise does not decide between them. +TAX_TOLERANCE = 0.01 + + +class mt_dependent_medical_expense_head_share(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "Montana head's share of dependents' medical expenses when married couples file separately on the same form" + unit = "/1" + documentation = ( + "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 " + "(Form 2 line 20 in both columns), holding every other itemized " + "deduction fixed. It compares a small candidate set: the equal split " + "(spouse_allocation_rate), all to the head, all to the spouse, and " + "the two shares at which the head's or the spouse's 7.5% floor " + "starts to bind. Ties go to the share closest to the equal split, so " + "the result does not depend on which spouse is labelled head. A full " + "search over every share, which could find a lower tax where a " + "bracket boundary falls between candidates, is left as a later " + "optimization. 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. + "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". + "https://web.archive.org/web/20201030190413/http://www.mtrules.org/gateway/ruleno.asp?RN=42.15.524", + ) + defined_for = StateCode.MT + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.mt.tax.income + equal_split = p.deductions.itemized.spouse_allocation_rate + default = np.full(tax_unit.count, equal_split, dtype=float) + if not p.married_filing_separately_on_same_return_allowed: + return default + person = tax_unit.members + head = person("is_tax_unit_head", period) + spouse = person("is_tax_unit_spouse", period) + head_or_spouse = head | spouse + expense = person("other_medical_expenses", period) + pool = tax_unit.sum(expense * ~head_or_spouse) + own_expense = head_or_spouse * expense + other_itemized = person("mt_non_medical_itemized_deductions_indiv", period) + + def couple_tax(head_share): + share = tax_unit.project(head_share) + column_share = where(head, share, where(spouse, 1 - share, 0)) + line_1a = own_expense + column_share * tax_unit.project(pool) + itemized = other_itemized + mt_medical_deduction( + person, period, parameters, line_1a + ) + return mt_separate_tax_before_refundable_credits( + tax_unit, period, parameters, itemized + ) + + # The shares at which each column's floor starts to bind: the head's + # column reaches its floor at head share room / pool, and the + # spouse's at 1 - room / pool. + room = mt_medical_floor_room(person, period, parameters) + safe_pool = where(pool > 0, pool, 1) + head_kink = np.clip(tax_unit.sum(room * head) / safe_pool, 0, 1) + spouse_kink = np.clip(1 - tax_unit.sum(room * spouse) / safe_pool, 0, 1) + candidates = [ + np.full(tax_unit.count, 0.0), + np.full(tax_unit.count, 1.0), + head_kink, + spouse_kink, + ] + best_share = default + best_tax = couple_tax(default) + for candidate 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 + best_share = where(take, candidate, best_share) + best_tax = where(take, tax, best_tax) + married = tax_unit("tax_unit_married", period) + return where(married & (pool > 0), best_share, default) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py index 43db4d2020d..c3931ef2fa2 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_and_dental_expenses_indiv.py @@ -12,22 +12,30 @@ class mt_medical_and_dental_expenses_indiv(Variable): "expenses are recorded on the person cared for, including amounts " "other household members paid on that person's behalf, so the " "inputs do not say which spouse paid a dependent's bills. Each " - "spouse's own expenses stay in that spouse's column. A dependent's " - "expenses are divided equally between spouses filing separately on " - "the same form (spouse_allocation_rate, the split the model uses for " - "the charitable deduction); the form lets the couple divide them in " - "any proportion. An unmarried filer's column holds every " + "spouse's own expenses stay in that spouse's column. The model treats " + "every dependent's bill as paid from an account both spouses own: " + "for such bills the form lets the couple divide them between the " + "columns in any proportion, whereas a bill paid from one spouse's own " + "funds belongs to that spouse, and the model has no payer data to " + "tell them apart. The head's column takes the share " + "mt_dependent_medical_expense_head_share of every dependent's " + "expenses, chosen to minimize the couple's Montana tax, and the " + "spouse's column the rest. An unmarried filer's column holds every " "dependent's expenses. The pool is every member other than the head " "and spouse, so the head's and spouse's columns together hold every " "member's other medical expenses, as the joint return's line 1 does." ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + # 2021 instructions, "Allocation of deductions" (PDF page 10). + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2021_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=10", # PDF pages 11, 32-33. Page 11, "Allocation of deductions": payments # from accounts owned by both spouses for expenses not attributable # specifically to one spouse "can be allocated to either spouse in # any proportional amount". Pages 32-33: line 1 instructions. "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", + # 2023 instructions, "Allocation of deductions" (PDF pages 12-13). + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=12", # ARM 42.15.524(3)(a)-(b), in effect 2010-2024 (repealed effective # Jan. 25, 2025), for spouses who filed a joint federal return or # none: a deduction clearly attributable to one spouse is claimed @@ -41,11 +49,13 @@ class mt_medical_and_dental_expenses_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - p = parameters(period).gov.states.mt.tax.income.deductions.itemized expense = person("other_medical_expenses", period) head_or_spouse = person("is_tax_unit_head_or_spouse", period) dependent_expense = person.tax_unit.sum(expense * ~head_or_spouse) married = person.tax_unit("tax_unit_married", period) head = person("is_tax_unit_head", period) - share = where(married, head_or_spouse * p.spouse_allocation_rate, head) + spouse = person("is_tax_unit_spouse", period) + head_share = person.tax_unit("mt_dependent_medical_expense_head_share", period) + married_share = where(head, head_share, where(spouse, 1 - head_share, 0)) + share = where(married, married_share, head) return head_or_spouse * expense + share * dependent_expense diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py index eca0ee4e7ec..8f0fd89f9f3 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import mt_medical_deduction class mt_medical_expense_deduction_indiv(Variable): @@ -34,23 +35,7 @@ class mt_medical_expense_deduction_indiv(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.states.mt.tax.income.deductions.itemized - floor = parameters(period).gov.irs.deductions.itemized.medical.floor - expense = person("mt_medical_and_dental_expenses_indiv", period) - # Law does not define Montana AGI as the cap. - # Tax form points to page 1, line 14, which is Montana AGI. - medical_floor = floor * person("mt_agi_indiv", period) - premiums = person("mt_eligible_medical_insurance_premiums", period) - if p.state_specific_deduction_applies: - # Line 1 floors expenses other than premiums; lines 2 and 3 - # deduct the premiums in full. - long_term_care_premiums = person( - "mt_eligible_long_term_care_insurance_premiums", period - ) - deduction = ( - max_(0, expense - medical_floor) + premiums + long_term_care_premiums - ) - else: - deduction = max_(0, premiums + expense - medical_floor) - is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return is_head_or_spouse * deduction + line_1a = person("mt_medical_and_dental_expenses_indiv", period) + # Law does not define Montana AGI as the floor's base; the form points + # to page 1, line 14, which is Montana AGI. + return mt_medical_deduction(person, period, parameters, line_1a) From 8accee23e7d098a8a0500f383e1ae407abbb3fc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:26:00 +0000 Subject: [PATCH 06/14] Choose the Montana separate-path itemize election by tax Spouses filing on the same form must both itemize or both take the standard deduction, which the taxpayer may elect, so the election now compares the couple's combined line 20 under each, instead of summed deductions; ties keep the old rule. The joint path takes the larger deduction and is unchanged. One existing case flips: itemizing lowers that couple's tax although its summed itemized deductions are smaller. A downstream mt_income_tax case pins the flow through the itemized deductions and the election. Co-Authored-By: Claude Opus 5.5 --- .../deductions/mt_tax_unit_itemizes.yaml | 63 +++++++++++++++++-- .../states/mt/tax/income/mt_income_tax.yaml | 63 +++++++++++++++++++ .../general/mt_tax_unit_itemizes.py | 47 +++++++++++++- 3 files changed, 166 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml index a9033e68da4..9c45bea3eae 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml @@ -16,7 +16,7 @@ output: mt_tax_unit_itemizes: false -- name: Combined-AGI care phaseout keeps itemized deductions below the standard deduction +- name: Combined-AGI care phaseout keeps itemized deductions below the standard deduction, but itemizing lowers the tax period: 2022 input: people: @@ -47,9 +47,64 @@ # max(min(.2 * 20,000, 5,090), 2,260) = 4,000 and the 2,260 floor. # https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=35 # Itemized total = 4,650 + 700 + 700 = 6,050 < 4,000 + 2,260 = 6,260. - # Using each spouse's AGI would give the spouse 1,200 and total 6,550, - # incorrectly switching the couple to itemization. + # Using each spouse's AGI would give the spouse 1,200 and total 6,550. mt_itemized_deductions_indiv: [5_350, 700, 0] mt_standard_deduction_indiv: [4_000, 2_260, 0] + # The election goes by the couple's tax, not the sums. The spouse's + # column has no income, so only the head's deduction matters. With the + # 2,710 exemption and the child's 2,710 in the head's column: + # itemizing, 20,000 - 2,710 - 5,350 - 2,710 = 9,230 gives 176 + 330 x 4% + # = 189.20; standard, 20,000 - 2,710 - 4,000 - 2,710 = 10,580 gives + # 176 + 1,680 x 4% = 243.20. So the couple itemizes. + mt_tax_unit_itemizes: true + mt_deductions_indiv: [5_350, 700, 0] + +- name: Spouses filing separately take the standard deduction when itemizing would raise their tax + # Itemized deductions sum to more than the standard deductions, but the + # couple may elect the standard deduction (MCA 2021 15-30-2132(1); spouses + # must both itemize or both take it), so the election goes by tax. The + # child's 10,250 of bills is split equally here by input. + period: 2022 + absolute_error_margin: 0.05 + input: + people: + head: + age: 40 + is_tax_unit_head: true + mt_agi_indiv: 60_000 + mt_non_medical_itemized_deductions_indiv: 3_000 + spouse: + age: 40 + is_tax_unit_spouse: true + mt_agi_indiv: 15_000 + mt_non_medical_itemized_deductions_indiv: 700 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 10_250 + tax_units: + tax_unit: + members: [head, spouse, child] + mt_dependent_medical_expense_head_share: 0.5 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + # Medical: head 5,125 - 7.5% x 60,000 = 625; spouse 5,125 - 1,125 = 4,000. + mt_itemized_deductions_indiv: [3_625, 4_700, 0] + # 2022: 20% of AGI, at most 5,090 and at least 2,260. + mt_standard_deduction_indiv: [5_090, 3_000, 0] + # Itemized 8,325 > standard 8,090, but the tax decides. Exemptions are + # 2,710 each, and the child's goes to the head. 2022 rates reach 734 at + # 19,800 and are 6.75% above; 300 at 12,000 and 4% from 8,900 to 12,000; + # 83 at 5,800 and 3% to 8,900. + # Itemizing: head 60,000 - 3,625 - 5,420 = 50,955 gives 734 + 31,155 x + # 6.75% = 2,836.9625; spouse 15,000 - 4,700 - 2,710 = 7,590 gives 83 + + # 1,790 x 3% = 136.70; together 2,973.66. + # Standard: head 60,000 - 5,090 - 5,420 = 49,490 gives 734 + 29,690 x + # 6.75% = 2,738.075; spouse 15,000 - 3,000 - 2,710 = 9,290 gives 176 + + # 390 x 4% = 191.60; together 2,929.675. mt_tax_unit_itemizes: false - mt_deductions_indiv: [4_000, 2_260, 0] + mt_deductions_indiv: [5_090, 3_000, 0] + mt_income_tax_before_refundable_credits_indiv: [2_738.08, 191.60, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml index 62509f8f889..178d59e2f46 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml @@ -37,3 +37,66 @@ state_code: MT output: mt_income_tax: 4_000 + +- name: A dependent's medical bills lower a couple's tax when spouses file separately on the same form + # TY2022. The head has 60,000 of Montana AGI and 3,000 of other itemized + # deductions, the spouse 15,000 and 700, and their child 10,250 of medical + # bills. The couple may divide the bills in any proportion and elect the + # standard deduction, so the model takes the lowest tax over the candidate + # shares and the two elections. Exemptions are 2,710 each, and the child's + # goes to the head. 2022 rates: 734 at 19,800 and 6.75% above; 300 at + # 12,000 and 5% to 15,400; 176 at 8,900 and 4% to 12,000; 83 at 5,800 and + # 3% to 8,900; 33 at 3,300 and 2% to 5,800; 1% below 3,300. + # Combined tax by share of the bills to the head, itemizing (floors 4,500 + # and 1,125): + # all to the head: medical 5,750 and 0. Head 60,000 - 8,750 - 5,420 = + # 45,830: 734 + 26,030 x 6.75% = 2,491.025. Spouse 15,000 - 700 - + # 2,710 = 11,590: 176 + 2,690 x 4% = 283.60. Total 2,774.625. + # all to the spouse: medical 0 and 9,125. Head 51,580: 2,879.15. Spouse + # 2,465: 24.65. Total 2,903.80. + # equal split: medical 625 and 4,000. Total 2,973.66. + # the head's floor kink (head 4,500, spouse 5,750): 2,879.15 + 117.95 = + # 2,997.10; the spouse's (head 9,125, spouse 1,125): 2,566.9625 + + # 283.60 = 2,850.5625. + # Standard deductions (5,090 and 3,000): 2,738.075 + 191.60 = 2,929.675. + # The joint return (AGI 75,000, standard deduction 10,180 over itemized + # 10,250 - 5,625 = 4,625, three exemptions): 75,000 - 10,180 - 8,130 = + # 56,690 gives 734 + 36,890 x 6.75% = 3,224.075. + # Before this change the bills were left out and the couple took the + # standard deduction (2,929.675). Splitting them equally with the election + # by deduction sums (8,325 > 8,090) would give 2,973.66. + period: 2022 + absolute_error_margin: 0.05 + input: + people: + head: + age: 40 + is_tax_unit_head: true + mt_agi_indiv: 60_000 + mt_non_medical_itemized_deductions_indiv: 3_000 + spouse: + age: 40 + is_tax_unit_spouse: true + mt_agi_indiv: 15_000 + mt_non_medical_itemized_deductions_indiv: 700 + child: + age: 10 + is_tax_unit_dependent: true + other_medical_expenses: 10_250 + tax_units: + tax_unit: + members: [head, spouse, child] + mt_agi_joint: 75_000 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + mt_dependent_medical_expense_head_share: 1 + mt_medical_and_dental_expenses_indiv: [10_250, 0, 0] + mt_itemized_deductions_indiv: [8_750, 700, 0] + mt_tax_unit_itemizes: true + mt_income_tax_before_refundable_credits_indiv: [2_491.03, 283.60, 0] + mt_income_tax_joint: 3_224.08 + mt_files_separately: true + mt_income_tax: 2_774.63 diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/general/mt_tax_unit_itemizes.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/general/mt_tax_unit_itemizes.py index dd58d10efe0..5bf1e96d0df 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/general/mt_tax_unit_itemizes.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/general/mt_tax_unit_itemizes.py @@ -1,14 +1,55 @@ from policyengine_us.model_api import * +from policyengine_us.tools.mt_separate_return import ( + mt_separate_tax_before_refundable_credits, +) + +# Elections whose taxes differ by no more than this are treated as equal, so +# float32 noise does not decide between them. +TAX_TOLERANCE = 0.01 class mt_tax_unit_itemizes(Variable): value_type = bool entity = TaxUnit label = "Whether the tax unit in Montana itemizes the deductions when married filing separately" + documentation = ( + "The deduction election on the separate (status 2a) path. Spouses " + "filing separately on the same form must both claim the standard " + "deduction or both itemize, and the standard deduction is allowed if " + "the taxpayer elects it, so the couple takes whichever election gives " + "the lower combined tax before refundable credits (Form 2 line 20 in " + "both columns), evaluated in closed form with the column variables' " + "formulas. Itemizing uses the tax-minimizing split of the dependents' " + "medical expenses (mt_dependent_medical_expense_head_share). When the " + "two taxes are equal, the larger combined deduction decides, as it " + "did before. The joint path does not read this variable: a single " + "return takes the larger deduction, which cannot raise its tax. In " + "years without separate filing on the same form, the larger combined " + "deduction decides." + ) + reference = ( + # 2022 Form 2 instructions, PDF page 11: spouses "must either both + # claim the standard deduction, or both itemize their deductions". + "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", + # MCA 2021 15-30-2132(1): the standard deduction "is allowed if + # elected by the taxpayer". + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0320/0150-0300-0210-0320.html", + ) definition_period = YEAR defined_for = StateCode.MT def formula(tax_unit, period, parameters): - itemized_ded = add(tax_unit, period, ["mt_itemized_deductions_indiv"]) - standard_ded = add(tax_unit, period, ["mt_standard_deduction_indiv"]) - return itemized_ded > standard_ded + itemized = tax_unit.members("mt_itemized_deductions_indiv", period) + standard = tax_unit.members("mt_standard_deduction_indiv", period) + larger_itemized = tax_unit.sum(itemized) > tax_unit.sum(standard) + p = parameters(period).gov.states.mt.tax.income + if not p.married_filing_separately_on_same_return_allowed: + return larger_itemized + tax_itemizing = mt_separate_tax_before_refundable_credits( + tax_unit, period, parameters, itemized + ) + tax_standard = mt_separate_tax_before_refundable_credits( + tax_unit, period, parameters, standard + ) + tied = np.abs(tax_itemizing - tax_standard) <= TAX_TOLERANCE + return where(tied, larger_itemized, tax_itemizing < tax_standard) From 7102775bc34da8ccfb8a90470270c3573ff16468 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:26:00 +0000 Subject: [PATCH 07/14] Describe spouse_allocation_rate as a modeling convention On the same form the rule defers to the form's any-proportion allocation, so 0.5 is a convention (the charitable split and the medical tie-break). The ARM reference now points at archived text, and Pub. 502 anchors the equal split of jointly paid bills. Co-Authored-By: Claude Opus 5.5 --- .../itemized/spouse_allocation_rate.yaml | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml index 024fe7224bb..417d2d75fbf 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml @@ -1,13 +1,23 @@ -description: Share of tax unit itemized deductions allocated to each spouse when married filing separately on the same form. This is not a policy parameter but a mathematical representation of "divided equally". +description: Montana allocates this share of jointly paid itemized deductions to each spouse when married couples file separately on the same form. values: - # 0.5 means each spouse gets 50% of joint deductions + # A modeling convention, not a rate in the law. On the same form (status + # 2a), ARM 42.15.524(3)(b) defers to the form, which lets the couple + # allocate payments from accounts both spouses own "in any proportional + # amount"; an equal division is required only on separate forms, absent a + # written agreement (ARM 42.15.524(3)(c)). The model splits the charitable + # deduction equally by this rate, and uses it as the tie-break and starting + # candidate for the tax-minimizing split of dependents' medical bills + # (mt_dependent_medical_expense_head_share). The federal rule for separate + # returns also treats payments from a joint account as paid equally. 2021-01-01: 0.5 metadata: label: Montana itemized deductions spouse allocation rate period: year unit: /1 reference: - - title: Montana Administrative Rule 42.15.524 - Itemized Deductions of Married Taxpayers - href: https://rules.mt.gov/gateway/ruleno.asp?RN=42.15.524 - - title: Montana Form 2 Instructions - href: https://revenue.mt.gov/publications/montana-form-2-individual-income-tax-return-forms-and-instructions-includes-form-2ec + - title: Montana Administrative Rule 42.15.524 - Itemized Deductions of Married Taxpayers (archived text, in effect until its repeal effective Jan. 25, 2025) + href: https://web.archive.org/web/20201030190413/http://www.mtrules.org/gateway/ruleno.asp?RN=42.15.524 + - title: 2022 Montana Form 2 Instructions, Allocation of deductions + href: 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 + - title: IRS Publication 502, Separate returns + href: https://www.irs.gov/publications/p502 From 3146e8b86f6ee122bab2750682c2356ec9603e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:56:13 +0000 Subject: [PATCH 08/14] Keep Montana itemized deductions on the head and spouse only The split into non-medical and medical parts dropped the head-or-spouse mask on the medical part, so a medical deduction supplied for a dependent reached that dependent's itemized deductions. Restores the mask. Also updates the childcare boundary case: its summed deductions tie, but itemizing lowers the couple's tax, so the election now itemizes. Co-Authored-By: Claude Opus 5.5 --- .../mt_childcare_itemization_boundary.yaml | 20 ++++++++++++------- .../general/mt_itemized_deductions_indiv.py | 7 +++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml index f6f0e87b609..41e3e49579b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_childcare_itemization_boundary.yaml @@ -4,10 +4,11 @@ # Worksheet lines 1-6 (PDF p. 7): # https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 # Instructions for Form 2 line 15 require both spouses to use the same method -# (PDF p. 16); the model retains the standard deduction when totals tie: +# (PDF p. 16). The couple takes the method with the lower combined tax; the +# summed deductions decide only when the taxes tie: # https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=16 -- name: Combined-AGI care deduction leaves standard deductions at the itemization boundary +- name: Combined-AGI care deduction at the summed-deduction boundary; itemizing lowers the tax period: 2022 absolute_error_margin: 0.01 input: @@ -40,14 +41,19 @@ # Care = min(3_000, 2_400) - 0.5 * (20_000 + 0 - 18_000) # = 1_400; each spouse gets 700. Itemized total = 4_860 + 700 + 700 # = 6_260. Standard = max(20% * 20_000, 2_260) + 2_260 = 6_260. - # The unchanged tie rule selects standard deductions. Phasing out on - # each spouse's own AGI would incorrectly give 700 + 1_200 care and - # itemized total 6_760, crossing the choice boundary. + # Phasing out on each spouse's own AGI would incorrectly give 700 + + # 1_200 care and itemized total 6_760. mt_child_dependent_care_expense_deduction: [700, 700, 0] mt_itemized_deductions_indiv: [5_560, 700, 0] mt_standard_deduction_indiv: [4_000, 2_260, 0] - mt_tax_unit_itemizes: false - mt_deductions_indiv: [4_000, 2_260, 0] + # The totals tie, but the election goes by tax. The spouse's column has + # no income, so only the head's deduction matters (2,710 exemption, and + # the child's 2,710 in the head's column): itemizing, 20_000 - 2_710 - + # 5_560 - 2_710 = 9_020 gives 176 + 120 x 4% = 180.80; standard, + # 20_000 - 2_710 - 4_000 - 2_710 = 10_580 gives 176 + 1_680 x 4% = + # 243.20. So the couple itemizes. + mt_tax_unit_itemizes: true + mt_deductions_indiv: [5_560, 700, 0] - name: One more dollar of mortgage interest crosses the derived childcare itemization boundary period: 2022 diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py index 20ffc3f69d9..8c7e39218b2 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py @@ -28,6 +28,9 @@ def formula(person, period, parameters): # dependent's medical bills is chosen by comparing the couple's tax # with every other itemized deduction held fixed # (mt_dependent_medical_expense_head_share). - return person("mt_non_medical_itemized_deductions_indiv", period) + person( - "mt_medical_expense_deduction_indiv", period + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + medical = person("mt_medical_expense_deduction_indiv", period) + return ( + person("mt_non_medical_itemized_deductions_indiv", period) + + head_or_spouse * medical ) From 9e2bc13d17394fc157906e761578fd8bb9d62e38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:08:48 +0000 Subject: [PATCH 09/14] Add the balancing share to Montana's dependent medical split Between the two floor kinks the total medical deduction is fixed and the couple's tax is convex in the head's share, so its minimum is where the columns' incomes before the dependent exemptions balance. The candidate set missed that point and left some lower-income couples up to about $56 above the minimum. Adds the review's TY2022 witness (131.40) on mt_income_tax and a test that the closed form equals the columns' line 20 when the capital gain credit is non-zero. Co-Authored-By: Claude Opus 5.5 --- .../states/mt/tax/income/mt_income_tax.yaml | 55 ++++++++++++++ ...t_mt_dependent_medical_split_invariants.py | 72 +++++++++++++++++++ ...mt_dependent_medical_expense_head_share.py | 40 +++++++++-- 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml index 178d59e2f46..f54bd030cca 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml @@ -100,3 +100,58 @@ mt_income_tax_joint: 3_224.08 mt_files_separately: true mt_income_tax: 2_774.63 + +- name: The tax-minimizing split balances the two columns' incomes + # TY2022. Montana AGI is 25,000 for the head and 15,000 for the spouse, with + # no other itemized deductions; their child has 25,000 of medical bills. + # Exemptions are 2,710 each and floors 7.5% of AGI (1,875 and 1,125). + # 2022 rates: 1% to 3,300; 33 at 3,300 and 2% to 5,800; 83 at 5,800 and 3% + # to 8,900; 176 at 8,900 and 4% to 12,000; 300 at 12,000 and 5% to 15,400. + # While both columns are above their floors, the total deduction is fixed + # and each column's income before the child's exemption is its level less + # its share of the bills: head 25,000 - 2,710 + 1,875 = 24,165 - 25,000 s; + # spouse 15,000 - 2,710 + 1,125 = 13,415 - 25,000 (1 - s). They balance at + # s = 0.5 + (24,165 - 13,415) / 50,000 = 0.715: + # head line 1a 17,875, deduction 16,000; 25,000 - 2,710 - 16,000 = 6,290; + # spouse line 1a 7,125, deduction 6,000; 15,000 - 2,710 - 6,000 = 6,290. + # The child's 2,710 is halved: 4,935 each, taxed 33 + 1,635 x 2% = 65.70. + # Total 131.40. + # Other candidates: equal split 178.20 + 9.15 = 187.35; all to the head + # 0 + 203.20 = 203.20; all to the spouse 720.80. Standard deductions (5,000 + # and 3,000): 429 + 191.60 = 620.60. Joint return: 40,000 - 22,000 itemized + # - 8,130 exemptions = 9,870, taxed 176 + 970 x 4% = 214.80. + period: 2022 + absolute_error_margin: 0.05 + input: + people: + head: + age: 40 + is_tax_unit_head: true + mt_agi_indiv: 25_000 + mt_non_medical_itemized_deductions_indiv: 0 + spouse: + age: 40 + is_tax_unit_spouse: true + mt_agi_indiv: 15_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.715 + mt_medical_and_dental_expenses_indiv: [17_875, 7_125, 0] + mt_medical_expense_deduction_indiv: [16_000, 6_000, 0] + mt_tax_unit_itemizes: true + mt_income_tax_before_refundable_credits_indiv: [65.70, 65.70, 0] + mt_income_tax_joint: 214.80 + mt_files_separately: true + mt_income_tax: 131.40 diff --git a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py index 97852dd3021..83c2b5b39dc 100644 --- a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py +++ b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py @@ -42,7 +42,12 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period + from policyengine_us import Simulation +from policyengine_us.tools.mt_separate_return import ( + mt_separate_tax_before_refundable_credits, +) # Dollars; the model computes in float32 and incomes reach $150,000. TOLERANCE = 0.05 @@ -255,3 +260,70 @@ def test_seeded_population(year): } ) _check(drawn, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_closed_form_matches_columns(year): + """The closed form the choices compare equals the columns' line 20. + + Couples with wages and long-term capital gains, so the 2021-2023 capital + gain credit (Montana's non-refundable credit) is non-zero, and the federal + return feeds Montana AGI. A closed form that drifts from the column + variables would make the choices minimize a tax the columns don't owe. + """ + rng = np.random.default_rng(SEED + 10 * year) + people, tax_units, households, marital_units = {}, {}, {}, {} + for i in range(30): + members = [] + for role in ("head", "spouse"): + name = f"u{i}_{role}" + people[name] = { + "age": {year: 45}, + "employment_income": {year: float(rng.integers(0, 120_001))}, + "long_term_capital_gains": {year: float(rng.integers(0, 40_001))}, + "other_medical_expenses": { + year: float(rng.choice([0, rng.integers(1, 8_001)])) + }, + } + members.append(name) + marital_units[f"u{i}_couple"] = {"members": list(members)} + child = f"u{i}_child" + people[child] = { + "age": {year: 10}, + "is_tax_unit_dependent": {year: True}, + "other_medical_expenses": {year: float(rng.integers(0, 30_001))}, + } + marital_units[f"u{i}_child_alone"] = {"members": [child]} + members.append(child) + tax_units[f"u{i}"] = {"members": members} + households[f"u{i}"] = {"members": members, "state_code": {year: "MT"}} + situation = { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households, + } + sim = Simulation(situation=situation) + tax_unit = sim.populations["tax_unit"] + unit = tax_unit.members_entity_id + n = tax_unit.count + columns = np.bincount( + unit, + weights=np.asarray( + sim.calculate("mt_income_tax_before_refundable_credits_indiv", year), + dtype=float, + ), + minlength=n, + ) + credits = np.bincount( + unit, + weights=np.asarray(sim.calculate("mt_non_refundable_credits", year), float), + minlength=n, + ) + # The guard is only meaningful if the credit is in play for most couples. + assert (credits > 0).sum() >= n // 2 + deductions = sim.calculate("mt_deductions_indiv", year) + closed_form = mt_separate_tax_before_refundable_credits( + tax_unit, period(year), sim.tax_benefit_system.parameters, deductions + ) + np.testing.assert_allclose(closed_form, columns, atol=0.01) 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 6b9c92d1fa7..7e89204bf8e 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 @@ -25,13 +25,19 @@ class mt_dependent_medical_expense_head_share(Variable): "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 a small candidate set: the equal split " - "(spouse_allocation_rate), all to the head, all to the spouse, and " - "the two shares at which the head's or the spouse's 7.5% floor " - "starts to bind. Ties go to the share closest to the equal split, so " - "the result does not depend on which spouse is labelled head. A full " - "search over every share, which could find a lower tax where a " - "bracket boundary falls between candidates, is left as a later " - "optimization. The tax is evaluated in closed form with the column " + "(spouse_allocation_rate), all to the head, all to the spouse, the " + "two shares at which the head's or the spouse's 7.5% floor starts to " + "bind, and the share at which the two columns' incomes before the " + "dependent exemptions are equal. Between the floor kinks the total " + "deduction does not depend on the share and the couple's tax is " + "convex in it, lowest where the columns balance; outside them the " + "tax is monotone in the share. So the set reaches the minimum unless " + "a column's non-refundable credits (the capital gain credit) hold 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 " "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." @@ -78,11 +84,31 @@ def couple_tax(head_share): safe_pool = where(pool > 0, pool, 1) head_kink = np.clip(tax_unit.sum(room * head) / safe_pool, 0, 1) spouse_kink = np.clip(1 - tax_unit.sum(room * spouse) / safe_pool, 0, 1) + # The share at which the two columns' incomes before the dependent + # exemptions are equal. While both columns are above their 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 income is + # level - share x pool, where level is its income with line 1a at its + # floor; the spouse's uses 1 - share. + kink_deduction = mt_medical_deduction( + person, period, parameters, own_expense + room + ) + level = ( + person("mt_agi_indiv", period) + - person("mt_personal_exemptions_indiv", period) + - other_itemized + - kink_deduction + + room + ) + 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), head_kink, spouse_kink, + balance, ] best_share = default best_tax = couple_tax(default) From dca8b29047446fe00d0983dcbecd20b931b331d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:20:01 +0000 Subject: [PATCH 10/14] Describe spouse_allocation_rate as a modeling convention The description said Montana allocates this share, which the comment below it contradicts. Name all four readers, including the dependent care deduction, where the equal split is the form's rule rather than a convention. Co-Authored-By: Claude Opus 5.5 --- .../itemized/spouse_allocation_rate.yaml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml index 417d2d75fbf..692621782b2 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/spouse_allocation_rate.yaml @@ -1,14 +1,21 @@ -description: Montana allocates this share of jointly paid itemized deductions to each spouse when married couples file separately on the same form. +description: Montana itemized deductions paid jointly are split by this share between the spouses' columns when married couples file separately on the same form; this is a modeling convention, not a rate in law. values: # A modeling convention, not a rate in the law. On the same form (status # 2a), ARM 42.15.524(3)(b) defers to the form, which lets the couple # allocate payments from accounts both spouses own "in any proportional # amount"; an equal division is required only on separate forms, absent a - # written agreement (ARM 42.15.524(3)(c)). The model splits the charitable - # deduction equally by this rate, and uses it as the tie-break and starting - # candidate for the tax-minimizing split of dependents' medical bills - # (mt_dependent_medical_expense_head_share). The federal rule for separate - # returns also treats payments from a joint account as paid equally. + # written agreement (ARM 42.15.524(3)(c)). Readers: + # - mt_non_medical_itemized_deductions_indiv and + # mt_itemized_deductions_for_federal_itemization_indiv split the + # charitable deduction equally by this rate; + # - mt_dependent_medical_expense_head_share uses it as the tie-break and + # starting candidate for the tax-minimizing split of dependents' medical + # bills; + # - mt_child_dependent_care_expense_deduction gives each spouse this share + # of the deduction. There the equal split is the law's rule, not a + # convention: each spouse enters one-half of Form 2441-M line 7. + # The federal rule for separate returns also treats payments from a joint + # account as paid equally. 2021-01-01: 0.5 metadata: label: Montana itemized deductions spouse allocation rate From c6cdfe2df7badf1c758e9216a926bdc2c14a0438 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:22:42 +0000 Subject: [PATCH 11/14] Compare the chosen split with interior shares too Property 1 compared the chosen tax only with the chooser's own candidates, so dropping a candidate that finds the minimum failed no test. Without the capital gain credit the minimum over all shares is at a candidate, so the chosen tax must also be at most the tax at shares 0.1 to 0.9. Co-Authored-By: Claude Opus 5.5 --- .../unit/test_mt_dependent_medical_split_invariants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py index 83c2b5b39dc..dc866b21d17 100644 --- a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py +++ b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py @@ -14,7 +14,9 @@ 1. The chosen tax is at most the tax at every candidate share when itemizing: all to the head, all to the spouse, the equal split, and the shares at which each column's 7.5% floor starts to bind (computed here from the - inputs, not read from the model). + inputs, not read from the model), and the shares 0.1 to 0.9. With no + capital gain credit the minimum over all shares is at a candidate, so the + interior shares catch a chooser that misses where the columns balance. 2. The chosen tax is at most the tax with the standard deduction in both columns. 3. The couple's Montana tax (mt_income_tax) is at most its tax before this @@ -176,7 +178,7 @@ def _check(couples_drawn, year): ("spouse", 0.0), ("head_kink", head_kink), ("spouse_kink", spouse_kink), - ]: + ] + [(f"grid{k}", k / 10) for k in range(1, 10)]: inputs = { "mt_dependent_medical_expense_head_share": s, "mt_tax_unit_itemizes": True, From cad9f36902063a2c204d4fc6276aa5aa024b9926 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:22:55 +0000 Subject: [PATCH 12/14] Drop the floor-kink candidates and the Hypothesis test Where a column is below its 7.5% floor, bills moved into it are wasted, so the tax is monotone there and a kink share can only tie all-to-one-spouse. Removing both kinks changed no tax or chosen share on a 270-couple grid and no test result, so the chooser evaluates four candidates instead of six. The Hypothesis test killed none of the mutants even at 30 examples, while the seeded and closed-form tests kill them all, so it is dropped. The hand computations in the YAML cases now note where the balancing share falls. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_indiv.yaml | 9 ++-- .../states/mt/tax/income/mt_income_tax.yaml | 3 ++ ...t_mt_dependent_medical_split_invariants.py | 52 ++++--------------- ...mt_dependent_medical_expense_head_share.py | 52 +++++++++---------- 4 files changed, 42 insertions(+), 74 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml index e7b4e6885e3..da1d5b324d5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml @@ -227,8 +227,7 @@ # each; the child's goes to the earner, the larger income): earner # 60,000 - 5,420 = 54,580, at the 6.75% rate; partner 20,000 - 2,710 = # 17,290, in the 6% bracket from 15,400 and 5% from 12,000. - # Tax saved by the medical deduction at each candidate share to the - # earner: + # Tax saved by the medical deduction at each share to the earner: # all to the partner: partner 500 + 6,000 - 1,500 = 5,000, taking # 17,290 to 12,290: 1,890 x 6% + 3,110 x 5% = 268.90; # all to the earner: earner 1,000 + 6,000 - 4,500 = 2,500 x 6.75% = @@ -237,6 +236,8 @@ # 118.90; earner 4,000 is under the floor; # the floors' kinks (shares 3,500 / 6,000 and 1 - 1,000 / 6,000): 90.00 # and 101.25. + # The incomes before the medical deduction differ by more than the + # bills, so the balancing share clips to 1 (all to the earner). # So all of the child's bills go to the partner's column. mt_dependent_medical_expense_head_share: [0, 1] mt_medical_and_dental_expenses_indiv: [1_000, 6_500, 0, 1_000, 6_500, 0] @@ -308,12 +309,14 @@ # Premiums stay under the floor with the other expenses from 2024. # Floors: head 7.5% x 40,000 = 3,000; spouse 7.5% x 10,000 = 750. 2024 # separate rates: 4.7% to 20,500 and 5.9% above, so the head's column is - # taxed at 5.9% and the spouse's at 4.7%. Tax saved at each candidate: + # taxed at 5.9% and the spouse's at 4.7%. Tax saved at each share: # all to the head: (2,000 + 4,000) - 3,000 = 3,000 x 5.9% = 177.00; # all to the spouse: 4,000 - 750 = 3,250 x 4.7% = 152.75; # equal split: 1,000 x 5.9% + 1,250 x 4.7% = 117.75; # the floors' kinks (shares 1,000 / 4,000 and 1 - 750 / 4,000): # 2,250 x 4.7% = 105.75 and 2,250 x 5.9% = 132.75. + # The balancing share clips to 1: the incomes differ by more than the + # bills. mt_dependent_medical_expense_head_share: 1 mt_medical_and_dental_expenses_indiv: [4_000, 0, 0] mt_medical_expense_deduction_indiv: [3_000, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml index f54bd030cca..35d6eef0495 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml @@ -58,6 +58,9 @@ # the head's floor kink (head 4,500, spouse 5,750): 2,879.15 + 117.95 = # 2,997.10; the spouse's (head 9,125, spouse 1,125): 2,566.9625 + # 283.60 = 2,850.5625. + # the balancing share: the columns' levels (60,000 - 2,710 - 3,000 + + # 4,500 and 15,000 - 2,710 - 700 + 1,125) differ by 46,075, more than + # the bills, so it clips to 1 (all to the head). # Standard deductions (5,090 and 3,000): 2,738.075 + 191.60 = 2,929.675. # The joint return (AGI 75,000, standard deduction 10,180 over itemized # 10,250 - 5,625 = 4,625, three exemptions): 75,000 - 10,180 - 8,130 = diff --git a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py index dc866b21d17..eb585c5bae2 100644 --- a/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py +++ b/policyengine_us/tests/unit/test_mt_dependent_medical_split_invariants.py @@ -5,18 +5,18 @@ attributable to one spouse in any proportion, and may elect the standard deduction (both spouses or neither). The model treats every dependent's bill as jointly paid, takes the head's share of those bills -(mt_dependent_medical_expense_head_share) from a candidate set to minimize the -couple's combined tax before refundable credits (Form 2 line 20 in both +(mt_dependent_medical_expense_head_share) from a closed-form candidate set to +minimize the couple's combined tax before refundable credits (Form 2 line 20 in both columns), and picks the election (mt_tax_unit_itemizes) by the same tax. -For every generated couple: +For each couple in a seeded population of 40 per year: -1. The chosen tax is at most the tax at every candidate share when itemizing: - all to the head, all to the spouse, the equal split, and the shares at - which each column's 7.5% floor starts to bind (computed here from the - inputs, not read from the model), and the shares 0.1 to 0.9. With no - capital gain credit the minimum over all shares is at a candidate, so the - interior shares catch a chooser that misses where the columns balance. +1. The chosen tax is at most the tax at each of these shares when itemizing: + all to the head, all to the spouse, the equal split, the shares at which + each column's 7.5% floor starts to bind (computed here from the inputs, + not read from the model), and 0.1 to 0.9. With no capital gain credit the + minimum over all shares is at 0, 1 or where the columns' incomes balance, + so the interior shares catch a chooser that misses the balance point. 2. The chosen tax is at most the tax with the standard deduction in both columns. 3. The couple's Montana tax (mt_income_tax) is at most its tax before this @@ -41,8 +41,6 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st from policyengine_core.periods import period @@ -57,21 +55,6 @@ YEARS = [2021, 2022, 2023] EQUAL_SPLIT = 0.5 -money = st.integers(0, 150_000).map(float) -bills = st.one_of(st.just(0.0), st.integers(1, 40_000).map(float)) -small = st.one_of(st.just(0.0), st.integers(1, 12_000).map(float)) - - -@st.composite -def couples(draw): - return { - "agi": [draw(money), draw(money)], - "own_medical": [draw(small), draw(small)], - "other_itemized": [draw(small), draw(small)], - "dependent_bills": [draw(bills) for _ in range(draw(st.integers(1, 2)))], - "more_bills": draw(st.integers(1, 10_000).map(float)), - } - def _kinks(couple, pool): """The head shares at which each column's floor starts to bind.""" @@ -220,23 +203,6 @@ def _check(couples_drawn, year): assert (no_bills <= fixed_tax[:, labels == "old"][:, 0] + TOLERANCE).all() -SETTINGS = dict( - max_examples=6, - deadline=None, - derandomize=True, - suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], -) - - -@settings(**SETTINGS) -@given( - year=st.sampled_from(YEARS), - couples_drawn=st.lists(couples(), min_size=1, max_size=6), -) -def test_mt_dependent_medical_split_invariants(year, couples_drawn): - _check(couples_drawn, year) - - SEED = 20261102 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 7e89204bf8e..4a8b4c51087 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,20 +24,20 @@ 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 a small candidate set: the equal split " - "(spouse_allocation_rate), all to the head, all to the spouse, the " - "two shares at which the head's or the spouse's 7.5% floor starts to " - "bind, and the share at which the two columns' incomes before the " - "dependent exemptions are equal. Between the floor kinks the total " - "deduction does not depend on the share and the couple's tax is " - "convex in it, lowest where the columns balance; outside them the " - "tax is monotone in the share. So the set reaches the minimum unless " - "a column's non-refundable credits (the capital gain credit) hold 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 " + "deduction fixed. It compares four candidates: 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. " + "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 " "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." @@ -77,20 +77,18 @@ def couple_tax(head_share): tax_unit, period, parameters, itemized ) - # The shares at which each column's floor starts to bind: the head's - # column reaches its floor at head share room / pool, and the - # spouse's at 1 - room / pool. + # 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 + # 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 + # moved into it are wasted, so the tax is monotone there and the + # shares at which a floor starts to bind never beat 0 or 1. room = mt_medical_floor_room(person, period, parameters) safe_pool = where(pool > 0, pool, 1) - head_kink = np.clip(tax_unit.sum(room * head) / safe_pool, 0, 1) - spouse_kink = np.clip(1 - tax_unit.sum(room * spouse) / safe_pool, 0, 1) - # The share at which the two columns' incomes before the dependent - # exemptions are equal. While both columns are above their 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 income is - # level - share x pool, where level is its income with line 1a at its - # floor; the spouse's uses 1 - share. kink_deduction = mt_medical_deduction( person, period, parameters, own_expense + room ) @@ -106,8 +104,6 @@ def couple_tax(head_share): candidates = [ np.full(tax_unit.count, 0.0), np.full(tax_unit.count, 1.0), - head_kink, - spouse_kink, balance, ] best_share = default From c08dd183f68fabaff6f73f2335420a9ba48d1d54 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:37:45 +0000 Subject: [PATCH 13/14] Test that the Montana balancing share counts the head's premiums No case had medical insurance premiums, so dropping the kink deduction (the 2021-2023 premiums) from each column's level passed every test. With 3,000 of premiums on the head of the balancing witness, the share moves from 0.715 to 0.655, which this case asserts with both columns' line 1a and line 20. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QpcqEDJ2tmDgRbDecMcN7j --- .../states/mt/tax/income/mt_income_tax.yaml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml index 35d6eef0495..6dba89768c8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml @@ -158,3 +158,46 @@ mt_income_tax_joint: 214.80 mt_files_separately: true mt_income_tax: 131.40 + +- name: The balancing share counts the head's medical insurance premiums + # TY2022. The case above, with 3,000 of medical insurance premiums on the + # head. The premiums lower the head's level by 3,000: + # head 25,000 - 2,710 - 3,000 + 1,875 = 21,165; spouse 13,415. + # s = 0.5 + (21,165 - 13,415) / 50,000 = 0.5 + 7,750 / 50,000 = 0.655. + # Head line 1a 16,375, deduction 14,500 + 3,000 = 17,500; + # 25,000 - 2,710 - 17,500 = 4,790. + # Spouse line 1a 8,625, deduction 7,500; 15,000 - 2,710 - 7,500 = 4,790. + # The child's 2,710 is halved: 3,435 each, taxed 33 + 135 x 2% = 35.70. + # Leaving the premiums out of the level would keep s at 0.715, giving + # line 20 of 32.90 and 38.60 (71.50 in total). + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + mt_agi_indiv: 25_000 + mt_non_medical_itemized_deductions_indiv: 0 + mt_eligible_medical_insurance_premiums: 3_000 + spouse: + age: 40 + is_tax_unit_spouse: true + mt_agi_indiv: 15_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.655 + mt_medical_and_dental_expenses_indiv: [16_375, 8_625, 0] + mt_income_tax_before_refundable_credits_indiv: [35.70, 35.70, 0] From 4c615b1c7919aa7f134e85a746238c4cb1b5781e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:10:33 +0000 Subject: [PATCH 14/14] Give the Montana premium case a raw premium input and its joint outputs The case set mt_eligible_medical_insurance_premiums, a derived variable that mt_premium_deduction_joint doesn't read, so its joint return had no premiums. Setting medical_expense_health_insurance_premiums makes both paths see them; the case now also pins the joint tax (115.10), the separate-filing election and the final tax (71.40). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6K4hbmpvP2YevFmELw --- .../baseline/gov/states/mt/tax/income/mt_income_tax.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml index 6dba89768c8..b14e93f80dd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_income_tax.yaml @@ -179,7 +179,7 @@ is_tax_unit_head: true mt_agi_indiv: 25_000 mt_non_medical_itemized_deductions_indiv: 0 - mt_eligible_medical_insurance_premiums: 3_000 + medical_expense_health_insurance_premiums: 3_000 spouse: age: 40 is_tax_unit_spouse: true @@ -201,3 +201,8 @@ mt_dependent_medical_expense_head_share: 0.655 mt_medical_and_dental_expenses_indiv: [16_375, 8_625, 0] mt_income_tax_before_refundable_credits_indiv: [35.70, 35.70, 0] + # Joint: 40,000 - 3 x 2,710 - (22,000 + 3,000) = 6,870 taxable, + # tax 33 + 50 + 1,070 x 3% = 115.10. + mt_income_tax_joint: 115.10 + mt_files_separately: true + mt_income_tax: 71.40