From ed10af5e658bbb21f30cc631e63b5917a0e18f49 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 20:15:23 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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