From 08490864ebbfc9d060b3d3e60e507f3ce1318ac4 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 13 May 2026 22:28:00 -0400 Subject: [PATCH 1/2] Fix AMT capital-gains overflow into the 20% bracket below the threshold Form 6251 Part III Line 27 references the regular-tax ordinary-income portion (QDCG Worksheet line 5 or Schedule D Tax Worksheet line 21). The code instead read `loss_limited_net_capital_gains` (the LTCG amount itself), which shrank the 15% bracket window and pushed gains into the 20% bracket even when AMTI minus exemption sat far below the $583,750 MFJ threshold. Line 23 was also storing the tax (= $0 at the current 0% rate) instead of the amount, which the form re-uses in Line 24 and Line 32. Both errors compounded across all years 2018-2025. Switching Line 23 to the amount and Line 27 to `dwks14` (already used for Line 20) restores faithful Form 6251 math. Updated three existing tests that locked in the pre-fix output and added an integration test reproducing the taxsim #424 case. Fixes #8292 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fix-amt-cg-bracket-overflow.fixed.md | 1 + .../alternative_minimum_tax.yaml | 37 ++++++ .../amt_tax_including_cg.yaml | 18 ++- .../amt_tax_including_cg.py | 105 ++++++++++-------- 4 files changed, 110 insertions(+), 51 deletions(-) create mode 100644 changelog.d/fix-amt-cg-bracket-overflow.fixed.md diff --git a/changelog.d/fix-amt-cg-bracket-overflow.fixed.md b/changelog.d/fix-amt-cg-bracket-overflow.fixed.md new file mode 100644 index 00000000000..2b785dd6f70 --- /dev/null +++ b/changelog.d/fix-amt-cg-bracket-overflow.fixed.md @@ -0,0 +1 @@ +Fixed the AMT capital-gains calculation to read the regular-tax ordinary-income portion (Schedule D Tax Worksheet line 14 / line 21) for Form 6251 Part III line 27, so that filers whose AMTI minus exemption is below the 20% LTCG bracket threshold no longer overflow gains into the 20% bracket. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml index 08a71dc319d..f09f13e8a81 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml @@ -115,3 +115,40 @@ capital_gains_tax: 0 output: alternative_minimum_tax: 1_000 + +# Locks in the fix for taxsim #424 / issue #8292. Pre-fix, Form 6251 +# Part III Line 27 read `loss_limited_net_capital_gains` (the LTCG +# amount) instead of `dwks14` (the regular-tax ordinary-income portion), +# which shrank the 15% bracket window and overflowed gains into the 20% +# bracket even though AMTI - exemption sat well below the $583,750 MFJ +# 20% threshold. The reported $8,634 AMT add-on should be $0 for any +# year the bug existed (2018-2025). +- name: AMT add-on is zero for MFJ filer whose AMTI minus exemption is below the 20% cap gains threshold (taxsim #424) + absolute_error_margin: 50 + period: 2024 + input: + people: + head: + age: 56 + qualified_dividend_income: 65_148.79 + taxable_interest_income: 20_301.98 + long_term_capital_gains: 390_720.38 + taxable_pension_income: 33_581.32 + is_tax_unit_head: true + spouse: + age: 20 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + tax_unit_itemizes: false + spm_units: + spm_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: NM + output: + alternative_minimum_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml index 3a0c72d8f3e..c878cc2c938 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml @@ -32,7 +32,10 @@ dwks14: 0 unrecaptured_section_1250_gain: 0 output: - amt_tax_including_cg: 22_700 + # AMTI - exemption = $100k; ordinary AMTI = $70k * 26% = $18,200. + # Preferential base = min($100k, $30k) = $30k, all in the 0% bracket + # (room = $44,625 single, ordinary regular income = $0). + amt_tax_including_cg: 18_200 - name: AMT with capital gains in second bracket period: 2023 @@ -44,7 +47,11 @@ dwks14: 0 unrecaptured_section_1250_gain: 0 output: - amt_tax_including_cg: 144_086 + # Ordinary AMTI tax: 26% * $220,700 + 28% * $229,300 = $121,586. + # Preferential base $150k: 0% bracket consumes $44,625; remaining + # $105,375 at 15% = $15,806.25; nothing reaches the 20% bracket + # (room = $492,300 - $44,625 = $447,675). + amt_tax_including_cg: 137_392.25 - name: AMT with unrecaptured section 1250 gains period: 2023 @@ -56,4 +63,9 @@ dwks14: 0 unrecaptured_section_1250_gain: 200_000 output: - amt_tax_including_cg: 230_586 + # Ordinary AMTI tax: 26% * $220,700 + 28% * $279,300 = $135,586. + # Preferential base $300k: 0% bracket consumes $44,625; remaining + # $255,375 at 15% = $38,306.25. The $200k unrecaptured section 1250 + # gain stays in line 36 = $1M - ($500k + $300k) = $200k at 25% = + # $50,000. + amt_tax_including_cg: 223_892.25 diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py index c8387a0950e..b9b6013feac 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py @@ -11,76 +11,82 @@ class amt_tax_including_cg(Variable): reference = "https://www.irs.gov/pub/irs-pdf/f6251.pdf" def formula(tax_unit, period, parameters): - # Line 12 + # Form 6251 Part III, Line 12: AMTI minus exemption reduced_income = tax_unit("amt_income_less_exemptions", period) - # Line 13 - schedule D line 13 + # Line 13: amount from QDCG Worksheet line 4 or Schedule D Tax + # Worksheet line 13 (qualified dividends + LTCG net of unrecaptured + # section 1250 and 28%-rate gains). cg_distributions = tax_unit("dwks13", period) - # Line 14 - schedule D line 19 + # Line 14: Schedule D line 19 (unrecaptured section 1250 gain). section_1250_gain_worksheet = tax_unit("unrecaptured_section_1250_gain", period) - # Line 15 - smaller of the sum of Line 13 and Line 14 or Schedule D line 10 + # Line 15: smaller of (Line 13 + Line 14) or Schedule D Tax Worksheet + # line 10. total_transactions_reported = tax_unit("dwks10", period) capped_capital_gains = min_( cg_distributions + section_1250_gain_worksheet, total_transactions_reported, ) - # Line 16 - smaller of Line 15 or Line 12 + # Line 16: smaller of Line 12 or Line 15. capped_income = min_(capped_capital_gains, reduced_income) - # Line 17 - Line 12 minus Line 16 + # Line 17: Line 12 minus Line 16 (ordinary AMTI). excess_income = max_(0, reduced_income - capped_income) - # Line 18 - Apply CG tax rates to Line 17 + # Line 18: apply the 26%/28% AMT bracket to Line 17. p = parameters(period).gov.irs income_taxes_at_amt_rates = p.income.amt.brackets.calc(excess_income) - # Line 19 - First CG tax bracket threshold + # Line 19: 0% LTCG bracket threshold for the filing status. filing_status = tax_unit("filing_status", period) cg_bracket = p.capital_gains.thresholds["1"][filing_status] - # Line 20 - Schedule D Line 14 - lt_capital_loss_carryover = tax_unit("dwks14", period) - # Line 21 - Line 20 minus Line 19 - reduced_cg_bracket = max_(0, cg_bracket - lt_capital_loss_carryover) - # Line 22 - smaller of Line 12 or Line 13 + # Line 20: amount from QDCG Worksheet line 5 or Schedule D Tax + # Worksheet line 14 (as figured for the regular tax). This is the + # ordinary-income portion of taxable income. + regular_ordinary_income = tax_unit("dwks14", period) + # Line 21: Line 19 minus Line 20. Room left in the 0% bracket after + # accounting for ordinary income already filling it. + reduced_cg_bracket = max_(0, cg_bracket - regular_ordinary_income) + # Line 22: smaller of Line 12 or Line 13. smaller_of_income_or_cg = min_(reduced_income, cg_distributions) - # Line 23 - smaller of Line 22 or Line 21 (amount is taxed at 0%) + # Line 23: smaller of Line 21 or Line 22. This is the amount taxed at + # 0% (not the tax — the form re-uses this amount in Line 24 and Line + # 32, so we keep the quantity here rather than collapsing to the tax). + amount_at_first_rate = min_(smaller_of_income_or_cg, reduced_cg_bracket) cg_first_rate = p.capital_gains.rates["1"] - disregarded_gains = ( - min_(smaller_of_income_or_cg, reduced_cg_bracket) * cg_first_rate + cg_first_bracket_tax = amount_at_first_rate * cg_first_rate + # Line 24: Line 22 minus Line 23 (preferential-rate base remaining + # after the 0% bracket). + income_after_first_rate = max_( + 0, smaller_of_income_or_cg - amount_at_first_rate ) - # Line 24 - Line 22 minus Line 23 - taxable_income_including_cg = max_( - smaller_of_income_or_cg - disregarded_gains, 0 - ) - # Line 25 - Second CG tax bracket threshold + # Line 25: 20% LTCG bracket threshold for the filing status. second_cg_bracket = p.capital_gains.thresholds["2"][filing_status] - # Line 26 - same as line 21 - # Line 27 - Schedule D Line 21 - loss_limited_net_capital_gains = tax_unit( - "loss_limited_net_capital_gains", period - ) - # Line 28 - Line 26 plus Line 27 - first_cg_bracket_increased_by_loss = ( - loss_limited_net_capital_gains + reduced_cg_bracket + # Line 26: same as Line 21. + # Line 27: amount from QDCG Worksheet line 5 or Schedule D Tax + # Worksheet line 21 (as figured for the regular tax). For the QDCG + # path this is the same ordinary-income value used on Line 20. + # Line 28: Line 26 plus Line 27. + first_cg_bracket_increased_by_ordinary = ( + reduced_cg_bracket + regular_ordinary_income ) - # Line 29 Line 25 minus Line 28 + # Line 29: Line 25 minus Line 28 (room left in the 15% bracket). reduced_second_cg_bracket = max_( - 0, second_cg_bracket - first_cg_bracket_increased_by_loss - ) - # Line 30 - smaller of Line 24 or Line 29 - capped_income_including_cg = min_( - taxable_income_including_cg, reduced_second_cg_bracket + 0, second_cg_bracket - first_cg_bracket_increased_by_ordinary ) - # Line 31 - multiply Line 30by second CG tax rate - cg_second_bracket_tax = capped_income_including_cg * p.capital_gains.rates["2"] - # Line 32 - Line 23 plus Line 30 - taxed_gains = disregarded_gains + capped_income_including_cg - # Line 33 - Line 22 minus Line 32 - excess_taxed_gains = max_(0, smaller_of_income_or_cg - taxed_gains) - # Line 34 - multiply Line 33 by third CG tax rate + # Line 30: smaller of Line 24 or Line 29 (amount taxed at 15%). + amount_at_second_rate = min_(income_after_first_rate, reduced_second_cg_bracket) + # Line 31: Line 30 times the 15% LTCG rate. + cg_second_bracket_tax = amount_at_second_rate * p.capital_gains.rates["2"] + # Line 32: Line 23 plus Line 30 (cumulative preferential amounts). + taxed_gains_amount = amount_at_first_rate + amount_at_second_rate + # Line 33: Line 22 minus Line 32 (amount taxed at 20%). + excess_taxed_gains = max_(0, smaller_of_income_or_cg - taxed_gains_amount) + # Line 34: Line 33 times the 20% LTCG rate. cg_third_bracket_tax = excess_taxed_gains * p.capital_gains.rates["3"] - # Line 35 - sum of Line 17, Line 32, Line 33 - final_taxed_income = excess_income + taxed_gains + excess_taxed_gains - # Line 36 Line 12 minus Line 35 + # Line 35: Line 17 plus Line 32 plus Line 33. + final_taxed_income = excess_income + taxed_gains_amount + excess_taxed_gains + # Line 36: Line 12 minus Line 35 (unrecaptured section 1250 portion). final_excess = max_(0, reduced_income - final_taxed_income) - # Line 37 - Multiply Line 36 by the AMT specific capital gain excess tax rate - # The increased tax does not applied if no recaptured section 1250 gains + # Line 37: Line 36 times the 25% rate, only when unrecaptured section + # 1250 gains exist (per the form's "skip lines 35-37 if line 14 is + # zero" instruction). unrecaptured_section_1250_gain = tax_unit( "unrecaptured_section_1250_gain", period ) @@ -89,9 +95,12 @@ def formula(tax_unit, period, parameters): 0, final_excess * p.income.amt.capital_gains.capital_gain_excess_tax_rate, ) - # Line 38 - sum of Line 18, Line 31, Line 34, and Line 37 + # Line 38: sum of Lines 18, 31, 34, and 37 (plus Line 23's 0% + # contribution, which is always zero with a 0% bracket rate but kept + # in the sum for structural clarity). return ( income_taxes_at_amt_rates + + cg_first_bracket_tax + cg_second_bracket_tax + cg_third_bracket_tax + excess_tax From ddd5a382c7ae82181103554f2d4e14307acbf2fc Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 13 May 2026 22:52:34 -0400 Subject: [PATCH 2/2] Add Form 6251 Part III boundary tests for AMT cap-gains fix Five new cases exercise: - 0% bracket threshold (entire base in 0%) - 20% bracket threshold (entire base at 15%) - Above 20% threshold (real 20% bracket usage) - Mixed preferential + non-zero regular-tax ordinary income (locks in the Line 27 fix specifically) - Joint filer with substantial cap gains below joint 20% threshold Each test's expected value was manually computed from Form 6251 Part III line-by-line and matches PE output to the cent. All 60 AMT tests and 821 federal-income tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../amt_tax_including_cg.yaml | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml index c878cc2c938..6759e34ef7c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml @@ -69,3 +69,90 @@ # gain stays in line 36 = $1M - ($500k + $300k) = $200k at 25% = # $50,000. amt_tax_including_cg: 223_892.25 + +# Boundary at the 0% LTCG threshold ($44,625 single 2023). All +# preferential base falls inside the 0% bracket, so the entire +# computation should collapse to ordinary AMTI * 26%. +- name: All CG, preferential base exactly at the 0% bracket threshold + period: 2023 + input: + filing_status: SINGLE + amt_income_less_exemptions: 44_625 + dwks10: 44_625 + dwks13: 44_625 + dwks14: 0 + unrecaptured_section_1250_gain: 0 + output: + # L22 = L23 = $44,625; L24 = $0; nothing in 15% or 20%; L17 = $0. + amt_tax_including_cg: 0 + +# Exercises the 15%/20% boundary: AMTI - exemption equals the single +# 20% bracket threshold ($492,300). Everything above $44,625 is in +# the 15% bracket, nothing in the 20% bracket. +- name: All CG, preferential base exactly at the 20% bracket threshold + period: 2023 + input: + filing_status: SINGLE + amt_income_less_exemptions: 492_300 + dwks10: 492_300 + dwks13: 492_300 + dwks14: 0 + unrecaptured_section_1250_gain: 0 + output: + # 0% bracket consumes $44,625; remaining $447,675 at 15% = + # $67,151.25; nothing reaches 20%. + amt_tax_including_cg: 67_151.25 + +# Exercises the actual 20% bracket (regression for the L27 fix — +# pre-fix this case would have over-allocated to the 20% bracket). +- name: All CG, preferential base above the 20% bracket threshold + period: 2023 + input: + filing_status: SINGLE + amt_income_less_exemptions: 600_000 + dwks10: 600_000 + dwks13: 600_000 + dwks14: 0 + unrecaptured_section_1250_gain: 0 + output: + # 0%: $44,625; 15%: $447,675 (= $492,300 - $44,625) * 15% = + # $67,151.25; 20%: $107,700 (= $600,000 - $44,625 - $447,675) + # * 20% = $21,540. Total $88,691.25. + amt_tax_including_cg: 88_691.25 + +# Locks in the L27 fix specifically: with non-zero regular-tax +# ordinary income, the 0% bracket room shrinks and the 15% bracket +# starts higher. Pre-fix would have used LTCG amount instead of +# ordinary income on L27 and shrunk the 15% window incorrectly. +- name: Mixed preferential + regular ordinary income, AMTI below 20% threshold + period: 2023 + input: + filing_status: SINGLE + amt_income_less_exemptions: 300_000 + dwks10: 100_000 + dwks13: 100_000 + dwks14: 50_000 + unrecaptured_section_1250_gain: 0 + output: + # Ordinary AMTI tax: 26% * $200,000 = $52,000. + # 0% room: max(0, $44,625 - $50,000) = $0 -> nothing at 0%. + # 15% room: $492,300 - $50,000 = $442,300; cap gain $100,000 + # entirely at 15% = $15,000. + # Total: $52,000 + $15,000 = $67,000. + amt_tax_including_cg: 67_000 + +# Joint filer using the joint 20% bracket threshold ($553,850 in +# 2023). Same logic as the issue 8292 reproducer. +- name: Joint filer with substantial cap gains below the joint 20% threshold + period: 2023 + input: + filing_status: JOINT + amt_income_less_exemptions: 400_000 + dwks10: 400_000 + dwks13: 400_000 + dwks14: 0 + unrecaptured_section_1250_gain: 0 + output: + # 0%: $89,250 (joint); 15%: $310,750 * 15% = $46,612.50; + # 20%: $0. + amt_tax_including_cg: 46_612.50