From 29c153ecd7f5deb48727c91d86eea552fae6f15f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:15:59 -0400 Subject: [PATCH 1/6] Count a filer's gains without netted losses and Social Security once in Montana elderly credit income Gross household income is federal AGI "without regard to loss" (MCA 15-30-2337(9)(a)); 2024 Schedule 2EC line 7 says "do not include any capital losses". The loss add-back restores only loss_ald, which holds the capital loss deducted in AGI. A filer's short-term loss netted against their own long-term gain never reaches it, so a member on their own return with a $10,000 long-term gain and a $4,000 short-term loss counted $6,000; as a dependent the same person counted $10,000. Restore the netted loss for everyone who is not a tax unit dependent, as dependent_gross_income does. The untaxed Social Security add-back subtracted both taxable_social_security and dependent_taxable_social_security. A person input as a dependent who is also the head (every adult in the unit input as a dependent) has neither counted, so part of their benefits dropped out. Subtract only the taxable part that irs_gross_income or dependent_gross_income actually counted. Review findings S1 (DTrim99) and P2 (GPT-6.1 Sol) on PolicyEngine/policyengine-us#9882, whose changes landed in #9915. Co-Authored-By: Claude Opus 5.5 --- .../fix-mt-ehrc-filer-gains-ss-roles.fixed.md | 1 + .../gross_income_sources.yaml | 9 ++-- ...or_renter_credit_gross_household_income.py | 42 +++++++++++++++---- 3 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md diff --git a/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md b/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md new file mode 100644 index 00000000000..4a3ddef2131 --- /dev/null +++ b/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md @@ -0,0 +1 @@ +Montana's elderly homeowner/renter credit now counts a filer's capital gains without the losses netted against them on the same return, and counts Social Security once when a person is input as both a tax unit dependent and the head. diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml index 7c3c2184cdf..cf106919fa7 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/gross_income_sources.yaml @@ -80,8 +80,11 @@ values: - mt_elderly_homeowner_or_renter_credit_received - mt_elderly_homeowner_or_renter_credit_federal_refundable_credits - mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received -# Social Security under § 15-30-2337(9)(a)(viii) is added separately in -# mt_elderly_homeowner_or_renter_credit_gross_household_income.py because the -# untaxed portion is computed as social_security - taxable_social_security. +# mt_elderly_homeowner_or_renter_credit_gross_household_income.py adds three +# items separately: a tax unit dependent's own gross income (their income is +# on their own return, not in adjusted_gross_income_person); the untaxed +# portion of Social Security under § 15-30-2337(9)(a)(viii), computed as +# social_security less the taxable part already counted; and the losses +# deducted in federal AGI. # This year's elderly homeowner/renter credit is excluded to avoid a circular # reference; the credit received during the year counts instead. diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py index c6f1065035f..1f10e5273a5 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py @@ -53,16 +53,20 @@ def formula(person, period, parameters): person("dependent_gross_income", period) - own_deductions ) # The income above counts only the taxable portion of Social - # Security: a filer's in federal AGI, a dependent's in their own - # gross income. Add the untaxed portion so all SS is counted per - # § 15-30-2337(9)(a)(viii). + # Security: a filer's in federal AGI (irs_gross_income counts people + # who are not dependents), a dependent's in their own gross income + # (dependent_gross_income counts people who are not the head or + # spouse). Subtract only the part one of them counted, then add the + # rest, so all SS is counted once per § 15-30-2337(9)(a)(viii) even + # when a person is input as both a dependent and the head. social_security = person("social_security", period) - taxable_social_security = add( - person, - period, - ["taxable_social_security", "dependent_taxable_social_security"], + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + counted_social_security = where( + is_dependent, + ~head_or_spouse * person("dependent_taxable_social_security", period), + person("taxable_social_security", period), ) - untaxed_social_security = max_(social_security - taxable_social_security, 0) + untaxed_social_security = max_(social_security - counted_social_security, 0) # Income is federal AGI "without regard to loss" (§ 15-30-2337(9)(a)), # so the business and capital losses deducted in computing federal # AGI are added back (2023 Elderly Homeowner/Renter Credit Schedule, @@ -70,4 +74,24 @@ def formula(person, period, parameters): # losses."). The return's losses are added once, to the head. head = person("is_tax_unit_head", period) losses = head * person.tax_unit("loss_ald", period) - return sources + dependent_income + untaxed_social_security + losses + # loss_ald holds only the capital loss deducted in AGI. A filer's + # short-term loss netted against their own long-term gain (or the + # reverse) never reaches it, so restore that loss here: count each + # gain without its losses, as the dependent path does and as 2024 + # Schedule 2EC line 7 requires ("do not include any capital + # losses"). Taking the larger of the floored parts and the net keeps + # a capital_gains value supplied directly as an input. + long_term_gains = max_(0, person("long_term_capital_gains", period)) + short_term_gains = max_(0, person("short_term_capital_gains", period)) + capital_gains = person("capital_gains", period) + gains_without_losses = max_(long_term_gains + short_term_gains, capital_gains) + netted_capital_loss = ~is_dependent * ( + gains_without_losses - max_(0, capital_gains) + ) + return ( + sources + + dependent_income + + untaxed_social_security + + losses + + netted_capital_loss + ) From 88e09fef0dff3ee6485c61410a1f614d86a48998 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 05:21:50 -0400 Subject: [PATCH 2/6] Add Schedule 2EC cases for netted gains, the dependent gate and overlapping roles Four cases fail on main (a filer's and a claimant's netted capital loss, Social Security for a person input as both dependent and head, and the credit-level netting case); five guard behavior main already has (the dependent gate for a minor alone in a tax unit, a business loss on another member's return, cross-spouse netting on a joint return, and a dependent in another member's tax unit). Co-Authored-By: Claude Opus 5.5 --- ...mt_elderly_homeowner_or_renter_credit.yaml | 85 +++++++++ ..._renter_credit_gross_household_income.yaml | 173 ++++++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml index 472a0218dd0..0891f65af24 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml @@ -580,3 +580,88 @@ # = 1_099.89; line 21 = 100%; line 22 = 1_099.89. mt_elderly_homeowner_or_renter_credit_net_household_income: [900.11, 0] mt_elderly_homeowner_or_renter_credit: [1_099.89, 0] + +- name: 2024 Schedule 2EC, a household member's netted capital loss does not raise the credit + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 30 + long_term_capital_gains: 10_000 + short_term_capital_gains: -4_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 5 = 20_000; line 17 = 10_000 (the gain without the loss); line + # 18 = 30_000; line 20 = 17_400; line 22 = 17_400 x 0.05 = 870; line 27 = + # 2_000 - 870 = 1_130; line 29 = 100%; line 30 = 1_130. Netting the loss + # would give line 18 = 26_000, line 22 = 670 and the 1_150 cap. + mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] + mt_elderly_homeowner_or_renter_credit: [1_130, 0] + +- name: 2024 Schedule 2EC, a capital loss netted between spouses on a joint return is added back once + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: 10_000 + real_estate_taxes: 2_000 + person2: + age: 68 + short_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Federal AGI 20_000 + 10_000 - 4_000 = 26_000; the 4_000 loss is added + # back once. Line 18 = 30_000; line 22 = 870; line 30 = 1_130. + mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] + mt_elderly_homeowner_or_renter_credit: [1_130, 0] + +- name: 2024 Schedule 2EC, a minor filing alone in the household counts once + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + is_tax_unit_dependent: false + person2: + age: 16 + employment_income: 10_000 + is_tax_unit_dependent: false + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 18 = 20_000 + 10_000 = 30_000; line 30 = 1_130. Counting the + # minor's wages twice would give 40_000, line 22 = 1_370 and a credit of + # (2_000 - 1_370) x 30% = 189. + mt_elderly_homeowner_or_renter_credit: [1_130, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml index ec00f8c2fad..502bfc29e52 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml @@ -336,3 +336,176 @@ # extent that the amount recovered reduced the claimant's Montana income # tax in a prior year". mt_elderly_homeowner_or_renter_credit_gross_household_income: 10_000 + +- name: A filer's short-term loss netted against their own long-term gain does not reduce gross household income + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 30 + long_term_capital_gains: 10_000 + short_term_capital_gains: -4_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # § 15-30-2337(9)(a): federal AGI "without regard to loss"; 2024 + # Schedule 2EC line 7: "do not include any capital losses". The member's + # own return nets the 4_000 loss into a 6_000 gain, so no loss reaches + # loss_ald; line 17 still carries the 10_000 gain. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: A claimant's short-term loss netted against their own long-term gain does not reduce gross household income + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: 10_000 + short_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Line 5 pensions 20_000 plus line 7 capital gain 10_000, without the + # 4_000 loss: 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + +- name: A minor alone in a tax unit and not input as a dependent counts once + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + is_tax_unit_dependent: false + person2: + age: 16 + employment_income: 10_000 + is_tax_unit_dependent: false + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # The minor is neither head (no adult in the unit), spouse nor + # dependent. irs_gross_income counts people who are not dependents and + # dependent_gross_income people who are not the head or spouse, so only + # the dependent gate keeps the 10_000 wages from counting twice. + is_tax_unit_head: [true, false] + is_tax_unit_dependent: [false, false] + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: Social Security counts in full for a person input as a dependent who is also the head + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + is_tax_unit_dependent: false + person2: + age: 40 + social_security_retirement: 60_000 + is_tax_unit_dependent: true + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Every adult in tax_unit2 is input as a dependent, so person2 is also + # its head. Neither irs_gross_income (dependents) nor + # dependent_gross_income (heads) counts their taxable benefits, so none + # may be subtracted: § 15-30-2337(9)(a)(viii) counts all 60_000. + is_tax_unit_head: [true, true] + is_tax_unit_dependent: [false, true] + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 60_000] + +- name: A business loss on another household member's return does not reduce gross household income + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 40 + taxable_interest_income: 10_000 + self_employment_income: -5_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # The member's federal AGI is 10_000 - 5_000 = 5_000; the 5_000 loss is + # added back on their own return's head. 2024 Schedule 2EC line 10: + # "Do not include any losses generated from the business." + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: A dependent in another household member's tax unit counts + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 15_000 + person2: + age: 35 + employment_income: 25_000 + person3: + age: 5 + person4: + age: 8 + employment_income: 3_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + state_name: MT + output: + # Three generations. The daughter's line 8 credits: federal EITC with + # two children 6_960 - 21.06% x (25_000 - 22_720) = 6_479.83; refundable + # child tax credit min(2 x 1_700, 15% x (25_000 - 2_500)) = 3_375; + # Montana EITC 10% = 647.98. Her total: 25_000 + 6_479.83 + 3_375 + + # 647.98 = 35_502.81. The grandchild is her dependent; their 3_000 of + # wages count through the dependent path. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [15_000, 35_502.81, 0, 3_000] From 5c9ac0570e58a1347539ad417a9ca3af9a201b52 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:24:43 -0400 Subject: [PATCH 3/6] Restore the year's gain hidden by a capital loss carryover in Montana elderly credit income long_term_capital_gains already includes the prior-year long-term capital loss carryover as a loss (Schedule D line 15; long_term_capital_loss_carryover is a memo). A carryover is a loss, so it must not reduce gross household income (MCA 15-30-2337(9)(a); 2024 Schedule 2EC line 7, "do not include any capital losses"). Add it back to get the year's own long-term gain, as az_property_tax_credit_capital_gains does, and restore only the gain it hid, on both income paths. Restoration applies where an income path counts the person, so a person input as both a dependent and the head stays excluded from both, as before. New cases: a carryover smaller than, larger than and equal to the year's gain; a dependent's carryover; a long-term loss netted against a short-term gain; losses exceeding the gain; and Social Security for a spouse also input as a dependent. Review finding P2 (GPT-6.1 Sol) on #10139. Co-Authored-By: Claude Opus 5.5 --- ...mt_elderly_homeowner_or_renter_credit.yaml | 24 +++ ..._renter_credit_gross_household_income.yaml | 162 ++++++++++++++++++ ...or_renter_credit_gross_household_income.py | 45 +++-- 3 files changed, 219 insertions(+), 12 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml index 0891f65af24..32639b69686 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml @@ -665,3 +665,27 @@ # minor's wages twice would give 40_000, line 22 = 1_370 and a credit of # (2_000 - 1_370) x 30% = 189. mt_elderly_homeowner_or_renter_credit: [1_130, 0] + +- name: 2024 Schedule 2EC, a capital loss carryover does not raise the credit + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: 6_000 + long_term_capital_loss_carryover: 4_000 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Line 5 = 20_000; line 7 = the year's 10_000 gain; line 18 = 30_000; + # line 22 = 870; line 30 = 1_130. Counting the 6_000 net would give line + # 18 = 26_000, line 22 = 670 and the 1_150 cap. + mt_elderly_homeowner_or_renter_credit: 1_130 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml index 502bfc29e52..f318c2f1dc3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml @@ -509,3 +509,165 @@ # 647.98 = 35_502.81. The grandchild is her dependent; their 3_000 of # wages count through the dependent path. mt_elderly_homeowner_or_renter_credit_gross_household_income: [15_000, 35_502.81, 0, 3_000] + +- name: A prior-year capital loss carryover does not reduce the year's gain + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + # A 10_000 gain this year less a 4_000 carryover (Schedule D line 15). + long_term_capital_gains: 6_000 + long_term_capital_loss_carryover: 4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Line 7 counts the year's gain, 10_000, without the carried loss. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + +- name: A carryover larger than the year's gain leaves the gain counted and the rest unused + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + # A 2_000 gain this year less a 10_000 carryover. + long_term_capital_gains: -8_000 + long_term_capital_loss_carryover: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # 20_000 + the year's 2_000 gain; the unused 8_000 of carryover counts + # neither as income nor as a loss. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 22_000 + +- name: A carryover with no gain this year adds nothing + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: -5_000 + long_term_capital_loss_carryover: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + mt_elderly_homeowner_or_renter_credit_gross_household_income: 20_000 + +- name: A dependent's capital loss carryover does not reduce their gain + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 16 + long_term_capital_gains: 6_000 + long_term_capital_loss_carryover: 4_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 17: the dependent's 10_000 gain this year, as on a filer's return. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: A long-term loss netted against a short-term gain does not reduce gross household income + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: -4_000 + short_term_capital_gains: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + +- name: A gain is counted when the person's capital losses exceed it + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: -10_000 + short_term_capital_gains: 4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Federal AGI 20_000 - 3_000 (the § 1211(b) limit); the 3_000 is added + # back and the 4_000 short-term gain counts: 24_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 24_000 + +- name: Social Security counts in full for a spouse who is also input as a dependent + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + is_tax_unit_dependent: false + person2: + age: 50 + social_security_retirement: 60_000 + is_tax_unit_dependent: true + person3: + age: 45 + social_security_retirement: 60_000 + is_tax_unit_dependent: true + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2, person3] + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2, person3] + households: + household: + members: [person1, person2, person3] + state_name: MT + output: + # Both adults of tax_unit2 are input as dependents, so they stay head and + # spouse as well. § 15-30-2337(9)(a)(viii) counts all of each one's + # benefits. + is_tax_unit_head: [true, true, false] + is_tax_unit_spouse: [false, false, true] + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 60_000, 60_000] diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py index 1f10e5273a5..a7ebc1c990b 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py @@ -74,24 +74,45 @@ def formula(person, period, parameters): # losses."). The return's losses are added once, to the head. head = person("is_tax_unit_head", period) losses = head * person.tax_unit("loss_ald", period) - # loss_ald holds only the capital loss deducted in AGI. A filer's - # short-term loss netted against their own long-term gain (or the - # reverse) never reaches it, so restore that loss here: count each - # gain without its losses, as the dependent path does and as 2024 - # Schedule 2EC line 7 requires ("do not include any capital - # losses"). Taking the larger of the floored parts and the net keeps - # a capital_gains value supplied directly as an input. - long_term_gains = max_(0, person("long_term_capital_gains", period)) + # Count each person's capital gains without losses, as 2024 Schedule + # 2EC line 7 requires ("do not include any capital losses"). Two + # losses reduce the gains the income paths count without reaching + # loss_ald, which holds only the capital loss deducted in AGI: + # - a short-term loss netted against the same person's long-term gain + # (or the reverse) inside capital_gains; + # - the prior-year long-term capital loss carryover, which + # long_term_capital_gains already includes as a loss, as Schedule D + # line 15 does. Adding it back gives the year's own long-term gain + # (as az_property_tax_credit_capital_gains does); only the gain it + # hid is restored, never the unused loss. + # Taking the larger of the floored parts and the net keeps a + # capital_gains value supplied directly as an input. + carryover = max_(0, person("long_term_capital_loss_carryover", period)) + long_term_gains = max_(0, person("long_term_capital_gains", period) + carryover) short_term_gains = max_(0, person("short_term_capital_gains", period)) capital_gains = person("capital_gains", period) - gains_without_losses = max_(long_term_gains + short_term_gains, capital_gains) - netted_capital_loss = ~is_dependent * ( - gains_without_losses - max_(0, capital_gains) + gains_without_losses = max_( + long_term_gains + short_term_gains, capital_gains + carryover ) + # The gains each income path already counted: irs_gross_income + # floors the net, dependent_gross_income floors each part. A person + # input as both a dependent and the head is counted by neither path, + # so nothing is restored for them. + counted_gains = where( + is_dependent, + ~head_or_spouse + * max_( + max_(0, person("long_term_capital_gains", period)) + short_term_gains, + capital_gains, + ), + max_(0, capital_gains), + ) + counted_by_a_path = ~(is_dependent & head_or_spouse) + lost_capital_gains = counted_by_a_path * (gains_without_losses - counted_gains) return ( sources + dependent_income + untaxed_social_security + losses - + netted_capital_loss + + lost_capital_gains ) From 737f3facfdccb33143378725b3dfbec36e047e9a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:24:56 -0400 Subject: [PATCH 4/6] Describe the carryover in the changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md b/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md index 4a3ddef2131..6ea58342f76 100644 --- a/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md +++ b/changelog.d/fix-mt-ehrc-filer-gains-ss-roles.fixed.md @@ -1 +1 @@ -Montana's elderly homeowner/renter credit now counts a filer's capital gains without the losses netted against them on the same return, and counts Social Security once when a person is input as both a tax unit dependent and the head. +Montana's elderly homeowner/renter credit now counts each person's capital gains without losses netted on the same return or carried over from a prior year, and counts Social Security once when a person is input as both a tax unit dependent and the head or spouse. From 2449fbd7845d2dab22aca077b5d0f730cfe220b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 07:11:15 -0400 Subject: [PATCH 5/6] Count a carryover only against gains a direct net capital_gains input shows; add capital gains property tests When capital_gains is supplied directly as a net amount, the long-term and short-term parts default to zero, so adding the carryover to the long-term part invented a gain: a direct -5,000 net with a 5,000 carryover counted 5,000 (review round 2 on #10139). Compute gains without losses as the year's net, max(0, capital_gains + carryover), plus the netting between the year's long-term and short-term amounts, max(0, a) + max(0, b) - max(0, a + b). With parts supplied this is the floored parts; with a direct net it is the net alone. test_mt_elderly_credit_capital_gains.py checks, for random long-term and short-term amounts or a direct net, a carryover and distributions, that a household member's gains count without losses whether they are a dependent, file their own return or are the claimant, against a reference written from Schedule 2EC line 7, and that the credit is the same either way. It is derandomized (20 examples, 11 s of test time locally); a 300-example random local run passed, and it fails on main and on the previous head. Co-Authored-By: Claude Opus 5.5 --- ...mt_elderly_homeowner_or_renter_credit.yaml | 23 ++ ..._renter_credit_gross_household_income.yaml | 65 ++++++ .../test_mt_elderly_credit_capital_gains.py | 197 ++++++++++++++++++ ...or_renter_credit_gross_household_income.py | 19 +- 4 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 policyengine_us/tests/test_mt_elderly_credit_capital_gains.py diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml index 32639b69686..8b0b1484799 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml @@ -689,3 +689,26 @@ # line 22 = 870; line 30 = 1_130. Counting the 6_000 net would give line # 18 = 26_000, line 22 = 670 and the 1_150 cap. mt_elderly_homeowner_or_renter_credit: 1_130 + +- name: 2024 Schedule 2EC, a carryover against a net loss supplied directly adds no gain + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 30_000 + capital_gains: -5_000 + long_term_capital_loss_carryover: 5_000 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Line 18 = 30_000; line 22 = 17_400 x 0.05 = 870; line 30 = 1_130. + # Counting the carryover as a gain would give 35_000 and 880 x 40% = 352. + mt_elderly_homeowner_or_renter_credit: 1_130 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml index f318c2f1dc3..35c1732014b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml @@ -671,3 +671,68 @@ is_tax_unit_head: [true, true, false] is_tax_unit_spouse: [false, false, true] mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 60_000, 60_000] + +- name: A carryover with a net capital loss supplied directly counts no gain + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 30_000 + # A net amount supplied directly, without long-term and short-term + # parts; with the 5_000 carryover added back, the year's net is zero. + capital_gains: -5_000 + long_term_capital_loss_carryover: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # Federal AGI 30_000 - 3_000 (§ 1211(b)); the 3_000 is added back. No + # gain this year, so line 7 is zero: 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + +- name: A carryover with a net capital gain supplied directly restores the year's gain + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + capital_gains: 6_000 + long_term_capital_loss_carryover: 4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # The year's net gain is 6_000 + 4_000 = 10_000: 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 + +- name: A dependent's carryover with a net capital loss supplied directly counts no gain + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + person2: + age: 16 + employment_income: 10_000 + capital_gains: -5_000 + long_term_capital_loss_carryover: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] diff --git a/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py b/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py new file mode 100644 index 00000000000..a2d8710f180 --- /dev/null +++ b/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py @@ -0,0 +1,197 @@ +"""Property tests: capital gains in Montana elderly homeowner/renter credit +income are counted without losses. + +MCA 15-30-2337(9)(a) defines income as "federal adjusted gross income, +without regard to loss", and 2024 Schedule 2EC line 7 says "do not include +any capital losses". A person's capital gains therefore enter gross household +income as the year's gains without losses. Three losses can hide them: +a short-term loss netted against a long-term gain (or the reverse), the +prior-year long-term capital loss carryover that long_term_capital_gains +already includes, and losses netted against capital gain distributions. The +model counts the gains through three paths (federal AGI and the loss_ald +add-back for filers; dependent_gross_income for tax unit dependents) and +restores what each path nets away. + +For a claimant aged 62 or older with a pension, and one other household +member whose capital gains are drawn either as long-term and short-term +parts or as a net capital_gains amount supplied directly, with a carryover +and distributions: + +1. Gross household income equals the pension plus the member's gains without + losses: max(0, long-term + carryover) + max(0, short-term) from the parts, + or max(0, net + carryover) from a direct net amount, plus distributions. + This holds whether the member is the claimant's dependent (A), files + their own return (B), or is the claimant's own gains on the claimant's + return (C, the claimant alone with the member's capital inputs). +2. The claimant's credit is the same in A and B. + +The reference is written from the form, independently of the model. The +model computes in single precision, so comparisons allow one cent or eight +float32 spacings at the case's total, whichever is larger. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2024 +PENSION = 20_000 +PROPERTY_TAX = 2_000 +TOLERANCE = 0.01 + + +def capital_inputs(c=None): + """Every capital input, set for every person: once capital_gains is an + input for some people in a simulation, the others get its default (zero), + not long_term_capital_gains + short_term_capital_gains. A direct net amount + has no long-term or short-term parts.""" + c = c or dict(direct=True, net=0, carryover=0, distributions=0) + if c["direct"]: + long_term, short_term, net = 0, 0, c["net"] + else: + long_term, short_term = c["long_term"], c["short_term"] + net = long_term + short_term + return { + "long_term_capital_gains": {YEAR: long_term}, + "short_term_capital_gains": {YEAR: short_term}, + "capital_gains": {YEAR: net}, + "long_term_capital_loss_carryover": {YEAR: c["carryover"]}, + "non_sch_d_capital_gains": {YEAR: c["distributions"]}, + } + + +def build_situation(cases): + """Per case, three Montana households: (A) the member is the claimant's + dependent, (B) the member files their own return and (C) the claimant + alone, with the member's capital inputs on the claimant's return.""" + people, tax_units, marital_units, households = {}, {}, {}, {} + for i, c in enumerate(cases): + for arrangement in "ABC": + key = f"{arrangement}{i}" + claimant = f"claimant_{key}" + people[claimant] = { + "age": {YEAR: 70}, + "taxable_pension_income": {YEAR: PENSION}, + "real_estate_taxes": {YEAR: PROPERTY_TAX}, + "is_tax_unit_dependent": {YEAR: False}, + **capital_inputs(), + } + marital_units[f"marital_unit_{key}"] = {"members": [claimant]} + if arrangement == "C": + people[claimant].update(capital_inputs(c)) + tax_units[f"tax_unit_{key}"] = {"members": [claimant]} + households[f"household_{key}"] = { + "members": [claimant], + "state_code": {YEAR: "MT"}, + } + continue + member = f"member_{key}" + # Set for everyone: an input for some people gives the others its + # default, not its formula. + people[member] = { + "age": {YEAR: 30}, + "is_tax_unit_dependent": {YEAR: arrangement == "A"}, + **capital_inputs(c), + } + marital_units[f"marital_unit_{key}_member"] = {"members": [member]} + if arrangement == "A": + tax_units[f"tax_unit_{key}"] = {"members": [claimant, member]} + else: + tax_units[f"tax_unit_{key}"] = {"members": [claimant]} + tax_units[f"tax_unit_{key}_member"] = {"members": [member]} + households[f"household_{key}"] = { + "members": [claimant, member], + "state_code": {YEAR: "MT"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households, + } + + +def gains_without_losses(c): + """Schedule 2EC line 7 (and line 17 for another member): the year's + capital gains without losses, plus distributions.""" + if c["direct"]: + gains = max(0, c["net"] + c["carryover"]) + else: + gains = max(0, c["long_term"] + c["carryover"]) + max(0, c["short_term"]) + return gains + c["distributions"] + + +def check(cases): + simulation = Simulation(situation=build_situation(cases)) + income = np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit_gross_household_income", + YEAR, + map_to="household", + ) + ).reshape(len(cases), 3) + credit = np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit", YEAR, map_to="household" + ) + ).reshape(len(cases), 3) + for i, c in enumerate(cases): + expected = PENSION + gains_without_losses(c) + scale = PENSION + sum( + abs(c[k]) + for k in ["long_term", "short_term", "net", "carryover", "distributions"] + ) + tol = max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + # 1. Every path counts the gains without losses. + assert income[i] == pytest.approx([expected] * 3, abs=tol), c + # 2. The credit does not depend on how the member files. + assert credit[i, 0] == pytest.approx(credit[i, 1], abs=tol), c + return income, credit + + +def test_examples(): + """A 10,000 long-term gain this year with a 4,000 carryover (6,000 net); + a 4,000 short-term loss against it; and a direct -5,000 net with a 5,000 + carryover, which hides no gain.""" + zero = dict(long_term=0, short_term=0, net=0, carryover=0, distributions=0) + cases = [ + {**zero, "direct": False, "long_term": 6_000, "carryover": 4_000}, + {**zero, "direct": False, "long_term": 10_000, "short_term": -4_000}, + {**zero, "direct": True, "net": -5_000, "carryover": 5_000}, + ] + income, credit = check(cases) + assert income[:, 0].tolist() == pytest.approx([30_000, 30_000, 20_000]) + # (30_000 - 12_600) x 0.05 = 870 and 2_000 - 870 = 1_130; the third case + # has 20_000: (7_400 x 0.035 = 259), so the 1_150 cap. + assert credit[:, 0].tolist() == pytest.approx([1_130, 1_130, 1_150], abs=0.01) + + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + SLOW = [hypothesis.HealthCheck.too_slow] + SIGNED = st.integers(min_value=-30_000, max_value=30_000) + NONNEGATIVE = st.integers(min_value=0, max_value=20_000) + + @st.composite + def case(draw): + return { + "direct": draw(st.booleans()), + "long_term": draw(st.one_of(st.just(0), SIGNED)), + "short_term": draw(st.one_of(st.just(0), SIGNED)), + "net": draw(st.one_of(st.just(0), SIGNED)), + "carryover": draw(st.one_of(st.just(0), NONNEGATIVE)), + "distributions": draw(st.one_of(st.just(0), NONNEGATIVE)), + } + + @hypothesis.settings( + max_examples=20, deadline=None, derandomize=True, suppress_health_check=SLOW + ) + @hypothesis.given(st.lists(case(), min_size=1, max_size=8)) + def test_gains_without_losses(cases): + check(cases) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py index a7ebc1c990b..c6c0003337e 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py @@ -85,15 +85,22 @@ def formula(person, period, parameters): # line 15 does. Adding it back gives the year's own long-term gain # (as az_property_tax_credit_capital_gains does); only the gain it # hid is restored, never the unused loss. - # Taking the larger of the floored parts and the net keeps a - # capital_gains value supplied directly as an input. + # The year's net gain is capital_gains plus the carryover. Netting + # between the year's long-term and short-term amounts adds + # max(0, a) + max(0, b) - max(0, a + b) on top of it. When the parts + # are supplied this leaves the floored parts. When capital_gains is + # supplied directly as a net amount, without parts, the netting term + # is zero, so the carryover is not counted as a gain the parts never + # showed. carryover = max_(0, person("long_term_capital_loss_carryover", period)) - long_term_gains = max_(0, person("long_term_capital_gains", period) + carryover) - short_term_gains = max_(0, person("short_term_capital_gains", period)) + long_term = person("long_term_capital_gains", period) + carryover + short_term = person("short_term_capital_gains", period) + short_term_gains = max_(0, short_term) capital_gains = person("capital_gains", period) - gains_without_losses = max_( - long_term_gains + short_term_gains, capital_gains + carryover + netted_between_terms = ( + max_(0, long_term) + short_term_gains - max_(0, long_term + short_term) ) + gains_without_losses = max_(0, capital_gains + carryover) + netted_between_terms # The gains each income path already counted: irs_gross_income # floors the net, dependent_gross_income floors each part. A person # input as both a dependent and the head is counted by neither path, From ca9c11bc4181efff7d2d544dd21ea5344af9399d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:14:44 -0400 Subject: [PATCH 6/6] Let the long-term and short-term parts decide Montana elderly credit gains; test every sign region in one simulation Once capital_gains is an input for anyone in a simulation, everyone without one holds its default of zero, not the sum of their parts. The previous identity started from capital_gains, so a dependent with only a 10,000 long-term gain, beside a claimant whose capital_gains was supplied as 0, counted nothing (review round 3 on #10139). The parts now decide the gains whenever either is nonzero, max(0, long-term + carryover) + max(0, short-term); without parts, capital_gains is a net supplied directly and counts as max(0, net + carryover). The restoration replaces whatever the income path counted, so an inconsistent capital_gains input yields to the parts. test_mt_elderly_credit_capital_gains.py now builds one simulation from a grid covering every sign region of the long-term amount plus carryover, the short-term amount and a direct net, with and without distributions, in three input styles (parts with their net, parts with the net left to default, a net alone) and three placements (dependent, own return, claimant). It replaces 20 Hypothesis-built simulations: 3.1 s of test time locally, peak footprint 1.25 GB against 1.24 GB for importing the model and building a one-person simulation, so about 8 MB for the test itself. It fails on main and on the previous two heads. Co-Authored-By: Claude Opus 5.5 --- ...mt_elderly_homeowner_or_renter_credit.yaml | 24 ++ ..._renter_credit_gross_household_income.yaml | 46 +++ .../test_mt_elderly_credit_capital_gains.py | 273 ++++++++++-------- ...or_renter_credit_gross_household_income.py | 37 ++- 4 files changed, 241 insertions(+), 139 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml index 8b0b1484799..66476008e3e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml @@ -712,3 +712,27 @@ # Line 18 = 30_000; line 22 = 17_400 x 0.05 = 870; line 30 = 1_130. # Counting the carryover as a gain would give 35_000 and 880 x 40% = 352. mt_elderly_homeowner_or_renter_credit: 1_130 + +- name: 2024 Schedule 2EC, a dependent's gain counts when another person's capital_gains is supplied + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + capital_gains: 0 + real_estate_taxes: 2_000 + person2: + age: 16 + long_term_capital_gains: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 18 = 20_000 + 10_000 = 30_000; line 22 = 870; line 30 = 1_130. + mt_elderly_homeowner_or_renter_credit: [1_130, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml index 35c1732014b..88c8b004aa0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.yaml @@ -736,3 +736,49 @@ state_name: MT output: mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: A dependent's long-term gain counts when another person's capital_gains is supplied + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + capital_gains: 0 + person2: + age: 16 + long_term_capital_gains: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # capital_gains is an input for person1, so person2 holds its default of + # zero rather than the sum of their parts. The parts decide: line 17 + # counts the dependent's 10_000 gain. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 10_000] + +- name: The long-term and short-term parts decide the gains when capital_gains disagrees with them + period: 2024 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + long_term_capital_gains: 10_000 + short_term_capital_gains: -4_000 + capital_gains: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_name: MT + output: + # The parts show a 10_000 gain and a 4_000 loss; line 7 counts 10_000, + # not the inconsistent 20_000 net: 30_000. + mt_elderly_homeowner_or_renter_credit_gross_household_income: 30_000 diff --git a/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py b/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py index a2d8710f180..2377af122f6 100644 --- a/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py +++ b/policyengine_us/tests/test_mt_elderly_credit_capital_gains.py @@ -1,35 +1,50 @@ -"""Property tests: capital gains in Montana elderly homeowner/renter credit -income are counted without losses. +"""Invariant tests: capital gains in Montana elderly homeowner/renter credit +income are counted without losses, however they are supplied. MCA 15-30-2337(9)(a) defines income as "federal adjusted gross income, without regard to loss", and 2024 Schedule 2EC line 7 says "do not include -any capital losses". A person's capital gains therefore enter gross household -income as the year's gains without losses. Three losses can hide them: -a short-term loss netted against a long-term gain (or the reverse), the -prior-year long-term capital loss carryover that long_term_capital_gains -already includes, and losses netted against capital gain distributions. The -model counts the gains through three paths (federal AGI and the loss_ald -add-back for filers; dependent_gross_income for tax unit dependents) and +any capital losses". So a person's capital gains enter gross household income +as the year's gains without losses. Three losses can hide them: +- a short-term loss netted against a long-term gain, or the reverse; +- the prior-year long-term capital loss carryover, which + long_term_capital_gains already includes; +- losses netted against capital gain distributions. + +The model counts gains through federal AGI and the loss_ald add-back for +filers, and through dependent_gross_income for tax unit dependents, and restores what each path nets away. -For a claimant aged 62 or older with a pension, and one other household -member whose capital gains are drawn either as long-term and short-term -parts or as a net capital_gains amount supplied directly, with a carryover -and distributions: - -1. Gross household income equals the pension plus the member's gains without - losses: max(0, long-term + carryover) + max(0, short-term) from the parts, - or max(0, net + carryover) from a direct net amount, plus distributions. - This holds whether the member is the claimant's dependent (A), files - their own return (B), or is the claimant's own gains on the claimant's - return (C, the claimant alone with the member's capital inputs). +The gain is a piecewise-linear function of a few amounts. So instead of +random draws, one vectorized simulation covers a grid that reaches every +sign region of the long-term amount plus carryover, the short-term amount, +and a net amount, with and without distributions. It supplies them in three +styles: + +- parts: long-term and short-term amounts, with capital_gains set to their + sum; +- sparse parts: long-term and short-term amounts with capital_gains omitted. + Because capital_gains is an input for other people in the same + simulation, these people hold its default of zero, not the sum of their + parts (review round 3 of #10139); +- net: capital_gains alone, without parts. + +Each case appears three ways: (A) a household member who is the claimant's +dependent, (B) a member who files their own return, and (C) the claimant +themselves. + +1. Gross household income is the claimant's pension plus the gains without + losses plus distributions. The reference is written from Schedule 2EC + line 7, independently of the model: with parts, + max(0, long-term + carryover) + max(0, short-term); without them, + max(0, net + carryover). 2. The claimant's credit is the same in A and B. -The reference is written from the form, independently of the model. The -model computes in single precision, so comparisons allow one cent or eight -float32 spacings at the case's total, whichever is larger. +The model computes in single precision, so comparisons allow one cent or +eight float32 spacings at the case's total, whichever is larger. """ +import itertools + import numpy as np import pytest @@ -40,31 +55,52 @@ PROPERTY_TAX = 2_000 TOLERANCE = 0.01 +LONG_TERM = [-12_000, -3_000, 0, 4_000, 11_000] +SHORT_TERM = [-7_000, 0, 2_000, 9_000] +NET = [-9_000, -2_500, 0, 3_000, 14_000] +CARRYOVER = [0, 2_500, 15_000] +DISTRIBUTIONS = [0, 4_000] + + +def grid(): + cases = [] + for style in ["parts", "sparse"]: + for lt, st, co, dist in itertools.product( + LONG_TERM, SHORT_TERM, CARRYOVER, DISTRIBUTIONS + ): + if lt == 0 and st == 0: + continue # no parts: covered by the net style + cases.append( + dict(style=style, long_term=lt, short_term=st, net=lt + st) + | dict(carryover=co, distributions=dist) + ) + for net, co, dist in itertools.product(NET, CARRYOVER, DISTRIBUTIONS): + cases.append( + dict(style="net", long_term=0, short_term=0, net=net) + | dict(carryover=co, distributions=dist) + ) + return cases -def capital_inputs(c=None): - """Every capital input, set for every person: once capital_gains is an - input for some people in a simulation, the others get its default (zero), - not long_term_capital_gains + short_term_capital_gains. A direct net amount - has no long-term or short-term parts.""" - c = c or dict(direct=True, net=0, carryover=0, distributions=0) - if c["direct"]: - long_term, short_term, net = 0, 0, c["net"] - else: - long_term, short_term = c["long_term"], c["short_term"] - net = long_term + short_term - return { - "long_term_capital_gains": {YEAR: long_term}, - "short_term_capital_gains": {YEAR: short_term}, - "capital_gains": {YEAR: net}, + +def capital_inputs(c): + inputs = { "long_term_capital_loss_carryover": {YEAR: c["carryover"]}, "non_sch_d_capital_gains": {YEAR: c["distributions"]}, } + if c["style"] != "net": + inputs["long_term_capital_gains"] = {YEAR: c["long_term"]} + inputs["short_term_capital_gains"] = {YEAR: c["short_term"]} + if c["style"] != "sparse": + inputs["capital_gains"] = {YEAR: c["net"]} + return inputs + + +NO_CAPITAL = dict(style="parts", long_term=0, short_term=0, net=0) | dict( + carryover=0, distributions=0 +) def build_situation(cases): - """Per case, three Montana households: (A) the member is the claimant's - dependent, (B) the member files their own return and (C) the claimant - alone, with the member's capital inputs on the claimant's return.""" people, tax_units, marital_units, households = {}, {}, {}, {} for i, c in enumerate(cases): for arrangement in "ABC": @@ -74,34 +110,31 @@ def build_situation(cases): "age": {YEAR: 70}, "taxable_pension_income": {YEAR: PENSION}, "real_estate_taxes": {YEAR: PROPERTY_TAX}, + # Set for everyone: an input for some people gives the others + # its default, not its formula. "is_tax_unit_dependent": {YEAR: False}, - **capital_inputs(), + **capital_inputs(c if arrangement == "C" else NO_CAPITAL), } marital_units[f"marital_unit_{key}"] = {"members": [claimant]} + members = [claimant] if arrangement == "C": - people[claimant].update(capital_inputs(c)) tax_units[f"tax_unit_{key}"] = {"members": [claimant]} - households[f"household_{key}"] = { - "members": [claimant], - "state_code": {YEAR: "MT"}, - } - continue - member = f"member_{key}" - # Set for everyone: an input for some people gives the others its - # default, not its formula. - people[member] = { - "age": {YEAR: 30}, - "is_tax_unit_dependent": {YEAR: arrangement == "A"}, - **capital_inputs(c), - } - marital_units[f"marital_unit_{key}_member"] = {"members": [member]} - if arrangement == "A": - tax_units[f"tax_unit_{key}"] = {"members": [claimant, member]} else: - tax_units[f"tax_unit_{key}"] = {"members": [claimant]} - tax_units[f"tax_unit_{key}_member"] = {"members": [member]} + member = f"member_{key}" + people[member] = { + "age": {YEAR: 30}, + "is_tax_unit_dependent": {YEAR: arrangement == "A"}, + **capital_inputs(c), + } + marital_units[f"marital_unit_{key}_member"] = {"members": [member]} + members.append(member) + if arrangement == "A": + tax_units[f"tax_unit_{key}"] = {"members": members} + else: + tax_units[f"tax_unit_{key}"] = {"members": [claimant]} + tax_units[f"tax_unit_{key}_member"] = {"members": [member]} households[f"household_{key}"] = { - "members": [claimant, member], + "members": members, "state_code": {YEAR: "MT"}, } return { @@ -113,85 +146,77 @@ def build_situation(cases): def gains_without_losses(c): - """Schedule 2EC line 7 (and line 17 for another member): the year's - capital gains without losses, plus distributions.""" - if c["direct"]: + """Schedule 2EC line 7 (line 17 for another member) plus distributions.""" + if c["style"] == "net": gains = max(0, c["net"] + c["carryover"]) else: gains = max(0, c["long_term"] + c["carryover"]) + max(0, c["short_term"]) return gains + c["distributions"] -def check(cases): +@pytest.fixture(scope="module") +def results(): + cases = grid() simulation = Simulation(situation=build_situation(cases)) + shape = (len(cases), 3) income = np.asarray( simulation.calculate( "mt_elderly_homeowner_or_renter_credit_gross_household_income", YEAR, map_to="household", ) - ).reshape(len(cases), 3) + ).reshape(shape) credit = np.asarray( simulation.calculate( "mt_elderly_homeowner_or_renter_credit", YEAR, map_to="household" ) - ).reshape(len(cases), 3) + ).reshape(shape) + return cases, income, credit + + +def tolerance(c): + scale = PENSION + sum( + abs(c[k]) for k in ["long_term", "short_term", "net", "carryover"] + ) + scale += c["distributions"] + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def test_gains_count_without_losses_on_every_path(results): + cases, income, _ = results for i, c in enumerate(cases): expected = PENSION + gains_without_losses(c) - scale = PENSION + sum( - abs(c[k]) - for k in ["long_term", "short_term", "net", "carryover", "distributions"] - ) - tol = max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) - # 1. Every path counts the gains without losses. - assert income[i] == pytest.approx([expected] * 3, abs=tol), c - # 2. The credit does not depend on how the member files. - assert credit[i, 0] == pytest.approx(credit[i, 1], abs=tol), c - return income, credit - - -def test_examples(): - """A 10,000 long-term gain this year with a 4,000 carryover (6,000 net); - a 4,000 short-term loss against it; and a direct -5,000 net with a 5,000 - carryover, which hides no gain.""" - zero = dict(long_term=0, short_term=0, net=0, carryover=0, distributions=0) - cases = [ - {**zero, "direct": False, "long_term": 6_000, "carryover": 4_000}, - {**zero, "direct": False, "long_term": 10_000, "short_term": -4_000}, - {**zero, "direct": True, "net": -5_000, "carryover": 5_000}, - ] - income, credit = check(cases) - assert income[:, 0].tolist() == pytest.approx([30_000, 30_000, 20_000]) - # (30_000 - 12_600) x 0.05 = 870 and 2_000 - 870 = 1_130; the third case - # has 20_000: (7_400 x 0.035 = 259), so the 1_150 cap. - assert credit[:, 0].tolist() == pytest.approx([1_130, 1_130, 1_150], abs=0.01) - - -try: - import hypothesis - import hypothesis.strategies as st -except ImportError: # pragma: no cover - hypothesis = None - -if hypothesis is not None: - SLOW = [hypothesis.HealthCheck.too_slow] - SIGNED = st.integers(min_value=-30_000, max_value=30_000) - NONNEGATIVE = st.integers(min_value=0, max_value=20_000) - - @st.composite - def case(draw): - return { - "direct": draw(st.booleans()), - "long_term": draw(st.one_of(st.just(0), SIGNED)), - "short_term": draw(st.one_of(st.just(0), SIGNED)), - "net": draw(st.one_of(st.just(0), SIGNED)), - "carryover": draw(st.one_of(st.just(0), NONNEGATIVE)), - "distributions": draw(st.one_of(st.just(0), NONNEGATIVE)), - } - - @hypothesis.settings( - max_examples=20, deadline=None, derandomize=True, suppress_health_check=SLOW + assert income[i] == pytest.approx([expected] * 3, abs=tolerance(c)), c + + +def test_credit_does_not_depend_on_how_the_member_files(results): + cases, _, credit = results + for i, c in enumerate(cases): + assert credit[i, 0] == pytest.approx(credit[i, 1], abs=tolerance(c)), c + + +def test_hand_checked_cases(results): + """Two grid cases are checked by hand. A long-term amount of 11,000 that + already nets a 15,000 carryover, with a 9,000 short-term gain and no net + supplied (sparse): the year's long-term gain is 26,000, so 20,000 + + 26,000 + 9,000 = 55,000, and the credit is 0 (55,000 is at least + 45,000). A net -2,500 supplied + directly with a 2,500 carryover: no gain, so 20,000; (20,000 - 12,600) x + 0.035 = 259, and min(2,000 - 259, 1,150) = 1,150.""" + cases, income, credit = results + + def find(**kw): + return next(i for i, c in enumerate(cases) if kw.items() <= c.items()) + + i = find( + style="sparse", + long_term=11_000, + short_term=9_000, + carryover=15_000, + distributions=0, ) - @hypothesis.given(st.lists(case(), min_size=1, max_size=8)) - def test_gains_without_losses(cases): - check(cases) + assert income[i].tolist() == pytest.approx([55_000] * 3) + assert credit[i].tolist() == pytest.approx([0] * 3) + j = find(style="net", net=-2_500, carryover=2_500, distributions=0) + assert income[j].tolist() == pytest.approx([20_000] * 3) + assert credit[j].tolist() == pytest.approx([1_150] * 3, abs=0.01) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py index c6c0003337e..4f6ba36ed86 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_gross_household_income.py @@ -85,31 +85,38 @@ def formula(person, period, parameters): # line 15 does. Adding it back gives the year's own long-term gain # (as az_property_tax_credit_capital_gains does); only the gain it # hid is restored, never the unused loss. - # The year's net gain is capital_gains plus the carryover. Netting - # between the year's long-term and short-term amounts adds - # max(0, a) + max(0, b) - max(0, a + b) on top of it. When the parts - # are supplied this leaves the floored parts. When capital_gains is - # supplied directly as a net amount, without parts, the netting term - # is zero, so the carryover is not counted as a gain the parts never - # showed. + # The long-term and short-term parts, when either is nonzero, decide + # the gains: max(0, long-term + carryover) + max(0, short-term). + # capital_gains can disagree with them: once it is an input for + # anyone in a simulation, everyone without one holds its default of + # zero, not the sum of their parts. Without parts, capital_gains is a + # net amount supplied directly, and the gain is max(0, net + + # carryover); the carryover is never counted as a gain the parts did + # not show. carryover = max_(0, person("long_term_capital_loss_carryover", period)) - long_term = person("long_term_capital_gains", period) + carryover - short_term = person("short_term_capital_gains", period) - short_term_gains = max_(0, short_term) + long_term_capital_gains = person("long_term_capital_gains", period) + short_term_capital_gains = person("short_term_capital_gains", period) + short_term_gains = max_(0, short_term_capital_gains) capital_gains = person("capital_gains", period) - netted_between_terms = ( - max_(0, long_term) + short_term_gains - max_(0, long_term + short_term) + parts_supplied = (long_term_capital_gains != 0) | ( + short_term_capital_gains != 0 + ) + gains_without_losses = where( + parts_supplied, + max_(0, long_term_capital_gains + carryover) + short_term_gains, + max_(0, capital_gains + carryover), ) - gains_without_losses = max_(0, capital_gains + carryover) + netted_between_terms # The gains each income path already counted: irs_gross_income - # floors the net, dependent_gross_income floors each part. A person + # floors the net, dependent_gross_income floors each part. The + # difference replaces them with the gains above; it is negative only + # when an inconsistent capital_gains input exceeds the parts. A person # input as both a dependent and the head is counted by neither path, # so nothing is restored for them. counted_gains = where( is_dependent, ~head_or_spouse * max_( - max_(0, person("long_term_capital_gains", period)) + short_term_gains, + max_(0, long_term_capital_gains) + short_term_gains, capital_gains, ), max_(0, capital_gains),