From 47d58522b2760d210afc8ea8591f150750ca7c1d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:07:55 -0400 Subject: [PATCH 01/12] Add excluded foreign earned income to Massachusetts gross income M.G.L. c. 62 s. 2(a)(1)(C) lists "Earned income from foreign sources excluded under section nine hundred and eleven of the Code" among the items added to federal gross income. ma_gross_income subtracted the federal stacking amount instead, so a $10,000 exclusion left Massachusetts gross income $20,000 too low. The new TaxUnit input ma_foreign_earned_income_exclusion_addback is the section 911(a) amount actually excluded (Form 2555 line 43, before line 44 allocable deductions, without the line 50 housing deduction). It defaults to foreign_earned_income_exclusion, which is exact when Form 2555 lines 44 and 50 and Foreign Earned Income Tax Worksheet line 2b are zero; an entered amount, including zero, replaces the default. The restored wages reach Part B through its residual definition in s. 2(b)(2); Parts A and C are unchanged. Adds the hub spec's five YAML cases plus explicit-zero, non-MA and zero-floor controls, and property/differential tests against the old formula. Co-Authored-By: Claude Opus 5.5 --- .../ma-gross-income-feie-addback.fixed.md | 1 + .../gross_income/foreign_earned_income.yaml | 118 ++++ .../test_ma_foreign_earned_income_addback.py | 590 ++++++++++++++++++ ...foreign_earned_income_exclusion_addback.py | 36 ++ .../income/gross_income/ma_gross_income.py | 22 +- 5 files changed, 758 insertions(+), 9 deletions(-) create mode 100644 changelog.d/ma-gross-income-feie-addback.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml create mode 100644 policyengine_us/tests/test_ma_foreign_earned_income_addback.py create mode 100644 policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py diff --git a/changelog.d/ma-gross-income-feie-addback.fixed.md b/changelog.d/ma-gross-income-feie-addback.fixed.md new file mode 100644 index 00000000000..789ac184fc7 --- /dev/null +++ b/changelog.d/ma-gross-income-feie-addback.fixed.md @@ -0,0 +1 @@ +Add foreign earned income excluded under section 911 to Massachusetts gross income (M.G.L. c. 62 s. 2(a)(1)(C)) instead of subtracting it, through a new ma_foreign_earned_income_exclusion_addback input that defaults to foreign_earned_income_exclusion. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml new file mode 100644 index 00000000000..cf5644f3112 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml @@ -0,0 +1,118 @@ +# M.G.L. c. 62 s. 2(a)(1)(C) adds foreign earned income excluded under +# section 911 of the Code to Massachusetts gross income, and s. 2(b)(2) puts +# it in Part B. Other income inputs are the amounts left after the exclusion. + +- name: Excluded foreign wages enter Part B and preserve Parts A and C + period: 2025 + input: + state_code: MA + age: 35 + employment_income: 32_000 + foreign_earned_income_exclusion: 10_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + ma_part_b_taxable_income_deductions: 0 + ma_part_b_taxable_income_exemption: 4_400 + output: + # Federal: 32,000 + 6,000 + 3,000 + 4,000. + irs_gross_income: 45_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + # MA: 45,000 + 10,000. + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + ma_part_b_taxable_income: 37_600 + # (37,600 + 6,000 + 4,000) * 5% + 3,000 * 8.5%. + ma_income_tax_before_credits: 2_635 + +- name: Fully excluded foreign wages remain Massachusetts income + period: 2025 + input: + state_code: MA + irs_gross_income: 0 + foreign_earned_income_exclusion: 10_000 + output: + # 0 + 10,000. + ma_gross_income: 10_000 + ma_part_b_gross_income: 10_000 + +- name: Foreign earnings addition preserves loss adjustment + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + ma_gross_income_loss_adjustment: -15_000 + output: + # 50,000 - 15,000 + 10,000. + ma_gross_income: 45_000 + ma_part_b_gross_income: 45_000 + +- name: Foreign earnings addition preserves existing Massachusetts exclusions + period: 2025 + input: + state_code: MA + irs_gross_income: 60_000 + foreign_earned_income_exclusion: 20_000 + taxable_social_security: 7_500 + salt_refund_income: 2_000 + taxable_public_pension_income: 10_000 + output: + # 60,000 + 20,000 - 7,500 - 2,000 - 10,000. + ma_gross_income: 60_500 + ma_part_b_gross_income: 60_500 + +- name: Gross exclusion differs from net federal stacking + period: 2025 + input: + state_code: MA + irs_gross_income: 45_000 + foreign_earned_income_exclusion: 8_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + output: + # No housing deduction or allocable ALD; disallowed itemized expenses 2,000. + # Stacking = 10,000 - 2,000; MA gross = 45,000 + 10,000. + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + +- name: An entered zero addition replaces the federal stacking default + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 0 + output: + ma_foreign_earned_income_exclusion_addback: 0 + # 50,000 + 0. + ma_gross_income: 50_000 + ma_part_b_gross_income: 50_000 + +- name: The addition applies only in Massachusetts + period: 2025 + input: + state_code: NY + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + output: + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 0 + +- name: The addition counts against losses before the zero floor + period: 2025 + input: + state_code: MA + irs_gross_income: 10_000 + foreign_earned_income_exclusion: 25_000 + ma_gross_income_loss_adjustment: -30_000 + output: + # max(0, 10,000 - 30,000 + 25,000). + ma_gross_income: 5_000 + ma_part_b_gross_income: 5_000 diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py new file mode 100644 index 00000000000..274d68a6938 --- /dev/null +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -0,0 +1,590 @@ +"""Property and differential tests for the Massachusetts addition of foreign +earned income excluded under section 911. + +M.G.L. c. 62 s. 2(a)(1)(C) adds to federal gross income "Earned income from +foreign sources excluded under section nine hundred and eleven of the Code", +and s. 2(b)(2) makes Part B gross income the Massachusetts gross income "not +included in Part A or Part C gross income". The excluded amount is +`ma_foreign_earned_income_exclusion_addback` (Form 2555 line 43). It defaults +to `foreign_earned_income_exclusion` and can be entered directly. Before this +change, `ma_gross_income` subtracted the exclusion instead. + +Write B for federal gross income plus `ma_gross_income_loss_adjustment`, less +taxable Social Security, state and local tax refunds and exempt public +pensions. Properties that hold for every household: + +1. Massachusetts gross income is max(0, B + addback), where the addback is + the entered amount or, when none is entered, the exclusion. Against the + formula before this change, max(0, B - exclusion), gross income rises by + the addback plus the exclusion wherever neither zero floor binds. Outside + Massachusetts, or with no exclusion and no entered addback, gross income, + Part B and the taxes are bit-for-bit unchanged. Massachusetts tax before + credits never falls. +2. Raising the addback by D leaves federal gross income, AGI, the loss + adjustment and Parts A and C unchanged. It raises gross income by + max(0, B + addback + D) - max(0, B + addback) and Part B by the matching + change past its own floor, so by exactly D where neither floor binds. The + same holds when the exclusion rises and no addback is entered. +3. Gross income is never negative, is zero while B + addback is at most + zero, and never falls as the addback rises. +4. An entered addback, including zero, replaces the default. An entered zero + beside a positive exclusion gives the Massachusetts gross income results + of a household with no exclusion, and an entered amount gives the same + results whatever the exclusion. +""" + +from functools import lru_cache + +import numpy as np +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEARS = [2025, 2026] +DELTA = 7_500 + +GROSS = [ + "ma_foreign_earned_income_exclusion_addback", + "ma_gross_income", + "ma_part_a_gross_income", + "ma_part_b_gross_income", + "ma_part_c_gross_income", +] +TAX_UNIT_COMPONENTS = ["adjusted_gross_income", "ma_gross_income_loss_adjustment"] +PERSON_COMPONENTS = [ + "irs_gross_income", + "taxable_social_security", + "salt_refund_income", + "taxable_public_pension_income", +] +TAXES = [ + "ma_income_tax_before_credits", + "ma_income_tax", + "state_income_tax", + "income_tax", +] +# Federal and Part A and C amounts the addback must not move. +FIXED_BY_ADDBACK = [ + "irs_gross_income", + "adjusted_gross_income", + "ma_gross_income_loss_adjustment", + "ma_part_a_gross_income", + "ma_part_c_gross_income", +] + + +def household(state, status, wages, exclusion, **amounts): + return { + "state": state, + "status": status, + "wages": wages, + "spouse_wages": amounts.get("spouse_wages", 0), + "dividends": amounts.get("dividends", 0), + "short_term_gains": amounts.get("short_term_gains", 0), + "long_term_gains": amounts.get("long_term_gains", 0), + "self_employment": amounts.get("self_employment", 0), + "public_pension": amounts.get("public_pension", 0), + "salt_refund": amounts.get("salt_refund", 0), + "exclusion": exclusion, + "entered": amounts.get("entered"), + } + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + person_tax_unit = [] + 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"]}, + "short_term_capital_gains": {year: h["short_term_gains"]}, + "long_term_capital_gains": {year: h["long_term_gains"]}, + "self_employment_income": {year: h["self_employment"]}, + "taxable_public_pension_income": {year: h["public_pension"]}, + "salt_refund_income": {year: h["salt_refund"]}, + } + members = [head] + person_tax_unit.append(i) + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = { + "age": {year: 45}, + "employment_income": {year: h["spouse_wages"]}, + } + members.append(spouse) + person_tax_unit.append(i) + marital_units[f"marital_unit_{i}"] = {"members": list(members)} + tax_unit = { + "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"]}, + "foreign_earned_income_exclusion": {year: h["exclusion"]}, + } + if h["entered"] is not None: + tax_unit["ma_foreign_earned_income_exclusion_addback"] = { + year: h["entered"] + } + tax_units[f"tax_unit_{i}"] = tax_unit + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: h["state"]}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + situation = { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + return situation, np.array(person_tax_unit) + + +def before_the_addition(): + """A reform restoring `ma_gross_income` as it was before this change.""" + from policyengine_core.reforms import Reform + + from policyengine_us.model_api import ( + USD, + YEAR, + StateCode, + TaxUnit, + Variable, + add, + max_, + ) + + class ma_gross_income(Variable): + value_type = float + entity = TaxUnit + label = "MA gross income before the section 911 addition" + unit = USD + definition_period = YEAR + defined_for = StateCode.MA + + def formula(tax_unit, period, parameters): + federal_gross_income = add(tax_unit, period, ["irs_gross_income"]) + loss_adjustment = tax_unit("ma_gross_income_loss_adjustment", period) + foreign_earned_income = tax_unit("foreign_earned_income_exclusion", period) + social_security_in_agi = add(tax_unit, period, ["taxable_social_security"]) + salt_refund_income = add(tax_unit, period, ["salt_refund_income"]) + public_pension = add(tax_unit, period, ["taxable_public_pension_income"]) + deductions = ( + foreign_earned_income + + social_security_in_agi + + salt_refund_income + + public_pension + ) + return max_(0, federal_gross_income + loss_adjustment - deductions) + + class reform(Reform): + def apply(self): + self.update_variable(ma_gross_income) + + return reform + + +@lru_cache(maxsize=None) +def system_before_the_addition(): + """Built once: constructing a reformed system takes seconds.""" + return CountryTaxBenefitSystem(reform=before_the_addition()) + + +def calculate_batch(households, year, before, taxes): + situation, person_tax_unit = build_situation(households, year) + simulation = Simulation( + situation=situation, + **({"tax_benefit_system": system_before_the_addition()} if before else {}), + ) + results = {} + for v in GROSS + TAX_UNIT_COMPONENTS + (TAXES if taxes else []): + results[v] = np.asarray(simulation.calculate(v, year), dtype=float) + for v in PERSON_COMPONENTS: + values = np.asarray(simulation.calculate(v, year), dtype=float) + results[v] = np.bincount( + person_tax_unit, weights=values, minlength=len(households) + ) + return results + + +def freeze(households): + return tuple(tuple(sorted(h.items())) for h in households) + + +@lru_cache(maxsize=None) +def _calculate(frozen, year, before, taxes): + households = [dict(h) for h in frozen] + # An input given to only some tax units in a batch sets the rest to the + # default (zero), not to the value the formula would compute, so + # households with and without an entered addback run as separate batches. + entered = np.array([h["entered"] is not None for h in households]) + results = {} + for group in [entered, ~entered]: + if not group.any(): + continue + batch = calculate_batch( + [h for h, g in zip(households, group) if g], year, before, taxes + ) + for v, values in batch.items(): + results.setdefault(v, np.empty(len(households))) + results[v][group] = values + return results + + +def calculate(households, year, before=False, taxes=False): + return _calculate(freeze(households), year, before, taxes) + + +def calculate_variants(variants, year): + """Several versions of the same households, run as one batch. + + Households are independent, so stacking the versions in one simulation + gives each the results it would get alone, with fewer simulations. + """ + results = calculate([h for variant in variants for h in variant], year) + sizes = np.cumsum([0] + [len(variant) for variant in variants]) + return [ + {v: values[start:end] for v, values in results.items()} + for start, end in zip(sizes[:-1], sizes[1:]) + ] + + +def tolerance(*amounts): + """One cent, plus the rounding of single-precision arithmetic. + + The model stores amounts as 32-bit floats, which carry about seven + significant digits. + """ + return 0.01 + 5e-7 * np.max(np.abs(np.array(amounts, dtype=float)), axis=0) + + +def in_massachusetts(households): + return np.array([h["state"] == "MA" for h in households]) + + +def expected_addback(households): + """The entered addback, or else the exclusion (Massachusetts only).""" + return np.array( + [ + (h["exclusion"] if h["entered"] is None else h["entered"]) + if h["state"] == "MA" + else 0 + for h in households + ], + dtype=float, + ) + + +def exclusions(households): + return np.array([h["exclusion"] for h in households], dtype=float) + + +def base_income(results): + """B: federal gross income and the loss adjustment, less the exclusions.""" + return ( + results["irs_gross_income"] + + results["ma_gross_income_loss_adjustment"] + - results["taxable_social_security"] + - results["salt_refund_income"] + - results["taxable_public_pension_income"] + ) + + +def with_entered(households, change): + """The same households with the entered addback set from each one.""" + return [{**h, "entered": change(h)} for h in households] + + +def default_addback(h): + return h["exclusion"] if h["entered"] is None else h["entered"] + + +def check_gross_income_adds_back_foreign_earnings(households, year): + law = calculate(households, year, taxes=True) + before = calculate(households, year, before=True, taxes=True) + ma = in_massachusetts(households) + addback = expected_addback(households) + exclusion = exclusions(households) + base = base_income(law) + + # The default is the exclusion; an entered amount replaces it. + slack = tolerance(addback, law["ma_foreign_earned_income_exclusion_addback"]) + assert ( + np.abs(law["ma_foreign_earned_income_exclusion_addback"][ma] - addback[ma]) + <= slack[ma] + ).all() + + # Gross income is max(0, B + addback) in Massachusetts and zero elsewhere. + gross = np.where(ma, np.maximum(0, base + addback), 0) + slack = tolerance(base, addback, gross, law["ma_gross_income"]) + assert (np.abs(law["ma_gross_income"] - gross) <= slack).all() + + # Differential: the formula before this change subtracted the exclusion. + gross_before = np.where(ma, np.maximum(0, base - exclusion), 0) + assert (np.abs(before["ma_gross_income"] - gross_before) <= slack).all() + unfloored = ma & (base - exclusion >= 0) & (base + addback >= 0) + change = law["ma_gross_income"] - before["ma_gross_income"] + assert ( + np.abs(change - (addback + exclusion))[unfloored] <= 2 * slack[unfloored] + ).all() + assert (law["ma_gross_income"] >= before["ma_gross_income"]).all() + + # Massachusetts tax before credits never falls when gross income rises. + assert ( + law["ma_income_tax_before_credits"] + >= before["ma_income_tax_before_credits"] + - tolerance(law["ma_income_tax_before_credits"]) + ).all() + + # Outside Massachusetts, or with nothing excluded or entered, nothing + # changes, bit for bit. + untouched = ~ma | ((exclusion == 0) & (addback == 0)) + for v in ["ma_gross_income", "ma_part_b_gross_income"] + TAXES: + assert np.array_equal(law[v][untouched], before[v][untouched]), v + assert not law["ma_gross_income"][~ma].any() + return law, before, unfloored, untouched + + +def check_foreign_earnings_enter_part_b_only(households, year): + ma = in_massachusetts(households) + for low_households, high_households in [ + # Raise an entered addback; the exclusion stays put. + ( + with_entered(households, default_addback), + with_entered(households, lambda h: default_addback(h) + DELTA), + ), + # Raise the exclusion with nothing entered. + ( + [{**h, "entered": None} for h in households], + [ + {**h, "exclusion": h["exclusion"] + DELTA, "entered": None} + for h in households + ], + ), + ]: + low, high = calculate_variants([low_households, high_households], year) + for v in FIXED_BY_ADDBACK: + assert np.array_equal(low[v], high[v]), v + base = base_income(low) + addback = expected_addback(low_households) + gross_change = np.where( + ma, + np.maximum(0, base + addback + DELTA) - np.maximum(0, base + addback), + 0, + ) + slack = tolerance(base, addback, high["ma_gross_income"]) + assert ( + np.abs(high["ma_gross_income"] - low["ma_gross_income"] - gross_change) + <= 2 * slack + ).all() + # Part B is the Massachusetts gross income not in Part A or Part C. + parts_a_and_c = low["ma_part_a_gross_income"] + low["ma_part_c_gross_income"] + residual_low = low["ma_gross_income"] - parts_a_and_c + residual_high = high["ma_gross_income"] - parts_a_and_c + part_b_change = np.maximum(0, residual_high) - np.maximum(0, residual_low) + assert ( + np.abs( + high["ma_part_b_gross_income"] + - low["ma_part_b_gross_income"] + - part_b_change + ) + <= 2 * slack + ).all() + # Where neither floor binds, both rise by exactly the change. + outside = ma & (base + addback >= 0) & (residual_low >= 0) + for v in ["ma_gross_income", "ma_part_b_gross_income"]: + rise = high[v] - low[v] + assert (np.abs(rise - DELTA)[outside] <= 2 * slack[outside]).all(), v + assert not (high["ma_gross_income"] - low["ma_gross_income"])[~ma].any() + return outside + + +def check_addition_respects_zero_floor(households, year): + ma = in_massachusetts(households) + law = calculate(households, year, taxes=True) + base = base_income(law) + addback = expected_addback(households) + assert (law["ma_gross_income"] >= 0).all() + assert (law["ma_part_b_gross_income"] >= 0).all() + floored = ma & (base + addback <= 0) + assert not law["ma_gross_income"][floored].any() + # Gross income never falls as the addback rises. Rounding is monotone, + # so this holds exactly. + steps = [(0, 0), (0.5, 0), (1, 0), (1, 10_000), (2, 50_000)] + scaled = calculate_variants( + [ + with_entered(households, lambda h: scale * default_addback(h) + shift) + for scale, shift in steps + ], + year, + ) + for lower, higher, step in zip(scaled, scaled[1:], steps[1:]): + assert (higher["ma_gross_income"] >= lower["ma_gross_income"]).all(), step + return floored, ma & (base < 0) & (base + addback > 0) + + +def check_entered_addback_overrides_default(households, year): + ma = in_massachusetts(households) + zero_entered, entered, entered_without_exclusion = calculate_variants( + [ + with_entered(households, lambda h: 0), + with_entered(households, lambda h: 12_345), + [{**h, "exclusion": 0, "entered": 12_345} for h in households], + ], + year, + ) + default, no_exclusion = calculate_variants( + [ + [{**h, "entered": None} for h in households], + [{**h, "exclusion": 0, "entered": None} for h in households], + ], + year, + ) + # An entered zero beside a positive exclusion counts nothing, as with no + # exclusion at all. + assert not zero_entered["ma_foreign_earned_income_exclusion_addback"].any() + for v in GROSS[1:]: + assert np.array_equal(zero_entered[v], no_exclusion[v]), v + # An entered amount does not depend on the exclusion. + for v in GROSS + TAX_UNIT_COMPONENTS: + assert np.array_equal(entered[v], entered_without_exclusion[v]), v + assert (entered["ma_foreign_earned_income_exclusion_addback"][ma] == 12_345).all() + # The default follows the exclusion. + assert np.array_equal( + default["ma_foreign_earned_income_exclusion_addback"], + np.where(ma, exclusions(households), 0), + ) + + +GRID = [ + household( + state, + status, + wages, + exclusion, + spouse_wages=40_000 if status == "JOINT" else 0, + dividends=dividends, + short_term_gains=short_term_gains, + long_term_gains=long_term_gains, + self_employment=self_employment, + public_pension=public_pension, + salt_refund=salt_refund, + entered=entered, + ) + for state in ("MA", "NY") + for status in ("SINGLE", "JOINT") + for wages in (0, 60_000) + for dividends, short_term_gains, long_term_gains in ( + (0, 0, 0), + (6_000, 3_000, 4_000), + (0, 5_000, -2_000), + ) + for self_employment in (0, -150_000) + for public_pension, salt_refund in ((0, 0), (10_000, 2_000)) + for exclusion in (0, 10_000, 130_000) + for entered in (None, 0, 25_000) +] + + +@pytest.mark.parametrize("year", YEARS) +def test_ma_gross_income_adds_back_foreign_earnings(year): + law, before, unfloored, untouched = check_gross_income_adds_back_foreign_earnings( + GRID, year + ) + # The grid reaches households the addition changes and ones it does not. + assert (unfloored & (expected_addback(GRID) > 0)).any() + assert ( + law["ma_income_tax_before_credits"] > before["ma_income_tax_before_credits"] + ).any() + assert untouched.any() and (~untouched).any() + + +@pytest.mark.parametrize("year", YEARS) +def test_ma_foreign_earnings_enter_part_b_only(year): + outside = check_foreign_earnings_enter_part_b_only(GRID, year) + assert outside.any() + + +@pytest.mark.parametrize("year", YEARS) +def test_ma_foreign_earnings_addition_respects_zero_floor(year): + floored, lifted = check_addition_respects_zero_floor(GRID, year) + # The grid reaches households held at the floor and ones the addition + # lifts off it. + assert floored.any() and lifted.any() + + +@pytest.mark.parametrize("year", YEARS) +def test_explicit_zero_addback_overrides_fallback(year): + check_entered_addback_overrides_default(GRID, year) + + +def test_review_example(): + # 2025, single, $32,000 of wages, $6,000 of dividends, $3,000 of + # short-term and $4,000 of long-term gains, and a $10,000 exclusion. + # Federal gross income is $45,000; Massachusetts gross income is $55,000, + # with $9,000 in Part A, $4,000 in Part C and $42,000 in Part B. Before + # this change it was $35,000, with $22,000 in Part B. + example = household( + "MA", + "SINGLE", + 32_000, + 10_000, + dividends=6_000, + short_term_gains=3_000, + long_term_gains=4_000, + ) + law, before, _, _ = check_gross_income_adds_back_foreign_earnings([example], 2025) + assert law["irs_gross_income"][0] == 45_000 + assert law["ma_gross_income"][0] == 55_000 + assert law["ma_part_a_gross_income"][0] == 9_000 + assert law["ma_part_c_gross_income"][0] == 4_000 + assert law["ma_part_b_gross_income"][0] == 42_000 + assert before["ma_gross_income"][0] == 35_000 + assert before["ma_part_b_gross_income"][0] == 22_000 + + +household_strategy = st.fixed_dictionaries( + { + "state": st.sampled_from(["MA", "MA", "MA", "NY"]), + "status": st.sampled_from(["SINGLE", "JOINT"]), + "wages": st.integers(0, 400_000), + "spouse_wages": st.one_of(st.just(0), st.integers(1, 200_000)), + "dividends": st.one_of(st.just(0), st.integers(1, 100_000)), + "short_term_gains": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "long_term_gains": st.one_of(st.just(0), st.integers(-30_000, 300_000)), + "self_employment": st.one_of(st.just(0), st.integers(-300_000, 150_000)), + "public_pension": st.one_of(st.just(0), st.integers(1, 80_000)), + "salt_refund": st.one_of(st.just(0), st.integers(1, 10_000)), + "exclusion": st.one_of(st.just(0), st.integers(1, 300_000)), + "entered": st.one_of(st.none(), st.just(0), st.integers(1, 300_000)), + } +) + +SETTINGS = dict( + max_examples=5, + deadline=None, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(household_strategy, min_size=1, max_size=25), + st.sampled_from(YEARS), +) +def test_random_households_keep_the_invariants(households, year): + check_gross_income_adds_back_foreign_earnings(households, year) + check_foreign_earnings_enter_part_b_only(households, year) + check_addition_respects_zero_floor(households, year) + check_entered_addback_overrides_default(households, year) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py new file mode 100644 index 00000000000..811d2560236 --- /dev/null +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -0,0 +1,36 @@ +from policyengine_us.model_api import * + + +class ma_foreign_earned_income_exclusion_addback(Variable): + value_type = float + entity = TaxUnit + label = "MA addition of foreign earned income excluded under section 911" + unit = USD + documentation = ( + "Foreign earned income and housing amounts excluded from federal gross " + "income under 26 U.S.C. 911(a), which Massachusetts adds back to gross " + "income: Form 2555 line 43 (the housing exclusion on line 36 plus the " + "foreign earned income exclusion on line 42, both spouses' forms on a " + "joint return), before the line 44 deductions allocable to the " + "excluded income. It leaves out the section 911(c)(4) housing " + "deduction (line 50), which is not an exclusion, and the itemized " + "deductions netted out of the federal 911(f) stacking amount. Other " + "income inputs are the amounts left after the exclusion. When not " + "provided, it equals foreign_earned_income_exclusion, the 911(f) " + "stacking amount (Foreign Earned Income Tax Worksheet line 2c), which " + "is exact only when Form 2555 lines 44 and 50 and worksheet line 2b " + "are zero. An entered amount, including zero, replaces that default." + ) + definition_period = YEAR + reference = ( + # c.62 s.2(a)(1)(C): "Earned income from foreign sources excluded + # under section nine hundred and eleven of the Code" is added to + # federal gross income. + "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section2", + # Form 2555 Part VIII, line 43: lines 36 and 42, before line 44. + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + # Foreign Earned Income Tax Worksheet lines 2a to 2c. + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", + ) + defined_for = StateCode.MA + adds = ["foreign_earned_income_exclusion"] diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py index 6d3440a1de1..e817ad38b85 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py @@ -17,20 +17,24 @@ def formula(tax_unit, period, parameters): federal_gross_income = add(tax_unit, period, ["irs_gross_income"]) # Add back lines 6/7 losses dropped by irs_gross_income. loss_adjustment = tax_unit("ma_gross_income_loss_adjustment", period) - # Exclude foreign earned income, Social Security, state/local tax refunds, - # and contributory public pensions. + # Under M.G.L. c. 62 § 2(a)(1)(C), earned income from foreign sources + # excluded under section 911 of the Code is added to MA gross income. + # It is neither interest, dividends nor capital gains, so the Part B + # residual taxes it under § 2(b)(2). + foreign_earned_income = tax_unit( + "ma_foreign_earned_income_exclusion_addback", period + ) + # Exclude Social Security, state/local tax refunds, and contributory + # public pensions. # Under M.G.L. c. 62 § 2(a)(2)(E), contributory pensions from the US, # Massachusetts, or reciprocal states are excluded from MA gross income. # Noncontributory or non-reciprocal public pensions are not exempt. # PolicyEngine treats taxable_public_pension_income as exempt public pensions. - foreign_earned_income = tax_unit("foreign_earned_income_exclusion", period) social_security_in_agi = add(tax_unit, period, ["taxable_social_security"]) salt_refund_income = add(tax_unit, period, ["salt_refund_income"]) public_pension = add(tax_unit, period, ["taxable_public_pension_income"]) - deductions = ( - foreign_earned_income - + social_security_in_agi - + salt_refund_income - + public_pension + deductions = social_security_in_agi + salt_refund_income + public_pension + return max_( + 0, + federal_gross_income + loss_adjustment + foreign_earned_income - deductions, ) - return max_(0, federal_gross_income + loss_adjustment - deductions) From 518c334ff2ac8a92bfea69fd9e00fa7efa7501d6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 19:40:58 -0400 Subject: [PATCH 02/12] Check that the MA foreign earnings addition offsets losses before the floor The zero-floor property only checked that gross income is non-negative, zero at the floor and monotone in the addback. The formula before this change satisfies all three, so the test passed on main. It now also checks that where federal gross income less the Massachusetts exclusions is negative but the addback lifts it above zero, gross income equals their sum, which main gets wrong (it returns zero). Co-Authored-By: Claude Opus 5.5 --- .../tests/test_ma_foreign_earned_income_addback.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py index 274d68a6938..aa75a18e245 100644 --- a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -26,7 +26,8 @@ change past its own floor, so by exactly D where neither floor binds. The same holds when the exclusion rises and no addback is entered. 3. Gross income is never negative, is zero while B + addback is at most - zero, and never falls as the addback rises. + zero, equals B + addback where the addback lifts a negative B above zero, + and never falls as the addback rises. 4. An entered addback, including zero, replaces the default. An entered zero beside a positive exclusion gives the Massachusetts gross income results of a household with no exclusion, and an entered amount gives the same @@ -416,6 +417,13 @@ def check_addition_respects_zero_floor(households, year): assert (law["ma_part_b_gross_income"] >= 0).all() floored = ma & (base + addback <= 0) assert not law["ma_gross_income"][floored].any() + # The addition offsets losses before the floor applies: where B is + # negative but B + addback is not, gross income is B + addback. + lifted = ma & (base < 0) & (base + addback > 0) + slack = tolerance(base, addback, law["ma_gross_income"]) + assert ( + np.abs(law["ma_gross_income"] - (base + addback))[lifted] <= slack[lifted] + ).all() # Gross income never falls as the addback rises. Rounding is monotone, # so this holds exactly. steps = [(0, 0), (0.5, 0), (1, 0), (1, 10_000), (2, 50_000)] @@ -428,7 +436,7 @@ def check_addition_respects_zero_floor(households, year): ) for lower, higher, step in zip(scaled, scaled[1:], steps[1:]): assert (higher["ma_gross_income"] >= lower["ma_gross_income"]).all(), step - return floored, ma & (base < 0) & (base + addback > 0) + return floored, lifted def check_entered_addback_overrides_default(households, year): From 3f50de6f214d04d5ad083caac248feaacef400dd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 20:59:21 -0400 Subject: [PATCH 03/12] Floor the MA foreign earnings default at zero and document its input convention Review follow-ups: - The addback defaulted to foreign_earned_income_exclusion through `adds`, which passed a negative entry straight through. Every federal reader of that input floors it at zero (worksheet line 2c: "If zero or less, enter -0-"), so the default is now max(0, foreign_earned_income_exclusion). A YAML case covers a -5,000 entry. - The documentation now states that other income inputs must already exclude the line 43 amount, says the default equals line 43 when line 50 equals line 44 plus line 2b (not "only when" all are zero), keeps line 2b's "or exclusions", and cites the 2025 Form 1 instructions (line 3). - The property tests release the reformed system and cached results when the module ends, as #9643 does, and the Hypothesis test is derandomized. Co-Authored-By: Claude Opus 5.5 --- .../ma-gross-income-feie-addback.fixed.md | 2 +- .../gross_income/foreign_earned_income.yaml | 15 +++++++- .../test_ma_foreign_earned_income_addback.py | 35 ++++++++++++++----- ...foreign_earned_income_exclusion_addback.py | 33 ++++++++++------- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/changelog.d/ma-gross-income-feie-addback.fixed.md b/changelog.d/ma-gross-income-feie-addback.fixed.md index 789ac184fc7..6a576814acf 100644 --- a/changelog.d/ma-gross-income-feie-addback.fixed.md +++ b/changelog.d/ma-gross-income-feie-addback.fixed.md @@ -1 +1 @@ -Add foreign earned income excluded under section 911 to Massachusetts gross income (M.G.L. c. 62 s. 2(a)(1)(C)) instead of subtracting it, through a new ma_foreign_earned_income_exclusion_addback input that defaults to foreign_earned_income_exclusion. +Add foreign earned income excluded under section 911 to Massachusetts gross income (M.G.L. c. 62 s. 2(a)(1)(C)) instead of subtracting it, through a new ma_foreign_earned_income_exclusion_addback input that defaults to the federal foreign_earned_income_exclusion stacking amount. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml index cf5644f3112..0bd26fd6a82 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml @@ -1,6 +1,6 @@ # M.G.L. c. 62 s. 2(a)(1)(C) adds foreign earned income excluded under # section 911 of the Code to Massachusetts gross income, and s. 2(b)(2) puts -# it in Part B. Other income inputs are the amounts left after the exclusion. +# it in Part B. Other income inputs must already exclude the excluded amount. - name: Excluded foreign wages enter Part B and preserve Parts A and C period: 2025 @@ -116,3 +116,16 @@ # max(0, 10,000 - 30,000 + 25,000). ma_gross_income: 5_000 ma_part_b_gross_income: 5_000 + +- name: A negative federal stacking amount adds nothing + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: -5_000 + output: + # Worksheet line 2c is never below zero: max(0, -5,000). + ma_foreign_earned_income_exclusion_addback: 0 + # 50,000 + 0. + ma_gross_income: 50_000 + ma_part_b_gross_income: 50_000 diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py index aa75a18e245..963af2a31cb 100644 --- a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -6,7 +6,8 @@ and s. 2(b)(2) makes Part B gross income the Massachusetts gross income "not included in Part A or Part C gross income". The excluded amount is `ma_foreign_earned_income_exclusion_addback` (Form 2555 line 43). It defaults -to `foreign_earned_income_exclusion` and can be entered directly. Before this +to `foreign_earned_income_exclusion` floored at zero and can be entered +directly. Before this change, `ma_gross_income` subtracted the exclusion instead. Write B for federal gross income plus `ma_gross_income_loss_adjustment`, less @@ -20,11 +21,13 @@ Massachusetts, or with no exclusion and no entered addback, gross income, Part B and the taxes are bit-for-bit unchanged. Massachusetts tax before credits never falls. -2. Raising the addback by D leaves federal gross income, AGI, the loss - adjustment and Parts A and C unchanged. It raises gross income by - max(0, B + addback + D) - max(0, B + addback) and Part B by the matching - change past its own floor, so by exactly D where neither floor binds. The - same holds when the exclusion rises and no addback is entered. +2. Raising the addback by D leaves federal gross income, the loss adjustment + and Parts A and C unchanged, and leaves AGI unchanged for these + households, none of which has an AGI item that reads the exclusion. It + raises gross income by max(0, B + addback + D) - max(0, B + addback) and + Part B by the matching change past its own floor, so by exactly D where + neither floor binds. The same holds when the exclusion rises and no + addback is entered. 3. Gross income is never negative, is zero while B + addback is at most zero, equals B + addback where the addback lifts a negative B above zero, and never falls as the addback rises. @@ -34,6 +37,7 @@ results whatever the exclusion. """ +import gc from functools import lru_cache import numpy as np @@ -199,6 +203,17 @@ def system_before_the_addition(): return CountryTaxBenefitSystem(reform=before_the_addition()) +@pytest.fixture(scope="module", autouse=True) +def release_cached_systems(): + """Release the reformed system and cached results when the module ends, + so the rest of the pytest process does not keep a second full system + resident.""" + yield + system_before_the_addition.cache_clear() + _calculate.cache_clear() + gc.collect() + + def calculate_batch(households, year, before, taxes): situation, person_tax_unit = build_situation(households, year) simulation = Simulation( @@ -272,10 +287,11 @@ def in_massachusetts(households): def expected_addback(households): - """The entered addback, or else the exclusion (Massachusetts only).""" + """The entered addback, or else the exclusion floored at zero + (Massachusetts only).""" return np.array( [ - (h["exclusion"] if h["entered"] is None else h["entered"]) + (max(0, h["exclusion"]) if h["entered"] is None else h["entered"]) if h["state"] == "MA" else 0 for h in households @@ -468,7 +484,7 @@ def check_entered_addback_overrides_default(households, year): # The default follows the exclusion. assert np.array_equal( default["ma_foreign_earned_income_exclusion_addback"], - np.where(ma, exclusions(households), 0), + np.where(ma, np.maximum(0, exclusions(households)), 0), ) @@ -579,6 +595,7 @@ def test_review_example(): SETTINGS = dict( max_examples=5, deadline=None, + derandomize=True, suppress_health_check=[ hypothesis.HealthCheck.too_slow, hypothesis.HealthCheck.data_too_large, diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py index 811d2560236..e9fb76b6843 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -8,18 +8,19 @@ class ma_foreign_earned_income_exclusion_addback(Variable): unit = USD documentation = ( "Foreign earned income and housing amounts excluded from federal gross " - "income under 26 U.S.C. 911(a), which Massachusetts adds back to gross " - "income: Form 2555 line 43 (the housing exclusion on line 36 plus the " - "foreign earned income exclusion on line 42, both spouses' forms on a " - "joint return), before the line 44 deductions allocable to the " - "excluded income. It leaves out the section 911(c)(4) housing " - "deduction (line 50), which is not an exclusion, and the itemized " - "deductions netted out of the federal 911(f) stacking amount. Other " - "income inputs are the amounts left after the exclusion. When not " - "provided, it equals foreign_earned_income_exclusion, the 911(f) " - "stacking amount (Foreign Earned Income Tax Worksheet line 2c), which " - "is exact only when Form 2555 lines 44 and 50 and worksheet line 2b " - "are zero. An entered amount, including zero, replaces that default." + "income under 26 U.S.C. 911(a), which Massachusetts adds to gross " + "income: Form 2555 line 43 (the line 36 housing exclusion plus the " + "line 42 foreign earned income exclusion, from both spouses' forms on a " + "joint return). It is not reduced by the line 44 deductions allocable " + "to the excluded income or by Foreign Earned Income Tax Worksheet line " + "2b, and it does not include the line 50 housing deduction, which is a " + "deduction rather than an exclusion. Other income inputs must already " + "exclude this amount. When not provided, it equals " + "foreign_earned_income_exclusion, the 911(f) stacking amount on " + "worksheet line 2c, floored at zero. That default equals line 43 when " + "Form 2555 lines 44 and 50 and worksheet line 2b are zero, or more " + "generally when line 50 equals line 44 plus line 2b. An entered " + "amount, including zero, replaces the default." ) definition_period = YEAR reference = ( @@ -27,10 +28,16 @@ class ma_foreign_earned_income_exclusion_addback(Variable): # under section nine hundred and eleven of the Code" is added to # federal gross income. "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section2", + # Form 1 line 3: compensation excluded under IRC s.911 must be + # included in line 3 for Massachusetts tax purposes. + "https://www.mass.gov/doc/2025-form-1-instructions/download#page=10", # Form 2555 Part VIII, line 43: lines 36 and 42, before line 44. "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", # Foreign Earned Income Tax Worksheet lines 2a to 2c. "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", ) defined_for = StateCode.MA - adds = ["foreign_earned_income_exclusion"] + + def formula(tax_unit, period, parameters): + # Worksheet line 2c is never below zero. + return max_(0, tax_unit("foreign_earned_income_exclusion", period)) From fa609c64bb12ae0858240d47ecd37624743917b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 06:07:02 -0400 Subject: [PATCH 04/12] Use the specified Massachusetts foreign earnings fallback --- .../income/gross_income/foreign_earned_income.yaml | 13 ------------- .../tests/test_ma_foreign_earned_income_addback.py | 9 ++++----- .../ma_foreign_earned_income_exclusion_addback.py | 7 ++----- 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml index 0bd26fd6a82..7e3db2d551c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml @@ -116,16 +116,3 @@ # max(0, 10,000 - 30,000 + 25,000). ma_gross_income: 5_000 ma_part_b_gross_income: 5_000 - -- name: A negative federal stacking amount adds nothing - period: 2025 - input: - state_code: MA - irs_gross_income: 50_000 - foreign_earned_income_exclusion: -5_000 - output: - # Worksheet line 2c is never below zero: max(0, -5,000). - ma_foreign_earned_income_exclusion_addback: 0 - # 50,000 + 0. - ma_gross_income: 50_000 - ma_part_b_gross_income: 50_000 diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py index 963af2a31cb..c1074819556 100644 --- a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -6,7 +6,7 @@ and s. 2(b)(2) makes Part B gross income the Massachusetts gross income "not included in Part A or Part C gross income". The excluded amount is `ma_foreign_earned_income_exclusion_addback` (Form 2555 line 43). It defaults -to `foreign_earned_income_exclusion` floored at zero and can be entered +to `foreign_earned_income_exclusion` and can be entered directly. Before this change, `ma_gross_income` subtracted the exclusion instead. @@ -287,11 +287,10 @@ def in_massachusetts(households): def expected_addback(households): - """The entered addback, or else the exclusion floored at zero - (Massachusetts only).""" + """The entered addback, or else the exclusion (Massachusetts only).""" return np.array( [ - (max(0, h["exclusion"]) if h["entered"] is None else h["entered"]) + (h["exclusion"] if h["entered"] is None else h["entered"]) if h["state"] == "MA" else 0 for h in households @@ -484,7 +483,7 @@ def check_entered_addback_overrides_default(households, year): # The default follows the exclusion. assert np.array_equal( default["ma_foreign_earned_income_exclusion_addback"], - np.where(ma, np.maximum(0, exclusions(households)), 0), + np.where(ma, exclusions(households), 0), ) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py index e9fb76b6843..df2706c22e6 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -17,7 +17,7 @@ class ma_foreign_earned_income_exclusion_addback(Variable): "deduction rather than an exclusion. Other income inputs must already " "exclude this amount. When not provided, it equals " "foreign_earned_income_exclusion, the 911(f) stacking amount on " - "worksheet line 2c, floored at zero. That default equals line 43 when " + "worksheet line 2c. That default equals line 43 when " "Form 2555 lines 44 and 50 and worksheet line 2b are zero, or more " "generally when line 50 equals line 44 plus line 2b. An entered " "amount, including zero, replaces the default." @@ -37,7 +37,4 @@ class ma_foreign_earned_income_exclusion_addback(Variable): "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", ) defined_for = StateCode.MA - - def formula(tax_unit, period, parameters): - # Worksheet line 2c is never below zero. - return max_(0, tax_unit("foreign_earned_income_exclusion", period)) + adds = ["foreign_earned_income_exclusion"] From b859c5b5ec724c5b7e84720722aeae28c114b949 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 07:52:27 -0400 Subject: [PATCH 05/12] Floor Massachusetts foreign earnings addback at zero --- .../ma-gross-income-feie-addback.fixed.md | 2 +- .../gross_income/foreign_earned_income.yaml | 11 +++++++++++ .../test_ma_foreign_earned_income_addback.py | 19 +++++++++---------- ...foreign_earned_income_exclusion_addback.py | 6 ++++-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/changelog.d/ma-gross-income-feie-addback.fixed.md b/changelog.d/ma-gross-income-feie-addback.fixed.md index 6a576814acf..34909e2440d 100644 --- a/changelog.d/ma-gross-income-feie-addback.fixed.md +++ b/changelog.d/ma-gross-income-feie-addback.fixed.md @@ -1 +1 @@ -Add foreign earned income excluded under section 911 to Massachusetts gross income (M.G.L. c. 62 s. 2(a)(1)(C)) instead of subtracting it, through a new ma_foreign_earned_income_exclusion_addback input that defaults to the federal foreign_earned_income_exclusion stacking amount. +Add foreign earned income excluded under section 911 to Massachusetts gross income (M.G.L. c. 62 s. 2(a)(1)(C)) instead of subtracting it, through a new ma_foreign_earned_income_exclusion_addback input that defaults to the federal foreign_earned_income_exclusion stacking amount, floored at zero. diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml index 7e3db2d551c..6d09a4c2092 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml @@ -105,6 +105,17 @@ ma_foreign_earned_income_exclusion_addback: 0 ma_gross_income: 0 +- name: A negative federal exclusion cannot reduce Massachusetts gross income + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: -5_000 + output: + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + ma_part_b_gross_income: 50_000 + - name: The addition counts against losses before the zero floor period: 2025 input: diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py index c1074819556..008c457cc36 100644 --- a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -6,21 +6,20 @@ and s. 2(b)(2) makes Part B gross income the Massachusetts gross income "not included in Part A or Part C gross income". The excluded amount is `ma_foreign_earned_income_exclusion_addback` (Form 2555 line 43). It defaults -to `foreign_earned_income_exclusion` and can be entered -directly. Before this -change, `ma_gross_income` subtracted the exclusion instead. +to `foreign_earned_income_exclusion`, floored at zero, and can be entered +directly. Before this change, `ma_gross_income` subtracted the exclusion instead. Write B for federal gross income plus `ma_gross_income_loss_adjustment`, less taxable Social Security, state and local tax refunds and exempt public -pensions. Properties that hold for every household: +pensions. Properties for nonnegative exclusions and entered addbacks: 1. Massachusetts gross income is max(0, B + addback), where the addback is - the entered amount or, when none is entered, the exclusion. Against the - formula before this change, max(0, B - exclusion), gross income rises by - the addback plus the exclusion wherever neither zero floor binds. Outside - Massachusetts, or with no exclusion and no entered addback, gross income, - Part B and the taxes are bit-for-bit unchanged. Massachusetts tax before - credits never falls. + the entered amount or, when none is entered, the nonnegative exclusion. + Against the formula before this change, max(0, B - exclusion), gross income + rises by the addback plus the exclusion wherever neither zero floor binds. + Outside Massachusetts, or with no exclusion and no entered addback, gross + income, Part B and the taxes are bit-for-bit unchanged. Massachusetts tax + before credits never falls. 2. Raising the addback by D leaves federal gross income, the loss adjustment and Parts A and C unchanged, and leaves AGI unchanged for these households, none of which has an AGI item that reads the exclusion. It diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py index df2706c22e6..2a243434f77 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -17,7 +17,7 @@ class ma_foreign_earned_income_exclusion_addback(Variable): "deduction rather than an exclusion. Other income inputs must already " "exclude this amount. When not provided, it equals " "foreign_earned_income_exclusion, the 911(f) stacking amount on " - "worksheet line 2c. That default equals line 43 when " + "worksheet line 2c, floored at zero. That default equals line 43 when " "Form 2555 lines 44 and 50 and worksheet line 2b are zero, or more " "generally when line 50 equals line 44 plus line 2b. An entered " "amount, including zero, replaces the default." @@ -37,4 +37,6 @@ class ma_foreign_earned_income_exclusion_addback(Variable): "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", ) defined_for = StateCode.MA - adds = ["foreign_earned_income_exclusion"] + + def formula(tax_unit, period, parameters): + return max_(0, tax_unit("foreign_earned_income_exclusion", period)) From 10ea7cc8f7132caac9281e42565dddecba0def07 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 11:34:50 -0400 Subject: [PATCH 06/12] Add section 911 exclusion and deduction leaf inputs --- changelog.d/foreign-earned-income-leaves.added.md | 1 + ...earned_income_exclusion_allocable_deductions.py | 14 ++++++++++++++ .../foreign_earned_income_exclusion_amount.py | 14 ++++++++++++++ ...arned_income_exclusion_disallowed_deductions.py | 14 ++++++++++++++ .../foreign_housing_deduction.py | 14 ++++++++++++++ .../foreign_housing_exclusion.py | 14 ++++++++++++++ 6 files changed, 71 insertions(+) create mode 100644 changelog.d/foreign-earned-income-leaves.added.md create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py diff --git a/changelog.d/foreign-earned-income-leaves.added.md b/changelog.d/foreign-earned-income-leaves.added.md new file mode 100644 index 00000000000..322ed417d63 --- /dev/null +++ b/changelog.d/foreign-earned-income-leaves.added.md @@ -0,0 +1 @@ +Add TaxUnit leaf inputs for the section 911 earned-income and housing exclusions, allocable deductions, housing deduction, and federal tax worksheet adjustments. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py new file mode 100644 index 00000000000..c7232f0728f --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_allocable_deductions(Variable): + value_type = float + entity = TaxUnit + label = "Deductions allocable to excluded foreign income" + unit = USD + documentation = "Deductions allowed in figuring AGI that are allocable to excluded foreign earned income and housing amounts: Form 2555 line 44, summed across both spouses on a joint return. These reduce line 45 but not the gross exclusions on line 43. This input only supplies the federal rate worksheet adjustment; other income and deduction inputs retain their existing model definitions." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#d_6", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py new file mode 100644 index 00000000000..cd63b609b35 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_amount(Variable): + value_type = float + entity = TaxUnit + label = "Foreign earned income exclusion under section 911(a)(1)" + unit = USD + documentation = "Foreign earned income excluded under 26 U.S.C. 911(a)(1): Form 2555 line 42, summed across both spouses on a joint return. This excludes the line 36 housing exclusion and the line 50 housing deduction." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#a_1", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py new file mode 100644 index 00000000000..d291514081d --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_disallowed_deductions(Variable): + value_type = float + entity = TaxUnit + label = "Foreign income deductions disallowed on the federal tax worksheet" + unit = USD + documentation = "Itemized deductions or exclusions disallowed because they relate to excluded foreign income: Foreign Earned Income Tax Worksheet line 2b. This adjustment is separate from Form 2555 line 44 and reduces the federal stacking amount, not Form 2555 line 43. This input only supplies the federal rate worksheet adjustment; other income and deduction inputs retain their existing model definitions." + definition_period = YEAR + reference = [ + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py new file mode 100644 index 00000000000..a44e4dc90e0 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_housing_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Foreign housing deduction" + unit = USD + documentation = "Foreign housing deduction under 26 U.S.C. 911(c)(4): Form 2555 line 50, summed across both spouses on a joint return. This enters the federal stacking worksheet but is not part of the gross exclusions on line 43. This input only supplies the federal rate worksheet adjustment; other income and deduction inputs retain their existing model definitions." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#c_4", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py new file mode 100644 index 00000000000..bb2dd81bb18 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_housing_exclusion(Variable): + value_type = float + entity = TaxUnit + label = "Foreign housing exclusion under section 911(a)(2)" + unit = USD + documentation = "Housing amount excluded under 26 U.S.C. 911(a)(2): Form 2555 line 36, summed across both spouses on a joint return. This is separate from the line 50 housing deduction." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#a_2", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] From 687ac6668d3aa795881c1468cd04fbed7a436ec9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 12:36:30 -0400 Subject: [PATCH 07/12] Derive federal and Massachusetts section 911 amounts from leaves --- .../ma-gross-income-feie-addback.fixed.md | 2 +- policyengine_us/data/economic_assumptions.py | 1 + .../gross_income/foreign_earned_income.yaml | 294 ++++++++++++++++++ ..._foreign_earned_income_exclusion_leaves.py | 222 +++++++++++++ .../foreign_earned_income.py | 58 +++- .../foreign_earned_income_exclusion_gross.py | 24 ++ ...foreign_earned_income_exclusion_addback.py | 33 +- 7 files changed, 626 insertions(+), 8 deletions(-) create mode 100644 policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py diff --git a/changelog.d/ma-gross-income-feie-addback.fixed.md b/changelog.d/ma-gross-income-feie-addback.fixed.md index 34909e2440d..e1df1e24da6 100644 --- a/changelog.d/ma-gross-income-feie-addback.fixed.md +++ b/changelog.d/ma-gross-income-feie-addback.fixed.md @@ -1 +1 @@ -Add foreign earned income excluded under section 911 to Massachusetts gross income (M.G.L. c. 62 s. 2(a)(1)(C)) instead of subtracting it, through a new ma_foreign_earned_income_exclusion_addback input that defaults to the federal foreign_earned_income_exclusion stacking amount, floored at zero. +Add section 911 gross foreign earned income and housing exclusions to Massachusetts gross income, derived from Form 2555 leaf inputs while preserving legacy federal stacking inputs and explicit Massachusetts overrides. diff --git a/policyengine_us/data/economic_assumptions.py b/policyengine_us/data/economic_assumptions.py index c72459f9833..14347e5fb70 100644 --- a/policyengine_us/data/economic_assumptions.py +++ b/policyengine_us/data/economic_assumptions.py @@ -26,6 +26,7 @@ "employment_income": "calibration.gov.irs.soi.employment_income", "employment_income_last_year": "calibration.gov.irs.soi.employment_income", "energy_efficient_home_improvement_credit": DEFAULT_MICRODATA_UPRATING, + "foreign_earned_income_exclusion": DEFAULT_MICRODATA_UPRATING, "foreign_tax_credit": DEFAULT_MICRODATA_UPRATING, "interest_deduction": DEFAULT_MICRODATA_UPRATING, "long_term_capital_gains": "calibration.gov.irs.soi.long_term_capital_gains", diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml index 6d09a4c2092..855448e1dd1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml @@ -127,3 +127,297 @@ # max(0, 10,000 - 30,000 + 25,000). ma_gross_income: 5_000 ma_part_b_gross_income: 5_000 + +# Leaf-input regressions for ruling d1051 (Form 2555 lines 42 and 36). +# Preserve the existing legacy aggregate-input fixtures alongside these cases. + +- name: Leaf exclusions enter Part B and preserve Parts A and C + period: 2025 + input: + state_code: MA + age: 35 + employment_income: 32_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + ma_part_b_taxable_income_deductions: 0 + ma_part_b_taxable_income_exemption: 4_400 + output: + irs_gross_income: 45_000 + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + ma_part_b_taxable_income: 37_600 + ma_income_tax_before_credits: 2_635 + +- name: Fully excluded wages supplied as leaves remain Massachusetts income + period: 2025 + input: + state_code: MA + irs_gross_income: 0 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 10_000 + ma_part_b_gross_income: 10_000 + +- name: Leaf exclusions preserve the Massachusetts loss adjustment + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + ma_gross_income_loss_adjustment: -15_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + ma_gross_income: 45_000 + ma_part_b_gross_income: 45_000 + +- name: Leaf exclusions preserve existing Massachusetts exclusions + period: 2025 + input: + state_code: MA + irs_gross_income: 60_000 + foreign_earned_income_exclusion_amount: 16_000 + foreign_housing_exclusion: 4_000 + taxable_social_security: 7_500 + salt_refund_income: 2_000 + taxable_public_pension_income: 10_000 + output: + foreign_earned_income_exclusion_gross: 20_000 + ma_gross_income: 60_500 + ma_part_b_gross_income: 60_500 + +- name: Gross leaf exclusions differ from an explicit legacy federal stacking input + period: 2025 + input: + state_code: MA + irs_gross_income: 45_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion: 8_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 8_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + +- name: Leaf exclusions count against losses before the Massachusetts zero floor + period: 2025 + input: + state_code: MA + irs_gross_income: 10_000 + foreign_earned_income_exclusion_amount: 20_000 + foreign_housing_exclusion: 5_000 + ma_gross_income_loss_adjustment: -30_000 + output: + foreign_earned_income_exclusion_gross: 25_000 + ma_gross_income: 5_000 + ma_part_b_gross_income: 5_000 + +- name: Section 911 earned-income exclusion alone derives the gross addback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 10_000 + output: + foreign_housing_exclusion: 0 + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Section 911 housing exclusion alone derives the gross addback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_housing_exclusion: 10_000 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Explicit zero federal aggregate overrides derived stacking without overriding gross leaves + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion: 0 + output: + foreign_earned_income_exclusion: 0 + foreign_earned_income_exclusion_gross: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Explicit zero leaves override the Massachusetts legacy fallback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 0 + foreign_earned_income_exclusion: 10_000 + output: + foreign_earned_income_exclusion: 10_000 + foreign_earned_income_exclusion_gross: 0 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: Federal worksheet deductions preserve gross Massachusetts exclusions + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 700 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 8_800 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: The derived federal worksheet amount has its own zero floor + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 1_000 + foreign_earned_income_exclusion_allocable_deductions: 2_000 + output: + foreign_earned_income_exclusion_gross: 1_000 + foreign_earned_income_exclusion: 0 + ma_foreign_earned_income_exclusion_addback: 1_000 + ma_gross_income: 51_000 + +- name: Housing deduction alone stacks federally without a Massachusetts exclusion addback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_housing_deduction: 500 + output: + foreign_earned_income_exclusion_gross: 0 + foreign_earned_income_exclusion: 500 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: An explicit Massachusetts zero overrides positive gross exclusion leaves + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + ma_foreign_earned_income_exclusion_addback: 0 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: Leaf exclusions do not create Massachusetts income outside Massachusetts + period: 2025 + input: + state_code: NY + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 0 + +- name: Legacy aggregate input still supplies the Massachusetts default + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + output: + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Explicit negative legacy federal input remains directly settable + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: -5_000 + output: + foreign_earned_income_exclusion: -5_000 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: Leaf worksheet adjustments reproduce the federal head-of-household stacking example + absolute_error_margin: 0.01 + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 60_000 + child1: + age: 10 + child2: + age: 8 + tax_units: + tax_unit: + members: [parent, child1, child2] + foreign_earned_income_exclusion_amount: 40_000 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 4_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 1_500 + households: + household: + members: [parent, child1, child2] + state_code: TX + output: + # Same federal worksheet net as the existing $40,000 legacy fixture: + # 45,000 - 4,000 + 500 - 1,500 = 40,000 (worksheet line 2c). + foreign_earned_income_exclusion_gross: 45_000 + foreign_earned_income_exclusion: 40_000 + adjusted_gross_income: 60_000 + taxable_income: 36_375 + taxable_income_plus_section_911_exclusion: 76_375 + # Official 2025 brackets: tax(76,375) - tax(40,000). + income_tax_main_rates: 5_517.50 + income_tax_before_credits: 5_517.50 + ctc_limiting_tax_liability: 5_517.50 + +- name: Leaf exclusions preserve the existing Form 2555 Worksheet B check + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + output: + foreign_earned_income_exclusion: 10_000 + ctc_credit_limit_worksheet_b_applies: false diff --git a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py new file mode 100644 index 00000000000..5385c0c33f7 --- /dev/null +++ b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py @@ -0,0 +1,222 @@ +"""Leaf-input identities, worksheet semantics, and legacy-input precedence.""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEAR = 2025 +LEAVES = ( + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", +) + + +def simulation_for_tax_units(inputs): + """Independent single-person tax units, with uniform input presence.""" + people, tax_units, households = {}, {}, {} + groups = {"marital_units": {}, "spm_units": {}, "families": {}} + for index, tax_unit_inputs in enumerate(inputs): + person = f"person_{index}" + members = [person] + people[person] = { + "age": {YEAR: 40}, + "irs_gross_income": {YEAR: 50_000}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + **tax_unit_inputs, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {YEAR: "MA", YEAR + 1: "MA"}, + } + for name in groups: + groups[name][f"{name}_{index}"] = {"members": members} + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + **groups, + } + ) + + +def leaf_simulation(rows): + return simulation_for_tax_units( + [{name: {YEAR: value} for name, value in zip(LEAVES, row)} for row in rows] + ) + + +@hypothesis.settings(max_examples=5, deadline=None, derandomize=True) +@hypothesis.example([(-10_000, 1_000, 0, 0, 0), (8_000, 2_000, 1_000, 500, 700)]) +@hypothesis.given( + st.lists( + st.tuples( + st.integers(-100_000, 150_000), + st.integers(-100_000, 150_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + ), + min_size=1, + max_size=8, + ) +) +def test_gross_sum_and_nonnegative_massachusetts_addback(rows): + simulation = leaf_simulation(rows) + amounts = np.asarray(rows, dtype=float) + gross = amounts[:, 0] + amounts[:, 1] + federal = np.maximum(0, gross - amounts[:, 2] + amounts[:, 3] - amounts[:, 4]) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion_gross", YEAR), + gross, + ) + addback = simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR) + np.testing.assert_array_equal(addback, np.maximum(0, gross)) + assert (addback >= 0).all() + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), federal + ) + + +@pytest.mark.parametrize( + "first", + [ + "foreign_earned_income_exclusion_gross", + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + ], +) +def test_explicit_zero_leaves_are_period_specific_and_ignore_default_caches(first): + simulation = simulation_for_tax_units( + [ + { + "foreign_earned_income_exclusion": { + YEAR: 9_000, + YEAR + 1: 11_000, + }, + "foreign_earned_income_exclusion_amount": {YEAR + 1: 0}, + "foreign_housing_exclusion": {YEAR + 1: 0}, + } + ] + ) + # Computing an unset leaf or the gross formula in another period must + # not turn its default zero into an explicitly supplied leaf input. + simulation.calculate(first, YEAR) + simulation.calculate(first, YEAR + 1) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [9_000] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR + 1), + [11_000], + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR), + [9_000], + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + [0], + ) + + +@pytest.mark.parametrize("entered", [0, 6_000]) +def test_set_input_marks_a_leaf_explicit_after_its_default_was_cached(entered): + simulation = simulation_for_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: 10_000}}] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_housing_exclusion", YEAR), [0] + ) + simulation.set_input( + "foreign_housing_exclusion", YEAR, np.array([entered], dtype=float) + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion_gross", YEAR), + [entered], + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR), + [entered], + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [10_000] + ) + + +def expected_legacy_carry(simulation, entered): + # The original pure dollar input used default uprating, then the system + # converted that national total to its per-capita series. Adding a + # formula must preserve the projected value. + parameters = simulation.tax_benefit_system.parameters + before = parameters( + str(YEAR) + ).calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita + after = parameters( + str(YEAR + 1) + ).calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita + return np.array([entered], dtype=np.float32) * (after / before) + + +@pytest.mark.parametrize("legacy", [0, 10_000, -5_000]) +def test_legacy_aggregate_keeps_its_existing_carry_over(legacy): + simulation = simulation_for_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: legacy}}] + ) + expected = expected_legacy_carry(simulation, legacy) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR + 1), + expected, + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + np.maximum(0, expected), + ) + + +def test_carried_leaf_inputs_keep_gross_and_federal_net_distinct(): + simulation = leaf_simulation([(8_000, 2_000, 1_000, 0, 0)]) + federal = simulation.calculate("foreign_earned_income_exclusion", YEAR + 1) + earned = simulation.calculate("foreign_earned_income_exclusion_amount", YEAR + 1) + housing = simulation.calculate("foreign_housing_exclusion", YEAR + 1) + deductions = simulation.calculate( + "foreign_earned_income_exclusion_allocable_deductions", YEAR + 1 + ) + gross = earned + housing + np.testing.assert_array_equal(federal, gross - deductions) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion_gross", YEAR + 1), gross + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + gross, + ) + assert (gross > federal).all() + + +def test_future_leaf_inputs_do_not_replace_legacy_carry(): + simulation = simulation_for_tax_units( + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_earned_income_exclusion_amount": {YEAR + 2: 20_000}, + } + ] + ) + expected = expected_legacy_carry(simulation, 10_000) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR + 1), expected + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + expected, + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py index 601559b2e36..67ddaa25ca8 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_core.parameters import get_parameter +from policyengine_core.simulations.simulation import _uprating_index_value +from policyengine_us.tools.default_uprating import DEFAULT_DOLLAR_INPUT_UPRATING class foreign_earned_income_exclusion(Variable): @@ -6,9 +9,62 @@ class foreign_earned_income_exclusion(Variable): entity = TaxUnit label = "Foreign earned income exclusion" unit = USD - documentation = "Income earned and any housing expense in foreign countries that is excluded from adjusted gross income under 26 U.S. Code § 911. Other income inputs are the amounts left after the exclusion. Section 911(f) adds this amount back to set the tax rates on that income: line 2c of the Foreign Earned Income Tax Worksheet (Form 2555 lines 45 and 50, less deductions disallowed because they relate to the excluded income). The net investment income tax adds back only the section 911(a)(1) part; see niit_magi_section_911_addition." + documentation = "Income earned and any housing expense in foreign countries that is excluded from adjusted gross income under 26 U.S. Code § 911. Other income inputs are the amounts left after the exclusion. Section 911(f) adds this amount back to set the tax rates on that income: line 2c of the Foreign Earned Income Tax Worksheet (Form 2555 lines 45 and 50, less deductions disallowed because they relate to the excluded income). The net investment income tax adds back only the section 911(a)(1) part; see niit_magi_section_911_addition. When not entered directly, it is derived as Form 2555 line 43 minus line 44, plus line 50, minus worksheet line 2b, floored at zero. An entered legacy amount, including zero, overrides that calculation. Without supplied leaf inputs, the legacy default, carry-over, and uprating behavior are preserved." definition_period = YEAR reference = [ "https://www.law.cornell.edu/uscode/text/26/911", "https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", ] + + def formula(tax_unit, period, parameters): + leaf_inputs = [ + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", + ] + if not any( + input_period.start <= period.start + for variable in leaf_inputs + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ): + # A formula cannot also declare Core uprating. Preserve the + # old dollar input's per-capita default uprating here instead, + # including signed inputs and Core's held-flat index boundaries. + simulation = tax_unit.simulation + holder = simulation.get_holder("foreign_earned_income_exclusion") + earlier_periods = [ + known_period + for known_period in holder.get_known_periods() + if known_period.unit == period.unit + and known_period.start < period.start + ] + if not earlier_periods: + return None + latest = max(earlier_periods, key=lambda known_period: known_period.start) + index = get_parameter( + simulation.tax_benefit_system.parameters, + DEFAULT_DOLLAR_INPUT_UPRATING + "_per_capita", + ) + before = _uprating_index_value(index, latest.start) + after = _uprating_index_value(index, period.start) + factor = ( + 1 if before is None or after is None or before == 0 else after / before + ) + return holder.get_array(latest, simulation.branch_name) * factor + gross = tax_unit("foreign_earned_income_exclusion_gross", period) + allocable_deductions = tax_unit( + "foreign_earned_income_exclusion_allocable_deductions", period + ) + housing_deduction = tax_unit("foreign_housing_deduction", period) + disallowed_deductions = tax_unit( + "foreign_earned_income_exclusion_disallowed_deductions", period + ) + # Worksheet line 2c: if zero or less, enter zero. Other income and + # deduction inputs retain their existing model definitions. + return max_( + 0, gross - allocable_deductions + housing_deduction - disallowed_deductions + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py new file mode 100644 index 00000000000..2f7e8359848 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_gross(Variable): + value_type = float + entity = TaxUnit + label = "Gross foreign earned income and housing exclusions" + unit = USD + documentation = ( + "Gross exclusions under 26 U.S.C. 911(a): Form 2555 line 43, equal " + "to the line 36 housing exclusion plus the line 42 foreign earned " + "income exclusion, summed across both spouses on a joint return. " + "This is before line 44 allocable deductions and excludes the line " + "50 housing deduction and federal tax worksheet line 2b adjustments." + ) + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#a", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] + adds = [ + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + ] diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py index 2a243434f77..9eca8685b1e 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -15,12 +15,15 @@ class ma_foreign_earned_income_exclusion_addback(Variable): "to the excluded income or by Foreign Earned Income Tax Worksheet line " "2b, and it does not include the line 50 housing deduction, which is a " "deduction rather than an exclusion. Other income inputs must already " - "exclude this amount. When not provided, it equals " - "foreign_earned_income_exclusion, the 911(f) stacking amount on " - "worksheet line 2c, floored at zero. That default equals line 43 when " - "Form 2555 lines 44 and 50 and worksheet line 2b are zero, or more " - "generally when line 50 equals line 44 plus line 2b. An entered " - "amount, including zero, replaces the default." + "exclude this amount. When any section 911 leaf input is supplied for " + "the year or carried from an earlier year, the default is " + "foreign_earned_income_exclusion_gross, floored at zero. For legacy " + "callers supplying no applicable leaves, the default " + "remains foreign_earned_income_exclusion, the worksheet line 2c " + "stacking amount, floored at zero. That legacy approximation equals " + "line 43 when Form 2555 lines 44 and 50 and worksheet line 2b are " + "zero, or when line 50 equals line 44 plus line 2b. An entered " + "Massachusetts amount, including zero, replaces either default." ) definition_period = YEAR reference = ( @@ -39,4 +42,22 @@ class ma_foreign_earned_income_exclusion_addback(Variable): defined_for = StateCode.MA def formula(tax_unit, period, parameters): + leaf_inputs = [ + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", + ] + # Core's input export helper tracks supplied values by year and + # branch. Cached default zeros must not replace the legacy fallback; + # explicitly supplied zeros must. Available since core 3.32.8. + if any( + input_period.start <= period.start + for variable in leaf_inputs + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ): + return max_(0, tax_unit("foreign_earned_income_exclusion_gross", period)) return max_(0, tax_unit("foreign_earned_income_exclusion", period)) From 7777c403bea90442f91c49699a36358d4a890eb3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 20:17:19 -0400 Subject: [PATCH 08/12] test: cover ambiguous section 911 batches and zero stacking CTC --- .../ctc_credit_limit_worksheet_b_applies.yaml | 20 +++ .../tests/test_section_911_batch_inputs.py | 160 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 policyengine_us/tests/test_section_911_batch_inputs.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml index 4b7b2ec6e97..6942873da58 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml @@ -20,6 +20,26 @@ output: ctc_credit_limit_worksheet_b_applies: false +- name: Form 2555 filer with a zero net stacking amount cannot use Worksheet B. + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_earned_income_exclusion_amount: 1_000 + foreign_earned_income_exclusion_allocable_deductions: 2_000 + output: + foreign_earned_income_exclusion: 0 + ctc_credit_limit_worksheet_b_applies: false + +- name: Housing deduction filer cannot use Worksheet B when worksheet deductions offset the stacking amount. + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 500 + output: + foreign_earned_income_exclusion: 0 + ctc_credit_limit_worksheet_b_applies: false + - name: Does not apply in 2021, when the CTC was fully refundable. period: 2021 input: diff --git a/policyengine_us/tests/test_section_911_batch_inputs.py b/policyengine_us/tests/test_section_911_batch_inputs.py new file mode 100644 index 00000000000..4fcc294ad06 --- /dev/null +++ b/policyengine_us/tests/test_section_911_batch_inputs.py @@ -0,0 +1,160 @@ +"""Public simulation batches must preserve each tax unit's input representation. + +Python coverage exercises JSON provenance and the Simulation constructor, which +the YAML runner's Core SimulationBuilder does not exercise. +""" + +import numpy as np +import pytest + +from policyengine_us.tests.test_foreign_earned_income_exclusion_leaves import ( + LEAVES, + YEAR, + simulation_for_tax_units, +) + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +OUTPUTS = ( + "foreign_earned_income_exclusion", + "foreign_earned_income_exclusion_gross", + "ma_foreign_earned_income_exclusion_addback", + "ma_gross_income", +) + + +@pytest.mark.parametrize( + "inputs", + [ + [ + {"foreign_earned_income_exclusion": {YEAR: 10_000}}, + { + "foreign_earned_income_exclusion_amount": {YEAR: 8_000}, + "foreign_housing_exclusion": {YEAR: 2_000}, + }, + ], + [ + {"foreign_earned_income_exclusion": {YEAR: 0}}, + {"foreign_earned_income_exclusion_amount": {YEAR: 10_000}}, + ], + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_housing_exclusion": {YEAR: 0}, + }, + {"foreign_earned_income_exclusion": {YEAR: 20_000}}, + ], + ], + ids=["aggregate-and-leaves", "explicit-zero-aggregate", "explicit-zero-leaf"], +) +def test_mixed_tax_unit_representations_raise_a_clear_error(inputs): + # Core fills omitted entity inputs with zeros. A global leaf switch or + # aggregate input array would silently replace one unit's own amounts. + with pytest.raises(ValueError, match=r"(?i)(batch|tax.unit)"): + simulation_for_tax_units(inputs) + + +@pytest.mark.parametrize("with_aggregate", [False, True]) +def test_temporally_mixed_leaf_components_raise_despite_matching_union_coverage( + with_aggregate, +): + inputs = [ + { + "foreign_earned_income_exclusion_amount": {YEAR: 1_000}, + "foreign_housing_exclusion": {YEAR + 1: 100}, + }, + { + "foreign_earned_income_exclusion_amount": {YEAR + 1: 2_000}, + "foreign_housing_exclusion": {YEAR: 200}, + }, + ] + if with_aggregate: + for row in inputs: + row["foreign_earned_income_exclusion"] = {YEAR: 10_000} + # Each unit supplies some leaf in both years, but the later arrays zero-fill + # the other unit's carried amount for each individual component. + with pytest.raises(ValueError, match=r"(?i)(batch|tax.unit)"): + simulation_for_tax_units(inputs) + + +def assert_batch_matches_individual_tax_units(inputs): + """Changing population composition cannot change a unit's own results.""" + batch = simulation_for_tax_units(inputs) + individuals = [simulation_for_tax_units([row]) for row in inputs] + for period in (YEAR, YEAR + 1): + for variable in OUTPUTS: + expected = np.array( + [ + simulation.calculate(variable, period)[0] + for simulation in individuals + ] + ) + np.testing.assert_array_equal(batch.calculate(variable, period), expected) + + +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) +@hypothesis.example([0, 10_000, -5_000]) +@hypothesis.given(st.lists(st.integers(-100_000, 150_000), min_size=2, max_size=4)) +def test_uniform_legacy_batches_match_isolated_tax_units(values): + assert_batch_matches_individual_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: value}} for value in values] + ) + + +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) +@hypothesis.example([(0, 0, 0, 0, 0), (8_000, 2_000, 1_000, 500, 700)]) +@hypothesis.given( + st.lists( + st.tuples( + st.integers(-100_000, 150_000), + st.integers(-100_000, 150_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + ), + min_size=2, + max_size=4, + ) +) +def test_uniform_leaf_batches_match_isolated_tax_units(rows): + assert_batch_matches_individual_tax_units( + [{name: {YEAR: value} for name, value in zip(LEAVES, row)} for row in rows] + ) + + +def test_leaf_batches_can_supply_different_form_2555_components(): + assert_batch_matches_individual_tax_units( + [ + {"foreign_earned_income_exclusion_amount": {YEAR: 8_000}}, + {"foreign_housing_exclusion": {YEAR: 2_000}}, + {"foreign_housing_deduction": {YEAR: 0}}, + ] + ) + + +def test_none_does_not_turn_a_legacy_unit_into_a_leaf_unit(): + assert_batch_matches_individual_tax_units( + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_housing_exclusion": {YEAR: None}, + }, + {"foreign_earned_income_exclusion": {YEAR: 20_000}}, + ] + ) + + +def test_uniform_aggregate_overrides_with_explicit_zero_leaves_remain_valid(): + assert_batch_matches_individual_tax_units( + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_housing_exclusion": {YEAR: 0}, + }, + { + "foreign_earned_income_exclusion": {YEAR: 20_000}, + "foreign_housing_exclusion": {YEAR: 2_000}, + }, + ] + ) From b5fed7f04459885d5710368630f6134de916db47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 20:39:42 -0400 Subject: [PATCH 09/12] fix: reject ambiguous section 911 tax-unit input histories --- policyengine_us/system.py | 5 ++ policyengine_us/tools/section_911.py | 78 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 policyengine_us/tools/section_911.py diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 158e6443f43..d238dd272a7 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -35,6 +35,7 @@ uprate_parameters, ) from .tools.default_uprating import add_default_uprating +from .tools.section_911 import validate_section_911_batch_inputs from .tools.per_capita_uprating import ( add_per_capita_parameters_for_parameter_uprating, add_per_capita_uprating, @@ -242,6 +243,10 @@ def __init__(self, *args, **kwargs): start_instant: Annotated[str, "ISO date format YYYY-MM-DD"] = kwargs.pop( "start_instant", DEFAULT_START_DATE ) + situation = kwargs.get("situation", args[2] if len(args) > 2 else None) + validate_section_911_batch_inputs( + situation, kwargs.get("default_input_period") or self.default_input_period + ) args, kwargs = self._prepare_spm_system( args, kwargs, kwargs.pop("spm", None), start_instant ) diff --git a/policyengine_us/tools/section_911.py b/policyengine_us/tools/section_911.py new file mode 100644 index 00000000000..492a1c7def8 --- /dev/null +++ b/policyengine_us/tools/section_911.py @@ -0,0 +1,78 @@ +"""Input provenance for Form 2555 amounts.""" + +from policyengine_core.periods import period as parse_period + + +SECTION_911_LEAF_INPUTS = ( + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", +) + + +def validate_section_911_batch_inputs(situation, default_period): + """Reject ambiguous JSON batches before Core fills omitted inputs with zeros. + + Core stores explicit-input provenance for whole arrays, not individual tax + units. With both aggregate overrides and leaves, units must have matching + input-period coverage. Values and the particular leaf components may differ. + Dataset and setter arrays already describe every entity in the array. + """ + if situation is None: + return + tax_units = situation.get("tax_units", {}) + if len(tax_units) < 2: + return + + aggregates = ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + "foreign_earned_income_exclusion_gross", + "ma_foreign_earned_income_exclusion_addback", + ) + + def input_periods(inputs, variable): + values = inputs.get(variable) + if values is None: + return frozenset() + if not isinstance(values, dict): + values = {default_period: values} + return frozenset( + parse_period(input_period) + for input_period, value in values.items() + if value is not None + ) + + signatures = [] + component_periods = {name: set() for name in SECTION_911_LEAF_INPUTS} + for inputs in tax_units.values(): + aggregate_periods = tuple(input_periods(inputs, name) for name in aggregates) + leaf_periods = frozenset() + for name in SECTION_911_LEAF_INPUTS: + supplied_periods = input_periods(inputs, name) + leaf_periods |= supplied_periods + if supplied_periods: + component_periods[name].add(supplied_periods) + signatures.append((aggregate_periods, leaf_periods)) + + has_aggregates = any(any(aggregate_periods) for aggregate_periods, _ in signatures) + has_leaves = any(leaf_periods for _, leaf_periods in signatures) + mixed_representations = ( + has_aggregates + and has_leaves + and any(signature != signatures[0] for signature in signatures[1:]) + ) + # A later array for the same leaf also zero-fills another unit's carried + # amount, even in an otherwise leaf-only batch. Absent components are safe. + mixed_leaf_periods = any( + len(coverage) > 1 for coverage in component_periods.values() + ) + if mixed_representations or mixed_leaf_periods: + raise ValueError( + "Batched tax units cannot mix section 911 aggregate or Form 2555 " + "leaf inputs with different input-period coverage. " + "Supply consistent aggregate and leaf input periods across tax " + "units, including explicit zeros, or calculate the units separately." + ) From 53be21e91a4c652f6cf4f1284f762eade49538eb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 20:47:36 -0400 Subject: [PATCH 10/12] fix: integrate Form 2555 leaves with MAGI and CTC eligibility --- .../section-911-leaf-integration.fixed.md | 1 + policyengine_us/system.py | 8 +- ..._foreign_earned_income_exclusion_leaves.py | 75 ++++++++++++++++++- .../tests/test_section_911_batch_inputs.py | 44 +++++++++++ .../ctc_credit_limit_worksheet_b_applies.py | 12 ++- .../foreign_housing_deduction.py | 2 +- .../section_911_excluded_income.py | 34 +++++++-- 7 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 changelog.d/section-911-leaf-integration.fixed.md diff --git a/changelog.d/section-911-leaf-integration.fixed.md b/changelog.d/section-911-leaf-integration.fixed.md new file mode 100644 index 00000000000..3df41641112 --- /dev/null +++ b/changelog.d/section-911-leaf-integration.fixed.md @@ -0,0 +1 @@ +Derive the section 911 MAGI add-back from Form 2555 leaves before worksheet deductions, recognize Form 2555 filers with zero stacking amounts for CTC Worksheet B, and reject ambiguous mixed-input tax-unit batches. diff --git a/policyengine_us/system.py b/policyengine_us/system.py index d238dd272a7..c4c90688ea2 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -243,13 +243,13 @@ def __init__(self, *args, **kwargs): start_instant: Annotated[str, "ISO date format YYYY-MM-DD"] = kwargs.pop( "start_instant", DEFAULT_START_DATE ) - situation = kwargs.get("situation", args[2] if len(args) > 2 else None) - validate_section_911_batch_inputs( - situation, kwargs.get("default_input_period") or self.default_input_period - ) args, kwargs = self._prepare_spm_system( args, kwargs, kwargs.pop("spm", None), start_instant ) + validate_section_911_batch_inputs( + kwargs.get("situation"), + kwargs.get("default_input_period") or self.default_input_period, + ) super().__init__(*args, **kwargs) reform = create_structural_reforms_from_parameters( diff --git a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py index 5385c0c33f7..866c880963d 100644 --- a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py +++ b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py @@ -74,7 +74,8 @@ def test_gross_sum_and_nonnegative_massachusetts_addback(rows): simulation = leaf_simulation(rows) amounts = np.asarray(rows, dtype=float) gross = amounts[:, 0] + amounts[:, 1] - federal = np.maximum(0, gross - amounts[:, 2] + amounts[:, 3] - amounts[:, 4]) + section_911 = gross - amounts[:, 2] + amounts[:, 3] + federal = np.maximum(0, section_911 - amounts[:, 4]) np.testing.assert_array_equal( simulation.calculate("foreign_earned_income_exclusion_gross", YEAR), gross, @@ -85,6 +86,56 @@ def test_gross_sum_and_nonnegative_massachusetts_addback(rows): np.testing.assert_array_equal( simulation.calculate("foreign_earned_income_exclusion", YEAR), federal ) + # MAGI adds Form 2555 lines 45 and 50, before worksheet line 2b. + np.testing.assert_array_equal( + simulation.calculate("section_911_excluded_income", YEAR), section_911 + ) + + +def test_section_911_excluded_income_does_not_subtract_worksheet_deductions(): + simulation = leaf_simulation( + [(8_000, 2_000, 1_000, 500, 0), (8_000, 2_000, 1_000, 500, 700)] + ) + # Schedule 1-A lines 2b and 2c add Form 2555 lines 45 and 50. + # Worksheet line 2b changes federal stacking, but not this MAGI amount. + np.testing.assert_array_equal( + simulation.calculate("section_911_excluded_income", YEAR), + [9_500, 9_500], + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), + [9_500, 8_800], + ) + + +def test_additional_senior_deduction_magi_adds_form_2555_before_worksheet_deductions(): + leaves = (8_000, 2_000, 1_000, 500, 700) + simulation = simulation_for_tax_units( + [ + { + "adjusted_gross_income": {YEAR: 100_000}, + **{name: {YEAR: value} for name, value in zip(LEAVES, leaves)}, + } + ] + ) + # Schedule 1-A adds lines 45 and 50 (9,500), while the tax worksheet + # subtracts the 700 of related itemized deductions for stacking only. + np.testing.assert_array_equal( + simulation.calculate("additional_senior_deduction_magi", YEAR), + [109_500], + ) + + +def test_section_911_excluded_income_preserves_negative_form_2555_line_45(): + simulation = leaf_simulation([(1_000, 0, 2_000, 0, 0)]) + # Publication 54, How To Report Deductions, Example 4 permits line 44 + # to exceed line 43. The zero floor belongs to worksheet line 2c. + np.testing.assert_array_equal( + simulation.calculate("section_911_excluded_income", YEAR), [-1_000] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [0] + ) @pytest.mark.parametrize( @@ -220,3 +271,25 @@ def test_future_leaf_inputs_do_not_replace_legacy_carry(): simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), expected, ) + + +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) +@hypothesis.given(st.integers(1, 150_000), st.integers(0, 150_000)) +def test_form_2555_filers_skip_worksheet_b_even_when_stacking_is_zero(amount, excess): + simulation = simulation_for_tax_units( + [ + { + "foreign_earned_income_exclusion_amount": {YEAR: amount}, + "foreign_earned_income_exclusion_allocable_deductions": { + YEAR: amount + excess + }, + "ctc_qualifying_children": {YEAR: 1}, + } + ] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [0] + ) + np.testing.assert_array_equal( + simulation.calculate("ctc_credit_limit_worksheet_b_applies", YEAR), [False] + ) diff --git a/policyengine_us/tests/test_section_911_batch_inputs.py b/policyengine_us/tests/test_section_911_batch_inputs.py index 4fcc294ad06..7fdd239b008 100644 --- a/policyengine_us/tests/test_section_911_batch_inputs.py +++ b/policyengine_us/tests/test_section_911_batch_inputs.py @@ -4,9 +4,12 @@ the YAML runner's Core SimulationBuilder does not exercise. """ +from copy import deepcopy + import numpy as np import pytest +from policyengine_us import Simulation from policyengine_us.tests.test_foreign_earned_income_exclusion_leaves import ( LEAVES, YEAR, @@ -18,12 +21,53 @@ OUTPUTS = ( "foreign_earned_income_exclusion", + "section_911_excluded_income", "foreign_earned_income_exclusion_gross", "ma_foreign_earned_income_exclusion_addback", "ma_gross_income", ) +@pytest.fixture(scope="module") +def scalar_period_situation(): + # Reuse only the raw entity layout, with independent dictionaries per case. + return simulation_for_tax_units([{}, {}]).situation_input + + +@pytest.mark.parametrize("constructor_style", ["keyword", "positional"]) +@pytest.mark.parametrize("other_period", [YEAR, YEAR + 1]) +def test_scalar_leaf_period_matches_constructor_default_input_period( + scalar_period_situation, constructor_style, other_period +): + situation = deepcopy(scalar_period_situation) + situation["tax_units"]["tax_unit_0"]["foreign_earned_income_exclusion_amount"] = ( + 8_000 + ) + situation["tax_units"]["tax_unit_1"]["foreign_earned_income_exclusion_amount"] = { + other_period: 2_000 + } + + def construct(): + if constructor_style == "keyword": + return Simulation(situation=situation, default_input_period=YEAR) + # Core's default_input_period is its seventh positional argument. + return Simulation(None, None, situation, None, None, False, YEAR) + + if other_period != YEAR: + with pytest.raises(ValueError, match=r"(?i)(batch|tax.unit)"): + construct() + else: + simulation = construct() + for variable in ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + "ma_foreign_earned_income_exclusion_addback", + ): + np.testing.assert_array_equal( + simulation.calculate(variable, YEAR), [8_000, 2_000] + ) + + @pytest.mark.parametrize( "inputs", [ diff --git a/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.py b/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.py index 07af7bbaf36..d2a45a00c93 100644 --- a/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.py +++ b/policyengine_us/variables/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import SECTION_911_LEAF_INPUTS class ctc_credit_limit_worksheet_b_applies(Variable): @@ -33,7 +34,16 @@ def formula(tax_unit, period, parameters): has_qualifying_child = tax_unit("ctc_qualifying_children", period) > 0 # Form 2555 filers skip Worksheet B from 2015, when the refundable CTC # was first denied to them. - files_form_2555 = tax_unit("foreign_earned_income_exclusion", period) > 0 + files_form_2555 = np.logical_or.reduce( + [ + tax_unit(variable, period) > 0 + for variable in ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + *SECTION_911_LEAF_INPUTS, + ) + ] + ) excluded = ( files_form_2555 & p.refundable.foreign_earned_income_exclusion_bar_applies ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py index a44e4dc90e0..cc7550eef7c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py @@ -6,7 +6,7 @@ class foreign_housing_deduction(Variable): entity = TaxUnit label = "Foreign housing deduction" unit = USD - documentation = "Foreign housing deduction under 26 U.S.C. 911(c)(4): Form 2555 line 50, summed across both spouses on a joint return. This enters the federal stacking worksheet but is not part of the gross exclusions on line 43. This input only supplies the federal rate worksheet adjustment; other income and deduction inputs retain their existing model definitions." + documentation = "Foreign housing deduction under 26 U.S.C. 911(c)(4): Form 2555 line 50, summed across both spouses on a joint return and reported on Schedule 1 line 24j. This supplies federal stacking and MAGI adjustments, outside the gross exclusions on line 43. It does not automatically reduce AGI: other income and deduction inputs retain their existing reported/net definitions." definition_period = YEAR reference = [ "https://www.law.cornell.edu/uscode/text/26/911#c_4", diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py index c7fecc900da..4f53757cb87 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import SECTION_911_LEAF_INPUTS class section_911_excluded_income(Variable): @@ -10,12 +11,12 @@ class section_911_excluded_income(Variable): "Foreign earned income and housing amounts excluded from gross income " "under 26 U.S.C. 911, plus the section 911(c)(4) foreign housing " "deduction: Form 2555 lines 45 and 50 (both spouses' forms on a joint " - "return). Modified adjusted gross incomes add this amount back. It is " - "larger than foreign_earned_income_exclusion, the amount the section " - "911(f) rate stacking uses, by any deductions disallowed because they " - "relate to the excluded income. When not provided, it equals " - "foreign_earned_income_exclusion, which is exact when no deductions " - "are disallowed." + "return). Modified adjusted gross incomes add this amount back, before " + "the federal stacking worksheet subtracts disallowed deductions and " + "floors its result at zero. Applicable Form 2555 leaves determine this " + "amount, preserving a negative line 45. Without those leaves, the " + "legacy foreign_earned_income_exclusion amount is the fallback; it " + "cannot reconstruct the pre-worksheet amount." ) definition_period = YEAR reference = ( @@ -28,5 +29,24 @@ class section_911_excluded_income(Variable): # The Foreign Earned Income Tax Worksheet subtracts the disallowed # deductions from the same amount (lines 2a to 2c). "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", + # Publication 54's deduction example 4 reports a negative line 45. + "https://www.irs.gov/publications/p54", ) - adds = ["foreign_earned_income_exclusion"] + + def formula(tax_unit, period, parameters): + has_leaf_inputs = any( + input_period.start <= period.start + for variable in SECTION_911_LEAF_INPUTS + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ) + if not has_leaf_inputs: + return tax_unit("foreign_earned_income_exclusion", period) + + gross = tax_unit("foreign_earned_income_exclusion_gross", period) + allocable_deductions = tax_unit( + "foreign_earned_income_exclusion_allocable_deductions", period + ) + housing_deduction = tax_unit("foreign_housing_deduction", period) + return gross - allocable_deductions + housing_deduction From 1b1f326eb8af2291b6058fdcca1c504c1fc1f677 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 21:12:24 -0400 Subject: [PATCH 11/12] test: keep Form 2555 policy examples in variable YAML files --- .../section_911_excluded_income.yaml | 55 +++++++++++++++++++ .../additional_senior_deduction_magi.yaml | 25 +++++++++ ..._foreign_earned_income_exclusion_leaves.py | 46 ---------------- 3 files changed, 80 insertions(+), 46 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml index 65038ebf808..4c3977ea8fc 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml @@ -37,3 +37,58 @@ # Foreign Earned Income Tax Worksheet line 3: taxable income 85,400 # (100,000 less the 14,600 standard deduction) plus line 2c, 15,000. taxable_income_plus_section_911_exclusion: 100_400 + +- name: Worksheet deductions reduce stacking but not Form 2555 lines 45 and 50 + period: 2025 + input: + people: + person1: + age: 40 + irs_gross_income: 50_000 + person2: + age: 40 + irs_gross_income: 50_000 + tax_units: + tax_unit1: + members: [person1] + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 0 + tax_unit2: + members: [person2] + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 700 + households: + household1: + members: [person1] + state_code: MA + household2: + members: [person2] + state_code: MA + output: + # Schedule 1-A lines 2b and 2c add Form 2555 lines 45 and 50. + section_911_excluded_income: [9_500, 9_500] + # Foreign Earned Income Tax Worksheet line 2b affects stacking only. + foreign_earned_income_exclusion: [9_500, 8_800] + +- name: A negative Form 2555 line 45 remains signed before the stacking floor + period: 2025 + input: + age: 40 + irs_gross_income: 50_000 + state_code: MA + foreign_earned_income_exclusion_amount: 1_000 + foreign_housing_exclusion: 0 + foreign_earned_income_exclusion_allocable_deductions: 2_000 + foreign_housing_deduction: 0 + foreign_earned_income_exclusion_disallowed_deductions: 0 + output: + # Publication 54, How To Report Deductions, Example 4 permits line 44 + # to exceed line 43. The zero floor belongs to worksheet line 2c. + section_911_excluded_income: -1_000 + foreign_earned_income_exclusion: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml index 70ff23859c7..9be76ab7ef9 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml @@ -63,3 +63,28 @@ puerto_rico_income: 10_000 output: additional_senior_deduction_magi: 110_000 + +- name: Senior MAGI adds Form 2555 lines 45 and 50 before worksheet deductions + period: 2025 + input: + people: + person1: + age: 40 + irs_gross_income: 50_000 + tax_units: + tax_unit: + members: [person1] + adjusted_gross_income: 100_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 700 + households: + household: + members: [person1] + state_code: MA + output: + # Schedule 1-A adds lines 45 and 50 (9,500), while the tax worksheet + # subtracts the 700 of related itemized deductions for stacking only. + additional_senior_deduction_magi: 109_500 diff --git a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py index 866c880963d..2510da6ef04 100644 --- a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py +++ b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py @@ -92,52 +92,6 @@ def test_gross_sum_and_nonnegative_massachusetts_addback(rows): ) -def test_section_911_excluded_income_does_not_subtract_worksheet_deductions(): - simulation = leaf_simulation( - [(8_000, 2_000, 1_000, 500, 0), (8_000, 2_000, 1_000, 500, 700)] - ) - # Schedule 1-A lines 2b and 2c add Form 2555 lines 45 and 50. - # Worksheet line 2b changes federal stacking, but not this MAGI amount. - np.testing.assert_array_equal( - simulation.calculate("section_911_excluded_income", YEAR), - [9_500, 9_500], - ) - np.testing.assert_array_equal( - simulation.calculate("foreign_earned_income_exclusion", YEAR), - [9_500, 8_800], - ) - - -def test_additional_senior_deduction_magi_adds_form_2555_before_worksheet_deductions(): - leaves = (8_000, 2_000, 1_000, 500, 700) - simulation = simulation_for_tax_units( - [ - { - "adjusted_gross_income": {YEAR: 100_000}, - **{name: {YEAR: value} for name, value in zip(LEAVES, leaves)}, - } - ] - ) - # Schedule 1-A adds lines 45 and 50 (9,500), while the tax worksheet - # subtracts the 700 of related itemized deductions for stacking only. - np.testing.assert_array_equal( - simulation.calculate("additional_senior_deduction_magi", YEAR), - [109_500], - ) - - -def test_section_911_excluded_income_preserves_negative_form_2555_line_45(): - simulation = leaf_simulation([(1_000, 0, 2_000, 0, 0)]) - # Publication 54, How To Report Deductions, Example 4 permits line 44 - # to exceed line 43. The zero floor belongs to worksheet line 2c. - np.testing.assert_array_equal( - simulation.calculate("section_911_excluded_income", YEAR), [-1_000] - ) - np.testing.assert_array_equal( - simulation.calculate("foreign_earned_income_exclusion", YEAR), [0] - ) - - @pytest.mark.parametrize( "first", [ From 53a78a1af329dff8d8604299ee0c98bd4c9fd672 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 21:26:07 -0400 Subject: [PATCH 12/12] test: preserve main capital-loss allocation in section 911 coverage --- .../gross_income/ma_part_b_gross_income.yaml | 36 +++++++++++++++++++ .../test_ma_foreign_earned_income_addback.py | 34 +++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml index 007a22c4a0f..5bf0bb465e0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml @@ -226,3 +226,39 @@ ma_part_c_agi: 4_000 # 5% x 53,600 + 8.5% x 3,000 + 5% x 4,000 = 2,680 + 255 + 200. ma_income_tax_before_credits: 3_135 + +- name: Foreign-earned leaves preserve Part B earnings with offsetting capital gains + period: 2025 + input: + people: + head1: + age: 40 + short_term_capital_gains: 5_000 + long_term_capital_gains: -2_000 + head2: + age: 40 + short_term_capital_gains: 5_000 + long_term_capital_gains: -2_000 + tax_units: + tax_unit1: + members: [head1] + foreign_earned_income_exclusion_amount: 0 + tax_unit2: + members: [head2] + foreign_earned_income_exclusion_amount: 7_500 + households: + household1: + members: [head1] + state_code: MA + household2: + members: [head2] + state_code: MA + output: + # The federal gross-income starting point includes net capital gains. + irs_gross_income: [3_000, 3_000] + ma_foreign_earned_income_exclusion_addback: [0, 7_500] + ma_gross_income: [3_000, 10_500] + # c.62 s.2(b)(2), (c)(2)(b), and (d)(1)(M): capital losses offset + # capital gains on Schedules B/D; they cannot reduce Part B earnings. + ma_part_b_gross_income: [0, 7_500] + ma_part_a_agi: [3_000, 3_000] diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py index 008c457cc36..f80fc5bf9a8 100644 --- a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -4,7 +4,11 @@ M.G.L. c. 62 s. 2(a)(1)(C) adds to federal gross income "Earned income from foreign sources excluded under section nine hundred and eleven of the Code", and s. 2(b)(2) makes Part B gross income the Massachusetts gross income "not -included in Part A or Part C gross income". The excluded amount is +included in Part A or Part C gross income". Part B removes the dividends and +positive net capital gain included in federal gross income; capital losses do +not reduce Part B under s. 2(d)(1)(M). Massachusetts Part A and Part C netting +can differ from federal netting, so their separate gross amounts are not the +subtraction used to isolate Part B. The excluded amount is `ma_foreign_earned_income_exclusion_addback` (Form 2555 line 43). It defaults to `foreign_earned_income_exclusion`, floored at zero, and can be entered directly. Before this change, `ma_gross_income` subtracted the exclusion instead. @@ -302,6 +306,25 @@ def exclusions(households): return np.array([h["exclusion"] for h in households], dtype=float) +def federally_included_capital_income(households): + """Independent capital-income reference from the household input amounts. + + This fixture puts all dividends and gains on the head; spouses have only + wages, and no capital gain distributions are supplied outside Schedule D. + """ + # M.G.L. c. 62 § 2(b)(2) excludes capital income from Part B, and + # § 2(d)(1)(M) disallows the federal capital-loss deduction. Remove the + # dividends and positive net gain included federally, rather than Part A + # and Part C amounts whose Massachusetts loss netting can differ (#9954). + return np.array( + [ + h["dividends"] + max(0, h["short_term_gains"] + h["long_term_gains"]) + for h in households + ], + dtype=float, + ) + + def base_income(results): """B: federal gross income and the loss adjustment, less the exclusions.""" return ( @@ -400,10 +423,11 @@ def check_foreign_earnings_enter_part_b_only(households, year): np.abs(high["ma_gross_income"] - low["ma_gross_income"] - gross_change) <= 2 * slack ).all() - # Part B is the Massachusetts gross income not in Part A or Part C. - parts_a_and_c = low["ma_part_a_gross_income"] + low["ma_part_c_gross_income"] - residual_low = low["ma_gross_income"] - parts_a_and_c - residual_high = high["ma_gross_income"] - parts_a_and_c + # c.62 § 2(b)(2), § 2(d)(1)(M): remove the capital income included + # federally; different Massachusetts netting cannot reduce Part B. + capital_income = federally_included_capital_income(low_households) + residual_low = low["ma_gross_income"] - capital_income + residual_high = high["ma_gross_income"] - capital_income part_b_change = np.maximum(0, residual_high) - np.maximum(0, residual_low) assert ( np.abs(