From 277bccb8fe364693586271cac5acf941d22bcabf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 09:15:46 -0400 Subject: [PATCH] Halve the MFS SALT cap after the phase-down, not before 26 USC 164(b)(6)(B), as amended by P.L. 119-21 sec. 70120(a), limits a married individual filing separately to half the applicable limitation amount. 164(b)(7)(B) reduces that amount by 30% of modified AGI over the threshold (half the threshold for a separate filer) and bars the reduction from taking it below $10,000. The 2025 Schedule A State and Local Tax Deduction Worksheet follows the same order: line 1 is $40,000 for every filing status, line 5 halves only the threshold, line 9 applies the $10,000 floor and line 10 halves the result. salt_cap applied the full 30% reduction to the already-halved $20,000 MFS cap, so separate filers phased down twice as fast as the law allows: at $300,000 MAGI in 2025 the model gave $5,000 instead of (40,000 - 15,000) / 2 = $12,500. The new phase_out.separate_fraction parameter (0.5) scales a separate filer's reduction, which is equivalent to halving after the reduction because the SEPARATE cap and floor already hold the halves. The floor also raised any cap below it to $10,000, so a reform setting the cap to $0 for 2025-2029 still allowed $10,000. 164(b)(7)(B)(iii) only stops the reduction; the floor is now min(cap, floor). Adds variable-named YAML cases and Hypothesis invariants (worksheet differential, the shift identity S(M) = C(M + T/2) / 2, the split inequality 2 S(M) >= C(2M), bounds, monotonicity, a Lipschitz bound and a reform floor case), and corrects the one existing case that encoded the old order. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-salt-cap-mfs-halving.fixed.md | 1 + .../phase_out/floor/amount.yaml | 5 + .../phase_out/separate_fraction.yaml | 15 + .../tests/core/test_salt_cap_invariants.py | 262 ++++++++++++++++++ .../salt_and_real_estate/salt_deduction.yaml | 4 +- .../deductions/itemizing/salt_cap.yaml | 177 ++++++++++++ .../deductions/itemizing/salt_cap.py | 20 +- 7 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fix-salt-cap-mfs-halving.fixed.md create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/separate_fraction.yaml create mode 100644 policyengine_us/tests/core/test_salt_cap_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.yaml diff --git a/changelog.d/fix-salt-cap-mfs-halving.fixed.md b/changelog.d/fix-salt-cap-mfs-halving.fixed.md new file mode 100644 index 00000000000..4a4df939117 --- /dev/null +++ b/changelog.d/fix-salt-cap-mfs-halving.fixed.md @@ -0,0 +1 @@ +Halve the married-filing-separately SALT cap after the 2025-2029 phase-down and $10,000 floor rather than before, as 26 USC 164(b)(6)(B) and (7)(B) and the 2025 Schedule A worksheet require, and stop the floor from raising a reformed cap that starts below it. diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/floor/amount.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/floor/amount.yaml index 5114b2ea3f0..d65e17797dd 100644 --- a/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/floor/amount.yaml +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/floor/amount.yaml @@ -9,6 +9,11 @@ metadata: reference: - title: H.R.1 - One Big Beautiful Bill Act href: https://www.congress.gov/bill/119th-congress/house-bill/1/text + - title: 26 U.S. Code § 164(b)(7)(B)(iii) + href: https://www.law.cornell.edu/uscode/text/26/164#b_7_B + # A separate filer deducts half of the floored amount (line 10). + - title: 2025 Instructions for Schedule A, State and Local Tax Deduction Worksheet, lines 9 and 10 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2025.pdf#page=7 SINGLE: 2025-01-01: 10_000 diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/separate_fraction.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/separate_fraction.yaml new file mode 100644 index 00000000000..e6ce3f9fb6f --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/salt_and_real_estate/phase_out/separate_fraction.yaml @@ -0,0 +1,15 @@ +description: The IRS reduces the SALT cap of married individuals filing separately by this fraction of the phase-down, since they deduct this fraction of the applicable limitation amount after it is phased down. +values: + 2025-01-01: 0.5 + +metadata: + unit: /1 + period: year + label: SALT cap phase-down fraction for married filing separately + reference: + - title: 26 U.S. Code § 164(b)(6)(B) + href: https://www.law.cornell.edu/uscode/text/26/164#b_6_B + - title: P.L. 119-21, sec. 70120(a), 139 Stat. 169 + href: https://www.congress.gov/119/plaws/publ21/PLAW-119publ21.pdf#page=99 + - title: 2025 Instructions for Schedule A, State and Local Tax Deduction Worksheet, line 10 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2025.pdf#page=7 diff --git a/policyengine_us/tests/core/test_salt_cap_invariants.py b/policyengine_us/tests/core/test_salt_cap_invariants.py new file mode 100644 index 00000000000..b9e57f2aee8 --- /dev/null +++ b/policyengine_us/tests/core/test_salt_cap_invariants.py @@ -0,0 +1,262 @@ +"""Invariants for the SALT cap, 26 USC 164(b)(6)(B) and (7). + +P.L. 119-21 sec. 70120 sets an applicable limitation amount L ($40,000 for +2025), reduces it by 30% of modified AGI over a threshold T ($500,000 for +2025; half for a separate filer), bars the reduction from taking it below +$10,000, and limits a separate filer to half the result. The 2025 Schedule A +State and Local Tax Deduction Worksheet follows the same order: line 1 is L +for every filing status, line 5 halves only the threshold, line 9 applies the +$10,000 floor, and line 10 halves the result for a separate filer. + +With e(M, t) = max(0, M - t), the cap of a filer with modified AGI M is + + C(M) = max(L - 0.3 e(M, T), 10,000) (other filing statuses) + S(M) = max(L - 0.3 e(M, T / 2), 10,000) / 2 (married filing separately) + +so the statute implies two relations between the separate and joint caps: + +1. Shift identity: S(M) = C(M + T / 2) / 2. A separate filer's cap is half + the joint cap at the MAGI shifted by the separate threshold. It is not + half the joint cap at twice the MAGI: C(2M) / 2 halves L but applies the + full 30% rate, which is the order of operations the statute rejects. +2. Split inequality: 2 S(M) >= C(2M), strictly when 0 < e(M, T / 2) < + (L - 10,000) / 0.3. Two separate returns with equal MAGI keep more cap + than one joint return at their combined MAGI while the phase-down runs. + +Hypothesis draws batches of tax units, each with a filing status, a modified +AGI concentrated near the kinks, an increment, and real estate taxes. Each +batch runs as one vectorized simulation, and again with every AGI raised by +its increment. For every unit: + +a. Differential: salt_cap equals the worksheet computed from the statute's + dollar amounts, not from the model's parameters. +b. Identity and inequality 1 and 2 above, for 2025 through 2027. +c. Bounds: min(cap, floor) <= salt_cap <= cap for the filing status. +d. Monotone and Lipschitz: raising MAGI by d lowers the cap by at least 0 + and at most 30% of d, or 15% of d for a separate filer. +e. Accounting: salt_deduction = min(salt_cap, SALT paid limited to AGI less + exemptions). + +A reform test checks 164(b)(7)(B)(iii): the floor limits the reduction and +never raises a cap set below it. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + +TOLERANCE = 0.05 # dollars; the model computes in float32 + +STATUSES = ["SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"] + +# 164(b)(7)(A) and (B)(ii): L and T, with 2027 at 101% of 2026 as +# (7)(A)(iii) and (B)(ii)(III) prescribe (both products are whole dollars). +# 2024 is under the TCJA cap, 164(b)(6)(B) before the amendment, and 2030 +# under 164(b)(7)(A)(iv), each with no phase-down. +STATUTE = { + 2024: dict(limit=10_000, threshold=None), + 2025: dict(limit=40_000, threshold=500_000), + 2026: dict(limit=40_400, threshold=505_000), + 2027: dict(limit=40_400 * 1.01, threshold=505_000 * 1.01), + 2030: dict(limit=10_000, threshold=None), +} +PHASE_DOWN_YEARS = [2025, 2026, 2027] +RATE = 0.3 +FLOOR = 10_000 + + +def worksheet(year, status, magi): + """2025 Schedule A State and Local Tax Deduction Worksheet, line 9 or 10.""" + law = STATUTE[year] + separate = status == "SEPARATE" + cap = law["limit"] # Line 1. + if law["threshold"] is not None: + threshold = law["threshold"] / 2 if separate else law["threshold"] # Line 5. + excess = max(0.0, magi - threshold) # Line 6. + cap = max(cap - RATE * excess, FLOOR) # Lines 7 to 9. + return cap / 2 if separate else cap # Line 10. + + +KINKS = sorted( + { + point + for law in STATUTE.values() + if law["threshold"] is not None + for point in [ + law["threshold"] / 2, + law["threshold"], + law["threshold"] / 2 + (law["limit"] - FLOOR) / RATE, + law["threshold"] + (law["limit"] - FLOOR) / RATE, + ] + } +) + +magis = st.one_of( + st.integers(0, 1_200_000).map(float), + st.builds( + lambda kink, offset: max(0.0, round(kink) + offset), + st.sampled_from(KINKS), + st.integers(-2_000, 2_000), + ), +) +increments = st.one_of(st.just(1.0), st.integers(1, 200_000).map(float)) +taxes = st.integers(0, 60_000).map(float) + + +@st.composite +def units(draw): + return { + "status": draw(st.sampled_from(STATUSES)), + "magi": draw(magis), + "increment": draw(increments), + "real_estate_taxes": draw(taxes), + } + + +def _situation(rows, year): + people, tax_units, households = {}, {}, {} + for i, row in enumerate(rows): + members = [f"head_{i}"] + people[f"head_{i}"] = { + "age": {year: 45}, + "real_estate_taxes": {year: row["real_estate_taxes"]}, + } + if row["status"] == "JOINT": + members.append(f"spouse_{i}") + people[f"spouse_{i}"] = {"age": {year: 45}} + tax_units[f"tax_unit_{i}"] = { + "members": members, + "filing_status": {year: row["status"]}, + "adjusted_gross_income": {year: row["magi"]}, + "state_and_local_sales_or_income_tax": {year: 0}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def _run(rows, year, reform=None): + sim = Simulation(situation=_situation(rows, year), reform=reform) + return { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in ["salt_cap", "salt_deduction", "salt", "exemptions"] + } + + +def _joint_caps(magis, year): + rows = [ + {"status": "JOINT", "magi": float(magi), "real_estate_taxes": 0.0} + for magi in magis + ] + return _run(rows, year)["salt_cap"] + + +def _check(rows, year): + status = np.array([row["status"] for row in rows]) + separate = status == "SEPARATE" + magi = np.array([row["magi"] for row in rows]) + increment = np.array([row["increment"] for row in rows]) + base = _run(rows, year) + raised = _run( + [{**row, "magi": row["magi"] + row["increment"]} for row in rows], year + ) + cap = base["salt_cap"] + + # a. Differential against the worksheet. + expected = np.array([worksheet(year, s, m) for s, m in zip(status, magi)]) + np.testing.assert_allclose(cap, expected, atol=TOLERANCE) + + # c. Bounds. + law = STATUTE[year] + top = np.where(separate, law["limit"] / 2, law["limit"]) + bottom = np.where(separate, min(law["limit"], FLOOR) / 2, min(law["limit"], FLOOR)) + assert (cap <= top + TOLERANCE).all() + assert (cap >= bottom - TOLERANCE).all() + + # d. Monotone and Lipschitz in MAGI. + drop = cap - raised["salt_cap"] + slope = np.where(separate, RATE / 2, RATE) + has_phase_down = law["threshold"] is not None + assert (drop >= -TOLERANCE).all() + assert (drop <= slope * increment * has_phase_down + TOLERANCE).all() + + # e. Accounting. + allowed = np.minimum(base["salt"], np.maximum(0, magi - base["exemptions"])) + np.testing.assert_allclose( + base["salt_deduction"], np.minimum(cap, allowed), atol=TOLERANCE + ) + + if year in PHASE_DOWN_YEARS and separate.any(): + # b. Shift identity and split inequality, against joint filers. + threshold = law["threshold"] + np.testing.assert_allclose( + cap[separate], + _joint_caps(magi[separate] + threshold / 2, year) / 2, + atol=TOLERANCE, + ) + joint_double = _joint_caps(2 * magi[separate], year) + assert (2 * cap[separate] >= joint_double - TOLERANCE).all() + excess = np.maximum(0, magi[separate] - threshold / 2) + running = (excess > 0) & (excess < (law["limit"] - FLOOR) / RATE) + assert (2 * cap[separate][running] > joint_double[running] + TOLERANCE).all() + + +# Each example is one batch, so a few examples cover many units. +SETTINGS = dict( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(sorted(STATUTE)), st.lists(units(), min_size=5, max_size=40)) +def test_salt_cap_invariants(year, rows): + _check(rows, year) + + +@pytest.mark.parametrize("year", sorted(STATUTE)) +def test_salt_cap_grid(year): + # Every filing status on a $5,000 MAGI grid through the last kink. + rows = [ + { + "status": status, + "magi": float(magi), + "increment": 2_500.0, + "real_estate_taxes": 50_000.0, + } + for status in STATUSES + for magi in range(0, 700_001, 5_000) + ] + _check(rows, year) + + +def test_floor_never_raises_a_lower_cap(): + # 164(b)(7)(B)(iii) bars the reduction from taking the applicable + # limitation amount below $10,000; it does not lift a smaller amount. + p = "gov.irs.deductions.itemized.salt_and_real_estate.cap" + reform = Reform.from_dict( + { + f"{p}.JOINT": {"2026": 0}, + f"{p}.SINGLE": {"2026": 6_000}, + f"{p}.SEPARATE": {"2026": 3_000}, + }, + country_id="us", + ) + rows = [ + {"status": status, "magi": float(magi), "real_estate_taxes": 50_000.0} + for status in ["JOINT", "SINGLE", "SEPARATE"] + for magi in [100_000, 300_000, 505_000, 600_000, 1_000_000] + ] + cap = _run(rows, 2026, reform=reform)["salt_cap"] + expected = [ + {"JOINT": 0, "SINGLE": 6_000, "SEPARATE": 3_000}[r["status"]] for r in rows + ] + np.testing.assert_allclose(cap, expected, atol=TOLERANCE) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml index d376886c3f0..dbc3b8c500b 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml @@ -62,7 +62,9 @@ adjusted_gross_income: 290_000 state_and_local_sales_or_income_tax: 1_000 output: - salt_deduction: 8_000 + # 26 USC 164(b)(6)(B), (7)(B): (40,000 - 30% x 40,000) / 2 = 14,000. + salt_cap: 14_000 + salt_deduction: 11_000 - name: Fully phased out, floor applies period: 2026 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.yaml new file mode 100644 index 00000000000..5414486a75b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.yaml @@ -0,0 +1,177 @@ +# 26 USC 164(b)(6)(B) and (7), as amended by P.L. 119-21 sec. 70120, and the +# 2025 Schedule A State and Local Tax Deduction Worksheet. A separate filer +# phases down the full applicable limitation amount (line 1, $40,000 in 2025) +# by 30% of modified AGI over half the threshold (line 5, $250,000), floors it +# at $10,000 (line 9), and then deducts half of it (line 10). + +- name: Separate filer halves the cap after the phase-down + period: 2025 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 300_000 + state_and_local_sales_or_income_tax: 0 + output: + # (40,000 - 30% x (300,000 - 250,000)) / 2 = (40,000 - 15,000) / 2. + salt_cap: 12_500 + salt_deduction: 12_500 + +- name: Separate filer above the old floor point but below the statutory one + period: 2025 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 340_000 + state_and_local_sales_or_income_tax: 0 + output: + # (40,000 - 30% x 90,000) / 2 = 6,500. The $10,000 floor (line 9) first + # binds at $350,000 modified AGI, so a separate filer's $5,000 does too. + salt_cap: 6_500 + salt_deduction: 6_500 + +- name: Separate filer 2026 cap above the floor + period: 2026 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 320_000 + state_and_local_sales_or_income_tax: 0 + output: + # (40,400 - 30% x (320,000 - 252,500)) / 2 = (40,400 - 20,250) / 2. + salt_cap: 10_075 + salt_deduction: 10_075 + +- name: Separate filer reaches the floor at the statutory kink + period: 2025 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 350_000 + state_and_local_sales_or_income_tax: 0 + output: + # max(40,000 - 30% x 100,000, 10,000) / 2 = 10,000 / 2. + salt_cap: 5_000 + salt_deduction: 5_000 + +- name: Separate filer above the kink keeps half the floor + period: 2025 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 400_000 + state_and_local_sales_or_income_tax: 0 + output: + # max(40,000 - 30% x 150,000, 10,000) / 2 = 10,000 / 2. + salt_cap: 5_000 + salt_deduction: 5_000 + +- name: Separate filer below the halved threshold keeps half the cap + period: 2025 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 250_000 + state_and_local_sales_or_income_tax: 0 + output: + salt_cap: 20_000 + salt_deduction: 20_000 + +- name: Joint filer phase-down is unchanged + period: 2025 + input: + people: + person1: + real_estate_taxes: 50_000 + person2: + real_estate_taxes: 0 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 550_000 + state_and_local_sales_or_income_tax: 0 + output: + # 40,000 - 30% x (550,000 - 500,000). + salt_cap: 25_000 + salt_deduction: 25_000 + +- name: Single filer phase-down is unchanged + period: 2026 + input: + people: + person1: + real_estate_taxes: 50_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 555_000 + state_and_local_sales_or_income_tax: 0 + output: + # 40,400 - 30% x (555,000 - 505,000). + salt_cap: 25_400 + salt_deduction: 25_400 + +- name: Head of household floor is unchanged + period: 2025 + input: + people: + person1: + real_estate_taxes: 50_000 + tax_units: + tax_unit: + members: [person1] + filing_status: HEAD_OF_HOUSEHOLD + adjusted_gross_income: 700_000 + state_and_local_sales_or_income_tax: 0 + output: + # max(40,000 - 30% x 200,000, 10,000). + salt_cap: 10_000 + salt_deduction: 10_000 + +- name: Separate filer after the phase-down expires + period: 2030 + input: + people: + person1: + real_estate_taxes: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + adjusted_gross_income: 300_000 + state_and_local_sales_or_income_tax: 0 + output: + # 164(b)(7)(A)(iv): $10,000 with no phase-down; half for a separate filer. + salt_cap: 5_000 + salt_deduction: 5_000 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py index 890bde74376..ded2e9ffb15 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py @@ -7,7 +7,12 @@ class salt_cap(Variable): label = "SALT cap" unit = USD definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/164" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/164#b_6", + "https://www.law.cornell.edu/uscode/text/26/164#b_7", + "https://www.congress.gov/119/plaws/publ21/PLAW-119publ21.pdf#page=99", + "https://www.irs.gov/pub/irs-prior/i1040sca--2025.pdf#page=7", + ) def formula(tax_unit, period, parameters): filing_status = tax_unit("filing_status", period) @@ -17,9 +22,20 @@ def formula(tax_unit, period, parameters): agi = tax_unit("adjusted_gross_income", period) agi_excess = max_(0, agi - p.phase_out.threshold[filing_status]) phase_out = p.phase_out.rate * agi_excess + # 26 USC 164(b)(6)(B) halves a separate filer's applicable + # limitation amount after 164(b)(7)(B) reduces and floors it; only + # the threshold is halved first (Schedule A SALT worksheet lines 5 + # and 10). The SEPARATE cap and floor already hold those halves, + # so the reduction is halved too. + separate = filing_status == filing_status.possible_values.SEPARATE + phase_out = where( + separate, p.phase_out.separate_fraction * phase_out, phase_out + ) phased_out_cap = max_(0, max_cap - phase_out) if p.phase_out.floor.applies: - floor = p.phase_out.floor.amount[filing_status] + # 164(b)(7)(B)(iii) bars the reduction from taking the cap + # below the floor; it never raises a cap set below the floor. + floor = min_(max_cap, p.phase_out.floor.amount[filing_status]) return max_(phased_out_cap, floor) return phased_out_cap return max_cap