From ed10af5e658bbb21f30cc631e63b5917a0e18f49 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 20:15:23 -0400 Subject: [PATCH 1/8] Cap the regular tax at the tax on all taxable income and use it on Form 6251 line 10 26 U.S.C. 1(h)(1) says the tax "shall not exceed" the sum of the subparagraph (A) to (F) amounts, and Schedule D Tax Worksheet line 47 takes the smaller of line 45 and line 46 (the tax on all of line 1). capital_gains_tax now stops at tax_on_taxable_income_at_main_rates (new, line 46) less income_tax_main_rates. The cap binds where gain taxed at 15 percent falls in the 12 percent bracket: up to $3.75 single and $7.50 joint in 2025, and $28.50 and $57 in 2026. The Biden and Harris capital gains reforms apply the same limit, and the additional_tax_bracket reform figures line 46 on its own schedule. regular_tax_before_credits was a second Schedule D Tax Worksheet (2017 numbering) whose result was min(worksheet tax including the gains, income_tax_main_rates excluding them). Form 6251 line 10 then added capital_gains_tax. Whenever the worksheet path fell below the main-rates path, line 10 understated the regular tax: before #9781 that happened for 18 dataset tax units in 2025 ($328M weighted), because dwks10 ignored the Form 4952 line 4g election and because the worksheet took line 28 as min(line 21, line 22) instead of their sum. regular_tax_before_credits is now income_tax_main_rates plus capital_gains_tax, the regular tax income_tax_before_credits charges, and Form 6251 line 10 uses it alone. Tests: real-unit regression cases for tax units 1101624, 1004930, 1156248 and 1032460; line 45/46 boundary cases for 2025 and 2026 (single, joint, head of household, Form 2555); the existing worksheet property tests now compare with line 47 and Foreign Earned Income Tax Worksheet line 25 exactly. Co-Authored-By: Claude Opus 5.5 --- .../fix-dwks-regular-tax-lines.fixed.md | 1 + .../additional_tax_bracket_reform.py | 95 ++---- .../budget_2025/capital_gains_tax_increase.py | 9 +- .../capital_gains/harris_capital_gains.py | 9 +- .../alternative_minimum_tax.yaml | 10 +- .../alternative_minimum_tax/integration.yaml | 3 +- .../regular_tax_before_credits.yaml | 300 +++++++++++++++++- .../tax_on_taxable_income_at_main_rates.yaml | 57 ++++ .../additional_tax_bracket_reform.yaml | 8 + .../test_form_4952_section_911_interaction.py | 12 +- .../tests/test_form_6251_part_iii.py | 36 ++- ...t_negative_main_rates_inverted_brackets.py | 31 +- .../tests/test_section_911_tax_stacking.py | 47 +-- .../alternative_minimum_tax.py | 9 +- .../regular_tax_before_credits.py | 95 ++---- .../tax_on_taxable_income_at_main_rates.py | 48 +++ .../capital_gains/capital_gains_tax.py | 32 +- .../section_911_capital_gain_excess.py | 10 +- 18 files changed, 591 insertions(+), 221 deletions(-) create mode 100644 changelog.d/fix-dwks-regular-tax-lines.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.yaml create mode 100644 policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.py diff --git a/changelog.d/fix-dwks-regular-tax-lines.fixed.md b/changelog.d/fix-dwks-regular-tax-lines.fixed.md new file mode 100644 index 00000000000..033c9255769 --- /dev/null +++ b/changelog.d/fix-dwks-regular-tax-lines.fixed.md @@ -0,0 +1 @@ +Cap the regular tax at the tax on all taxable income at the ordinary rates (26 U.S.C. 1(h)(1), Schedule D Tax Worksheet line 47), and make regular_tax_before_credits, which Form 6251 line 10 uses, the regular tax the model charges (income_tax_main_rates plus capital_gains_tax) instead of a second worksheet computation. diff --git a/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py b/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py index 13fad485592..8cd60d4f0ee 100644 --- a/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py +++ b/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py @@ -5,80 +5,9 @@ amount_taxed_below_rate, tax_at_main_rates, ) -from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( - schedule_d_tax_worksheet_after_capital_gain_excess, -) def create_additional_tax_bracket() -> Reform: - class regular_tax_before_credits(Variable): - value_type = float - entity = TaxUnit - definition_period = YEAR - label = "Regular tax before credits" - documentation = "Regular tax on regular taxable income before credits" - unit = USD - - def formula(tax_unit, period, parameters): - p = parameters(period).gov.irs - filing_status = tax_unit("filing_status", period) - # As in the baseline formula, a Form 2555 filer stacks taxable - # income on the excluded amount (26 U.S.C. 911(f)). - dwks1 = tax_unit("taxable_income_plus_section_911_exclusion", period) - worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( - tax_unit, period, tax_unit("taxable_income", period) - ) - - dwks16 = min_(p.capital_gains.thresholds["1"][filing_status], dwks1) - dwks17 = min_(tax_unit("dwks14", period), dwks16) - dwks20 = dwks16 - dwks17 - lowest_rate_tax = p.capital_gains.rates["1"] * dwks20 - # Break in worksheet lines - dwks13 = worksheet.line_13 - dwks21 = min_(dwks1, dwks13) - dwks22 = dwks20 - dwks23 = max_(0, dwks21 - dwks22) - dwks25 = min_(p.capital_gains.thresholds["2"][filing_status], dwks1) - dwks19 = tax_unit("dwks19", period) - dwks26 = min_(dwks19, dwks20) - dwks27 = max_(0, dwks25 - dwks26) - dwks28 = min_(dwks23, dwks27) - dwks29 = p.capital_gains.rates["2"] * dwks28 - dwks30 = dwks22 + dwks28 - dwks31 = dwks21 - dwks30 - dwks32 = p.capital_gains.rates["3"] * dwks31 - # Break in worksheet lines - dwks33 = min_(worksheet.line_9, worksheet.unrecaptured_section_1250_gain) - dwks10 = worksheet.line_10 - dwks34 = dwks10 + dwks19 - dwks36 = max_(0, dwks34 - dwks1) - dwks37 = max_(0, dwks33 - dwks36) - - p = parameters(period).gov.irs.income - - dwks38 = p.amt.capital_gains.capital_gain_excess_tax_rate * dwks37 - # Break in worksheet lines - dwks39 = dwks19 + dwks20 + dwks28 + dwks31 + dwks37 - dwks40 = dwks1 - dwks39 - dwks41 = p.amt.brackets.rates[-1] * dwks40 - - # Compute regular tax using bracket rates and thresholds - # The shared schedule clamps inverted brackets as in the - # baseline formulas (#9084). - reg_taxinc = max_(0, dwks19) - bracket = parameters(period).gov.contrib.additional_tax_bracket.bracket - reg_tax = tax_at_main_rates(reg_taxinc, filing_status, bracket) - - # Return to worksheet lines - dwks42 = reg_tax - dwks43 = dwks29 + dwks32 + dwks38 + dwks41 + dwks42 + lowest_rate_tax - excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) - tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) - dwks43 = where(excluded > 0, max_(0, dwks43 - tax_on_excluded), dwks43) - dwks44 = tax_unit("income_tax_main_rates", period) - dwks45 = min_(dwks43, dwks44) - return where(tax_unit("has_qdiv_or_ltcg", period), dwks45, dwks44) - class income_tax_main_rates(Variable): value_type = float entity = TaxUnit @@ -110,6 +39,28 @@ def formula(tax_unit, period, parameters): tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) return where(excluded > 0, max_(0, tax - tax_on_excluded), tax) + class tax_on_taxable_income_at_main_rates(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "Tax on all taxable income at the main rates" + reference = "https://www.law.cornell.edu/uscode/text/26/1#h_1" + unit = USD + + def formula(tax_unit, period, parameters): + # Schedule D Tax Worksheet line 46 on the reform's rate schedule, + # as in the baseline formula on the baseline schedule. Section + # 1(h)(1) caps the regular tax at it (capital_gains_tax). + taxable_income = tax_unit( + "taxable_income_plus_section_911_exclusion", period + ) + bracket = parameters(period).gov.contrib.additional_tax_bracket.bracket + filing_status = tax_unit("filing_status", period) + tax = tax_at_main_rates(max_(0, taxable_income), filing_status, bracket) + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) + return where(excluded > 0, max_(0, tax - tax_on_excluded), tax) + class taxable_income_taxed_below_25_percent(Variable): value_type = float entity = TaxUnit @@ -135,7 +86,7 @@ def formula(tax_unit, period, parameters): class reform(Reform): def apply(self): self.update_variable(income_tax_main_rates) - self.update_variable(regular_tax_before_credits) + self.update_variable(tax_on_taxable_income_at_main_rates) self.update_variable(taxable_income_taxed_below_25_percent) return reform diff --git a/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py b/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py index f0233f9e3cd..c87177265ba 100644 --- a/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py +++ b/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py @@ -3,6 +3,7 @@ section_911_net_capital_gain_other_than_dividends, ) from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.capital_gains_tax import ( + limit_to_tax_at_main_rates, rate_gain_taxed_at_28_percent, ) @@ -144,7 +145,13 @@ def formula(tax_unit, period, parameters): adjusted_net_cg, taxable_unrecaptured_gain, ) - return main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax + # As in the baseline formula, section 1(h)(1) keeps the regular + # tax within the tax on all taxable income at the main rates. + return limit_to_tax_at_main_rates( + tax_unit, + period, + main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax, + ) class reform(Reform): def apply(self): diff --git a/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py b/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py index fdbc86ebcba..49c106c3d69 100644 --- a/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py +++ b/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py @@ -3,6 +3,7 @@ section_911_net_capital_gain_other_than_dividends, ) from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.capital_gains_tax import ( + limit_to_tax_at_main_rates, rate_gain_taxed_at_28_percent, ) @@ -134,7 +135,13 @@ def formula(tax_unit, period, parameters): taxable_unrecaptured_gain, ) - return main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax + # As in the baseline formula, section 1(h)(1) keeps the regular + # tax within the tax on all taxable income at the main rates. + return limit_to_tax_at_main_rates( + tax_unit, + period, + main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax, + ) class reform(Reform): def apply(self): 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 e39db40df01..ba2904afa58 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 @@ -99,7 +99,6 @@ foreign_tax_credit: 0 regular_tax_before_credits: 11_000 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 0 output: alternative_minimum_tax: 0 @@ -112,7 +111,6 @@ foreign_tax_credit: 0 regular_tax_before_credits: 11_000 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 0 output: alternative_minimum_tax: 1_000 @@ -168,7 +166,6 @@ foreign_tax_credit: 0 regular_tax_before_credits: 30_000 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 0 output: # Line 3 = 280,000. Line 4 = 280,000 x 28% - 4,782 = 73,618. Line 5 = # 130,000 x 26% = 33,800. Line 6 = 39,818, which goes on Form 6251 line 7. @@ -187,7 +184,6 @@ foreign_tax_credit: 0 regular_tax_before_credits: 30_000 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 0 output: # Form 6251 line 7 = 150,000 x 26% = 39,000. alternative_minimum_tax: 9_000 @@ -203,7 +199,6 @@ foreign_tax_credit: 0 regular_tax_before_credits: 0 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 0 output: # Line 3 = 180,000. Line 4 = 180,000 x 28% - 2,391 = 48,009. Line 5 = # 130,000 x 28% - 2,391 = 34,009. Line 6 = 14,000. @@ -219,9 +214,9 @@ amt_part_iii_required: true amt_tax_including_cg: 57_785 foreign_tax_credit: 0 - regular_tax_before_credits: 0 + # Form 6251 line 10: the regular tax, all of it capital gains tax. + regular_tax_before_credits: 42_637.50 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 42_637.50 output: # Form 6251 line 39 = 311,900 x 28% - 4,782 = 82,550, line 38 = 57,785, # line 40 = 57,785. Worksheet line 5 = 100,000 x 26% = 26,000, line 6 = @@ -238,6 +233,5 @@ foreign_tax_credit: 0 regular_tax_before_credits: 0 form_4972_lumpsum_distributions: 0 - capital_gains_tax: 0 output: alternative_minimum_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/integration.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/integration.yaml index 23a7ed89800..ff44250ad9e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/integration.yaml @@ -54,8 +54,9 @@ # $759,250 = $151,850; line 22 tax on $134,250 = $25,067; line 25 # $201,779.50. taxable_income: 1_059_250 - regular_tax_before_credits: 25_067 + income_tax_main_rates: 25_067 capital_gains_tax: 176_712.5 + regular_tax_before_credits: 201_779.5 # Form 6251: line 1 $1,059,250; line 2a $15,750; line 4 $1,075,000 plus # 25% * ($1,075,000 - $900,350) = $43,662.50 for married filing # separately (line 4 instructions) = $1,118,662.50. Line 5: $68,500 - diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml index eea12a96d21..b29396fc57c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml @@ -31,7 +31,301 @@ output: # Line 1 = 134,250 and the gains are cut to 34,250, so the ordinary # income is the 100,000 excluded and its tax cancels against line 5 of - # the Foreign Earned Income Tax Worksheet. The 5,137.50 of capital gains - # tax is counted separately in capital_gains_tax. - regular_tax_before_credits: 0 + # the Foreign Earned Income Tax Worksheet. The regular tax is the + # 5,137.50 of capital gains tax. + income_tax_main_rates: 0 capital_gains_tax: 5_137.50 + regular_tax_before_credits: 5_137.50 + +# Tax units from the default dataset (populace_us_2024 at +# populace-us-2024-spm-20260909), with their 2025 inputs as the +# microsimulation uprates them. In a 2025 run before #9781, these were among +# the 18 tax units whose regular_tax_before_credits fell below +# income_tax_main_rates, which understated Form 6251 line 10. All 18 elect +# under Form 4952 line 4g. net_capital_gain applied the election but worksheet +# line 10 (dwks10) did not, so worksheet line 21 (dwks19) was too small. A +# second Schedule D Tax Worksheet, which took worksheet line 28 as the smaller +# of lines 21 and 22 instead of their sum, then taxed line 23 at 15 percent +# instead of 20. Form 6251 line 10 used the smaller of that worksheet's tax and +# income_tax_main_rates. Line 10 is now the regular tax the model charges, +# income_tax_main_rates plus capital_gains_tax. +- name: Dataset tax unit 1101624, a Form 4952 election against long-term gain + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 60 + long_term_capital_gains: 110_314.59 + short_term_capital_gains: 15_101.07 + qualified_dividend_income: 917.84 + person2: + age: 58 + long_term_capital_gains: 110_314.59 + short_term_capital_gains: 15_101.07 + qualified_dividend_income: 917.84 + investment_income_elected_form_4952: 141_871.53 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + taxable_income: 3_437_082.75 + amt_income_less_exemptions: 3_447_082.75 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Schedule D Tax Worksheet: line 7 220,629.18; line 8 141,871.53; line 9 + # 78,757.65; line 6 1,835.68; line 10 80,593.33, the net capital gain. + net_capital_gain: 80_593.33 + dwks10: 80_593.33 + # Line 21 = line 18 = 3,437,082.75 - 80,593.33. + dwks19: 3_356_489.42 + # Line 44: 37% x 3,356,489.42 - 75,937.50. Line 34: 20% x 80,593.33. + income_tax_main_rates: 1_165_963.59 + capital_gains_tax: 16_118.67 + # Line 47 = line 45 = 1,182,082.26 (line 46: 1,195,768.12). + regular_tax_before_credits: 1_182_082.26 + # Form 6251: line 17 3,366,489.42; line 18 937,835.04; line 33 80,593.33 + # (20%: 16,118.67); line 38 953,953.71, below line 10. + amt_tax_including_cg: 953_953.71 + alternative_minimum_tax: 0 + +- name: Dataset tax unit 1004930, an election larger than the gains and dividends + absolute_error_margin: 0.5 + period: 2025 + input: + people: + person1: + age: 85 + investment_income_elected_form_4952: 130_193.69 + person2: + age: 85 + long_term_capital_gains: 80_880.30 + short_term_capital_gains: 10_988.80 + qualified_dividend_income: 2_748.49 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + taxable_income: 186_864.59 + amt_income_less_exemptions: 84_564.59 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Line 8 takes all 80,880.30 of the gain and line 5 the other 49,313.39 + # from the 2,748.49 of dividends, so lines 6, 9 and 10 are zero. + net_capital_gain: 0 + dwks10: 0 + dwks19: 186_864.59 + # 11,157 + 22% x (186,864.59 - 96,950). + income_tax_main_rates: 30_938.21 + capital_gains_tax: 0 + regular_tax_before_credits: 30_938.21 + # Form 6251 line 39: 26% x 84,564.59 = 21,986.79, below line 10. + alternative_minimum_tax: 0 + +- name: Dataset tax unit 1156248, an election and unrecaptured section 1250 gain + absolute_error_margin: 0.5 + period: 2025 + input: + people: + person1: + age: 36 + long_term_capital_gains: 11_073.12 + short_term_capital_gains: 1_512.26 + qualified_dividend_income: 763.81 + investment_income_elected_form_4952: 6_819.79 + person2: + age: 36 + long_term_capital_gains: 171.63 + short_term_capital_gains: 23.44 + qualified_dividend_income: 50.92 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + taxable_income: 535_956.88 + amt_income_less_exemptions: 430_456.88 + unrecaptured_section_1250_gain: 85.57 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Schedule D Tax Worksheet: line 9 4,424.96; line 10 5,239.69; line 12 + # 85.57; line 13 5,154.12; line 14 530,802.76; line 18 530,717.19; line + # 19 394,600; line 21 530,717.19. + net_capital_gain: 5_239.69 + dwks10: 5_239.69 + dwks19: 530_717.19 + # Line 44: 35% x 530,717.19 - 60,905.50. Line 30 5,154.12 at 15% + # (773.12) and line 39 85.57 at 25% (21.39). + income_tax_main_rates: 124_845.52 + capital_gains_tax: 794.51 + # Line 47 = line 45 = 125,640.03 (line 46: 126,679.41). + regular_tax_before_credits: 125_640.03 + # Form 6251: line 18 114,278.81; line 31 773.12; line 37 21.39. + amt_tax_including_cg: 115_073.32 + alternative_minimum_tax: 0 + +- name: Dataset tax unit 1032460, an election that spills into qualified dividends + absolute_error_margin: 0.05 + period: 2025 + input: + people: + person1: + age: 76 + person2: + age: 76 + long_term_capital_gains: 1_109.12 + short_term_capital_gains: 153.57 + qualified_dividend_income: 2_804.45 + investment_income_elected_form_4952: 2_681.56 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + taxable_income: 11_777.39 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Line 8 takes the 1,109.12 of gain and line 5 the other 1,572.44 from + # the dividends: line 6 1,232.01, line 9 0, line 10 1,232.01. + net_capital_gain: 1_232.01 + dwks10: 1_232.01 + dwks19: 10_545.38 + # 10% x 10,545.38; the dividends are taxed at 0 percent. + income_tax_main_rates: 1_054.54 + capital_gains_tax: 0 + regular_tax_before_credits: 1_054.54 + +# 26 U.S.C. 1(h)(1): with a net capital gain, "the tax imposed by this section +# for such taxable year shall not exceed the sum of" subparagraphs (A) to (F). +# The 2025 Schedule D Tax Worksheet takes "the smaller of line 45 or line 46" +# on line 47, and the Qualified Dividends and Capital Gain Tax Worksheet "the +# smaller of line 23 or line 24" on line 25. The two differ where gain taxed at +# 15 percent falls in the 12 percent bracket: in 2025 the 0 percent rate +# amount ends $125 ($250 joint, $100 head of household) below the top of that +# bracket, and in 2026 $950 ($1,900 joint, $1,250 head of household). These +# cases put that much qualified dividend income on top of ordinary income that +# fills the 0 percent rate amount. Amounts are taxed at the rates, as the Tax +# Computation Worksheet does; the Tax Table rounds within $50 rows. Line +# numbers are the Schedule D Tax Worksheet's, which gives the same tax. +- name: 2025 single, line 45 exceeds line 46 by $3.75 + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 48_475 + qualified_dividend_income: 125 + output: + # Line 21 = 48,350; tax 1,192.50 + 12% x 36,425 = 5,563.50. + income_tax_main_rates: 5_563.50 + # Line 46: tax on 48,475 = 1,192.50 + 12% x 36,550 = 5,578.50. + tax_on_taxable_income_at_main_rates: 5_578.50 + # Line 30 = 125 at 15% = 18.75, so line 45 = 5,582.25. Line 47 = 5,578.50, + # which leaves 15.00 for the gains. + capital_gains_tax: 15 + regular_tax_before_credits: 5_578.50 + +- name: 2025 joint, line 45 exceeds line 46 by $7.50 + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: JOINT + taxable_income: 96_950 + qualified_dividend_income: 250 + output: + # Line 44: 2,385 + 12% x (96,700 - 23,850) = 11,127. Line 46: 2,385 + 12% + # x 73,100 = 11,157. Line 45: 11,127 + 15% x 250 = 11,164.50. + income_tax_main_rates: 11_127 + tax_on_taxable_income_at_main_rates: 11_157 + capital_gains_tax: 30 + regular_tax_before_credits: 11_157 + +- name: 2026 single, line 45 exceeds line 46 by $28.50 + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: SINGLE + taxable_income: 50_400 + qualified_dividend_income: 950 + output: + # Rev. Proc. 2025-32, sections 4.01 (Table 3) and 4.03: the 12 percent + # bracket ends at 50,400 and the 0 percent rate amount at 49,450. + # Line 44: 1,240 + 12% x 37,050 = 5,686. Line 46: 5,800. Line 45: 5,686 + # + 15% x 950 = 5,828.50. + income_tax_main_rates: 5_686 + tax_on_taxable_income_at_main_rates: 5_800 + capital_gains_tax: 114 + regular_tax_before_credits: 5_800 + +- name: 2026 head of household, line 45 exceeds line 46 by $37.50 + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: HEAD_OF_HOUSEHOLD + taxable_income: 67_450 + qualified_dividend_income: 1_250 + output: + # Rev. Proc. 2025-32, Table 2 and section 4.03: 12 percent to 67,450, 0 + # percent rate amount 66,200. Line 44: 1,770 + 12% x 48,500 = 7,590. + # Line 46: 7,740. Line 45: 7,590 + 15% x 1,250 = 7,777.50. + income_tax_main_rates: 7_590 + tax_on_taxable_income_at_main_rates: 7_740 + capital_gains_tax: 150 + regular_tax_before_credits: 7_740 + +- name: 2025 single, more gain than the overlap leaves line 45 below line 46 + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 49_350 + qualified_dividend_income: 1_000 + output: + # Line 45: 5,563.50 + 15% x 1,000 = 5,713.50. Line 46: 5,578.50 + 22% x + # 875 = 5,771. Line 47 is line 45. + income_tax_main_rates: 5_563.50 + tax_on_taxable_income_at_main_rates: 5_771 + capital_gains_tax: 150 + regular_tax_before_credits: 5_713.50 + +- name: 2025 single, gain partly in the 0 percent rate amount leaves line 45 below line 46 + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 48_475 + qualified_dividend_income: 475 + output: + # Line 21 = 48,000 (tax 5,521.50); line 22 = 350 at 0%; line 30 = 125 at + # 15% (18.75). Line 45 = 5,540.25; line 46 = 5,578.50. + income_tax_main_rates: 5_521.50 + tax_on_taxable_income_at_main_rates: 5_578.50 + capital_gains_tax: 18.75 + regular_tax_before_credits: 5_540.25 + +- name: 2025 single Form 2555 filer, line 47 less the tax on the excluded amount + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 8_475 + qualified_dividend_income: 125 + foreign_earned_income_exclusion: 40_000 + output: + # Foreign Earned Income Tax Worksheet: line 3 = 48,475; line 4 = Schedule + # D Tax Worksheet line 47, the tax on 48,475 (5,578.50), as in the first + # 2025 case; line 5 = tax on 40,000 = 1,192.50 + 12% x 28,075 = 4,561.50; + # line 6 = 1,017. + income_tax_main_rates: 1_002 + tax_on_taxable_income_at_main_rates: 1_017 + capital_gains_tax: 15 + regular_tax_before_credits: 1_017 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.yaml new file mode 100644 index 00000000000..6de0024d4db --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.yaml @@ -0,0 +1,57 @@ +- name: Without dividends or gains, the tax at the main rates + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 60_000 + output: + # 2025: 5,578.50 + 22% x (60,000 - 48,475). + tax_on_taxable_income_at_main_rates: 8_114 + income_tax_main_rates: 8_114 + +- name: Long-term gain is taxed at the main rates too + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 60_000 + long_term_capital_gains: 20_000 + output: + # Schedule D Tax Worksheet line 46: the tax on all of line 1. Line 44 + # taxes only line 21, 40,000: 1,192.50 + 12% x 28,075 = 4,561.50. + tax_on_taxable_income_at_main_rates: 8_114 + income_tax_main_rates: 4_561.50 + +- name: Married filing jointly, 2026 + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: JOINT + taxable_income: 250_000 + qualified_dividend_income: 50_000 + output: + # Rev. Proc. 2025-32, section 4.01, Table 1: 35,932 + 24% x (250,000 - + # 211,400). + tax_on_taxable_income_at_main_rates: 45_196 + +- name: A Form 2555 filer subtracts the tax on the excluded amount + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: HEAD_OF_HOUSEHOLD + taxable_income: 36_375 + long_term_capital_gains: 10_000 + foreign_earned_income_exclusion: 40_000 + output: + # Foreign Earned Income Tax Worksheet: line 3 = 76,375, taxed at + # 9,977.50; line 5, the tax on 40,000, is 4,460. + tax_on_taxable_income_at_main_rates: 5_517.50 + +- name: No taxable income + period: 2025 + input: + filing_status: SINGLE + taxable_income: 0 + long_term_capital_gains: 5_000 + output: + tax_on_taxable_income_at_main_rates: 0 diff --git a/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml b/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml index 69b8fdc532f..84a7f9e0935 100644 --- a/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml +++ b/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml @@ -17,6 +17,8 @@ output: regular_tax_before_credits: 328_110 income_tax_main_rates: 328_110 + # No gains: line 46 is the tax at the main rates on the same schedule. + tax_on_taxable_income_at_main_rates: 328_110 - name: Additional tax bracket is not applied. period: 2027 @@ -36,6 +38,8 @@ output: regular_tax_before_credits: 290_114 income_tax_main_rates: 290_114 + # No gains: line 46 is the tax at the main rates on the same schedule. + tax_on_taxable_income_at_main_rates: 290_114 - name: Additional tax bracket is applied, taxable income is below the threshold. period: 2027 @@ -56,6 +60,8 @@ output: regular_tax_before_credits: 166_046 income_tax_main_rates: 166_046 + # No gains: line 46 is the tax at the main rates on the same schedule. + tax_on_taxable_income_at_main_rates: 166_046 - name: Additional tax bracket is not applied, taxable income is below the threshold. period: 2027 @@ -75,6 +81,8 @@ output: regular_tax_before_credits: 145_893 income_tax_main_rates: 145_893 + # No gains: line 46 is the tax at the main rates on the same schedule. + tax_on_taxable_income_at_main_rates: 145_893 # 26 U.S.C. 1(h)(1)(A)(ii)(I), "the amount of taxable income taxed at a rate # below 25 percent", follows the rate schedule in force. From 2026 the diff --git a/policyengine_us/tests/test_form_4952_section_911_interaction.py b/policyengine_us/tests/test_form_4952_section_911_interaction.py index 912637a8972..57bcad7642d 100644 --- a/policyengine_us/tests/test_form_4952_section_911_interaction.py +++ b/policyengine_us/tests/test_form_4952_section_911_interaction.py @@ -595,10 +595,12 @@ def assert_matches_2025_forms(households, law): stacked_income = taxable_income + h["exclusion"] slack = tolerance(stacked_income) regular_tax = float(law["regular_tax"][i]) - assert regular_tax == pytest.approx(line_6_feitw, abs=slack), (i, h) - # The line 47 cap, which the model's section 1(h) formulas omit, - # binds by at most the rate overlap. - cap_gap = regular_tax - line_6_capped + assert float(law["regular_tax_before_credits"][i]) == pytest.approx( + regular_tax, abs=slack + ), (i, h) + assert regular_tax == pytest.approx(line_6_capped, abs=slack), (i, h) + # The line 47 cap binds by at most the rate overlap. + cap_gap = line_6_feitw - line_6_capped assert -slack <= cap_gap <= law["rate_overlap"][i] + slack, (i, h) gain = section_1222_gain(h) if status == "SEPARATE" and h["dividends"] + gain > 0: @@ -613,7 +615,7 @@ def assert_matches_2025_forms(households, law): ordinary_income, status, ) - amt = max(0, line_7 - line_6_feitw) + amt = max(0, line_7 - line_6_capped) assert float(law["alternative_minimum_tax"][i]) == pytest.approx( amt, abs=tolerance(stacked_income, taxable_excess) ), (i, h) diff --git a/policyengine_us/tests/test_form_6251_part_iii.py b/policyengine_us/tests/test_form_6251_part_iii.py index eb25558d626..1126bdcf26c 100644 --- a/policyengine_us/tests/test_form_6251_part_iii.py +++ b/policyengine_us/tests/test_form_6251_part_iii.py @@ -35,10 +35,11 @@ the Schedule D Tax Worksheet. Its 2025 lines 1 to 47 are transcribed below too, and for those households the model is compared with: -- line 47, the regular tax (`income_tax_main_rates` plus - `capital_gains_tax`), which taxes at the regular rates the greater of line - 18 or line 20 (line 21), where line 19 is the taxable income taxed at a - rate below 25 percent (26 U.S.C. 1(h)(1)(A)(ii)(I)); +- line 47, the regular tax (`regular_tax_before_credits`, which is + `income_tax_main_rates` plus `capital_gains_tax`), which taxes at the + regular rates the greater of line 18 or line 20 (line 21), where line 19 is + the taxable income taxed at a rate below 25 percent (26 U.S.C. + 1(h)(1)(A)(ii)(I)), and is never more than line 46; - Form 6251 lines 38 to 40 and 11, with line 14 from Schedule D line 19, line 15 capped by worksheet line 10, line 27 from worksheet line 21 and lines 35 to 37 taxing the rest of the unrecaptured gain at 25 percent. @@ -303,6 +304,7 @@ def capital_gains_worksheet_line_4(household): "dwks14", "dwks19", "income_tax_main_rates", + "tax_on_taxable_income_at_main_rates", "regular_tax_before_credits", "capital_gains_tax", "amt_income_less_exemptions", @@ -401,10 +403,9 @@ def assert_matches_form_6251(households, law): base = float(law["amt_base_tax"][i]) assert part_iii == pytest.approx(line_38, abs=tolerance(line_12)), (i, h) assert base == pytest.approx(line_39, abs=tolerance(line_12)), (i, h) - # Lines 9 to 11, with no foreign tax credit and no Form 4972. - line_10 = float(law["regular_tax_before_credits"][i]) + float( - law["capital_gains_tax"][i] - ) + # Lines 9 to 11, with no foreign tax credit and no Form 4972. Line 10 + # is the regular tax, gains included. + line_10 = float(law["regular_tax_before_credits"][i]) amt = max(0, line_40 - line_10) assert float(law["alternative_minimum_tax"][i]) == pytest.approx( amt, abs=tolerance(line_12, line_10) @@ -445,6 +446,9 @@ def assert_matches_schedule_d_tax_worksheet(households, law): regular_tax = float(law["income_tax_main_rates"][i]) + float( law["capital_gains_tax"][i] ) + assert float(law["regular_tax_before_credits"][i]) == pytest.approx( + regular_tax, abs=tolerance(line_1) + ), (i, h) if line_1 <= 0: assert regular_tax == 0, (i, h) continue @@ -458,10 +462,9 @@ def assert_matches_schedule_d_tax_worksheet(households, law): assert float(law[model_line][i]) == pytest.approx( worksheet[worksheet_line], abs=tolerance(line_1) ), (i, h, model_line) - # Line 45. The model does not take the smaller of lines 45 and 46, - # which differ only when a little gain taxed at 15 percent sits where - # the regular rate is 12 percent. - assert regular_tax == pytest.approx(worksheet[45], abs=tolerance(line_1)), ( + # Line 47, the smaller of lines 45 and 46. They differ when a little + # gain taxed at 15 percent sits where the regular rate is 12 percent. + assert regular_tax == pytest.approx(worksheet[47], abs=tolerance(line_1)), ( i, h, worksheet, @@ -483,9 +486,9 @@ def assert_matches_schedule_d_tax_worksheet(households, law): assert float(law["amt_base_tax"][i]) == pytest.approx( line_39, abs=tolerance(line_12) ), (i, h) - # Line 11, with line 10 the worksheet's regular tax (line 45, as + # Line 11, with line 10 the worksheet's regular tax (line 47, as # above) and no foreign tax credit. - amt = max(0, line_40 - worksheet[45]) + amt = max(0, line_40 - worksheet[47]) assert float(law["alternative_minimum_tax"][i]) == pytest.approx( amt, abs=tolerance(line_12, line_1) ), (i, h) @@ -514,6 +517,11 @@ def assert_schedule_d_properties(households, law): ] ) assert np.array_equal(line_21[plain], line_14[plain]) + # Line 47 is never more than line 46, the tax on all taxable income at the + # regular rates (26 U.S.C. 1(h)(1), "shall not exceed"). + regular_tax = law["regular_tax_before_credits"].astype(float) + line_46 = law["tax_on_taxable_income_at_main_rates"].astype(float) + assert (regular_tax <= line_46 + tol).all() # --------------------------------------------------------------------------- diff --git a/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py b/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py index 995a588fc11..6af6c56020d 100644 --- a/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py +++ b/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py @@ -11,14 +11,16 @@ above bracket 6 top $305,875), worth a flat −$82,486 per married-filing-separately filer under TCJA-expiration counterfactuals. -Three code sites run the schedule and all must clamp together: -`income_tax_main_rates`, the AMT regular-tax worksheet -(`regular_tax_before_credits`, taken when the unit has qualified -dividends or long-term gains), and the `additional_tax_bracket` contrib -reform's copies of both. Clamping only the first diverges the AMT -comparator from the corrected main-rates tax and manufactures phantom -AMT for any affected filer with $1 of preferential income. They now -share one loop, `tax_at_main_rates`, which carries the clamp. +Every code site that runs the schedule must clamp: `income_tax_main_rates`, +`tax_on_taxable_income_at_main_rates` (the tax on all taxable income, +which caps the regular tax under section 1(h)(1)), and the +`additional_tax_bracket` contrib reform's copies of both. The AMT used to +compare against a second worksheet copy of the schedule, and clamping only +`income_tax_main_rates` then manufactured phantom AMT for any affected +filer with $1 of preferential income; Form 6251 line 10 is now +`regular_tax_before_credits`, the main-rates tax plus the capital gains +tax. The sites share one loop, `tax_at_main_rates`, which carries the +clamp. Note the fixture detail that makes these tests real: situation values must be period-keyed (`{"2026": ...}`). An undated `filing_status` @@ -86,6 +88,7 @@ def test_zero_income_filer_owes_zero_under_inverted_brackets(inverted_bracket_sy tax_benefit_system=inverted_bracket_system, situation=separate_filer(0) ) assert sim.calculate("income_tax_main_rates", 2026)[0] == 0 + assert sim.calculate("tax_on_taxable_income_at_main_rates", 2026)[0] == 0 def test_low_income_filer_unaffected_by_inversion_above_their_income( @@ -130,11 +133,11 @@ def test_main_rates_never_negative_across_incomes(inverted_bracket_system): def test_amt_comparator_stays_consistent_with_preferential_income( inverted_bracket_system, ): - # One dollar of qualified dividends routes the AMT regular-tax - # comparison through the duplicate worksheet schedule. If that copy - # is not clamped identically, the corrected main-rates tax minus a - # still-corrupted comparator manufactures tens of thousands of - # dollars of phantom AMT. + # One dollar of qualified dividends brings in the capital gains tax + # and its section 1(h)(1) cap at the tax on all taxable income. If + # either schedule were not clamped identically, Form 6251 line 10 + # would diverge from the corrected main-rates tax and manufacture tens + # of thousands of dollars of phantom AMT. sim = Simulation( tax_benefit_system=inverted_bracket_system, situation=separate_filer(300_000, qualified_dividends=1), @@ -142,9 +145,11 @@ def test_amt_comparator_stays_consistent_with_preferential_income( amt = sim.calculate("alternative_minimum_tax", 2026)[0] main = sim.calculate("income_tax_main_rates", 2026)[0] before_credits = sim.calculate("income_tax_before_credits", 2026)[0] + regular = sim.calculate("regular_tax_before_credits", 2026)[0] assert amt == 0 assert main > 0 assert abs(before_credits - main) < 2 # only the $1 dividend's tax + assert before_credits == pytest.approx(regular) def test_mixed_statuses_only_the_separate_row_responds(inverted_bracket_system): diff --git a/policyengine_us/tests/test_section_911_tax_stacking.py b/policyengine_us/tests/test_section_911_tax_stacking.py index f0f22451084..d3275c213d2 100644 --- a/policyengine_us/tests/test_section_911_tax_stacking.py +++ b/policyengine_us/tests/test_section_911_tax_stacking.py @@ -27,19 +27,17 @@ Households live in Texas; some itemize. -Two limits of the model set the scope of these tests, and neither comes -from section 911: - -- `capital_gains_tax` omits the section 1(h)(1) cap at the tax on all - taxable income at ordinary rates (worksheet line 25, "the smaller of line - 23 or line 24"). The cap binds only where the 15% rate applies inside the - 12% bracket, a band of $100 to $250 of income. The model is therefore - compared with worksheet line 23, and checked to be within $7.50 of line - 25. -- `capital_gains_tax` taxes 28-percent rate gain in full even when it - exceeds taxable income, so the households here have no 28-percent rate - or unrecaptured section 1250 gain. YAML unit tests cover how the capital - gain excess reduces those two amounts. +The model takes the section 1(h)(1) cap at the tax on all taxable income +at ordinary rates (worksheet line 25, "the smaller of line 23 or line 24"), +so it is compared with line 25. The cap binds only where the 15% rate +applies inside the 12% bracket, a band of $100 to $250 of income in 2025, +so line 25 is checked to be within $7.50 of line 23. + +One limit of the model sets the scope of these tests, and it does not come +from section 911: `capital_gains_tax` taxes 28-percent rate gain in full +even when it exceeds taxable income, so the households here have no +28-percent rate or unrecaptured section 1250 gain. YAML unit tests cover how +the capital gain excess reduces those two amounts. """ import numpy as np @@ -167,9 +165,10 @@ def foreign_earned_income_tax_worksheet( ): """2025 Form 1040 instructions, page 37. - Returns line 6 figured with line 23 of the capital gains worksheet (what - the model computes), line 6 figured with its line 25 (what the form - says), and line 5 of the capital gains worksheet, which Form 6251 needs. + Returns line 6 figured with line 23 of the capital gains worksheet (the + tax before the section 1(h)(1) cap), line 6 figured with its line 25 + (what the form says and the model computes), and line 5 of the capital + gains worksheet, which Form 6251 needs. A filer with no exclusion gets the ordinary line 16 tax. """ line_1 = taxable_income @@ -394,12 +393,14 @@ def assert_matches_worksheets(households, law): ) regular_tax = float(law["regular_tax"][i]) stacked_income = taxable_income + h["exclusion"] - assert regular_tax == pytest.approx(line_6, abs=tolerance(stacked_income)), ( - i, - h, - ) - # The form's line 25 cap, which the model's section 1(h) formulas omit. - cap_gaps.append(regular_tax - line_6_capped) + assert float(law["regular_tax_before_credits"][i]) == pytest.approx( + regular_tax, abs=tolerance(stacked_income) + ), (i, h) + assert regular_tax == pytest.approx( + line_6_capped, abs=tolerance(stacked_income) + ), (i, h) + # The form's line 25 cap takes at most $7.50 off line 23. + cap_gaps.append(line_6 - line_6_capped) line_7 = form_6251_line_7( float(law["amt_income_less_exemptions"][i]), h["exclusion"], @@ -411,7 +412,7 @@ def assert_matches_worksheets(households, law): assert -tolerance(stacked_income) <= cap_gaps[-1], (i, h) assert cap_gaps[-1] <= 7.5 + tolerance(stacked_income), (i, h) # Form 6251 lines 9 to 11 with no foreign tax credit. - amt = max(0, line_7 - line_6) + amt = max(0, line_7 - line_6_capped) taxable_excess = float( law["amt_income_less_exemptions_plus_section_911_exclusion"][i] ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py index 5de3a171f33..a53e7d4c84f 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py @@ -53,12 +53,13 @@ def formula(tax_unit, period, parameters): foreign_tax_credit = tax_unit("foreign_tax_credit_potential", period) # Line 9 reduced_tax = total_amt_tax - foreign_tax_credit - # Line 10 contains regular tax before credits, lump sum distributions, and capital gains tax + # Line 10: Form 1040 line 16 less any tax from Form 4972, less the + # foreign tax credit. Line 16 is the regular tax including the + # capital gains tax, the same amount income_tax_before_credits adds. regular_tax_before_credits = tax_unit("regular_tax_before_credits", period) lump_sum_distributions = tax_unit("form_4972_lumpsum_distributions", period) - capital_gains_tax = tax_unit("capital_gains_tax", period) - tax_before_credits = regular_tax_before_credits + capital_gains_tax reduced_tax_before_credits = max_( - 0, tax_before_credits - foreign_tax_credit - lump_sum_distributions + 0, + regular_tax_before_credits - foreign_tax_credit - lump_sum_distributions, ) return max_(0, reduced_tax - reduced_tax_before_credits) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py index bfce22dbb4f..9ee0c8f4475 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py @@ -1,10 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.gov.irs.tax.federal_income.before_credits.tax_at_main_rates import ( - tax_at_main_rates, -) -from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( - schedule_d_tax_worksheet_after_capital_gain_excess, -) class regular_tax_before_credits(Variable): @@ -12,68 +6,29 @@ class regular_tax_before_credits(Variable): entity = TaxUnit definition_period = YEAR label = "Regular tax before credits" - documentation = "Regular tax on regular taxable income before credits" + documentation = ( + "The regular tax on all taxable income, qualified dividends and " + "capital gains included, before credits: the tax at the main rates " + "plus the capital gains tax, which income_tax_before_credits also " + "adds. Schedule D Tax Worksheet line 47 (or Qualified Dividends and " + "Capital Gain Tax Worksheet line 25, or the Tax Table or Tax " + "Computation Worksheet without dividends or gains); for a Form 2555 " + "filer, line 6 of the Foreign Earned Income Tax Worksheet. Form 6251 " + "line 10 starts from it." + ) unit = USD - - def formula(tax_unit, period, parameters): - p = parameters(period).gov.irs - filing_status = tax_unit("filing_status", period) - # A Form 2555 filer enters line 3 of the Foreign Earned Income Tax - # Worksheet on line 1 and refigures the capital gain lines for any - # capital gain excess (26 U.S.C. 911(f)). - dwks1 = tax_unit("taxable_income_plus_section_911_exclusion", period) - worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( - tax_unit, period, tax_unit("taxable_income", period) - ) - - dwks16 = min_(p.capital_gains.thresholds["1"][filing_status], dwks1) - dwks17 = min_(tax_unit("dwks14", period), dwks16) - dwks20 = dwks16 - dwks17 - lowest_rate_tax = p.capital_gains.rates["1"] * dwks20 - # Break in worksheet lines - dwks13 = worksheet.line_13 - dwks21 = min_(dwks1, dwks13) - dwks22 = dwks20 - dwks23 = max_(0, dwks21 - dwks22) - dwks25 = min_(p.capital_gains.thresholds["2"][filing_status], dwks1) - dwks19 = tax_unit("dwks19", period) - dwks26 = min_(dwks19, dwks20) - dwks27 = max_(0, dwks25 - dwks26) - dwks28 = min_(dwks23, dwks27) - dwks29 = p.capital_gains.rates["2"] * dwks28 - dwks30 = dwks22 + dwks28 - dwks31 = dwks21 - dwks30 - dwks32 = p.capital_gains.rates["3"] * dwks31 - # Break in worksheet lines - dwks33 = min_(worksheet.line_9, worksheet.unrecaptured_section_1250_gain) - dwks10 = worksheet.line_10 - dwks34 = dwks10 + dwks19 - dwks36 = max_(0, dwks34 - dwks1) - dwks37 = max_(0, dwks33 - dwks36) - - dwks38 = p.income.amt.capital_gains.capital_gain_excess_tax_rate * dwks37 - # Break in worksheet lines - dwks39 = dwks19 + dwks20 + dwks28 + dwks31 + dwks37 - dwks40 = dwks1 - dwks39 - dwks41 = p.income.amt.brackets.rates[-1] * dwks40 - - # Compute regular tax using bracket rates and thresholds - # The shared schedule clamps inverted brackets as in - # income_tax_main_rates (#9084), or the AMT comparator diverges from - # the main-rates tax and manufactures phantom AMT for qdiv/LTCG - # filers. - reg_taxinc = max_(0, dwks19) - reg_tax = tax_at_main_rates(reg_taxinc, filing_status, p.income.bracket) - - # Return to worksheet lines - dwks42 = reg_tax - dwks43 = dwks29 + dwks32 + dwks38 + dwks41 + dwks42 + lowest_rate_tax - # Foreign Earned Income Tax Worksheet, lines 4 to 6: the worksheet - # tax on line 3 less the tax on the excluded amount alone. - # income_tax_main_rates is already net of it. - excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) - tax_on_excluded = tax_at_main_rates(excluded, filing_status, p.income.bracket) - dwks43 = where(excluded > 0, max_(0, dwks43 - tax_on_excluded), dwks43) - dwks44 = tax_unit("income_tax_main_rates", period) - dwks45 = min_(dwks43, dwks44) - return where(tax_unit("has_qdiv_or_ltcg", period), dwks45, dwks44) + reference = [ + dict( + title="26 U.S. Code § 1(h)(1)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_1", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Schedule D Tax Worksheet, line 47", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=16", + ), + dict( + title="2025 Form 6251, line 10", + href="https://www.irs.gov/pub/irs-prior/f6251--2025.pdf#page=1", + ), + ] + adds = ["income_tax_main_rates", "capital_gains_tax"] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.py b/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.py new file mode 100644 index 00000000000..4d9ebdb2cb5 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_on_taxable_income_at_main_rates.py @@ -0,0 +1,48 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.before_credits.tax_at_main_rates import ( + tax_at_main_rates, +) + + +class tax_on_taxable_income_at_main_rates(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "Tax on all taxable income at the main rates" + documentation = ( + "The tax on all taxable income, capital gains and qualified dividends " + "included, at the ordinary rate schedule of 26 U.S.C. 1: Schedule D " + "Tax Worksheet line 46 and Qualified Dividends and Capital Gain Tax " + "Worksheet line 24. Section 1(h)(1) caps the regular tax at this " + "amount. For a taxpayer excluding foreign earned income, taxable " + "income includes the excluded amount and the tax on the excluded " + "amount alone is subtracted, as in income_tax_main_rates." + ) + unit = USD + reference = [ + dict( + title="26 U.S. Code § 1(h)(1)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_1", + ), + dict( + title="26 U.S. Code § 911(f)(1)(A)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_1_A", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Schedule D Tax Worksheet, lines 46 and 47", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=16", + ), + ] + + def formula(tax_unit, period, parameters): + # Worksheet line 1: taxable income, or for a Form 2555 filer line 3 + # of the Foreign Earned Income Tax Worksheet. + taxable_income = tax_unit("taxable_income_plus_section_911_exclusion", period) + bracket = parameters(period).gov.irs.income.bracket + filing_status = tax_unit("filing_status", period) + tax = tax_at_main_rates(max_(0, taxable_income), filing_status, bracket) + # 26 U.S.C. 911(f)(1)(A): less the tax on the excluded amount alone + # (Foreign Earned Income Tax Worksheet lines 5 and 6). + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) + return where(excluded > 0, max_(0, tax - tax_on_excluded), tax) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py index 9bef17774d5..10db9bbb169 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py @@ -38,6 +38,28 @@ def rate_gain_taxed_at_28_percent( ) +def limit_to_tax_at_main_rates(tax_unit, period, capital_gains_tax): + """The capital gains tax that 26 U.S.C. 1(h)(1) allows. + + With a net capital gain, "the tax imposed by this section for such + taxable year shall not exceed the sum of" the amounts in subparagraphs + (A) to (F), so the regular tax is the smaller of that sum and the tax on + all taxable income at the main rates (Schedule D Tax Worksheet line 47, + "the smaller of line 45 or line 46"; Qualified Dividends and Capital Gain + Tax Worksheet line 25). income_tax_main_rates is subparagraph (A), so the + tax on the gains is at most the rest of the tax at the main rates. The + limit binds where gain taxed at 15 percent sits in the 12 percent bracket, + which happens when the 0 percent rate amount is below the top of that + bracket. + """ + tax_at_main_rates_on_the_rest = max_( + 0, + tax_unit("tax_on_taxable_income_at_main_rates", period) + - tax_unit("income_tax_main_rates", period), + ) + return min_(capital_gains_tax, tax_at_main_rates_on_the_rest) + + class capital_gains_tax(Variable): value_type = float entity = TaxUnit @@ -53,6 +75,10 @@ class capital_gains_tax(Variable): title="26 U.S. Code § 911(f)", href="https://www.law.cornell.edu/uscode/text/26/911#f", ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Schedule D Tax Worksheet, line 47", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=16", + ), ] def formula(tax_unit, period, parameters): @@ -138,4 +164,8 @@ def formula(tax_unit, period, parameters): taxable_unrecaptured_gain, ) - return main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax + return limit_to_tax_at_main_rates( + tax_unit, + period, + main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax, + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py index 1cc7a77763a..e2f420d8c84 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py @@ -13,11 +13,11 @@ class section_911_capital_gain_excess(Variable): "taxable income plus the excluded amount, the capital gains are " "reduced by this excess, so that the excluded amount is never taxed " "at the capital gains rates. The section 1(h) formulas use this " - "amount; the Schedule D Tax Worksheet lines (dwks14, dwks19, " - "regular_tax_before_credits) measure the excess from worksheet line " - "10, as the worksheet does. On a return the two are the same amount, " - "and in the model dwks10 equals net_capital_gain for the same inputs, " - "apart from single-precision rounding." + "amount; the Schedule D Tax Worksheet lines (dwks14 and dwks19) " + "measure the excess from worksheet line 10, as the worksheet does. On " + "a return the two are the same amount, and in the model dwks10 equals " + "net_capital_gain for the same inputs, apart from single-precision " + "rounding." ) definition_period = YEAR reference = [ From 7b719357205de4e3edba76ba22b1ed422c6e48d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 20:15:52 -0400 Subject: [PATCH 2/8] Cite Rev. Proc. 2025-32 section 4 for 2026 amounts Section 3 of Rev. Proc. 2025-32 modifies 2025 items (only .01, the standard deduction, and .02, section 179); the 2026 adjusted items are section 4. The 2026 AMT breakpoints are section 4.10 (PDF page 16) and the 2026 additional standard deduction for the aged or blind is section 4.14(3) (PDF page 18). Also give the dependent standard deduction citation its section number (4.14(2)). Co-Authored-By: Claude Opus 5.5 --- .../states/me/tax/income/deductions/standard/aged_or_blind.yaml | 2 +- .../tests/core/test_me_standard_deduction_federal_conformity.py | 2 +- .../alternative_minimum_tax/amt_tax_including_cg.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_us/parameters/gov/states/me/tax/income/deductions/standard/aged_or_blind.yaml b/policyengine_us/parameters/gov/states/me/tax/income/deductions/standard/aged_or_blind.yaml index 41b88f928d6..7ba3defe3b5 100644 --- a/policyengine_us/parameters/gov/states/me/tax/income/deductions/standard/aged_or_blind.yaml +++ b/policyengine_us/parameters/gov/states/me/tax/income/deductions/standard/aged_or_blind.yaml @@ -18,7 +18,7 @@ metadata: href: https://www.mainelegislature.org/legis/statutes/36/title36sec5124-C.html - title: 36 M.R.S. Sec. 5124-C(1-B)(B) and (1-C)(B) (Maine additional standard deduction equals the IRC Section 63(c)(3) amount) href: https://legislature.maine.gov/statutes/36/title36sec5124-C.html - - title: IRS Rev. Proc. 2025-32, Section 3.14(3) (2026 federal additional standard deduction for the aged or the blind) + - title: IRS Rev. Proc. 2025-32, Section 4.14(3) (2026 federal additional standard deduction for the aged or the blind) href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=18 SINGLE: 2018-01-01: 1_600 diff --git a/policyengine_us/tests/core/test_me_standard_deduction_federal_conformity.py b/policyengine_us/tests/core/test_me_standard_deduction_federal_conformity.py index c739c1b0523..1fb402c0372 100644 --- a/policyengine_us/tests/core/test_me_standard_deduction_federal_conformity.py +++ b/policyengine_us/tests/core/test_me_standard_deduction_federal_conformity.py @@ -109,7 +109,7 @@ def test_me_standard_deduction_does_not_follow_federal_in_2026(): def test_me_standard_deduction_applies_the_federal_dependent_cap_in_2027(): - # IRC Section 63(c)(5), as adjusted by Rev. Proc. 2025-32 section .14(2): + # IRC Section 63(c)(5), as adjusted by Rev. Proc. 2025-32 section 4.14(2): # an individual who is a dependent of another taxpayer gets at most the # greater of the dependent floor and earned income plus a fixed addition, # capped at the ordinary basic standard deduction. Maine follows the federal 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 fd894743583..b89b41f7e71 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 @@ -327,7 +327,7 @@ dwks19: 136_000 unrecaptured_section_1250_gain: 0 output: - # Rev. Proc. 2025-32, section 3.10: for 2026 the 28 percent rate applies + # Rev. Proc. 2025-32, section 4.10: for 2026 the 28 percent rate applies # above $122,250 for married individuals filing separate returns # ($244,500 for all other taxpayers). Line 17 is all of line 12, # $136,000, so line 18 is 28% * $136,000 - 2% * $122,250 = $38,080 - From b96bb7fa655428210d141843d2e085dd05394ca6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 00:11:39 -0400 Subject: [PATCH 3/8] Add property and differential tests for the regular tax and the line 47 limit test_regular_tax_before_credits.py transcribes the 2025 Schedule D Tax Worksheet, lines 1 to 47, with every skip instruction, the Form 4952 election, capital gain distributions, short-term losses and Schedule D lines 18 and 19. The regular rates come from the 2025 Tax Computation Worksheet rows. 2026 runs on Rev. Proc. 2025-32's tables and capital gains amounts. The model matches lines 10, 13, 14, 21, 44, 46 and 47 for a grid, households at the 0 percent amount, and Hypothesis draws. Properties, 2018 to 2035: - 0 <= regular tax <= line 46; - 0 <= capital_gains_tax <= line 46 - income_tax_main_rates; - regular_tax_before_credits = main rates + capital gains tax; - income_tax_before_credits = regular + AMT; - the AMT is line 9 less line 10; - without net capital gain, the regular tax is line 46; - the worksheet lines (dwks10, dwks13, line 18 <= 21 <= 14); - the tax is monotone in wages, gains and dividends; - results are independent of batch order. The limit binds at the top of the 12 percent bracket in every year and filing status, with the published band widths for 2018 to 2026. The monotonicity check sets aside households with capital gain distributions and a Schedule D net loss, a known defect it found. Gross income adds non_sch_d_capital_gains outside the loss limit, while net_capital_gain nets them (a 2018 single filer's tax falls $0.12 with $1 more gain). No dataset tax unit has both. It is left for a follow-up. Also adds 2026 joint, separate and non-binding line 45/46 YAML cases, from the same Rev. Proc. 2025-32 figures as the Axiom companion tests. Co-Authored-By: Claude Opus 5.5 --- .../regular_tax_before_credits.yaml | 45 ++ .../tests/test_regular_tax_before_credits.py | 752 ++++++++++++++++++ 2 files changed, 797 insertions(+) create mode 100644 policyengine_us/tests/test_regular_tax_before_credits.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml index b29396fc57c..32e6afcea1c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml @@ -282,6 +282,51 @@ capital_gains_tax: 150 regular_tax_before_credits: 7_740 +- name: 2026 joint, line 45 exceeds line 46 by $57 + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: JOINT + taxable_income: 100_800 + qualified_dividend_income: 1_900 + output: + # Rev. Proc. 2025-32, Table 1 and section 4.03: 12 percent to 100,800, 0 + # percent rate amount 98,900. Line 44: 2,480 + 12% x 74,100 = 11,372. + # Line 46: 11,600. Line 45: 11,372 + 15% x 1,900 = 11,657. + income_tax_main_rates: 11_372 + tax_on_taxable_income_at_main_rates: 11_600 + capital_gains_tax: 228 + regular_tax_before_credits: 11_600 + +- name: 2026 separate, long-term gain, line 45 exceeds line 46 by $28.50 + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: SEPARATE + taxable_income: 50_400 + long_term_capital_gains: 950 + output: + # Rev. Proc. 2025-32, Table 4 and section 4.03, as for single filers. + income_tax_main_rates: 5_686 + tax_on_taxable_income_at_main_rates: 5_800 + capital_gains_tax: 114 + regular_tax_before_credits: 5_800 + +- name: 2026 single, gain into the 22 percent bracket leaves line 45 below line 46 + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: SINGLE + taxable_income: 60_000 + qualified_dividend_income: 10_000 + output: + # Line 44: 1,240 + 12% x 37,600 = 5,752. Line 45: 5,752 + 15% x 10,000 + # = 7,252. Line 46: 5,800 + 22% x 9,600 = 7,912. + income_tax_main_rates: 5_752 + tax_on_taxable_income_at_main_rates: 7_912 + capital_gains_tax: 1_500 + regular_tax_before_credits: 7_252 + - name: 2025 single, more gain than the overlap leaves line 45 below line 46 absolute_error_margin: 0.01 period: 2025 diff --git a/policyengine_us/tests/test_regular_tax_before_credits.py b/policyengine_us/tests/test_regular_tax_before_credits.py new file mode 100644 index 00000000000..c930b3f5915 --- /dev/null +++ b/policyengine_us/tests/test_regular_tax_before_credits.py @@ -0,0 +1,752 @@ +"""Property and differential tests for the regular tax on Form 6251 line 10. + +26 U.S.C. 1(h)(1): "If a taxpayer has a net capital gain for any taxable +year, the tax imposed by this section for such taxable year shall not exceed +the sum of" the amounts in subparagraphs (A) to (F). The 2025 Schedule D Tax +Worksheet adds those amounts on line 45, figures the tax on all of line 1 at +the regular rates on line 46, and enters "the smaller of line 45 or line 46" +on line 47, the tax on Form 1040 line 16. + +The model figures the regular tax once, by the section 1(h) formulas: +`income_tax_main_rates` taxes subparagraph (A)'s amount at the regular +rates, and `capital_gains_tax` adds subparagraphs (B) to (F), but no more +than the rest of `tax_on_taxable_income_at_main_rates` (line 46). +`regular_tax_before_credits` is the sum, and Form 6251 line 10 uses it. + +Two kinds of test: + +- Differential. Lines 1 to 47 of the 2025 Schedule D Tax Worksheet (2025 + Instructions for Schedule D, pages 15 and 16) are transcribed below line by + line, with every "skip" instruction, the Form 4952 line 4g election (lines + 3 to 9, with line 4 from Form 4952 lines 4d and 4e), capital gain + distributions (Schedule D line 13), short-term losses, and Schedule D lines + 18 and 19. The tax at the regular rates is the 2025 Tax Computation + Worksheet, extended below $100,000 at the same rates as the model does + (the Tax Table rounds within $50 rows). For 2026 the same worksheet runs on + Rev. Proc. 2025-32's amounts: the rate tables of section 4.01 and the + maximum zero and 15 percent rate amounts of section 4.03. Every household + is compared line by line with the model. Schedule D lines 18 and 19 are + the 28 percent rate gain and unrecaptured section 1250 gain as entered, as + the model takes them. +- Properties that hold for every household and year, from the model alone: + 1. 0 <= regular tax <= line 46, the tax on all taxable income at the + regular rates ("shall not exceed"). + 2. The preferential computation never exceeds the ordinary one: + 0 <= capital_gains_tax <= line 46 - income_tax_main_rates, so the + main-rates tax is never more than the regular tax. + 3. Identities: regular_tax_before_credits is income_tax_main_rates plus + capital_gains_tax; income_tax_before_credits is the regular tax plus + the AMT; and the AMT is Form 6251 line 9 less line 10 (the regular tax), + if more than zero. + 4. Without qualified dividends or net capital gain, the regular tax is + line 46. + 5. Worksheet lines: line 10 is the net capital gain, line 13 the adjusted + net capital gain, line 18 <= line 21 <= line 14, and the main-rates tax + is the tax on line 21. + 6. More wages, long-term gain or qualified dividends never lower the + regular tax. + 7. A household's results do not depend on the other households in its + batch or their order. + And, for every year from 2018 and every filing status, the line 46 limit + binds exactly where the 0 percent rate amount ends below the top of the + 12 percent bracket. +""" + +from collections import namedtuple + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system as SYSTEM + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +STATUSES = ["SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"] +INF = float("inf") + +# --------------------------------------------------------------------------- +# Dollar amounts. +# --------------------------------------------------------------------------- + +Amounts = namedtuple( + "Amounts", + [ + "zero_rate", # worksheet line 15 + "below_25_percent", # worksheet line 19, the top of the 24% bracket + "fifteen_rate", # worksheet line 26 + "brackets", # (top, rate) rows of the regular rate schedule + ], +) + + +def _rows(*tops): + """The 10 to 37 percent schedule with the given bracket tops.""" + rates = [0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.37] + return list(zip(list(tops) + [INF], rates)) + + +# 2025: Schedule D Tax Worksheet lines 15, 19 and 26 as printed; the bracket +# tops are the rows of the 2025 Tax Computation Worksheet (2025 Instructions +# for Form 1040, line 16), whose subtraction amounts imply the 10 and 12 +# percent tops (single: 22% x 48,475 - 5,086 = 5,578.50 = 10% x 11,925 + +# 12% x 36,550). +_UNMARRIED_2025 = (11_925, 48_475, 103_350, 197_300, 250_525) +AMOUNTS = { + 2025: Amounts( + zero_rate={ + "SINGLE": 48_350, + "SEPARATE": 48_350, + "JOINT": 96_700, + "SURVIVING_SPOUSE": 96_700, + "HEAD_OF_HOUSEHOLD": 64_750, + }, + below_25_percent={ + "SINGLE": 197_300, + "SEPARATE": 197_300, + "JOINT": 394_600, + "SURVIVING_SPOUSE": 394_600, + "HEAD_OF_HOUSEHOLD": 197_300, + }, + fifteen_rate={ + "SINGLE": 533_400, + "SEPARATE": 300_000, + "JOINT": 600_050, + "SURVIVING_SPOUSE": 600_050, + "HEAD_OF_HOUSEHOLD": 566_700, + }, + brackets={ + "SINGLE": _rows(*_UNMARRIED_2025, 626_350), + "SEPARATE": _rows(*_UNMARRIED_2025, 375_800), + "JOINT": _rows(23_850, 96_950, 206_700, 394_600, 501_050, 751_600), + "SURVIVING_SPOUSE": _rows( + 23_850, 96_950, 206_700, 394_600, 501_050, 751_600 + ), + "HEAD_OF_HOUSEHOLD": _rows( + 17_000, 64_850, 103_350, 197_300, 250_500, 626_350 + ), + }, + ), + # 2026: Rev. Proc. 2025-32, section 4.01 (Tables 1 to 4) and section 4.03. + 2026: Amounts( + zero_rate={ + "SINGLE": 49_450, + "SEPARATE": 49_450, + "JOINT": 98_900, + "SURVIVING_SPOUSE": 98_900, + "HEAD_OF_HOUSEHOLD": 66_200, + }, + below_25_percent={ + "SINGLE": 201_775, + "SEPARATE": 201_775, + "JOINT": 403_550, + "SURVIVING_SPOUSE": 403_550, + "HEAD_OF_HOUSEHOLD": 201_750, + }, + fifteen_rate={ + "SINGLE": 545_500, + "SEPARATE": 306_850, + "JOINT": 613_700, + "SURVIVING_SPOUSE": 613_700, + "HEAD_OF_HOUSEHOLD": 579_600, + }, + brackets={ + "SINGLE": _rows(12_400, 50_400, 105_700, 201_775, 256_225, 640_600), + "SEPARATE": _rows(12_400, 50_400, 105_700, 201_775, 256_225, 384_350), + "JOINT": _rows(24_800, 100_800, 211_400, 403_550, 512_450, 768_700), + "SURVIVING_SPOUSE": _rows( + 24_800, 100_800, 211_400, 403_550, 512_450, 768_700 + ), + "HEAD_OF_HOUSEHOLD": _rows( + 17_700, 67_450, 105_700, 201_750, 256_200, 640_600 + ), + }, + ), +} + + +def regular_rate_tax(amount, status, year): + """The tax on an amount at the regular rates.""" + tax, bottom = 0.0, 0.0 + for top, rate in AMOUNTS[year].brackets[status]: + if amount <= bottom: + break + tax += rate * (min(amount, top) - bottom) + bottom = top + return tax + + +def test_rate_schedules_match_the_printed_amounts(): + """The rows reproduce the printed tax amounts: the 2025 Tax Computation + Worksheet's subtraction amounts and Rev. Proc. 2025-32's bases.""" + # 2025, rate x amount - subtraction at the top of each row. + for status, row_top, rate, subtraction in [ + ("SINGLE", 197_300, 0.24, 7_153), + ("SINGLE", 626_350, 0.35, 30_452.75), + ("SEPARATE", 375_800, 0.35, 30_452.75), + ("JOINT", 751_600, 0.35, 60_905.50), + ("HEAD_OF_HOUSEHOLD", 250_500, 0.32, 24_676), + ]: + assert regular_rate_tax(row_top, status, 2025) == pytest.approx( + rate * row_top - subtraction + ) + # 2026, the base at the bottom of a row. + for status, row_bottom, base in [ + ("SINGLE", 640_600, 192_979.25), + ("SEPARATE", 384_350, 103_291.75), + ("JOINT", 768_700, 206_583.50), + ("HEAD_OF_HOUSEHOLD", 640_600, 191_171), + ]: + assert regular_rate_tax(row_bottom, status, 2026) == pytest.approx(base) + + +# --------------------------------------------------------------------------- +# The 2025 Schedule D Tax Worksheet, transcribed. +# --------------------------------------------------------------------------- + + +def schedule_d_tax_worksheet(h, line_1, status, year): + """Lines 1 to 47. Returns a dict of line amounts, with line 47 the tax.""" + a = AMOUNTS[year] + schedule_d_15 = h["long_term"] + h["distributions"] + schedule_d_16 = schedule_d_15 + h["short_term"] + schedule_d_18 = h["collectibles"] + schedule_d_19 = h["section_1250"] + line = {1: line_1} + line[46] = regular_rate_tax(line_1, status, year) + # "Exception: Don't use the Qualified Dividends and Capital Gain Tax + # Worksheet or this worksheet to figure your tax if: Line 15 or line 16 + # of Schedule D is zero or less and you have no qualified dividends ...; + # or Form 1040 ... line 15, is zero or less." + if (min(schedule_d_15, schedule_d_16) <= 0 and h["dividends"] == 0) or ( + line_1 <= 0 + ): + line[47] = line[46] + return line + line[2] = h["dividends"] + line[3] = h["election"] + # Form 4952 line 4d, "the excess, if any, of your total gains over your + # total losses", and line 4e, the smaller of line 4d or "the excess, if + # any, of your net long-term capital gain over your net short-term + # capital loss", with every capital asset held for investment. + line_4d = max(0, schedule_d_16) + line[4] = min(line_4d, max(0, schedule_d_15 - max(0, -h["short_term"]))) + line[5] = max(0, line[3] - line[4]) + line[6] = max(0, line[2] - line[5]) + line[7] = min(schedule_d_15, schedule_d_16) + line[8] = min(line[3], line[4]) + line[9] = max(0, line[7] - line[8]) + line[10] = line[6] + line[9] + line[11] = schedule_d_18 + schedule_d_19 + line[12] = min(line[9], line[11]) + line[13] = line[10] - line[12] + line[14] = max(0, line[1] - line[13]) + line[15] = a.zero_rate[status] + line[16] = min(line[1], line[15]) + line[17] = min(line[14], line[16]) + line[18] = max(0, line[1] - line[10]) + line[19] = min(line[1], a.below_25_percent[status]) + line[20] = min(line[14], line[19]) + line[21] = max(line[18], line[20]) + line[22] = line[16] - line[17] + for i in range(23, 44): + line[i] = 0 + # "If lines 1 and 16 are the same, skip lines 23 through 43." + if line[1] != line[16]: + line[23] = min(line[1], line[13]) + line[24] = line[22] + line[25] = max(0, line[23] - line[24]) + line[26] = a.fifteen_rate[status] + line[27] = min(line[1], line[26]) + line[28] = line[21] + line[22] + line[29] = max(0, line[27] - line[28]) + line[30] = min(line[25], line[29]) + line[31] = 0.15 * line[30] + line[32] = line[24] + line[30] + # "If lines 1 and 32 are the same, skip lines 33 through 43." + if line[1] != line[32]: + line[33] = line[23] - line[32] + line[34] = 0.20 * line[33] + # "If Schedule D, line 19, is zero or blank, skip lines 35 + # through 40." + if schedule_d_19 > 0: + line[35] = min(line[9], schedule_d_19) + line[36] = line[10] + line[21] + line[37] = line[1] + line[38] = max(0, line[36] - line[37]) + line[39] = max(0, line[35] - line[38]) + line[40] = 0.25 * line[39] + # "If Schedule D, line 18, is zero or blank, skip lines 41 + # through 43." + if schedule_d_18 > 0: + line[41] = line[21] + line[22] + line[30] + line[33] + line[39] + line[42] = line[1] - line[41] + line[43] = 0.28 * line[42] + line[44] = regular_rate_tax(line[21], status, year) + line[45] = line[31] + line[34] + line[40] + line[43] + line[44] + line[47] = min(line[45], line[46]) + return line + + +def assert_worksheet_identities(line): + """Identities of the transcription itself.""" + if 2 not in line: + return + # Lines 21, 22, 30, 33, 39 and 42 split line 1 among the regular rates + # and the 0, 15, 20, 25 and 28 percent rates. Where the worksheet skips + # lines, the lines it skips would be zero. + split = line[21] + line[22] + line[30] + line[33] + line[39] + line[42] + assert split == pytest.approx(line[1], abs=0.01), line + assert line[45] >= line[44] >= 0 + assert line[47] == min(line[45], line[46]) + assert line[18] <= line[21] <= max(line[14], line[18]) + + +# --------------------------------------------------------------------------- +# The model. +# --------------------------------------------------------------------------- + +OUTPUTS = [ + "filing_status", + "taxable_income", + "has_qdiv_or_ltcg", + "net_capital_gain", + "adjusted_net_capital_gain", + "capital_gains_excluded_from_taxable_income", + "dwks10", + "dwks13", + "dwks14", + "dwks19", + "income_tax_main_rates", + "capital_gains_tax", + "tax_on_taxable_income_at_main_rates", + "regular_tax_before_credits", + "amt_part_iii_required", + "amt_base_tax", + "amt_tax_including_cg", + "alternative_minimum_tax", + "income_tax_before_credits", +] + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = { + "age": {year: 45}, + "employment_income": {year: h["wages"]}, + "qualified_dividend_income": {year: h["dividends"]}, + "long_term_capital_gains": {year: h["long_term"]}, + "short_term_capital_gains": {year: h["short_term"]}, + "non_sch_d_capital_gains": {year: h["distributions"]}, + "investment_income_elected_form_4952": {year: h["election"]}, + # Schedule D line 18 is part of the long-term gain. + "long_term_capital_gains_on_collectibles": {year: h["collectibles"]}, + } + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = {"age": {year: 45}} + members.append(spouse) + marital_units[f"marital_unit_{i}"]["members"].append(spouse) + if h["status"] in ("HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"): + # Old enough to be a dependent without a child tax credit. + child = f"child_{i}" + people[child] = {"age": {year: 17}} + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_units[f"tax_unit_{i}"] = { + "members": members, + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default (single). + "filing_status": {year: h["status"]}, + "unrecaptured_section_1250_gain": {year: h["section_1250"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def calculate(households, year): + simulation = Simulation(situation=build_situation(households, year)) + results = {} + for v in OUTPUTS: + values = simulation.calculate(v, year) + results[v] = ( + values.decode_to_str() if v == "filing_status" else np.asarray(values) + ) + return results + + +def tolerance(*amounts): + """Five cents, plus the rounding of single-precision arithmetic. + + The model stores amounts as 32-bit floats, which carry about seven + significant digits. + """ + return 0.05 + 5e-7 * np.max(np.abs(amounts), axis=0) + + +# --------------------------------------------------------------------------- +# Checks. +# --------------------------------------------------------------------------- + + +def assert_matches_worksheet(households, law, year): + """The model against the transcribed worksheet, household by household.""" + for i, h in enumerate(households): + status = law["filing_status"][i] + assert status == h["status"], (i, status) + line_1 = float(law["taxable_income"][i]) + line = schedule_d_tax_worksheet(h, line_1, status, year) + assert_worksheet_identities(line) + slack = tolerance(line_1) + context = (i, h, line) + assert float(law["tax_on_taxable_income_at_main_rates"][i]) == pytest.approx( + line[46], abs=slack + ), context + assert float(law["regular_tax_before_credits"][i]) == pytest.approx( + line[47], abs=slack + ), context + if 2 in line: + assert float(law["income_tax_main_rates"][i]) == pytest.approx( + line[44], abs=slack + ), context + assert float(law["capital_gains_tax"][i]) == pytest.approx( + line[47] - line[44], abs=slack + ), context + for model_line, worksheet_line in ( + ("dwks10", 10), + ("dwks13", 13), + ("dwks14", 14), + ("dwks19", 21), + ): + assert float(law[model_line][i]) == pytest.approx( + line[worksheet_line], abs=slack + ), (model_line, context) + + +def assert_properties(households, law): + """Properties 1 to 5, for every household.""" + line_1 = law["taxable_income"].astype(float) + tol = tolerance(line_1) + main = law["income_tax_main_rates"].astype(float) + gains_tax = law["capital_gains_tax"].astype(float) + regular = law["regular_tax_before_credits"].astype(float) + line_46 = law["tax_on_taxable_income_at_main_rates"].astype(float) + amt = law["alternative_minimum_tax"].astype(float) + # 1. "Shall not exceed": 0 <= regular tax <= line 46. + assert (regular >= 0).all() + assert (regular <= line_46 + tol).all() + # 2. The preferential computation never exceeds the ordinary one. + assert (gains_tax >= 0).all() + assert (gains_tax <= line_46 - main + tol).all() + assert (main <= regular + tol).all() + # 3. Identities, to single-precision rounding (the model adds 32-bit + # floats: near $384,000 they are 3 cents apart). + assert (np.abs(regular - (main + gains_tax)) <= tol).all() + before_credits = law["income_tax_before_credits"].astype(float) + assert (np.abs(before_credits - (regular + amt)) <= tol).all() + # Form 6251 lines 9 to 11, with no foreign tax credit or Form 4972. + line_9 = np.where( + law["amt_part_iii_required"], + np.minimum(law["amt_base_tax"], law["amt_tax_including_cg"]), + law["amt_base_tax"], + ).astype(float) + assert (np.abs(amt - np.maximum(0, line_9 - regular)) <= tol).all() + # 4. No dividends or gain: the regular tax is line 46, bit for bit. + no_gain = law["net_capital_gain"] == 0 + assert np.array_equal(regular[no_gain], line_46[no_gain]) + # 5. Worksheet lines. + has_gains = law["has_qdiv_or_ltcg"].astype(bool) + assert np.allclose( + law["dwks10"], law["net_capital_gain"], rtol=0, atol=0.01 + 1e-6 * line_1 + ) + assert np.allclose( + law["dwks13"][has_gains], + law["adjusted_net_capital_gain"][has_gains], + rtol=0, + atol=(0.01 + 1e-6 * line_1)[has_gains], + ) + line_14 = law["dwks14"].astype(float) + line_21 = law["dwks19"].astype(float) + line_18 = np.where(has_gains, np.maximum(0, line_1 - law["dwks10"]), 0) + assert (line_18 <= line_21 + tol).all() + assert (line_21 <= line_14 + tol).all() + # income_tax_main_rates taxes line 21, the amount section 1(h)(1)(A) + # taxes at the regular rates. + regular_rate_amount = np.maximum( + 0, line_1 - law["capital_gains_excluded_from_taxable_income"] + ) + assert np.allclose( + line_21[has_gains], regular_rate_amount[has_gains], rtol=0, atol=tol[has_gains] + ) + + +# --------------------------------------------------------------------------- +# Households. +# --------------------------------------------------------------------------- + +DOLLARS = 2_000_000 + + +@st.composite +def households(draw): + long_term = draw(st.one_of(st.just(0), st.integers(-50_000, DOLLARS))) + short_term = draw(st.one_of(st.just(0), st.integers(-100_000, 300_000))) + distributions = draw(st.one_of(st.just(0), st.integers(1, 100_000))) + dividends = draw(st.one_of(st.just(0), st.integers(1, 400_000))) + gain = max(0, min(long_term, long_term + short_term) + distributions) + # Schedule D lines 18 and 19 as shares of the long-term gain. + collectibles = max(0, long_term) * draw(st.sampled_from([0, 0, 10, 50, 100])) // 100 + section_1250 = max(0, long_term) * draw(st.sampled_from([0, 0, 20, 60, 100])) // 100 + return { + "status": draw(st.sampled_from(STATUSES)), + "wages": draw(st.one_of(st.just(0), st.integers(1, 1_500_000))), + "dividends": dividends, + "long_term": long_term, + "short_term": short_term, + "distributions": distributions, + "election": draw( + st.one_of( + st.just(0), + st.just(0), + st.integers(1, DOLLARS), + st.sampled_from([gain, gain + dividends]), + ) + ), + "collectibles": collectibles, + "section_1250": section_1250, + } + + +def household(status, wages=0, dividends=0, long_term=0, **rest): + return { + "status": status, + "wages": wages, + "dividends": dividends, + "long_term": long_term, + "short_term": rest.get("short_term", 0), + "distributions": rest.get("distributions", 0), + "election": rest.get("election", 0), + "collectibles": rest.get("collectibles", 0), + "section_1250": rest.get("section_1250", 0), + } + + +# Wages that put taxable income, with the standard deduction, near the lines +# where the limit binds: the 0 percent rate amount, the top of the 12 percent +# bracket and the top of the 24 percent bracket. +GRID = [ + household(status, wages=wages, dividends=dividends, long_term=long_term, **rest) + for status in STATUSES + for wages in (0, 40_000, 64_000, 110_000, 250_000, 450_000, 900_000) + for dividends, long_term, rest in ( + (0, 0, {}), + (125, 0, {}), + (250, 0, {}), + (2_000, 0, {}), + (0, 30_000, {"short_term": -10_000}), + (5_000, 80_000, {"election": 20_000}), + (0, 200_000, {"collectibles": 50_000, "section_1250": 60_000}), + (10_000, 600_000, {"distributions": 4_000, "section_1250": 100_000}), + ) +] + + +# Standard deductions (2025: Rev. Proc. 2025-32, section 3.01; 2026: section +# 4.14(1)). The surviving spouse here has a dependent child. +STANDARD_DEDUCTION = { + 2025: {"SINGLE": 15_750, "SEPARATE": 15_750, "JOINT": 31_500}, + 2026: {"SINGLE": 16_100, "SEPARATE": 16_100, "JOINT": 32_200}, +} +for _year, _head in ((2025, 23_625), (2026, 24_150)): + STANDARD_DEDUCTION[_year]["HEAD_OF_HOUSEHOLD"] = _head + STANDARD_DEDUCTION[_year]["SURVIVING_SPOUSE"] = STANDARD_DEDUCTION[_year]["JOINT"] + + +def boundary_households(year): + """Ordinary taxable income equal to the 0 percent rate amount, and + qualified dividends up to the top of the 12 percent bracket, then a dollar + less and a dollar more: line 45 exceeds line 46 by 3 percent of the + dividends inside the band.""" + out = [] + for status in STATUSES: + zero_rate = AMOUNTS[year].zero_rate[status] + top_of_12 = AMOUNTS[year].brackets[status][1][0] + wages = zero_rate + STANDARD_DEDUCTION[year][status] + for dividends in (top_of_12 - zero_rate + d for d in (-1, 0, 1)): + out.append(household(status, wages=wages, dividends=dividends)) + return out + + +def test_grid_matches_worksheet_2025_and_2026(): + for year in (2025, 2026): + households = GRID + boundary_households(year) + law = calculate(households, year) + assert_matches_worksheet(households, law, year) + assert_properties(households, law) + lines = [ + schedule_d_tax_worksheet( + h, float(law["taxable_income"][i]), h["status"], year + ) + for i, h in enumerate(households) + ] + excess = np.array( + [line[45] - line[46] if 45 in line else 0.0 for line in lines] + ) + # Every boundary household's line 45 exceeds its line 46, by 3 + # percent of the dividends that fit in the band. + boundary = excess[len(GRID) :].reshape(len(STATUSES), 3) + bands = np.array( + [ + AMOUNTS[year].brackets[status][1][0] - AMOUNTS[year].zero_rate[status] + for status in STATUSES + ] + ) + expected = 0.03 * np.stack([bands - 1, bands, bands], axis=1) + # A dollar above the band is taxed at 22 percent on line 46 and 15 on + # line 45, which takes 7 cents off the excess. + expected[:, 2] -= 0.07 + assert boundary == pytest.approx(expected, abs=0.01), year + + +def test_line_45_above_line_46_by_hand(): + # 2026, single: 49,450 of ordinary taxable income and 950 of qualified + # dividends. Line 44, the tax on 49,450, is 1,240 + 12% x 37,050 = + # 5,686; line 30 is 950 (15%: 142.50), so line 45 is 5,828.50. Line 46, + # the tax on 50,400, is 5,800 (Rev. Proc. 2025-32, Table 3). + h = household("SINGLE", wages=50_400 + 16_100 - 950, dividends=950) + law = calculate([h], 2026) + assert law["taxable_income"][0] == 50_400 + line = schedule_d_tax_worksheet(h, 50_400, "SINGLE", 2026) + assert (line[44], line[45], line[46], line[47]) == pytest.approx( + (5_686, 5_828.50, 5_800, 5_800) + ) + assert law["income_tax_main_rates"][0] == pytest.approx(5_686) + assert law["capital_gains_tax"][0] == pytest.approx(114) + assert law["regular_tax_before_credits"][0] == pytest.approx(5_800) + assert_matches_worksheet([h], law, 2026) + + +def test_limit_binds_at_the_top_of_the_12_percent_bracket_every_year(): + """From 2018, for every filing status: taxable income at the top of the + 12 percent bracket, with qualified dividends from the 0 percent rate amount + up. The dividends are taxed at 15 percent on line 45 and 12 percent on + line 46, so the regular tax is line 46, and the capital gains tax is 12 + percent of the dividends.""" + for year in range(2018, 2036): + p = SYSTEM.parameters(f"{year}-01-01").gov.irs + situation = {"people": {}, "tax_units": {}, "households": {}} + bands = [] + for i, status in enumerate(STATUSES): + top_of_12 = float(p.income.bracket.thresholds["2"][status]) + zero_rate = float(p.capital_gains.thresholds["1"][status]) + band = max(0.0, top_of_12 - zero_rate) + bands.append(band) + situation["people"][f"person_{i}"] = { + "age": {year: 45}, + "qualified_dividend_income": {year: band}, + } + situation["tax_units"][f"tax_unit_{i}"] = { + "members": [f"person_{i}"], + "filing_status": {year: status}, + "taxable_income": {year: top_of_12}, + } + situation["households"][f"household_{i}"] = { + "members": [f"person_{i}"], + "state_code": {year: "TX"}, + } + sim = Simulation(situation=situation) + bands = np.array(bands) + ordinary_rate = p.income.bracket.rates["2"] + preferential_rate = p.capital_gains.rates["2"] + assert preferential_rate > ordinary_rate, year + regular = sim.calculate("regular_tax_before_credits", year) + line_46 = sim.calculate("tax_on_taxable_income_at_main_rates", year) + gains_tax = sim.calculate("capital_gains_tax", year) + assert np.allclose(regular, line_46, rtol=0, atol=0.01), year + assert np.allclose(gains_tax, ordinary_rate * bands, rtol=0, atol=0.01), year + # The band is $100 to $250 wide from 2018 to 2025 and $950 to $1,900 + # in 2026 (Rev. Proc. 2025-32), the published years. + if year <= 2025: + assert ((bands >= 100) & (bands <= 250)).all(), (year, bands) + elif year == 2026: + assert ((bands >= 950) & (bands <= 1_900)).all(), (year, bands) + + +# Fixed examples, so CI on an unrelated pull request draws the same households. +SETTINGS = dict( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(households(), min_size=1, max_size=25), st.sampled_from([2025, 2026]) +) +def test_random_households_match_worksheet(batch, year): + law = calculate(batch, year) + assert_matches_worksheet(batch, law, year) + assert_properties(batch, law) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(households(), min_size=1, max_size=15), + st.sampled_from([2018, 2021, 2024, 2025, 2026, 2027, 2030, 2035]), + st.integers(1, 50_000), +) +def test_random_households_keep_the_properties(batch, year, more): + """Properties 1 to 7 in years without a transcription.""" + count = len(batch) + variants = ( + batch + + [{**h, "wages": h["wages"] + more} for h in batch] + + [{**h, "long_term": max(0, h["long_term"]) + more} for h in batch] + + [{**h, "dividends": h["dividends"] + more} for h in batch] + ) + law = calculate(variants, year) + assert_properties(variants, law) + regular = law["regular_tax_before_credits"].astype(float).reshape(4, count) + tol = tolerance(law["taxable_income"].astype(float)).reshape(4, count) + # 6. More wages, long-term gain or dividends never lower the regular tax. + # (Raising a long-term loss to a gain is also more income.) + # A known defect, not an intended exception, sets aside households with + # capital gain distributions and a Schedule D net loss for long-term gain: + # gross income adds non_sch_d_capital_gains outside the Schedule D loss + # limit, while net_capital_gain nets the distributions on Schedule D line + # 13. More long-term gain then raises net capital gain but not taxable + # income: $50,000 of distributions, a $40,273 short-term loss and $1 of + # long-term gain lower a 2018 single filer's tax by $0.12. No tax unit in + # the default dataset has both. + netted = np.array( + [h["distributions"] > 0 and h["long_term"] + h["short_term"] < 0 for h in batch] + ) + for k in (1, 2, 3): + keep = ~netted if k == 2 else np.ones(count, dtype=bool) + assert (regular[k] >= regular[0] - tol[k])[keep].all(), k + # 7. The same households in reverse order give the same results. + reverse = calculate(batch[::-1], year) + for v in OUTPUTS: + assert np.array_equal(reverse[v][::-1], law[v][:count]), v From d5f4e883a9b1056d8398615eeace454a49230c78 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 03:10:48 -0400 Subject: [PATCH 4/8] Align the merged tests with main: #9831's reform variables and #9788's netting - test_additional_tax_bracket_unset_bracket.py (#9831) listed regular_tax_before_credits among the variables the reform replaces. On this branch the reform replaces tax_on_taxable_income_at_main_rates instead, and regular_tax_before_credits is income_tax_main_rates plus capital_gains_tax. The default-system leak check now covers the three replaced variables, and the finite and monotone checks cover the reform's three taxes: income_tax_main_rates, tax_on_taxable_income_at_main_rates and regular_tax_before_credits. - test_regular_tax_before_credits.py set aside households with capital gain distributions and a Schedule D net loss in the monotonicity property. #9788 nets the distributions inside the capital loss limit in AGI, so the property now holds for every household. The exclusion is gone, and the minimized counterexample (2018 single, $50,000 of distributions, a $40,273 short-term loss, $1 more long-term gain) is an explicit example; it failed before #9788 (the regular tax fell by $0.12) and passes now. Both files pass after the merge: 9 and 6 tests. Co-Authored-By: Claude Opus 5.5 --- ...st_additional_tax_bracket_unset_bracket.py | 34 +++++++++++++------ .../tests/test_regular_tax_before_credits.py | 27 ++++++++------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py b/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py index 43b7ad96afb..90bfa90f8cc 100644 --- a/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py +++ b/policyengine_us/tests/policy/contrib/additional_tax_bracket/test_additional_tax_bracket_unset_bracket.py @@ -45,13 +45,15 @@ - with its first seven brackets set to current law and the added bracket unset, it reproduces the baseline; - for any thresholds of brackets 7 and 8, finite or infinite, the reform's - ``income_tax_main_rates``, ``regular_tax_before_credits`` and - ``taxable_income_taxed_below_25_percent`` are finite, and its two taxes are - at least their values with the bracket removed when r8 >= r7. The - households have no 28 percent rate gain or unrecaptured section 1250 gain, - so the amount taxed below 25 percent does not change which of their income - is taxed at the regular rates (``dwks19`` and - ``capital_gains_excluded_from_taxable_income``). + ``income_tax_main_rates``, ``tax_on_taxable_income_at_main_rates``, + ``regular_tax_before_credits`` and ``taxable_income_taxed_below_25_percent`` + are finite, and its three taxes are at least their values with the bracket + removed when r8 >= r7. The households have no 28 percent rate gain or + unrecaptured section 1250 gain, so the amount taxed below 25 percent does + not change which of their income is taxed at the regular rates (``dwks19`` + and ``capital_gains_excluded_from_taxable_income``). The regular tax is + ``income_tax_main_rates`` plus ``capital_gains_tax``, which section 1(h)(1) + limits to ``tax_on_taxable_income_at_main_rates``, so it rises with both. No simulation here sets a foreign earned income exclusion, so the reform's subtraction of the tax on the excluded amount is covered only through the @@ -89,8 +91,18 @@ N_BRACKETS = 8 BRACKET = "gov.contrib.additional_tax_bracket.bracket" YEAR = 2026 -REFORM_TAXES = ["income_tax_main_rates", "regular_tax_before_credits"] # Every variable the reform replaces. +REPLACED_VARIABLES = [ + "income_tax_main_rates", + "tax_on_taxable_income_at_main_rates", + "taxable_income_taxed_below_25_percent", +] +# The reform's taxes: the two it replaces, and the regular tax built on them. +REFORM_TAXES = [ + "income_tax_main_rates", + "tax_on_taxable_income_at_main_rates", + "regular_tax_before_credits", +] REFORM_VARIABLES = REFORM_TAXES + ["taxable_income_taxed_below_25_percent"] DOWNSTREAM_VARIABLES = REFORM_VARIABLES + ["income_tax_before_credits", "income_tax"] # Simulation variables are float32, about seven significant digits, so @@ -385,8 +397,8 @@ def test_baseline_schedule_is_unchanged_in_every_year(): # --------------------------------------------------------------------------- ORDINARY_INCOMES = [0, 30_000, 250_000, 700_000, 3_000_000] -# (long-term capital gains, qualified dividends): none, and enough to take -# the Schedule D Tax Worksheet path in regular_tax_before_credits. +# (long-term capital gains, qualified dividends): none, and enough for a +# capital gains tax in regular_tax_before_credits. PREFERENTIAL_INCOME = [(0, 0), (150_000, 20_000)] @@ -427,7 +439,7 @@ def default_system_state(): bracket = baseline_system.parameters( f"{YEAR}-01-01" ).gov.contrib.additional_tax_bracket.bracket - variables = [baseline_system.variables[name] for name in REFORM_VARIABLES] + variables = [baseline_system.variables[name] for name in REPLACED_VARIABLES] values = [float(bracket.rates[str(i)]) for i in range(1, N_BRACKETS + 1)] + [ float(bracket.thresholds[str(i)][status]) for i in range(1, N_BRACKETS + 1) diff --git a/policyengine_us/tests/test_regular_tax_before_credits.py b/policyengine_us/tests/test_regular_tax_before_credits.py index c930b3f5915..bc542f86f0b 100644 --- a/policyengine_us/tests/test_regular_tax_before_credits.py +++ b/policyengine_us/tests/test_regular_tax_before_credits.py @@ -717,6 +717,19 @@ def test_random_households_match_worksheet(batch, year): st.sampled_from([2018, 2021, 2024, 2025, 2026, 2027, 2030, 2035]), st.integers(1, 50_000), ) +# Capital gain distributions with a Schedule D net loss. Before #9788, gross +# income added the distributions outside the Schedule D loss limit, while +# net_capital_gain nets them on Schedule D line 13, so $1 more long-term gain +# raised net capital gain but not taxable income and cut this 2018 single +# filer's regular tax by $0.12. +@hypothesis.example( + [ + household("SINGLE"), + household("SINGLE", short_term=-40_273, distributions=50_000), + ], + 2018, + 1, +) def test_random_households_keep_the_properties(batch, year, more): """Properties 1 to 7 in years without a transcription.""" count = len(batch) @@ -732,20 +745,8 @@ def test_random_households_keep_the_properties(batch, year, more): tol = tolerance(law["taxable_income"].astype(float)).reshape(4, count) # 6. More wages, long-term gain or dividends never lower the regular tax. # (Raising a long-term loss to a gain is also more income.) - # A known defect, not an intended exception, sets aside households with - # capital gain distributions and a Schedule D net loss for long-term gain: - # gross income adds non_sch_d_capital_gains outside the Schedule D loss - # limit, while net_capital_gain nets the distributions on Schedule D line - # 13. More long-term gain then raises net capital gain but not taxable - # income: $50,000 of distributions, a $40,273 short-term loss and $1 of - # long-term gain lower a 2018 single filer's tax by $0.12. No tax unit in - # the default dataset has both. - netted = np.array( - [h["distributions"] > 0 and h["long_term"] + h["short_term"] < 0 for h in batch] - ) for k in (1, 2, 3): - keep = ~netted if k == 2 else np.ones(count, dtype=bool) - assert (regular[k] >= regular[0] - tol[k])[keep].all(), k + assert (regular[k] >= regular[0] - tol[k]).all(), k # 7. The same households in reverse order give the same results. reverse = calculate(batch[::-1], year) for v in OUTPUTS: From 6a164e2a2fae95707a3038f8ce8b43beb94ef904 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:08:38 -0400 Subject: [PATCH 5/8] Say in the changelog that regular_tax_before_credits now includes the capital gains tax PolicyEngine/salt-amt-calculator's API reads regular_tax_before_credits as its regular tax, so the meaning change belongs in the release note. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-dwks-regular-tax-lines.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-dwks-regular-tax-lines.fixed.md b/changelog.d/fix-dwks-regular-tax-lines.fixed.md index 033c9255769..751247ecad3 100644 --- a/changelog.d/fix-dwks-regular-tax-lines.fixed.md +++ b/changelog.d/fix-dwks-regular-tax-lines.fixed.md @@ -1 +1 @@ -Cap the regular tax at the tax on all taxable income at the ordinary rates (26 U.S.C. 1(h)(1), Schedule D Tax Worksheet line 47), and make regular_tax_before_credits, which Form 6251 line 10 uses, the regular tax the model charges (income_tax_main_rates plus capital_gains_tax) instead of a second worksheet computation. +Cap the regular tax at the tax on all taxable income at the ordinary rates (26 U.S.C. 1(h)(1), Schedule D Tax Worksheet line 47), and make regular_tax_before_credits, which Form 6251 line 10 uses, the regular tax the model charges (income_tax_main_rates plus capital_gains_tax) instead of a second worksheet computation. regular_tax_before_credits now includes the capital gains tax. From d8a5aa3cfc17a944684113eecec9ac807e3ebf88 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 04:40:02 -0400 Subject: [PATCH 6/8] Point three Rev. Proc. 2025-32 links at the pages with their 2026 amounts Three links pointed into the wrong section of Rev. Proc. 2025-32: - the capital gains rate thresholds linked page 12, which is section 4.02 (kiddie tax). Section 4.03 and its 2026 amounts (49,450, 98,900, 66,200, 545,500, 613,700, 579,600 and 306,850) are on page 13; - the child tax credit amount ($2,200) and refundable maximum ($1,700) linked page 13, which holds sections 4.03 and 4.04. Section 4.05 and its amounts are on page 14. The other parameter links to the Rev. Proc. land on the page that has the 2026 amounts or on the page where their section starts. Co-Authored-By: Claude Opus 5.5 --- .../parameters/gov/irs/capital_gains/thresholds.yaml | 2 +- policyengine_us/parameters/gov/irs/credits/ctc/amount/base.yaml | 2 +- .../gov/irs/credits/ctc/refundable/individual_max.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_us/parameters/gov/irs/capital_gains/thresholds.yaml b/policyengine_us/parameters/gov/irs/capital_gains/thresholds.yaml index 2e2c3824377..10404e10bb5 100644 --- a/policyengine_us/parameters/gov/irs/capital_gains/thresholds.yaml +++ b/policyengine_us/parameters/gov/irs/capital_gains/thresholds.yaml @@ -16,7 +16,7 @@ metadata: - title: 26 U.S. Code § 1(j)(5)(B) - Capital gains rate thresholds href: https://www.law.cornell.edu/uscode/text/26/1#j_5_B - title: 2026 IRS data release - href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=12 + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=13 - title: 2025 IRS data release href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf#page=7 - title: 2024 IRS data release diff --git a/policyengine_us/parameters/gov/irs/credits/ctc/amount/base.yaml b/policyengine_us/parameters/gov/irs/credits/ctc/amount/base.yaml index 2a5f73e2b72..69de4bd838f 100644 --- a/policyengine_us/parameters/gov/irs/credits/ctc/amount/base.yaml +++ b/policyengine_us/parameters/gov/irs/credits/ctc/amount/base.yaml @@ -27,7 +27,7 @@ brackets: - title: 26 U.S. Code § 24(i)(2) - Inflation adjustment for credit amount href: https://www.law.cornell.edu/uscode/text/26/24#i_2 - title: 2026 IRS data release - href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=13 + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=14 # IRC §24(i)(2) adjusts the $2,200 statutory base using 2024 as reference year. # Current implementation uses incremental uprating from latest value, which # produces correct results but differs from statutory base-year calculation. diff --git a/policyengine_us/parameters/gov/irs/credits/ctc/refundable/individual_max.yaml b/policyengine_us/parameters/gov/irs/credits/ctc/refundable/individual_max.yaml index 0d5efe4a16b..5d118287b6d 100644 --- a/policyengine_us/parameters/gov/irs/credits/ctc/refundable/individual_max.yaml +++ b/policyengine_us/parameters/gov/irs/credits/ctc/refundable/individual_max.yaml @@ -31,7 +31,7 @@ metadata: - title: 26 U.S. Code § 24(i)(1) - Inflation adjustment for refundable amount href: https://www.law.cornell.edu/uscode/text/26/24#i_1 - title: 2026 IRS data release - href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=13 + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=14 - title: 2025 IRS data release href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf#page=8 - title: 2024 IRS data release From 2d1d7f94bf1918021383a1ef0a0dacba9b969197 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:12:42 -0400 Subject: [PATCH 7/8] Test 28-percent rate gain with a Form 4952 election against the 2025 worksheets - rate_gain_with_form_4952_election.yaml: the reproducer (no taxable income; taxable income below the gain; half and all of a collectibles gain elected; no election) and an election above the 24% bracket that leaves 28% on line 9 only, with Schedule D Tax Worksheet lines. - test_section_1h_regular_tax.py: for every household and year (2018 to 2030) with elections and both gains, the regular tax is at most line 46 figured from the rate parameters, exactly zero with no taxable income, and not negative; and electing more lowers it by at most the 12/15 rate overlap plus 3% of the smaller of the extra amount elected and the unrecaptured section 1250 gain. Pins the intended exception where an election moves gain from 28 to 25 percent (section 1(h)(1)(E)(ii) measures its excess with net capital gain, which section 1(h)(2) reduces): $68,874 to $67,374 on $50,000 elected. A 2025 differential compares Form 6251 Part III and line 11 after an election with nonzero Schedule D lines 18 and 19. - test_form_6251_part_iii.py: the Schedule D Tax Worksheet transcription takes amounts, Form 4952 lines 4g and 4e, and the Foreign Earned Income Tax Worksheet footnote's four modifications. - test_section_911_tax_stacking.py and test_form_4952_section_911_interaction.py: households with 28-percent rate and unrecaptured section 1250 gain are now compared with the forms (through the Schedule D Tax Worksheet and Form 6251 Part III with the AMT capital gain excess), including rows that reach modification 4, and kept in the monotonicity properties, with the corrected election bound in place of #9800's overlap-only bound. - Harris, Biden 2025 budget and additional tax bracket reforms: a case each where the line 47 limit binds. Co-Authored-By: Claude Opus 5.5 --- .../rate-gain-election-tests.changed.md | 1 + .../rate_gain_with_form_4952_election.yaml | 201 +++++++ .../additional_tax_bracket_reform.yaml | 25 + .../capital_gains_tax_increase.yaml | 29 + .../capital_gains/harris_capital_gains.yaml | 31 + .../test_form_4952_section_911_interaction.py | 174 ++++-- .../tests/test_form_6251_part_iii.py | 76 ++- .../tests/test_section_1h_regular_tax.py | 532 ++++++++++++++++++ .../tests/test_section_911_tax_stacking.py | 258 +++++++-- 9 files changed, 1216 insertions(+), 111 deletions(-) create mode 100644 changelog.d/rate-gain-election-tests.changed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml create mode 100644 policyengine_us/tests/test_section_1h_regular_tax.py diff --git a/changelog.d/rate-gain-election-tests.changed.md b/changelog.d/rate-gain-election-tests.changed.md new file mode 100644 index 00000000000..f1a2c4f878a --- /dev/null +++ b/changelog.d/rate-gain-election-tests.changed.md @@ -0,0 +1 @@ +Test the regular tax with 28-percent rate gain, unrecaptured section 1250 gain and a Form 4952 election against the 2025 Schedule D Tax Worksheet, extend the section 911(f) and Form 4952 tests to those gains, and pin a case where an election lawfully lowers the tax by moving gain from the 28 to the 25 percent rate. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml new file mode 100644 index 00000000000..39b3dca218b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml @@ -0,0 +1,201 @@ +# 28-percent rate gain (here all collectibles gain, Schedule D line 18) with +# and without a Form 4952 line 4g election, for a single filer aged 45 in +# 2025. Line references are to the 2025 Schedule D Tax Worksheet +# (Instructions for Schedule D, pages 15 and 16): +# line 9 line 7 (net capital gain) less line 8 (the part of the line 4g +# election that comes from the gain) +# line 11 Schedule D lines 18 and 19 +# line 12 the smaller of line 9 or line 11 +# line 13 line 10 less line 12 +# line 18 line 1 less line 10 +# line 19 the smaller of line 1 or $197,300, the top of the 24% bracket +# line 20 the smaller of line 14 or line 19 +# line 21 the larger of line 18 or line 20, taxed at the regular rates +# line 42 line 1 less lines 21, 22, 30, 33 and 39, taxed at 28% +# line 47 the smaller of line 45 (the sum) or line 46 (the tax on line 1) +# 26 U.S.C. 1(h)(1)(F) taxes at 28% only "the amount of taxable income in +# excess of the sum of the amounts on which tax is determined under the +# preceding subparagraphs", and the election removes the elected gain from +# net capital gain (1(h)(2)), so the 28% amount is never more than line 9 or +# taxable income. The standard deduction is $15,750. Tax on an amount +# between $103,350 and $197,300 is 24% of it less $7,153; between $250,525 +# and $626,350, 35% of it less $30,452.75 (2025 Tax Computation Worksheet). + +- name: Collectibles gain with no taxable income bears no tax + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 0 + long_term_capital_gains: 10_000 + long_term_capital_gains_on_collectibles: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 10,000 - 15,750, so zero. Lines 9 to 12 = 10,000; line 13 = + # 0; line 14 = 0; line 21 = 0; line 42 = 0. Line 47 = 0. + taxable_income: 0 + capital_gains_tax: 0 + income_tax_before_credits: 0 + +- name: Collectibles gain above taxable income is not taxed at 28% + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 10_750 + long_term_capital_gains: 10_000 + long_term_capital_gains_on_collectibles: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 20,750 - 15,750 = 5,000. Lines 9 to 12 = 10,000; line 13 = 0; + # line 14 = 5,000; line 16 = 5,000, the same as line 1, so lines 23 to + # 43 are skipped. Line 21 = 5,000; line 44 = line 46 = 10% of 5,000 = + # 500 at the exact rates the model uses (the Tax Table row 5,000-5,050 + # gives 503). Taxing all 10,000 at 28% would add 2,800. + taxable_income: 5_000 + income_tax_main_rates: 500 + capital_gains_tax: 0 + income_tax_before_credits: 500 + +- name: Half the collectibles gain elected as investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + investment_income_elected_form_4952: 50_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 184,250. Line 3 = 50,000; line 4 = 100,000; line 8 = 50,000; + # line 9 = 50,000; line 10 = 50,000. Line 11 = 100,000; line 12 = + # 50,000; line 13 = 0; line 14 = 184,250. Line 18 = 134,250; line 19 = + # 184,250; line 20 = 184,250; line 21 = 184,250, all of line 1, so line + # 42 = 0. Line 44 = line 46 = 24% of 184,250 less 7,153 = 37,067. + # Taxing line 18 at the regular rates (25,067) and 28% of the 50,000 left + # (14,000) would give 39,067, more than line 46. + taxable_income: 184_250 + dwks09: 50_000 + dwks10: 50_000 + dwks19: 184_250 + income_tax_main_rates: 37_067 + capital_gains_tax: 0 + income_tax_before_credits: 37_067 + +- name: All the collectibles gain elected as investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + investment_income_elected_form_4952: 100_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 8 = 100,000; line 9 = 0; line 10 = 0; line 12 = 0; line 13 = 0. + # Line 18 = 184,250 = line 21; line 42 = 0. Line 47 = tax on 184,250. + taxable_income: 184_250 + dwks10: 0 + dwks19: 184_250 + income_tax_main_rates: 37_067 + capital_gains_tax: 0 + income_tax_before_credits: 37_067 + +- name: The same collectibles gain with no election + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Lines 9 to 12 = 100,000; line 13 = 0; line 14 = 184,250. Line 18 = + # 84,250, but line 19 = 184,250 and line 20 = 184,250, so line 21 = + # 184,250: the gain falls below the top of the 24% bracket and is taxed + # at the regular rates (1(h)(1)(A)(ii)). Line 47 = 37,067. Taxing 84,250 + # at the regular rates (13,449) and all 100,000 at 28% would give 41,449. + taxable_income: 184_250 + dwks19: 184_250 + income_tax_main_rates: 37_067 + capital_gains_tax: 0 + income_tax_before_credits: 37_067 + +- name: An election above the 24% bracket leaves 28% on line 9 only + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 300_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + investment_income_elected_form_4952: 50_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 384,250. Line 9 = 50,000; line 10 = 50,000; line 11 = + # 100,000; line 12 = 50,000; line 13 = 0; line 14 = 384,250. Line 18 = + # 334,250; line 19 = 197,300; line 20 = 197,300; line 21 = 334,250. Line + # 22 = 0; lines 30 and 33 = 0; Schedule D line 19 is zero, so lines 35 + # to 40 are skipped. Line 41 = 334,250; line 42 = 50,000; line 43 = + # 14,000. Line 44 = 35% of 334,250 less 30,452.75 = 86,534.75. Line 45 = + # 100,534.75; line 46 = 104,034.75; line 47 = 100,534.75. The Form 6251 + # tentative minimum tax, 28% of 311,900 less 4,782 = 82,550, is lower, so + # there is no AMT. Taxing all 100,000 at 28% would tax the elected 50,000 + # twice: 114,534.75. + taxable_income: 384_250 + dwks19: 334_250 + income_tax_main_rates: 86_534.75 + capital_gains_tax: 14_000 + alternative_minimum_tax: 0 + income_tax_before_credits: 100_534.75 diff --git a/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml b/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml index 369dc5f9c36..a24a2270924 100644 --- a/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml +++ b/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml @@ -172,3 +172,28 @@ taxable_income_taxed_below_25_percent: 201_775 dwks19: 200_000 capital_gains_excluded_from_taxable_income: 100_000 + +# 26 U.S.C. 1(h)(1), "shall not exceed", on the reform's rate schedule. From +# 2026 the schedule taxes $49,300 to $119,400 (single) at 25 percent, below +# the 28 percent of subparagraph (F). Single, taxable income $100,000, all +# $50,000 of net capital gain 28-percent rate gain. Regular rate amount: +# the greater of $50,000 or the lesser of $49,300 and $100,000, $50,000; its +# tax is 10% of $12,150 plus 15% of $37,150 plus 25% of $700 = $6,962.50. +# Subparagraph (F): 28% of the other $50,000 = $14,000. The tax on all +# $100,000 is $6,787.50 plus 25% of $50,700 = $19,462.50, so the capital +# gains tax is $12,500, not $14,000. +- name: Additional tax bracket reform's own limit on the regular tax + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.additional_tax_bracket.additional_tax_bracket_reform.additional_tax_bracket + input: + gov.contrib.additional_tax_bracket.in_effect: true + filing_status: SINGLE + taxable_income: 100_000 + long_term_capital_gains: 50_000 + capital_gains_28_percent_rate_gain: 50_000 + output: + capital_gains_excluded_from_taxable_income: 50_000 + income_tax_main_rates: 6_962.50 + tax_on_taxable_income_at_main_rates: 19_462.50 + capital_gains_tax: 12_500 diff --git a/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml b/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml index 8ef857c3beb..2e452539778 100644 --- a/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml +++ b/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml @@ -28,3 +28,32 @@ output: taxable_income: 584_250 capital_gains_tax: 69_346 + +# 26 U.S.C. 1(h)(1), "shall not exceed": as in the baseline, the regular tax +# is at most the tax on all taxable income at the regular rates. 2025, +# single, $64,100 of wages and $125 of long-term gain: taxable income +# $48,475, of which $48,350 is ordinary income (tax $5,563.50). The gain +# lies above the $48,350 0% amount and is taxed at 15% ($18.75), but the tax +# on all $48,475 is $5,578.50, so the capital gains tax is $15. +- name: Reform active, the regular tax is limited to the tax on all taxable income + period: 2025 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.biden.budget_2025.capital_gains_tax_increase.capital_gains_tax_increase + input: + gov.contrib.biden.budget_2025.capital_gains.active: true + people: + head: + age: 45 + employment_income: 64_100 + long_term_capital_gains: 125 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + taxable_income: 48_475 + income_tax_main_rates: 5_563.50 + capital_gains_tax: 15 diff --git a/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml b/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml index 6cd59e14ac0..a9597c82040 100644 --- a/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml +++ b/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml @@ -117,3 +117,34 @@ taxable_income: 584_250 capital_gains_excluded_from_taxable_income: 386_950 capital_gains_tax: 69_346 + +# 26 U.S.C. 1(h)(1), "shall not exceed": as in the baseline, the regular tax +# is at most the tax on all taxable income at the regular rates. The +# reform's 0% amount for single filers ($47,025) ends $1,450 below the top +# of the 2025 12% bracket ($48,475). 2025, single, $62,775 of wages and +# $1,450 of long-term gain: taxable income $48,475, of which $47,025 is +# ordinary income (12% of it less $238.50 = $5,404.50). The reform taxes the +# gain at 15% ($217.50), but the tax on all $48,475 is $5,578.50, so the +# capital gains tax is $174. +- name: Reform applied, the regular tax is limited to the tax on all taxable income + period: 2025 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.harris.capital_gains.harris_capital_gains.harris_capital_gains + input: + gov.contrib.harris.capital_gains.in_effect: true + people: + head: + age: 45 + employment_income: 62_775 + long_term_capital_gains: 1_450 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + taxable_income: 48_475 + income_tax_main_rates: 5_404.50 + capital_gains_tax: 174 diff --git a/policyengine_us/tests/test_form_4952_section_911_interaction.py b/policyengine_us/tests/test_form_4952_section_911_interaction.py index 57bcad7642d..5e3a2a785c1 100644 --- a/policyengine_us/tests/test_form_4952_section_911_interaction.py +++ b/policyengine_us/tests/test_form_4952_section_911_interaction.py @@ -61,10 +61,14 @@ to the top of the 12% bracket (2025: $48,350 to $48,475 single), gain is taxed at 15% and ordinary income at 12%, so a dollar elected there lowers the tax by 3 cents, on the return as in the model - (test_election_can_lower_the_tax_where_15_percent_exceeds_12). The tax - (each of the three) falls by at most the 3 points times the width of that - band ($3.75 single, $7.50 joint in 2025), which `rate_overlap` computes - from the parameters. + (test_election_can_lower_the_tax_where_15_percent_exceeds_12). And where + the top of the 24% bracket sets the regular rate amount, an election can + move 28-percent rate gain to the 25% rate of unrecaptured section 1250 + gain (test_section_1h_regular_tax.py). The tax (each of the three) falls + by at most the 3 points times the width of that band ($3.75 single, $7.50 + joint in 2025), which `rate_overlap` computes from the parameters, plus 3 + points (28 less 25) of the smaller of the extra amount elected and the + unrecaptured section 1250 gain. 5. Worksheet lines. dwks10 equals net_capital_gain exactly for whole-dollar amounts and within single-precision rounding for amounts with cents; the model's lines 6, 9 and 10 equal the transcription in @@ -75,8 +79,10 @@ test_section_911_tax_stacking.py). With Schedule D lines 18 and 19 zero, Schedule D Tax Worksheet lines 14 to 47 are the Qualified Dividends and Capital Gain Tax Worksheet with worksheet line 6 for its line 2 and line 9 - for its line 3, and Form 6251 line 27 equals line 20. The model's regular - tax and AMT match. + for its line 3, and Form 6251 line 27 equals line 20. Otherwise the whole + Schedule D Tax Worksheet (transcribed in test_form_6251_part_iii.py) is + used, with the election on lines 3 and 4 and the footnote's four + modifications. The model's regular tax and AMT match. Amounts are whole dollars below 2**24 except where a household is drawn with cents. For every whole-dollar household the gains and the model's worksheet @@ -85,16 +91,12 @@ without itemized deductions. The rest are checked within single-precision rounding. -The scope limits of test_section_911_tax_stacking.py apply to the tax -comparisons (properties 4 and 6), which therefore use households without -28-percent rate gain or unrecaptured section 1250 gain. The model omits the -section 1(h)(1) cap at the tax on all taxable income at ordinary rates. It -uses the 26%/28% breakpoint of other filers for married filing separately on -Form 6251 line 18. And capital_gains_tax taxes 28-percent rate gain at 28% in -full, even where it exceeds taxable income or the net capital gain an election -leaves (Schedule D Tax Worksheet line 12 limits it to line 9): $10,000 of -collectibles gain and no taxable income gives $2,800 of tax. The households -are 45, so the exclusion does not reach the senior deduction. +The Schedule D Tax Worksheet transcription takes Schedule D lines 18 and 19 +as entered, without the short-term loss netting of the 28% Rate Gain and +Unrecaptured Section 1250 Gain Worksheets (1(h)(4)(B), 1(h)(6)), so property +6 compares households with either gain only when they have no short-term +loss. The +households are 45, so the exclusion does not reach the senior deduction. """ import numpy as np @@ -103,12 +105,15 @@ from policyengine_us import Simulation from policyengine_us.tests.test_form_4952_election_worksheet import ( + form_4952_line_4e, schedule_d_tax_worksheet, ) from policyengine_us.tests.test_section_911_tax_stacking import ( STATUSES, foreign_earned_income_tax_worksheet, + foreign_earned_income_tax_worksheet_schedule_d, form_6251_line_7, + form_6251_line_7_schedule_d, qualified_dividends_and_capital_gain_tax_worksheet, reduce_by_capital_gain_excess, tax_rate_schedule, @@ -331,6 +336,11 @@ def calculate(households, year, with_election=True, with_exclusion=True): results["rate_overlap"] = np.array( [overlaps[status] for status in results["filing_status"]] ) + gains = parameters.gov.irs.capital_gains + results["rate_gain_rate_difference"] = np.full( + len(households), + float(gains.other_cg_rate) - float(gains.unrecaptured_s_1250_rate), + ) return results @@ -568,54 +578,71 @@ def check(variable, expected, mask=exact): assert not law[variable][~excludes].any(), variable -def plain(households): - """Households inside the scope of the tax comparisons (see the module - docstring): no 28-percent rate or unrecaptured section 1250 gain.""" - return (column(households, "section_1250") == 0) & ( - column(households, "rate_gain_28") == 0 +def schedule_d_items(h): + """Schedule D Tax Worksheet amounts other than line 1 (see + test_form_6251_part_iii.py), with the election on lines 3 and 4.""" + schedule_d_line_15 = h["long_term"] + h["distributions"] + return dict( + qualified_dividends=h["dividends"], + form_4952_line_4g=h["election"], + form_4952_line_4e=form_4952_line_4e(h), + schedule_d_line_15=schedule_d_line_15, + schedule_d_line_16=schedule_d_line_15 + h["short_term"], + schedule_d_line_18=h["rate_gain_28"], + schedule_d_line_19=h["section_1250"], ) def assert_matches_2025_forms(households, law): """Property 6: the model against the transcribed 2025 forms.""" for i, h in enumerate(households): - if h["section_1250"] or h["rate_gain_28"]: + schedule_d = bool(h["section_1250"] or h["rate_gain_28"]) + if schedule_d and h["short_term"] < 0: + # See the module docstring. continue status = law["filing_status"][i] assert status == h["status"], (i, status) taxable_income = float(law["taxable_income"][i]) - # Schedule D Tax Worksheet lines 6 and 9 take the place of lines 2 - # and 3 of the Qualified Dividends and Capital Gain Tax Worksheet. - line_6, line_9, _ = schedule_d_tax_worksheet(h) - line_6_feitw, line_6_capped, ordinary_income = ( - foreign_earned_income_tax_worksheet( - taxable_income, h["exclusion"], line_6, line_9, status + taxable_excess = float(law["amt_income_less_exemptions"][i]) + if schedule_d: + items = schedule_d_items(h) + line_6_feitw, worksheet = foreign_earned_income_tax_worksheet_schedule_d( + taxable_income, h["exclusion"], items, status + ) + line_7 = form_6251_line_7_schedule_d( + taxable_excess, h["exclusion"], items, worksheet, status + ) + else: + # Schedule D Tax Worksheet lines 6 and 9 take the place of lines 2 + # and 3 of the Qualified Dividends and Capital Gain Tax Worksheet. + line_6, line_9, _ = schedule_d_tax_worksheet(h) + line_6_uncapped, line_6_feitw, ordinary_income = ( + foreign_earned_income_tax_worksheet( + taxable_income, h["exclusion"], line_6, line_9, status + ) + ) + # The line 47 cap binds by at most the rate overlap. + cap_gap = line_6_uncapped - line_6_feitw + cap_slack = tolerance(taxable_income + h["exclusion"]) + assert -cap_slack <= cap_gap <= law["rate_overlap"][i] + cap_slack, (i, h) + line_7 = form_6251_line_7( + taxable_excess, + h["exclusion"], + line_6, + line_9, + ordinary_income, + status, ) - ) stacked_income = taxable_income + h["exclusion"] slack = tolerance(stacked_income) + # Line 6 with the final line of the capital gains worksheet (line 25, + # or line 47 of the Schedule D Tax Worksheet). regular_tax = float(law["regular_tax"][i]) assert float(law["regular_tax_before_credits"][i]) == pytest.approx( regular_tax, abs=slack ), (i, h) - assert regular_tax == pytest.approx(line_6_capped, abs=slack), (i, h) - # The line 47 cap binds by at most the rate overlap. - cap_gap = line_6_feitw - line_6_capped - assert -slack <= cap_gap <= law["rate_overlap"][i] + slack, (i, h) - gain = section_1222_gain(h) - if status == "SEPARATE" and h["dividends"] + gain > 0: - # Form 6251 line 18 (see the module docstring). - continue - taxable_excess = float(law["amt_income_less_exemptions"][i]) - line_7 = form_6251_line_7( - taxable_excess, - h["exclusion"], - line_6, - line_9, - ordinary_income, - status, - ) - amt = max(0, line_7 - line_6_capped) + assert regular_tax == pytest.approx(line_6_feitw, abs=slack), (i, h) + amt = max(0, line_7 - line_6_feitw) assert float(law["alternative_minimum_tax"][i]) == pytest.approx( amt, abs=tolerance(stacked_income, taxable_excess) ), (i, h) @@ -710,7 +737,6 @@ def same_choice(lower, higher): fall = law["net_capital_gain"] - groups["more_elected"]["net_capital_gain"] assert (fall <= more + slack).all() - inside = plain(households) excluded = np.maximum( column(households, "exclusion"), groups["more_excluded"]["foreign_earned_income_exclusion"], @@ -721,7 +747,6 @@ def same_choice(lower, higher): "amt_income_less_exemptions_plus_section_911_exclusion" ], ) - overlap = law["rate_overlap"] no_dependents = np.array([h["status"] != "HEAD_OF_HOUSEHOLD" for h in households]) for v in ["regular_tax", "income_tax_before_credits", "income_tax"]: # tax_unit_itemizes treats liabilities within $0.01 as equal. @@ -731,14 +756,23 @@ def same_choice(lower, higher): def rows(lower, higher): if v == "income_tax": - return inside & no_dependents - return inside & same_choice(lower, higher) - - # More elected: the tax falls by at most the rate overlap. - for lower, higher in election_pairs: + return no_dependents + return same_choice(lower, higher) + + # More elected: the tax falls by at most the rate overlap plus 3 + # points of what the election can move from the 28 to the 25 percent + # rate (see the module docstring). The step from no election moves at + # most the election itself. + for (lower, higher), elected in zip( + election_pairs, [column(households, "election"), more] + ): dip = lower[v] - higher[v] mask = rows(lower, higher) - assert (dip[mask] <= (overlap + margin)[mask]).all(), v + bound = law["rate_overlap"] + law["rate_gain_rate_difference"] * np.minimum( + elected, column(households, "section_1250") + ) + bad = np.flatnonzero(mask & (dip > bound + margin)) + assert not bad.size, (v, bad[:3], dip[bad[:3]], bound[bad[:3]]) # Stacking never lowers the tax, and the tax never falls as the # excluded amount rises. for lower, higher in exclusion_pairs: @@ -747,7 +781,7 @@ def rows(lower, higher): assert (dip[mask] <= margin[mask]).all(), v # Section 911(f)(1)(A) applies "if such taxpayer has taxable income". for name in VARIANTS: - no_income = inside & (groups[name]["taxable_income"] == 0) + no_income = groups[name]["taxable_income"] == 0 assert not groups[name]["regular_tax"][no_income].any(), name @@ -833,6 +867,19 @@ def grid(): } for row in rows[::7] ] + # With no wages the capital gain excess (15,750, or less what is + # elected) is more than the 28-percent rate gain, so modification 4 + # lowers Schedule D line 19. + rows += [ + household( + long_term=50_000, + section_1250=30_000, + rate_gain_28=10_000, + election=election, + exclusion=40_025, + ) + for election in (0, 4_000) + ] return rows @@ -861,6 +908,21 @@ def test_grid(): assert (both & (law["capital_gains_tax"] > 0)).any() assert (both & (law["taxable_income"] == 0)).any() assert (both & (law["amt_section_911_capital_gain_excess"] > 0)).any() + # Modification 4 changes worksheet line 12, with and without an + # election, for households compared with the forms: the excess is beyond + # the 28-percent rate gain, and line 9 after modification 1 is above the + # unrecaptured section 1250 gain after modification 4. + rate_gain = column(GRID, "rate_gain_28") + line_9 = law["dwks09"] - excess + reduced_1250 = column(GRID, "section_1250") - (excess - rate_gain) + reaches_1250 = ( + (column(GRID, "section_1250") > 0) + & (column(GRID, "short_term") >= 0) + & (excess > rate_gain) + & (line_9 > reduced_1250) + ) + assert (reaches_1250 & elects).any() + assert (reaches_1250 & ~elects).any() # Electing more and stacking each raise the tax of some households. assert (groups["more_elected"]["regular_tax"] > law["regular_tax"] + 1).any() assert (law["regular_tax"] > groups["unstacked"]["regular_tax"] + 1).any() diff --git a/policyengine_us/tests/test_form_6251_part_iii.py b/policyengine_us/tests/test_form_6251_part_iii.py index 1126bdcf26c..f00f2f3cdae 100644 --- a/policyengine_us/tests/test_form_6251_part_iii.py +++ b/policyengine_us/tests/test_form_6251_part_iii.py @@ -212,16 +212,78 @@ def schedule_d_tax_worksheet_2025(household, line_1, status): (collectibles) and unrecaptured section 1250 gain. Returns the lines. """ long_term = household["long_term_gains"] - schedule_d_18 = max(0, long_term) * household.get("collectibles_share", 0) / 100 - schedule_d_19 = max(0, long_term) * household.get("section_1250_share", 0) / 100 + return schedule_d_tax_worksheet_lines_2025( + line_1, + qualified_dividends=household["qualified_dividends"], + form_4952_line_4g=0, + form_4952_line_4e=0, + # Schedule D line 15 is the long-term gain, line 16 the net gain. + schedule_d_line_15=long_term, + schedule_d_line_16=long_term + household["short_term_gains"], + schedule_d_line_18=max(0, long_term) + * household.get("collectibles_share", 0) + / 100, + schedule_d_line_19=max(0, long_term) + * household.get("section_1250_share", 0) + / 100, + status=status, + ) + + +def schedule_d_tax_worksheet_lines_2025( + line_1, + qualified_dividends, + form_4952_line_4g, + form_4952_line_4e, + schedule_d_line_15, + schedule_d_line_16, + schedule_d_line_18, + schedule_d_line_19, + status, + capital_gain_excess=0, +): + """2025 Schedule D Tax Worksheet, lines 1 to 47. + + Lines 3 and 4 are Form 4952 lines 4g (the amount elected as investment + income) and 4e. Schedule D lines 18 and 19 are as figured with no + short-term loss or loss carryover, so line 18 is also line 14 of the + Unrecaptured Section 1250 Gain Worksheet (lines 1 to 4 of the 28% Rate + Gain Worksheet). + + `capital_gain_excess` is for a Form 2555 filer's second worksheet (2025 + Instructions for Form 1040, page 37, footnote to the Foreign Earned + Income Tax Worksheet): line 1 is then that worksheet's line 3, and the + four modifications apply. Returns the lines, with Schedule D line 19 as + used. + """ line = {1: line_1} - line[2] = household["qualified_dividends"] - line[3] = line[4] = line[5] = 0 + line[2] = qualified_dividends + line[3] = form_4952_line_4g + line[4] = form_4952_line_4e + line[5] = max(0, line[3] - line[4]) line[6] = max(0, line[2] - line[5]) - # Schedule D line 15 is the long-term gain, line 16 the net gain. - line[7] = min(long_term, long_term + household["short_term_gains"]) + line[7] = min(schedule_d_line_15, schedule_d_line_16) line[8] = min(line[3], line[4]) line[9] = max(0, line[7] - line[8]) + schedule_d_18, schedule_d_19 = schedule_d_line_18, schedule_d_line_19 + if capital_gain_excess > 0: + # "1. Reduce (but not below zero) the amount you would otherwise + # enter on ... line 9 of your Schedule D Tax Worksheet by your + # capital gain excess. 2. Reduce (but not below zero) the amount you + # would otherwise enter on ... line 6 ... by any of your capital gain + # excess not used in (1) above." + unused = max(0, capital_gain_excess - line[9]) + line[9] = max(0, line[9] - capital_gain_excess) + line[6] = max(0, line[6] - unused) + # "3. Reduce (but not below zero) the amount on your Schedule D, line + # 18, by your capital gain excess. 4. Include your capital gain + # excess as a loss on line 16 of your Unrecaptured Section 1250 Gain + # Worksheet": its line 17 nets the excess against line 14, the 28% + # rate gain, and line 18 subtracts what is left. + schedule_d_19 = max( + 0, schedule_d_19 - max(0, capital_gain_excess - schedule_d_18) + ) + schedule_d_18 = max(0, schedule_d_18 - capital_gain_excess) line[10] = line[6] + line[9] line[11] = schedule_d_18 + schedule_d_19 line[12] = min(line[9], line[11]) @@ -272,7 +334,7 @@ def schedule_d_tax_worksheet_2025(household, line_1, status): # Lines 21, 22, 30, 33, 39 and 42 split line 1: the amounts taxed at # the regular rates and at 0, 15, 20, 25 and 28 percent. split = line[21] + line[22] + line[30] + line[33] + line[39] + line[42] - assert split == pytest.approx(line[1], abs=0.01), (household, line) + assert split == pytest.approx(line[1], abs=0.01), line line[44] = regular_tax_2025(line[21], status) line[45] = line[31] + line[34] + line[40] + line[43] + line[44] line[46] = regular_tax_2025(line[1], status) diff --git a/policyengine_us/tests/test_section_1h_regular_tax.py b/policyengine_us/tests/test_section_1h_regular_tax.py new file mode 100644 index 00000000000..5688abeba57 --- /dev/null +++ b/policyengine_us/tests/test_section_1h_regular_tax.py @@ -0,0 +1,532 @@ +"""The Form 4952 line 4g election and the regular tax under 26 U.S.C. 1(h). + +A Form 4952 line 4g election reduces net capital gain (section 1(h)(2)), so +the elected gain loses the capital gains rates. Section 1(h)(1) then taxes +the rest: subparagraph (A) at the regular rates the greater of taxable income +less net capital gain or the lesser of the taxable income taxed below 25 +percent and taxable income less adjusted net capital gain; (B) to (D) +adjusted net capital gain at 0, 15 and 20 percent; (E) unrecaptured section +1250 gain at 25 percent; and (F) "the amount of taxable income in excess of +the sum of the amounts on which tax is determined under the preceding +subparagraphs" at 28 percent. The 2025 Schedule D Tax Worksheet follows the +same steps: line 12 limits Schedule D lines 18 and 19 to line 9 (the gain, +other than qualified dividends, after the election), lines 41 to 43 tax the +rest of line 1 at 28 percent, and line 47 is "the smaller of line 45 or line +46". test_regular_tax_before_credits.py checks the regular tax against the +worksheet for 2025 and 2026 and its bounds across years; this file checks +what the election does, and the bounds with elections and both gains for +2018 to 2030. + +Properties, for every generated household and year (2018, 2022, 2025, 2026 +and 2030), with elections and both gains: + +1. The regular tax is never more than the tax on all taxable income at the + regular rates (line 46), figured here from the rate schedule parameters + rather than taken from the model. +2. With no taxable income the regular tax and the capital gains tax are + exactly zero. +3. Neither is negative. +4. Electing more never lowers the regular tax, or + income_tax_before_credits, by more than two amounts that the law itself + produces: + + - `rate_overlap` (test_form_4952_section_911_interaction.py): where the 0 + percent amount ends below the top of the 12 percent bracket, gain is + taxed at 15 percent and ordinary income at 12 percent ($3.75 single in + 2025); + - 3 percent (28 less 25) of the smaller of the extra amount elected and + the unrecaptured section 1250 gain. Subparagraph (E) taxes at 25 percent + the unrecaptured gain less "the excess (if any) of (I) the sum of the + amount on which tax is determined under subparagraph (A) plus the net + capital gain, over (II) taxable income" (worksheet lines 35 to 39). When + the top of the 24 percent bracket sets the (A) amount, that excess + shrinks with net capital gain, so an election moves gain from the 28 to + the 25 percent rate (test_election_can_move_gain_from_28_to_25_percent). + + Without unrecaptured section 1250 gain the first bound alone holds. + +Differential, for 2025: with the election on Schedule D Tax Worksheet lines 3 +and 4 (transcribed in test_form_6251_part_iii.py) and nonzero Schedule D lines +18 and 19, Form 6251 Part III (lines 13 to 15 and 27 from the worksheet after +the election) and line 11 match the model, and so does line 47. The +transcription takes Schedule D lines 18 and 19 as entered, without the +short-term loss netting of the 28% Rate Gain and Unrecaptured Section 1250 +Gain Worksheets (1(h)(4)(B), 1(h)(6)), so the households it compares have no +short-term loss. + +Households are 45, live in Texas and take the standard deduction, so the +election never changes taxable income. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.tests.test_form_4952_section_911_interaction import ( + rate_overlap, +) +from policyengine_us.tests.test_form_6251_part_iii import ( + STATUSES, + form_6251_part_iii_2025, + schedule_d_tax_worksheet_lines_2025, + tolerance, +) + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEARS = [2018, 2022, 2025, 2026, 2030] + +OUTPUTS = [ + "filing_status", + "taxable_income", + "has_qdiv_or_ltcg", + "net_capital_gain", + "dwks10", + "dwks13", + "dwks19", + "income_tax_main_rates", + "tax_on_taxable_income_at_main_rates", + "capital_gains_tax", + "amt_income_less_exemptions", + "amt_tax_including_cg", + "amt_base_tax", + "alternative_minimum_tax", + "income_tax_before_credits", +] + + +def household(**amounts): + h = { + "status": "SINGLE", + "wages": 0, + "dividends": 0, + "long_term": 0, + "short_term": 0, + "collectibles": 0, + "section_1250": 0, + "election": 0, + "more_elected": 1_000, + } + h.update(amounts) + return h + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = { + "age": {year: 45}, + "employment_income": {year: h["wages"]}, + "qualified_dividend_income": {year: h["dividends"]}, + "long_term_capital_gains": {year: h["long_term"]}, + "short_term_capital_gains": {year: h["short_term"]}, + "long_term_capital_gains_on_collectibles": {year: h["collectibles"]}, + "investment_income_elected_form_4952": {year: h["election"]}, + } + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = {"age": {year: 45}} + members.append(spouse) + marital_units[f"marital_unit_{i}"]["members"].append(spouse) + if h["status"] in ("HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"): + # Old enough to be a dependent without a child tax credit. + child = f"child_{i}" + people[child] = {"age": {year: 17}} + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_units[f"tax_unit_{i}"] = { + "members": members, + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default (single). + "filing_status": {year: h["status"]}, + "unrecaptured_section_1250_gain": {year: h["section_1250"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def calculate(households, year): + simulation = Simulation(situation=build_situation(households, year)) + results = {} + for v in OUTPUTS: + values = simulation.calculate(v, year) + results[v] = ( + values.decode_to_str() if v == "filing_status" else np.asarray(values) + ) + results["regular_tax"] = results["income_tax_main_rates"].astype(float) + results[ + "capital_gains_tax" + ].astype(float) + parameters = simulation.tax_benefit_system.parameters(f"{year}-01-01") + results["parameters"] = parameters + return results + + +def tax_at_regular_rates(amount, parameters, status): + """The tax on an amount at the rate schedule in the parameters.""" + bracket = parameters.gov.irs.income.bracket + tax, bottom = 0.0, 0.0 + for i in range(1, len(list(bracket.rates.__iter__())) + 1): + top = max(bottom, float(bracket.thresholds[str(i)][status])) + if amount > bottom: + tax += float(bracket.rates[str(i)]) * (min(amount, top) - bottom) + bottom = top + return tax + + +def column(households, key): + return np.array([h[key] for h in households], dtype=float) + + +# --------------------------------------------------------------------------- +# The properties. +# --------------------------------------------------------------------------- + + +def assert_properties(households, law): + """Properties 1 to 3.""" + parameters = law["parameters"] + taxable_income = law["taxable_income"].astype(float) + regular_tax = law["regular_tax"] + slack = tolerance(taxable_income) + for i, h in enumerate(households): + status = str(law["filing_status"][i]) + assert status == h["status"], (i, status) + # 1. Never more than the tax on all taxable income (line 46), from + # the parameters, and the model's own line 46 is that tax. + line_46 = tax_at_regular_rates(taxable_income[i], parameters, status) + assert regular_tax[i] <= line_46 + slack[i], (i, h, regular_tax[i], line_46) + assert float(law["tax_on_taxable_income_at_main_rates"][i]) == pytest.approx( + line_46, abs=slack[i] + ), (i, h) + # 2. No taxable income, no regular tax. + none = taxable_income <= 0 + assert not regular_tax[none].any() + assert not law["capital_gains_tax"][none].any() + # 3. Not negative. + assert (law["capital_gains_tax"] >= 0).all() + assert (regular_tax >= 0).all() + + +def election_dip_bound(households, law): + """The largest fall in the regular tax that electing `more_elected` more + can cause (property 4).""" + parameters = law["parameters"] + gains = parameters.gov.irs.capital_gains + rate_difference = float(gains.other_cg_rate) - float(gains.unrecaptured_s_1250_rate) + overlaps = { + str(status): rate_overlap(parameters, str(status)) + for status in set(law["filing_status"]) + } + return np.array( + [ + overlaps[str(status)] + + rate_difference * min(h["more_elected"], h["section_1250"]) + for status, h in zip(law["filing_status"], households) + ] + ) + + +def assert_election_bound(households, year): + """Property 4, with each household and the same one electing more in one + batch.""" + count = len(households) + more = [{**h, "election": h["election"] + h["more_elected"]} for h in households] + results = calculate(households + more, year) + law = { + v: values[:count] if isinstance(values, np.ndarray) else values + for v, values in results.items() + } + higher = { + v: values[count:] if isinstance(values, np.ndarray) else values + for v, values in results.items() + } + assert_properties(households + more, results) + # The election never changes taxable income here (standard deduction). + assert np.array_equal(law["taxable_income"], higher["taxable_income"]) + slack = tolerance(law["taxable_income"].astype(float)) + bound = election_dip_bound(households, law) + for v in ["regular_tax", "income_tax_before_credits"]: + dip = law[v].astype(float) - higher[v].astype(float) + bad = np.flatnonzero(dip > bound + slack) + assert not bad.size, (v, [households[i] for i in bad[:3]], dip[bad[:3]]) + return law, higher + + +# --------------------------------------------------------------------------- +# The 2025 forms. +# --------------------------------------------------------------------------- + + +def worksheet_for(h, line_1, status): + """The 2025 Schedule D Tax Worksheet for a household. Form 4952 line 4e + is the smaller of line 4d (the net gain) and the net capital gain from + property held for investment, as the model reads it.""" + long_term, short_term = h["long_term"], h["short_term"] + net_gain = max(0, long_term + short_term) + investment_gain = max(0, long_term - max(0, -short_term)) + return schedule_d_tax_worksheet_lines_2025( + line_1, + qualified_dividends=h["dividends"], + form_4952_line_4g=h["election"], + form_4952_line_4e=min(net_gain, investment_gain), + schedule_d_line_15=long_term, + schedule_d_line_16=long_term + short_term, + schedule_d_line_18=h["collectibles"], + schedule_d_line_19=h["section_1250"], + status=status, + ) + + +def assert_matches_2025_forms(households, law): + for i, h in enumerate(households): + assert h["short_term"] >= 0, h + status = str(law["filing_status"][i]) + line_1 = float(law["taxable_income"][i]) + regular_tax = float(law["regular_tax"][i]) + if line_1 <= 0: + assert regular_tax == 0, (i, h) + continue + worksheet = worksheet_for(h, line_1, status) + slack = tolerance(line_1) + # The model fills its worksheet lines only for a filer with + # dividends or gains (dwks19 is zero otherwise); the tax is compared + # for everyone. + if law["has_qdiv_or_ltcg"][i]: + for model_line, worksheet_line in ( + ("dwks10", 10), + ("dwks13", 13), + ("dwks19", 21), + ): + assert float(law[model_line][i]) == pytest.approx( + worksheet[worksheet_line], abs=slack + ), (i, h, model_line, worksheet) + assert regular_tax == pytest.approx(worksheet[47], abs=slack), ( + i, + h, + worksheet, + ) + # Form 6251 Part III with lines 13 to 15 and 27 from the worksheet + # (after the election), then lines 9 to 11 with no foreign tax + # credit. + line_12 = float(law["amt_income_less_exemptions"][i]) + _, line_38, line_39, line_40 = form_6251_part_iii_2025( + line_12, + worksheet[13], + worksheet[14], + status, + line_14=worksheet["schedule_d_19"], + worksheet_line_10=worksheet[10], + line_27=worksheet[21], + ) + amt_slack = tolerance(line_12, line_1) + assert float(law["amt_tax_including_cg"][i]) == pytest.approx( + line_38, abs=amt_slack + ), (i, h) + assert float(law["amt_base_tax"][i]) == pytest.approx(line_39, abs=amt_slack), ( + i, + h, + ) + assert float(law["alternative_minimum_tax"][i]) == pytest.approx( + max(0, line_40 - worksheet[47]), abs=amt_slack + ), (i, h) + + +# --------------------------------------------------------------------------- +# Tests. +# --------------------------------------------------------------------------- + +# Wages, dividends, long-term gain, short-term gain, collectibles gain and +# unrecaptured section 1250 gain. +GRID_INCOMES = [ + (0, 0, 10_000, 0, 10_000, 0), + (100_000, 0, 100_000, 0, 100_000, 0), + (300_000, 0, 100_000, 0, 100_000, 0), + (115_750, 0, 200_000, 0, 100_000, 100_000), + (60_000, 5_000, 80_000, 10_000, 20_000, 30_000), + (20_000, 25_000, 900_000, 0, 90_000, 90_000), + (700_000, 10_000, 400_000, 0, 0, 150_000), + # Ordinary income at the 0 percent amount and gain in the 12 percent + # bracket, single, 2025. + (64_100, 0, 125, 0, 0, 0), + (64_100, 0, 10_000, 0, 0, 0), +] + + +def grid(): + rows = [] + for status in STATUSES: + for ( + wages, + dividends, + long_term, + short_term, + collectibles, + s1250, + ) in GRID_INCOMES: + gain = long_term + short_term + for election in sorted( + {0, gain // 2, gain, gain + dividends // 2, gain + dividends + 9_000} + ): + rows.append( + household( + status=status, + wages=wages, + dividends=dividends, + long_term=long_term, + short_term=short_term, + collectibles=collectibles, + section_1250=s1250, + election=election, + more_elected=50_000, + ) + ) + return rows + + +GRID = grid() + + +def test_grid_matches_the_2025_forms(): + law = calculate(GRID, 2025) + assert_properties(GRID, law) + assert_matches_2025_forms(GRID, law) + # The grid reaches the 28 percent rate on what is left of line 9 after + # an election, the line 47 limit, and the AMT with an election. + elects = column(GRID, "election") > 0 + rate_gain = column(GRID, "collectibles") > 0 + line_46 = law["tax_on_taxable_income_at_main_rates"] + assert (elects & rate_gain & (law["capital_gains_tax"] > 0)).any() + assert (np.abs(law["regular_tax"] - line_46) < 0.01)[ + law["capital_gains_tax"] > 0 + ].any() + assert (elects & (law["alternative_minimum_tax"] > 0)).any() + + +@pytest.mark.parametrize("year", YEARS) +def test_grid_keeps_the_properties(year): + law, higher = assert_election_bound(GRID, year) + # Electing more raises the tax of some households. + assert (higher["regular_tax"] > law["regular_tax"] + 1).any() + + +def test_election_can_move_gain_from_28_to_25_percent(): + """An intended exception to "electing more never lowers the tax". + + 2025, single: $115,750 of wages and $200,000 of long-term gain, $100,000 + of it collectibles gain and $100,000 unrecaptured section 1250 gain. + Taxable income is $300,000. Schedule D Tax Worksheet with no election: + lines 9 to 12 are $200,000, line 13 is $0, line 18 $100,000, line 20 + $197,300 and line 21 $197,300. Line 35 is $100,000; line 38 is $200,000 + + $197,300 - $300,000 = $97,300, the part of the 1250 gain the regular + rates tax; line 39 is $2,700 (25%: $675) and line 42 $100,000 (28%: + $28,000). Electing $50,000 lowers line 10 to $150,000 and line 38 to + $47,300, so line 39 is $52,700 ($13,175) and line 42 $50,000 ($14,000): + the tax falls by $1,500, 3% of $50,000. Section 1(h)(1)(E)(ii) measures + the same excess with net capital gain, which the election reduces + (section 1(h)(2)). + """ + households = [ + household( + wages=115_750, + long_term=200_000, + collectibles=100_000, + section_1250=100_000, + election=election, + ) + for election in (0, 50_000) + ] + law = calculate(households, 2025) + assert list(law["taxable_income"]) == [300_000, 300_000] + assert list(law["regular_tax"]) == pytest.approx([68_874, 67_374], abs=0.01) + worksheets = [worksheet_for(h, 300_000, "SINGLE") for h in households] + assert [w[39] for w in worksheets] == [2_700, 52_700] + assert [w[42] for w in worksheets] == [100_000, 50_000] + assert [w[47] for w in worksheets] == pytest.approx([68_874, 67_374]) + assert law["alternative_minimum_tax"].tolist() == [0, 0] + assert_matches_2025_forms(households, law) + # Within the bound of property 4: 3% of the $50,000 elected. + bound = election_dip_bound([{**households[0], "more_elected": 50_000}], law) + assert bound[0] == pytest.approx(3.75 + 1_500) + dip = law["regular_tax"][0] - law["regular_tax"][1] + assert dip <= bound[0] + tolerance(300_000) + + +@st.composite +def households(draw, short_term_loss=True): + h = household( + status=draw(st.sampled_from(STATUSES)), + wages=draw(st.one_of(st.just(0), st.integers(1, 1_000_000))), + dividends=draw(st.one_of(st.just(0), st.integers(1, 300_000))), + long_term=draw(st.one_of(st.just(0), st.integers(1, 1_500_000))), + short_term=draw( + st.one_of( + st.just(0), + st.integers(-100_000 if short_term_loss else 1, 200_000), + ) + ), + more_elected=draw(st.one_of(st.integers(1, 500), st.integers(1, 400_000))), + ) + # 28-percent rate gain and unrecaptured section 1250 gain, as parts of + # the long-term gain; together they may exceed it, as Schedule D lines 18 + # and 19 can exceed line 9. + h["collectibles"] = draw(st.one_of(st.just(0), st.integers(0, h["long_term"]))) + h["section_1250"] = draw(st.one_of(st.just(0), st.integers(0, h["long_term"]))) + gain = max(0, h["long_term"] + min(0, h["short_term"])) + # Elections at the gain and at the gain plus dividends are where the + # election starts and stops reducing qualified dividends. + h["election"] = draw( + st.one_of( + st.just(0), + st.integers(1, 2_000_000), + st.sampled_from([gain, gain + h["dividends"]]), + st.integers(max(0, gain - 5), gain + h["dividends"] + 5), + ) + ) + return h + + +# Fixed examples, so CI on an unrelated pull request draws the same households. +SETTINGS = dict( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(households(), min_size=1, max_size=25), st.sampled_from(YEARS) +) +def test_random_households_keep_the_properties(batch, year): + assert_election_bound(batch, year) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given(st.lists(households(short_term_loss=False), min_size=1, max_size=25)) +# Found by this test: an election with no gains or dividends. The model's +# worksheet lines are zero without dividends or gains; the tax still matches. +@hypothesis.example([household(wages=122_146, election=50_223)]) +def test_random_households_match_the_2025_forms(batch): + law = calculate(batch, 2025) + assert_properties(batch, law) + assert_matches_2025_forms(batch, law) diff --git a/policyengine_us/tests/test_section_911_tax_stacking.py b/policyengine_us/tests/test_section_911_tax_stacking.py index 379db143da5..d6db99f6a15 100644 --- a/policyengine_us/tests/test_section_911_tax_stacking.py +++ b/policyengine_us/tests/test_section_911_tax_stacking.py @@ -17,33 +17,40 @@ (Foreign Earned Income Tax Worksheet, Qualified Dividends and Capital Gain Tax Worksheet, and Form 6251 with its own Foreign Earned Income Tax Worksheet and Part III) and compared with the model, household by - household. The model reaches the same tax by a different route: the - section 1(h) formulas in `capital_gains_excluded_from_taxable_income`, - `income_tax_main_rates` and `capital_gains_tax`. + household. A household with 28-percent rate gain or unrecaptured section + 1250 gain uses the Schedule D Tax Worksheet and the Form 6251 Part III + lines it feeds, transcribed in test_form_6251_part_iii.py, with the four + modifications of the Foreign Earned Income Tax Worksheet footnote and the + AMT capital gain excess. The model reaches the same tax by a different + route: the section 1(h) formulas in + `capital_gains_excluded_from_taxable_income`, `income_tax_main_rates` and + `capital_gains_tax`. - Properties that hold for every household: with nothing excluded, every amount that section 911(f) replaces is unchanged, bit for bit; the stacked tax is never below the unstacked tax; zero taxable income gives zero regular tax; and the tax never falls as the excluded amount rises. -Households live in Texas; some itemize. - -The model takes the section 1(h)(1) cap at the tax on all taxable income -at ordinary rates (worksheet line 25, "the smaller of line 23 or line 24"), -so it is compared with line 25. The cap binds only where the 15% rate -applies inside the 12% bracket, a band of $100 to $250 of income in 2025, -so line 25 is checked to be within $7.50 of line 23. - -One limit of the model sets the scope of these tests, and it does not come -from section 911: `capital_gains_tax` taxes 28-percent rate gain in full -even when it exceeds taxable income, so the households here have no -28-percent rate or unrecaptured section 1250 gain. YAML unit tests cover how -the capital gain excess reduces those two amounts. +Households live in Texas; some itemize. The model is compared with the +worksheets' final lines (line 25 of the capital gains worksheet, line 47 of +the Schedule D Tax Worksheet), which take the smaller of the section 1(h) sum +and the tax on all of line 1 (26 U.S.C. 1(h)(1), "shall not exceed"). The +cap binds only where the 15% rate applies inside the 12% bracket, a band of +$100 to $250 of income in 2025, so line 25 is checked to be within $7.50 of +line 23. The Schedule D Tax Worksheet transcription takes Schedule D lines +18 and 19 as entered, without the short-term loss netting of the 28% Rate +Gain and Unrecaptured Section 1250 Gain Worksheets (1(h)(4)(B), 1(h)(6)), so +households with either gain have no short-term loss in the worksheet +comparisons. """ import numpy as np import pytest from policyengine_us import Simulation +from policyengine_us.tests.test_form_6251_part_iii import ( + form_6251_part_iii_2025, + schedule_d_tax_worksheet_lines_2025, +) # Hypothesis is a dev extra; skip rather than fail collection without it. hypothesis = pytest.importorskip("hypothesis") @@ -166,9 +173,9 @@ def foreign_earned_income_tax_worksheet( """2025 Form 1040 instructions, page 37. Returns line 6 figured with line 23 of the capital gains worksheet (the - tax before the section 1(h)(1) cap), line 6 figured with its line 25 - (what the form says and the model computes), and line 5 of the capital - gains worksheet, which Form 6251 needs. + section 1(h) sum), line 6 figured with its line 25 (the smaller of that + sum and the tax on all of line 1, which is the form's and the model's + tax), and line 5 of the capital gains worksheet, which Form 6251 needs. A filer with no exclusion gets the ordinary line 16 tax. """ line_1 = taxable_income @@ -262,6 +269,95 @@ def form_6251_line_7( return max(0, line_4 - line_5) +def schedule_d_worksheet_items(household): + """Schedule D Tax Worksheet amounts other than line 1, for a household + with no short-term loss (so Schedule D lines 18 and 19 are the gains as + entered) and no Form 4952 election.""" + long_term = household["long_term_gains"] + return dict( + qualified_dividends=household["qualified_dividends"], + form_4952_line_4g=0, + form_4952_line_4e=0, + schedule_d_line_15=long_term, + schedule_d_line_16=long_term + household["short_term_gains"], + schedule_d_line_18=household.get("collectibles_gains", 0), + schedule_d_line_19=household.get("unrecaptured_section_1250_gain", 0), + ) + + +def uses_schedule_d_tax_worksheet(household): + return bool( + household.get("collectibles_gains", 0) + or household.get("unrecaptured_section_1250_gain", 0) + ) + + +def foreign_earned_income_tax_worksheet_schedule_d( + taxable_income, excluded, items, status +): + """2025 Form 1040 instructions, page 37, with the Schedule D Tax + Worksheet (`items` as schedule_d_worksheet_items returns them). + + The worksheet is completed through line 10 on line 3; the capital gain + excess is line 10 less taxable income; a second worksheet applies the + footnote's four modifications. Returns line 6 and the worksheet behind + line 4, whose lines 14 and 21 are Form 6251 lines 20 and 27 "as figured + for the regular tax" (they "take into account your regular tax capital + gain excess"). A filer with no exclusion gets the ordinary line 16 tax. + """ + line_1 = taxable_income + line_2c = max(0, excluded) + line_3 = line_1 + line_2c + worksheet = schedule_d_tax_worksheet_lines_2025(line_3, status=status, **items) + excess = max(0, worksheet[10] - line_1) if line_2c > 0 else 0 + if excess > 0: + worksheet = schedule_d_tax_worksheet_lines_2025( + line_3, status=status, capital_gain_excess=excess, **items + ) + # "If Form 1040 or 1040-SR, line 15, is zero, don't complete this + # worksheet." + if line_1 <= 0: + return 0.0, worksheet + line_5 = tax_rate_schedule(line_2c, status) + return max(0, worksheet[47] - line_5), worksheet + + +def form_6251_line_7_schedule_d( + taxable_excess, excluded, items, regular_worksheet, status +): + """Form 6251 line 7 through its Foreign Earned Income Tax Worksheet, for + a household that uses the Schedule D Tax Worksheet. + + 2025 Form 6251 instructions, pages 10 and 13. With no AMT adjustments + the AMT Schedule D Tax Worksheet is the regular one; the AMT capital gain + excess is its line 10 less Form 6251 line 6, and the four modifications + apply to it for lines 13 to 15 of Part III. Lines 20 and 27 come from + `regular_worksheet`, the worksheet figured for the regular tax. + """ + if taxable_excess <= 0: + return 0.0 + line_1 = taxable_excess + line_2c = max(0, excluded) + line_3 = line_1 + line_2c + amt_worksheet = schedule_d_tax_worksheet_lines_2025(line_3, status=status, **items) + excess = max(0, amt_worksheet[10] - line_1) if line_2c > 0 else 0 + if excess > 0: + amt_worksheet = schedule_d_tax_worksheet_lines_2025( + line_3, status=status, capital_gain_excess=excess, **items + ) + _, _, _, line_4 = form_6251_part_iii_2025( + line_3, + amt_worksheet[13], + regular_worksheet[14], + status, + line_14=amt_worksheet["schedule_d_19"], + worksheet_line_10=amt_worksheet[10], + line_27=regular_worksheet[21], + ) + line_5 = amt_rates(line_2c, status) + return max(0, line_4 - line_5) + + # --------------------------------------------------------------------------- # The model. # --------------------------------------------------------------------------- @@ -381,38 +477,54 @@ def tolerance(*amounts): def assert_matches_worksheets(households, law): """The 2025 model against the transcribed 2025 worksheets.""" - cap_gaps = [] for i, h in enumerate(households): status = law["filing_status"][i] assert status == h["status"], (i, status) taxable_income = float(law["taxable_income"][i]) gain = net_gain(h) dividends = h["qualified_dividends"] - line_6, line_6_capped, ordinary_income = foreign_earned_income_tax_worksheet( - taxable_income, h["exclusion"], dividends, gain, status - ) + taxable_excess = float(law["amt_income_less_exemptions"][i]) + if uses_schedule_d_tax_worksheet(h): + # See the module docstring. + assert h["short_term_gains"] >= 0, h + items = schedule_d_worksheet_items(h) + line_6, worksheet = foreign_earned_income_tax_worksheet_schedule_d( + taxable_income, h["exclusion"], items, status + ) + line_7 = form_6251_line_7_schedule_d( + taxable_excess, h["exclusion"], items, worksheet, status + ) + else: + line_6_uncapped, line_6, ordinary_income = ( + foreign_earned_income_tax_worksheet( + taxable_income, h["exclusion"], dividends, gain, status + ) + ) + # The form's line 25 cap takes at most $7.50 off line 23. + cap_gap = line_6_uncapped - line_6 + slack = tolerance(taxable_income + h["exclusion"]) + assert -slack <= cap_gap <= 7.5 + slack, (i, h) + line_7 = form_6251_line_7( + taxable_excess, + h["exclusion"], + dividends, + gain, + ordinary_income, + status, + ) regular_tax = float(law["regular_tax"][i]) stacked_income = taxable_income + h["exclusion"] assert float(law["regular_tax_before_credits"][i]) == pytest.approx( regular_tax, abs=tolerance(stacked_income) ), (i, h) - assert regular_tax == pytest.approx( - line_6_capped, abs=tolerance(stacked_income) - ), (i, h) - # The form's line 25 cap takes at most $7.50 off line 23. - cap_gaps.append(line_6 - line_6_capped) - line_7 = form_6251_line_7( - float(law["amt_income_less_exemptions"][i]), - h["exclusion"], - dividends, - gain, - ordinary_income, - status, + # Line 6 with line 25 of the capital gains worksheet or line 47 of + # the Schedule D Tax Worksheet. + assert regular_tax == pytest.approx(line_6, abs=tolerance(stacked_income)), ( + i, + h, ) - assert -tolerance(stacked_income) <= cap_gaps[-1], (i, h) - assert cap_gaps[-1] <= 7.5 + tolerance(stacked_income), (i, h) # Form 6251 lines 9 to 11 with no foreign tax credit. - amt = max(0, line_7 - line_6_capped) + amt = max(0, line_7 - line_6) taxable_excess = float( law["amt_income_less_exemptions_plus_section_911_exclusion"][i] ) @@ -496,18 +608,29 @@ def assert_invariants(households, year): "qualified_dividends": dividends, "long_term_gains": long_term, "short_term_gains": short_term, + "collectibles_gains": collectibles, + "unrecaptured_section_1250_gain": section_1250, "exclusion": exclusion, } for status in STATUSES for wages in (0, 20_000, 59_975, 140_000, 700_000) - for dividends, long_term, short_term in ( - (0, 0, 0), - (3_000, 0, 0), - (0, 50_000, 0), - (10_000, 40_000, -15_000), - (0, 30_000, 20_000), - (25_000, 900_000, 0), - (0, -8_000, 5_000), + for dividends, long_term, short_term, collectibles, section_1250 in ( + (0, 0, 0, 0, 0), + (3_000, 0, 0, 0, 0), + (0, 50_000, 0, 0, 0), + (10_000, 40_000, -15_000, 0, 0), + (0, 30_000, 20_000, 0, 0), + (25_000, 900_000, 0, 0, 0), + (0, -8_000, 5_000, 0, 0), + # 28-percent rate gain and unrecaptured section 1250 gain. + (0, 30_000, 0, 30_000, 0), + (5_000, 80_000, 10_000, 20_000, 15_000), + (0, 200_000, 0, 100_000, 100_000), + (20_000, 600_000, 40_000, 0, 100_000), + # With no wages the excess (15,750 single, more for other statuses) + # is more than the 28-percent rate gain, so modification 4 lowers + # Schedule D line 19, and line 12 with it. + (0, 50_000, 0, 10_000, 30_000), ) for exclusion in (0, 1, 40_025, 130_000, 260_000) ] @@ -523,6 +646,23 @@ def test_grid_matches_the_2025_worksheets(): assert (excludes & (law["capital_gains_tax"] > 0)).any() assert (excludes & (law["alternative_minimum_tax"] > 0)).any() assert (excludes & (law["taxable_income"] == 0)).any() + # And, with 28-percent rate gain or unrecaptured section 1250 gain, an + # excess that reaches each of them, and the AMT. + rate_gain = np.array([h["collectibles_gains"] > 0 for h in GRID]) + section_1250 = np.array([h["unrecaptured_section_1250_gain"] > 0 for h in GRID]) + excess = law["section_911_capital_gain_excess"] + collectibles = np.array([h["collectibles_gains"] for h in GRID]) + assert (rate_gain & (excess > 0)).any() + # Modification 4 changes worksheet line 12: the excess is beyond the + # 28-percent rate gain, and line 9 after modification 1 is above the + # unrecaptured section 1250 gain after modification 4. + unrecaptured = np.array([h["unrecaptured_section_1250_gain"] for h in GRID]) + line_9 = np.array([net_gain(h) for h in GRID]) - excess + reduced_1250 = unrecaptured - (excess - collectibles) + assert (section_1250 & (excess > collectibles) & (line_9 > reduced_1250)).sum() >= 2 + assert ( + (rate_gain | section_1250) & excludes & (law["alternative_minimum_tax"] > 0) + ).any() @pytest.mark.parametrize("year", [2018, 2022, 2025, 2026]) @@ -757,6 +897,28 @@ def get(variable): } ) +# Adds 28-percent rate gain (collectibles) and unrecaptured section 1250 gain, +# up to the long-term gain, to households with no short-term loss (see the +# module docstring). +schedule_d_household_strategy = st.builds( + lambda h, collectibles, section_1250: { + **h, + "collectibles_gains": ( + min(collectibles, max(0, h["long_term_gains"])) + if h["short_term_gains"] >= 0 + else 0 + ), + "unrecaptured_section_1250_gain": ( + min(section_1250, max(0, h["long_term_gains"])) + if h["short_term_gains"] >= 0 + else 0 + ), + }, + household_strategy, + st.one_of(st.just(0), st.integers(1, 400_000)), + st.one_of(st.just(0), st.integers(1, 400_000)), +) + # Fixed examples, so CI on an unrelated pull request draws the same households. SETTINGS = dict( max_examples=10, @@ -771,7 +933,7 @@ def get(variable): @hypothesis.settings(**SETTINGS) @hypothesis.given( - st.lists(household_strategy, min_size=1, max_size=25), + st.lists(schedule_d_household_strategy, min_size=1, max_size=25), st.sampled_from([2018, 2022, 2025, 2026]), ) def test_random_households_keep_the_invariants(households, year): @@ -779,6 +941,6 @@ def test_random_households_keep_the_invariants(households, year): @hypothesis.settings(**SETTINGS) -@hypothesis.given(st.lists(household_strategy, min_size=1, max_size=25)) +@hypothesis.given(st.lists(schedule_d_household_strategy, min_size=1, max_size=25)) def test_random_households_match_the_2025_worksheets(households): assert_matches_worksheets(households, calculate(households, 2025)) From d4056a096e092b24b7aeec58f206c073750d3ac2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:34:44 -0400 Subject: [PATCH 8/8] Tighten reach assertions and fix a stale docstring (hard-tier review) - test_section_1h_regular_tax.py: the grid reaches 28% tax after an election only if some elected household has a positive Schedule D Tax Worksheet line 42; a positive capital gains tax could be all at 25%. - test_section_911_tax_stacking.py, test_form_4952_section_911_interaction.py: floor the unrecaptured section 1250 gain after modification 4 at zero, so a household whose line 9 the excess exhausts does not count as reaching it. - test_form_6251_part_iii.py: the transcription, not the model, leaves out the short-term loss netting (#9841 added it to the model). Co-Authored-By: Claude Opus 5.5 --- .../tests/test_form_4952_section_911_interaction.py | 2 +- policyengine_us/tests/test_form_6251_part_iii.py | 2 +- .../tests/test_section_1h_regular_tax.py | 13 ++++++++++++- .../tests/test_section_911_tax_stacking.py | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/test_form_4952_section_911_interaction.py b/policyengine_us/tests/test_form_4952_section_911_interaction.py index 5e3a2a785c1..d608ab1b8c9 100644 --- a/policyengine_us/tests/test_form_4952_section_911_interaction.py +++ b/policyengine_us/tests/test_form_4952_section_911_interaction.py @@ -914,7 +914,7 @@ def test_grid(): # unrecaptured section 1250 gain after modification 4. rate_gain = column(GRID, "rate_gain_28") line_9 = law["dwks09"] - excess - reduced_1250 = column(GRID, "section_1250") - (excess - rate_gain) + reduced_1250 = np.maximum(0, column(GRID, "section_1250") - (excess - rate_gain)) reaches_1250 = ( (column(GRID, "section_1250") > 0) & (column(GRID, "short_term") >= 0) diff --git a/policyengine_us/tests/test_form_6251_part_iii.py b/policyengine_us/tests/test_form_6251_part_iii.py index f00f2f3cdae..2f41a38b045 100644 --- a/policyengine_us/tests/test_form_6251_part_iii.py +++ b/policyengine_us/tests/test_form_6251_part_iii.py @@ -498,7 +498,7 @@ def assert_matches_schedule_d_tax_worksheet(households, law): For households with no short-term loss, so that Schedule D lines 18 and 19 are the 28 percent rate gain and unrecaptured section 1250 gain as entered (a short-term loss would reduce them on the 28% Rate Gain and - Unrecaptured Section 1250 Gain Worksheets, which the model does not do). + Unrecaptured Section 1250 Gain Worksheets, which the transcription does not do). """ for i, h in enumerate(households): assert h["short_term_gains"] >= 0, h diff --git a/policyengine_us/tests/test_section_1h_regular_tax.py b/policyengine_us/tests/test_section_1h_regular_tax.py index 5688abeba57..37ffd3838de 100644 --- a/policyengine_us/tests/test_section_1h_regular_tax.py +++ b/policyengine_us/tests/test_section_1h_regular_tax.py @@ -411,7 +411,18 @@ def test_grid_matches_the_2025_forms(): elects = column(GRID, "election") > 0 rate_gain = column(GRID, "collectibles") > 0 line_46 = law["tax_on_taxable_income_at_main_rates"] - assert (elects & rate_gain & (law["capital_gains_tax"] > 0)).any() + taxed_at_28 = [ + i + for i, h in enumerate(GRID) + if elects[i] + and rate_gain[i] + and float(law["taxable_income"][i]) > 0 + and worksheet_for( + h, float(law["taxable_income"][i]), str(law["filing_status"][i]) + )[42] + > 0 + ] + assert taxed_at_28 assert (np.abs(law["regular_tax"] - line_46) < 0.01)[ law["capital_gains_tax"] > 0 ].any() diff --git a/policyengine_us/tests/test_section_911_tax_stacking.py b/policyengine_us/tests/test_section_911_tax_stacking.py index d6db99f6a15..f4c05a27fb5 100644 --- a/policyengine_us/tests/test_section_911_tax_stacking.py +++ b/policyengine_us/tests/test_section_911_tax_stacking.py @@ -658,7 +658,7 @@ def test_grid_matches_the_2025_worksheets(): # unrecaptured section 1250 gain after modification 4. unrecaptured = np.array([h["unrecaptured_section_1250_gain"] for h in GRID]) line_9 = np.array([net_gain(h) for h in GRID]) - excess - reduced_1250 = unrecaptured - (excess - collectibles) + reduced_1250 = np.maximum(0, unrecaptured - (excess - collectibles)) assert (section_1250 & (excess > collectibles) & (line_9 > reduced_1250)).sum() >= 2 assert ( (rate_gain | section_1250) & excludes & (law["alternative_minimum_tax"] > 0)