From a5cf2fd2b4bfad21cbad1d2bd22ec890bc460735 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 13:57:47 -0400 Subject: [PATCH 1/7] Fix EITC separated-spouse marital eligibility --- .../eitc-mfs-separation-conditions.fixed.md | 1 + .../eitc/eligibility/separate_filer.yaml | 8 +++---- .../ny/wftc/ny_working_families_tax_credit.py | 20 ++--------------- policyengine_us/tools/state_eitc_helpers.py | 22 ++++++++++++++++--- ...lived_with_taxpayer_more_than_half_year.py | 18 +++++++++++++++ .../eligibility/eitc_eligible.py | 13 +++-------- ..._legal_separation_and_apart_at_year_end.py | 20 +++++++++++++++++ .../eligibility/eitc_marital_eligible.py | 22 +++++++++++++++++++ ...itc_spouses_lived_apart_last_six_months.py | 16 ++++++++++++++ .../credits/earned_income/ca_eitc_eligible.py | 2 ++ .../income/credits/eitc/nm_eitc_eligible.py | 12 ++-------- .../eligible/ok_federal_eitc_eligible.py | 18 +++++++-------- 12 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 changelog.d/eitc-mfs-separation-conditions.fixed.md create mode 100644 policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_child_lived_with_taxpayer_more_than_half_year.py create mode 100644 policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_legal_separation_and_apart_at_year_end.py create mode 100644 policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py create mode 100644 policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py diff --git a/changelog.d/eitc-mfs-separation-conditions.fixed.md b/changelog.d/eitc-mfs-separation-conditions.fixed.md new file mode 100644 index 00000000000..c7dbc5c2e04 --- /dev/null +++ b/changelog.d/eitc-mfs-separation-conditions.fixed.md @@ -0,0 +1 @@ +Require a resident qualifying child and the statutory separation conditions for married separate filers to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. diff --git a/policyengine_us/parameters/gov/irs/credits/eitc/eligibility/separate_filer.yaml b/policyengine_us/parameters/gov/irs/credits/eitc/eligibility/separate_filer.yaml index 4d25043ef51..43a85d7edcf 100644 --- a/policyengine_us/parameters/gov/irs/credits/eitc/eligibility/separate_filer.yaml +++ b/policyengine_us/parameters/gov/irs/credits/eitc/eligibility/separate_filer.yaml @@ -1,13 +1,13 @@ -description: The US makes married filing separate filers eligible for the EITC when this is true. +description: The US enables the EITC exception to the joint-return requirement for separate filers with a resident qualifying child who meet the statutory separated-spouse conditions when this is true. metadata: unit: bool - label: EITC separate filers eligible + label: EITC separated-spouse exception enabled reference: - title: 26 U.S. Code ยง 32(d) - Earned income credit - href: https://www.law.cornell.edu/uscode/text/26/32#d + href: https://www.govinfo.gov/content/pkg/USCODE-2023-title26/pdf/USCODE-2023-title26-subtitleA-chap1-subchapA-partIV-subpartC-sec32.pdf#page=3 # ARPA changed it for 2021 forward. - title: American Rescue Plan Act Sec. 9623 - href: https://www.congress.gov/bill/117th-congress/house-bill/1319/text + href: https://www.congress.gov/117/bills/hr1319/BILLS-117hr1319enr.pdf#page=151 values: 2013-01-01: false 2021-01-01: true diff --git a/policyengine_us/reforms/states/ny/wftc/ny_working_families_tax_credit.py b/policyengine_us/reforms/states/ny/wftc/ny_working_families_tax_credit.py index a9fe23a6548..290d629647a 100644 --- a/policyengine_us/reforms/states/ny/wftc/ny_working_families_tax_credit.py +++ b/policyengine_us/reforms/states/ny/wftc/ny_working_families_tax_credit.py @@ -187,23 +187,15 @@ class younger_eitc_eligible(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/32#c_1_A" def formula(tax_unit, period, parameters): - eitc = parameters.gov.irs.credits.eitc(period) investment_income_eligible = tax_unit( "eitc_investment_income_eligible", period ) demographic_eligible = tax_unit("eitc_younger_demographic_eligible", period) no_older_children = tax_unit("eitc_older_children_count", period) == 0 - # Define eligibility before considering separate filer limitation. eligible = ( demographic_eligible & investment_income_eligible & no_older_children ) - # This parameter is true if separate filers are eligible. - if eitc.eligibility.separate_filer: - return eligible - # If separate filers are not eligible, check if the filer is separate. - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE - return eligible & ~separate + return eligible & tax_unit("eitc_marital_eligible", period) class younger_eitc(Variable): value_type = float @@ -382,25 +374,17 @@ class older_eitc_eligible(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/32#c_1_A" def formula(tax_unit, period, parameters): - eitc = parameters.gov.irs.credits.eitc(period) investment_income_eligible = tax_unit( "eitc_investment_income_eligible", period ) demographic_eligible = tax_unit("eitc_older_demographic_eligible", period) younger_eitc_eligible = tax_unit("younger_eitc_eligible", period) - # Define eligibility before considering separate filer limitation. eligible = ( demographic_eligible & investment_income_eligible & ~younger_eitc_eligible ) - # This parameter is true if separate filers are eligible. - if eitc.eligibility.separate_filer: - return eligible - # If separate filers are not eligible, check if the filer is separate. - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE - return eligible & ~separate + return eligible & tax_unit("eitc_marital_eligible", period) class older_eitc(Variable): value_type = float diff --git a/policyengine_us/tools/state_eitc_helpers.py b/policyengine_us/tools/state_eitc_helpers.py index 611d135f190..9dc8c618966 100644 --- a/policyengine_us/tools/state_eitc_helpers.py +++ b/policyengine_us/tools/state_eitc_helpers.py @@ -28,16 +28,32 @@ def eitc_section_911_eligible(tax_unit, period): def eitc_filing_status_eligible( tax_unit, period, parameters, separate_filer_eligible=None ): - """Apply the federal EITC separate-filer rule unless a state overrides it.""" + """Apply IRC 32(d), optionally using a state's frozen exception switch. + + The switch enables the separated-spouse exception, rather than making + every separate filer eligible. Qualifying children are counted before + identification requirements: IRC 32(c)(3)(D) limits the credit schedule, + not the separated-spouse exception in IRC 32(d)(2)(B). + """ if separate_filer_eligible is None: separate_filer_eligible = parameters( period ).gov.irs.credits.eitc.eligibility.separate_filer filing_status = tax_unit("filing_status", period) - return separate_filer_eligible | ( - filing_status != filing_status.possible_values.SEPARATE + not_separate = filing_status != filing_status.possible_values.SEPARATE + if not separate_filer_eligible: + return not_separate + + person = tax_unit.members + has_resident_qualifying_child = tax_unit.any( + person("is_eitc_qualifying_child", period) + & person("eitc_child_lived_with_taxpayer_more_than_half_year", period) ) + meets_separation_conditions = tax_unit( + "eitc_spouses_lived_apart_last_six_months", period + ) | tax_unit("eitc_legal_separation_and_apart_at_year_end", period) + return not_separate | (has_resident_qualifying_child & meets_separation_conditions) def calculate_eitc_demographic_eligibility( diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_child_lived_with_taxpayer_more_than_half_year.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_child_lived_with_taxpayer_more_than_half_year.py new file mode 100644 index 00000000000..5a5c5a26c1f --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_child_lived_with_taxpayer_more_than_half_year.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class eitc_child_lived_with_taxpayer_more_than_half_year(Variable): + value_type = bool + entity = Person + label = "Child lived with the EITC filer for more than half the tax year" + definition_period = YEAR + default_value = True + reference = "https://www.law.cornell.edu/uscode/text/26/32#d_2_B_ii" + documentation = """ + Whether this person resided with the tax unit filer for more than + half the tax year, including applicable temporary-absence exceptions. + Defaults to true to preserve the model's existing assumption that + qualifying child dependents reside with their filer. Set to false + when the child does not meet the residence-duration test. This input + alone does not establish that the person is a qualifying child. + """ diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py index f4be2784cbf..52c00d2869f 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py @@ -11,10 +11,10 @@ class eitc_eligible(Variable): "https://www.law.cornell.edu/uscode/text/26/32#c_1_A", # No EITC for an individual who claims the benefits of section 911. "https://www.law.cornell.edu/uscode/text/26/32#c_1_C", + "https://www.law.cornell.edu/uscode/text/26/32#d", ) def formula(tax_unit, period, parameters): - eitc = parameters.gov.irs.credits.eitc(period) investment_income_eligible = tax_unit("eitc_investment_income_eligible", period) demographic_eligible = tax_unit("eitc_demographic_eligible", period) # The head or spouse in the tax unit must have valid SSN card type to be eligible for the EITC @@ -24,17 +24,10 @@ def formula(tax_unit, period, parameters): # Section 32(c)(1)(C): "eligible individual" does not include an # individual who claims the benefits of section 911 (Form 2555). claims_section_911 = elects_section_911_exclusion(tax_unit, period) - # Define eligibility before considering separate filer limitation. - eligible = ( + return ( demographic_eligible & investment_income_eligible & filers_has_ssn & ~claims_section_911 + & tax_unit("eitc_marital_eligible", period) ) - # This parameter is true if separate filers are eligible. - if eitc.eligibility.separate_filer: - return eligible - # If separate filers are not eligible, check if the filer is separate. - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE - return eligible & ~separate diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_legal_separation_and_apart_at_year_end.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_legal_separation_and_apart_at_year_end.py new file mode 100644 index 00000000000..c558f58b129 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_legal_separation_and_apart_at_year_end.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class eitc_legal_separation_and_apart_at_year_end(Variable): + value_type = bool + entity = TaxUnit + label = "Filer has a qualifying separation instrument and separate household at year end" + definition_period = YEAR + default_value = False + reference = ( + "https://www.law.cornell.edu/uscode/text/26/32#d_2_B_iii_II", + "https://www.law.cornell.edu/uscode/text/26/121#d_3_C", + ) + documentation = """ + Whether the filer has a decree of separate maintenance, written + separation agreement, or other qualifying instrument under IRC + 121(d)(3)(C), other than a decree of divorce, and is not a member of + the spouse's household at the end of the tax year. Both conditions + must hold. Defaults to false when these facts are not supplied. + """ diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py new file mode 100644 index 00000000000..24216058cff --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * +from policyengine_us.tools.state_eitc_helpers import eitc_filing_status_eligible + + +class eitc_marital_eligible(Variable): + value_type = bool + entity = TaxUnit + label = "Meets EITC marital eligibility requirements" + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2023-title26/pdf/USCODE-2023-title26-subtitleA-chap1-subchapA-partIV-subpartC-sec32.pdf#page=3", + "https://www.congress.gov/117/bills/hr1319/BILLS-117hr1319enr.pdf#page=151", + ) + documentation = """ + Married separate filers must have a qualifying child residing with them + for more than half the year and satisfy either statutory separation + route. The exception is enabled from 2021. Child identification rules + do not determine whether the marital exception applies. + """ + + def formula(tax_unit, period, parameters): + return eitc_filing_status_eligible(tax_unit, period, parameters) diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py new file mode 100644 index 00000000000..b8bd3643513 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class eitc_spouses_lived_apart_last_six_months(Variable): + value_type = bool + entity = TaxUnit + label = "Spouses had different principal residences for the last six months" + definition_period = YEAR + default_value = False + reference = "https://www.law.cornell.edu/uscode/text/26/32#d_2_B_iii_I" + documentation = """ + Whether the filer and spouse had different principal places of abode + throughout the last six months of the tax year. A current separation + indicator alone does not establish this condition. Defaults to false + when this separation fact is not supplied. + """ diff --git a/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_eligible.py b/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_eligible.py index 8d6d28990ce..28e3be20de5 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_eligible.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/credits/earned_income/ca_eitc_eligible.py @@ -11,6 +11,7 @@ class ca_eitc_eligible(Variable): reference = ( "https://leginfo.legislature.ca.gov/faces/codes_displaySection.xhtml?lawCode=RTC§ionNum=17052.", "https://www.ftb.ca.gov/forms/2024/2024-3514-booklet.html", + "https://www.ftb.ca.gov/forms/2023/2023-3514-booklet.pdf#page=4", ) def formula(tax_unit, period, parameters): @@ -41,4 +42,5 @@ def formula(tax_unit, period, parameters): meets_age_requirements & meets_investment_income_requirements & meets_agi_requirements + & tax_unit("eitc_marital_eligible", period) ) diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/eitc/nm_eitc_eligible.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/eitc/nm_eitc_eligible.py index 3e0dfcf7267..00ad717d054 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/eitc/nm_eitc_eligible.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/eitc/nm_eitc_eligible.py @@ -12,15 +12,7 @@ class nm_eitc_eligible(Variable): def formula(tax_unit, period, parameters): # Use federal EITC eligibility, but replace demographic eligibility # with NM version. - eitc = parameters.gov.irs.credits.eitc(period) investment_income_eligible = tax_unit("eitc_investment_income_eligible", period) demographic_eligible = tax_unit("nm_eitc_demographic_eligible", period) - # Define eligibility before considering separate filer limitation. - eligible = demographic_eligible & investment_income_eligible - # This parameter is true if separate filers are eligible. - if eitc.eligibility.separate_filer: - return eligible - # If separate filers are not eligible, check if the filer is separate. - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE - return eligible & ~separate + marital_eligible = tax_unit("eitc_marital_eligible", period) + return demographic_eligible & investment_income_eligible & marital_eligible diff --git a/policyengine_us/variables/gov/states/ok/tax/income/credits/eitc/federal_credit/eligible/ok_federal_eitc_eligible.py b/policyengine_us/variables/gov/states/ok/tax/income/credits/eitc/federal_credit/eligible/ok_federal_eitc_eligible.py index a943e47be8d..f74fc6a8d09 100644 --- a/policyengine_us/variables/gov/states/ok/tax/income/credits/eitc/federal_credit/eligible/ok_federal_eitc_eligible.py +++ b/policyengine_us/variables/gov/states/ok/tax/income/credits/eitc/federal_credit/eligible/ok_federal_eitc_eligible.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.state_eitc_helpers import eitc_filing_status_eligible class ok_federal_eitc_eligible(Variable): @@ -31,12 +32,11 @@ def formula(tax_unit, period, parameters): ) # Check demographic (age) eligibility demographic_eligible = tax_unit("ok_federal_eitc_demographic_eligible", period) - # Define eligibility before considering separate filer limitation - eligible = demographic_eligible & investment_income_eligible - # This parameter is true if separate filers are eligible - if eitc.eligibility.separate_filer: - return eligible - # If separate filers are not eligible, check if the filer is separate - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE - return eligible & ~separate + # The frozen 2020 law predates the separated-spouse exception. + marital_eligible = eitc_filing_status_eligible( + tax_unit, + period, + parameters, + separate_filer_eligible=eitc.eligibility.separate_filer, + ) + return demographic_eligible & investment_income_eligible & marital_eligible From 478e8de3cc28c471d812aeb70dacffefec61ecbc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 14:18:31 -0400 Subject: [PATCH 2/7] Test EITC separated-spouse conditions and invariants --- .../earned_income/eitc_marital_eligible.yaml | 85 +++++ .../credits/earned_income/ca_eitc_mfs.yaml | 325 ++++++++++++++++++ ...est_eitc_marital_eligibility_properties.py | 181 ++++++++++ 3 files changed, 591 insertions(+) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_mfs.yaml create mode 100644 policyengine_us/tests/test_eitc_marital_eligibility_properties.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml new file mode 100644 index 00000000000..0fd60fc9329 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml @@ -0,0 +1,85 @@ +# IRC 32(d), as amended by ARPA section 9623, first applies to 2021. +- name: Joint filers do not need a child or separation facts before 2021 + period: 2020 + input: + filing_status: JOINT + is_eitc_qualifying_child: false + eitc_demographic_eligible: true + eitc_investment_income_eligible: true + filer_meets_eitc_identification_requirements: true + output: + eitc_marital_eligible: true + eitc_eligible: true + +- name: Joint filers do not need a child or separation facts after 2021 + period: 2025 + input: + filing_status: JOINT + is_eitc_qualifying_child: false + eitc_demographic_eligible: true + eitc_investment_income_eligible: true + filer_meets_eitc_identification_requirements: true + output: + eitc_marital_eligible: true + eitc_eligible: true + +- name: Single filers do not need a child or separation facts + period: 2025 + input: + filing_status: SINGLE + is_eitc_qualifying_child: false + output: + eitc_marital_eligible: true + +- name: MFS with a child and separation facts was ineligible in 2020 + period: 2020 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: true + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: true + output: + eitc_marital_eligible: false + +- name: MFS with a resident child first qualifies under the six-month route in 2021 + period: 2021 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: true + eitc_child_lived_with_taxpayer_more_than_half_year: true + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: false + output: + eitc_marital_eligible: true + +- name: MFS with a resident child first qualifies under the legal-instrument route in 2021 + period: 2021 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: true + eitc_child_lived_with_taxpayer_more_than_half_year: true + eitc_spouses_lived_apart_last_six_months: false + eitc_legal_separation_and_apart_at_year_end: true + output: + eitc_marital_eligible: true + +- name: A residence fact cannot replace the qualifying-child requirement + period: 2025 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: false + eitc_child_lived_with_taxpayer_more_than_half_year: true + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: true + output: + eitc_marital_eligible: false + +- name: General separation indicators do not establish either statutory route + period: 2025 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: true + is_separated: true + cohabitating_spouses: false + output: + eitc_marital_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_mfs.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_mfs.yaml new file mode 100644 index 00000000000..8f64e17295f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/credits/earned_income/ca_eitc_mfs.yaml @@ -0,0 +1,325 @@ +- name: Childless MFS cannot receive California or federal EITC in 2023 + period: 2023 + input: + people: + parent: + age: 40 + employment_income: 8_639 + tax_units: + unit: + members: [parent] + filing_status: SEPARATE + households: + household: + members: [parent] + state_code: CA + output: + ca_eitc: 0 + eitc: 0 + ca_yctc: 0 + +- name: Childless MFS cannot receive California or federal EITC in 2025 + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 8_639 + tax_units: + unit: + members: [parent] + filing_status: SEPARATE + households: + household: + members: [parent] + state_code: CA + output: + ca_eitc: 0 + eitc: 0 + ca_yctc: 0 + +- name: MFS parent without separation cannot receive CalEITC or YCTC in 2023 + period: 2023 + input: + people: + parent: + age: 40 + employment_income: 8_639 + is_separated: false + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + cohabitating_spouses: true + households: + household: + members: [parent, child] + state_code: CA + output: + ca_eitc: 0 + ca_yctc: 0 + eitc: 0 + +- name: MFS parent without separation cannot receive CalEITC or YCTC in 2025 + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 8_639 + is_separated: false + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + cohabitating_spouses: true + households: + household: + members: [parent, child] + state_code: CA + output: + ca_eitc: 0 + ca_yctc: 0 + eitc: 0 + +- name: MFS parent living apart for the last six months qualifies in 2023 + period: 2023 + absolute_error_margin: 0.02 + input: + people: + parent: + age: 40 + employment_income: 8_639 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: true + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_marital_eligible: true + eitc_eligible: true + ca_eitc_eligible: true + eitc: 2_937.26 + ca_eitc: 1_304.835 + ca_yctc: 1_117 + +- name: MFS parent with legal separation and separate year-end households qualifies in 2025 + period: 2025 + absolute_error_margin: 0.02 + input: + people: + parent: + age: 40 + employment_income: 8_639 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_marital_eligible: true + eitc_eligible: true + ca_eitc_eligible: true + eitc: 2_937.26 + ca_eitc: 1_548.173 + ca_yctc: 1_189 + +- name: Childless MFS remains ineligible even with both separation facts + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 8_639 + tax_units: + unit: + members: [parent] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [parent] + state_code: CA + output: + eitc_marital_eligible: false + ca_eitc: 0 + ca_yctc: 0 + eitc: 0 + +- name: A child who did not live with the MFS filer for more than half the year cannot enable the exception + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 8_639 + child: + age: 3 + is_tax_unit_dependent: true + eitc_child_lived_with_taxpayer_more_than_half_year: false + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_marital_eligible: false + ca_eitc: 0 + ca_yctc: 0 + eitc: 0 + +- name: A qualifying child without a valid SSN still satisfies the MFS exception + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 8_639 + child: + age: 3 + is_tax_unit_dependent: true + ssn_card_type: NONE + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: true + households: + household: + members: [parent, child] + state_code: CA + output: + is_eitc_qualifying_child: [false, true] + eitc_child_count: 0 + eitc_marital_eligible: true + eitc_eligible: true + ca_eitc_eligible: true + ca_yctc: 1_189 + +- name: CalEITC retains its own age rules for an eligible separated parent + period: 2025 + input: + people: + parent: + age: 20 + employment_income: 8_639 + child: + age: 3 + is_tax_unit_dependent: true + ssn_card_type: NONE + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_child_count: 0 + eitc_marital_eligible: true + eitc_eligible: false + ca_eitc_eligible: true + ca_yctc: 1_189 + +- name: CalEITC does not import the federal filer's SSN restriction + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 8_639 + ssn_card_type: NONE + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_marital_eligible: true + eitc_eligible: false + ca_eitc_eligible: true + ca_yctc: 1_189 + +- name: Zero earnings do not bypass the YCTC marital eligibility requirement + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 0 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_marital_eligible: false + ca_eitc_eligible: false + ca_eitc: 0 + ca_yctc: 0 + +- name: Zero earnings preserve YCTC for an MFS parent satisfying the separation exception + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 0 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + unit: + members: [parent, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: true + households: + household: + members: [parent, child] + state_code: CA + output: + eitc_marital_eligible: true + ca_eitc_eligible: true + ca_eitc: 0 + ca_yctc: 1_189 diff --git a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py new file mode 100644 index 00000000000..668a6ec7cb7 --- /dev/null +++ b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py @@ -0,0 +1,181 @@ +"""IRC 32(d) invariants over vectorized tax units and member populations. + +Invoke the production marital-eligibility formula with small entity proxies +and the actual exception parameter. Hypothesis varies qualifying-child, +residence and identification facts, while each unit is evaluated with all +four separation-fact combinations as both a separate and a joint filer. +YAML tests cover model integration; these generated populations check +monotonicity and aggregation without constructing or copying full models. +""" + +from itertools import product +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode + +from policyengine_us.tools.state_eitc_helpers import eitc_filing_status_eligible +from policyengine_us.variables.gov.irs.credits.earned_income.eligibility.eitc_marital_eligible import ( + eitc_marital_eligible, +) + + +ELIGIBILITY = ParameterNode( + "eligibility", + directory_path=str( + Path(__file__).resolve().parents[1] + / "parameters/gov/irs/credits/eitc/eligibility" + ), +) +SEPARATION_FACTS = list(product((False, True), repeat=2)) + + +def parameters(period): + return SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace( + credits=SimpleNamespace( + eitc=SimpleNamespace(eligibility=ELIGIBILITY(period)) + ) + ) + ) + ) + + +class FilingStatuses(np.ndarray): + possible_values = SimpleNamespace(SEPARATE="SEPARATE", JOINT="JOINT") + + +class TaxUnits: + """Provide entity access and group aggregation, without policy logic.""" + + def __init__(self, cases): + self.member_groups = [] + member_values = [] + statuses, six_months, legal_separation = [], [], [] + for case in cases: + for status in ("SEPARATE", "JOINT"): + for apart, legal in SEPARATION_FACTS: + unit_index = len(statuses) + statuses.append(status) + six_months.append(apart) + legal_separation.append(legal) + # Every unit has an adult as well as its generated members. + members = [(False, True, True), *case] + self.member_groups.extend([unit_index] * len(members)) + member_values.extend(members) + self.member_groups = np.asarray(self.member_groups, dtype=int) + self.count = len(statuses) + self.person_values = dict( + zip( + ( + "is_eitc_qualifying_child", + "eitc_child_lived_with_taxpayer_more_than_half_year", + "meets_eitc_identification_requirements", + ), + np.asarray(member_values, dtype=bool).T, + ) + ) + # Expose the existing SSN-restricted count as well, so accidentally + # using it instead of pre-identification children fails the properties. + identified_children = ( + self.person_values["is_eitc_qualifying_child"] + & self.person_values["meets_eitc_identification_requirements"] + ) + self.values = { + "filing_status": np.asarray(statuses).view(FilingStatuses), + "eitc_spouses_lived_apart_last_six_months": np.asarray(six_months), + "eitc_legal_separation_and_apart_at_year_end": np.asarray(legal_separation), + "eitc_child_count": np.bincount( + self.member_groups, + weights=identified_children, + minlength=self.count, + ), + } + + def __call__(self, variable, period): + return self.values[variable] + + def members(self, variable, period): + return self.person_values[variable] + + def any(self, values): + result = np.zeros(self.count, dtype=bool) + np.logical_or.at(result, self.member_groups, values) + return result + + +member_facts = st.tuples(st.booleans(), st.booleans(), st.booleans()) + + +@settings(max_examples=50, deadline=None, derandomize=True) +@given(cases=st.lists(st.lists(member_facts, max_size=5), min_size=1, max_size=12)) +@example( + cases=[ + [], + [(True, True, False)], + [(True, False, True), (False, True, True)], + ] +) +def test_marital_eligibility_invariants(cases): + tax_units = TaxUnits(cases) + has_resident_child = np.asarray( + [ + any(qualifies and resident for qualifies, resident, _ in case) + for case in cases + ] + ) + for year in (2020, 2021, 2025): + actual = eitc_marital_eligible.formula( + tax_units, f"{year}-01-01", parameters + ).reshape(len(cases), 2, len(SEPARATION_FACTS)) + separate, joint = actual[:, 0], actual[:, 1] + + # Joint filers satisfy the marital test for every child/separation fact. + assert joint.all() + # No child, or only a nonresident child, can never enable the exception. + assert not separate[~has_resident_child].any() + # Adding either true separation fact cannot remove eligibility. + assert np.all(separate[:, 0] <= separate[:, 1]) + assert np.all(separate[:, 0] <= separate[:, 2]) + assert np.all(separate[:, 1] <= separate[:, 3]) + assert np.all(separate[:, 2] <= separate[:, 3]) + + if year < 2021: + assert not separate.any() + else: + assert not separate[:, 0].any() + # Both legal routes work with a resident qualifying child, even + # when that child's identification fails the federal credit test. + assert separate[has_resident_child, 1:].all() + + +@pytest.mark.parametrize("year,frozen_switch", [(2025, False), (2020, True)]) +def test_state_frozen_exception_switch_overrides_current_year(year, frozen_switch): + period = f"{year}-01-01" + assert ELIGIBILITY(period).separate_filer != frozen_switch + tax_units = TaxUnits( + [ + [(True, True, False)], + [], + [(True, False, True)], + ] + ) + actual = eitc_filing_status_eligible( + tax_units, + period, + parameters, + separate_filer_eligible=frozen_switch, + ).reshape(3, 2, len(SEPARATION_FACTS)) + + # A frozen switch controls when the exception is available, while the + # resident-child and separation requirements still apply to both routes. + np.testing.assert_array_equal( + actual[0, 0], [False, frozen_switch, frozen_switch, frozen_switch] + ) + assert not actual[1:, 0].any() + assert actual[:, 1].all() From 46c538eaa033aaf6f344f94e3b487988fa5a1fa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:04:52 +0000 Subject: [PATCH 3/7] Default the EITC living-apart fact from is_separated; extend the exception to separated HoH IRC 32(d)(2)(B)(iii)(I) asks whether the spouses lived apart for the last six months, the same fact is_separated already carries for the head of household, filing status and IRC 21(e)(4) tests. Default eitc_spouses_lived_apart_last_six_months from a separated head or spouse, keeping it overridable, so separated parents filing separately keep the EITC. The legal-separation route stays separate. From 2021, IRC 32(d)(2)(A) determines marriage under 7703(a) only, so a separated filer whom 7703(b) lets file as head of household is still married for the EITC. Apply the same exception test to that unit. calculate_eitc_demographic_eligibility, used only by Washington, tested any member's age, so a childless filer under 25 qualified through a dependent's age. Restrict it to the filer and spouse, as the federal variable does. Co-Authored-By: Claude Opus 5.5 --- .../eitc-mfs-separation-conditions.fixed.md | 2 +- .../earned_income/eitc_marital_eligible.yaml | 219 +++++++++++++++++- .../wa_working_families_tax_credit.yaml | 70 ++++++ ...est_eitc_marital_eligibility_properties.py | 9 +- policyengine_us/tools/state_eitc_helpers.py | 36 ++- .../eligibility/eitc_marital_eligible.py | 7 +- ...itc_spouses_lived_apart_last_six_months.py | 19 +- 7 files changed, 345 insertions(+), 17 deletions(-) diff --git a/changelog.d/eitc-mfs-separation-conditions.fixed.md b/changelog.d/eitc-mfs-separation-conditions.fixed.md index c7dbc5c2e04..d0bfb64784b 100644 --- a/changelog.d/eitc-mfs-separation-conditions.fixed.md +++ b/changelog.d/eitc-mfs-separation-conditions.fixed.md @@ -1 +1 @@ -Require a resident qualifying child and the statutory separation conditions for married separate filers to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. +Require a resident qualifying child and the statutory separation conditions for married separate filers, and for separated head-of-household filers from 2021, to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. A head or spouse marked as separated meets the six-month living-apart condition by default. Washington's Working Families Tax Credit no longer meets the childless age test through a dependent's age. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml index 0fd60fc9329..207180905c2 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml @@ -74,12 +74,227 @@ output: eitc_marital_eligible: false -- name: General separation indicators do not establish either statutory route +- name: Separation of the head defaults the six-month living-apart route period: 2025 input: filing_status: SEPARATE is_eitc_qualifying_child: true is_separated: true - cohabitating_spouses: false output: + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: false + eitc_marital_eligible: true + +- name: The six-month living-apart input overrides the separation default + period: 2025 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: true + is_separated: true + eitc_spouses_lived_apart_last_six_months: false + output: + eitc_marital_eligible: false + +- name: Separation still requires a qualifying child + period: 2025 + input: + filing_status: SEPARATE + is_eitc_qualifying_child: false + is_separated: true + output: + eitc_spouses_lived_apart_last_six_months: true + eitc_marital_eligible: false + +- name: Separated MFS parent with a qualifying child receives the EITC + # IRC 32(d)(2)(B)(iii)(I): is_separated defaults the living-apart route. + # 2026, one child: 4_427 - 0.1598 x (30_000 - 23_890) = 3_450.62. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 30_000 + is_separated: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + households: + household: + members: [head, child] + state_code: TX + output: + eitc_spouses_lived_apart_last_six_months: true + eitc_marital_eligible: true + eitc_eligible: true + eitc: 3_450.62 + +- name: MFS parent with a qualifying child but no separation facts gets no EITC + # IRC 32(d)(1) requires a joint return from a married individual; neither + # route in 32(d)(2)(B)(iii) is established, so the exception doesn't apply. + period: 2026 + input: + people: + head: + age: 35 + employment_income: 30_000 + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + households: + household: + members: [head, child] + state_code: TX + output: + eitc_spouses_lived_apart_last_six_months: false + eitc_marital_eligible: false + eitc_eligible: false + eitc: 0 + +- name: MFS parent with a separation agreement and separate household at year end gets the EITC + # IRC 32(d)(2)(B)(iii)(II); same schedule as the separated-parent case. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 30_000 + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [head, child] + state_code: TX + output: + eitc_spouses_lived_apart_last_six_months: false + eitc_marital_eligible: true + eitc_eligible: true + eitc: 3_450.62 + +- name: A separated dependent does not establish the living-apart route + period: 2026 + input: + people: + head: + age: 35 + employment_income: 30_000 + child: + age: 17 + is_separated: true + child2: + age: 5 + tax_units: + tax_unit: + members: [head, child, child2] + filing_status: SEPARATE + households: + household: + members: [head, child, child2] + state_code: TX + output: + eitc_spouses_lived_apart_last_six_months: false + eitc_marital_eligible: false + +- name: Separated parent filing as head of household meets the exception by default + # head_of_household_eligible's IRC 7703(b) route gives HoH status; from + # 2021, IRC 32(d)(2)(A) still treats the filer as married under 7703(a). + period: 2025 + input: + people: + head: + age: 35 + employment_income: 30_000 + is_separated: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + eitc_spouses_lived_apart_last_six_months: true + eitc_marital_eligible: true + +- name: Separated head of household who did not live apart for six months fails from 2021 + period: 2025 + input: + people: + head: + age: 35 + employment_income: 30_000 + is_separated: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + eitc_spouses_lived_apart_last_six_months: false + households: + household: + members: [head, child] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD eitc_marital_eligible: false + eitc: 0 + +- name: Separated head of household was unmarried for the EITC before 2021 + # Before ARPA section 9623, IRC 32(d) applied all of section 7703, so the + # 7703(b) treated-unmarried filer needed no separation exception. + period: 2020 + input: + people: + head: + age: 35 + employment_income: 30_000 + is_separated: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + eitc_spouses_lived_apart_last_six_months: false + households: + household: + members: [head, child] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + eitc_marital_eligible: true + +- name: Unmarried head of household needs no separation facts + period: 2025 + input: + people: + head: + age: 35 + employment_income: 30_000 + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + eitc_spouses_lived_apart_last_six_months: false + eitc_marital_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml index 245657d5606..2093c23878c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml @@ -1657,3 +1657,73 @@ # ETA 3240.2026 gives the 2025 two-child maximum, $995. Measuring from # 62,000 would give 995 - 0.199 x (62,000 - 59,430) = 484. wa_working_families_tax_credit: 995 + +- name: Childless separate filer below the age band gets no credit from a dependent's age + # IRC 32(c)(1)(A)(ii)(II), in the frozen 2022 federal rules, applies the + # 25-64 age test to the filer, not a dependent. The 22-year-old filer has + # no qualifying child, and the 30-year-old qualifying relative does not + # satisfy the age test for them. + period: 2025 + input: + people: + head: + age: 22 + employment_income: 10_000 + dependent: + age: 30 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SEPARATE + households: + household: + members: [head, dependent] + state_code: WA + output: + wa_working_families_tax_credit: 0 + +- name: Childless single filer below the age band gets no credit from a dependent's age + period: 2025 + input: + people: + head: + age: 22 + employment_income: 10_000 + dependent: + age: 30 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SINGLE + households: + household: + members: [head, dependent] + state_code: WA + output: + wa_working_families_tax_credit: 0 + +- name: Childless separate filer in the age band keeps the state-only credit + # The filer is 30, so the age test is met. Earnings of 10,000 are below the + # childless phase-out start (maximum qualifying income less 2,500), so the + # credit is the 2025 no-child amount, $335. + period: 2025 + input: + people: + head: + age: 30 + employment_income: 10_000 + dependent: + age: 22 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SEPARATE + households: + household: + members: [head, dependent] + state_code: WA + output: + wa_working_families_tax_credit: 335 diff --git a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py index 668a6ec7cb7..09dc0ab118f 100644 --- a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py +++ b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py @@ -47,7 +47,9 @@ def parameters(period): class FilingStatuses(np.ndarray): - possible_values = SimpleNamespace(SEPARATE="SEPARATE", JOINT="JOINT") + possible_values = SimpleNamespace( + SEPARATE="SEPARATE", JOINT="JOINT", HEAD_OF_HOUSEHOLD="HEAD_OF_HOUSEHOLD" + ) class TaxUnits: @@ -80,6 +82,11 @@ def __init__(self, cases): np.asarray(member_values, dtype=bool).T, ) ) + # The first member of each unit is the filer; nobody is separated, so + # the separated head-of-household route is not exercised here. + filer = np.r_[True, self.member_groups[1:] != self.member_groups[:-1]] + self.person_values["is_tax_unit_head_or_spouse"] = filer + self.person_values["is_separated"] = np.zeros_like(filer) # Expose the existing SSN-restricted count as well, so accidentally # using it instead of pre-identification children fails the properties. identified_children = ( diff --git a/policyengine_us/tools/state_eitc_helpers.py b/policyengine_us/tools/state_eitc_helpers.py index bda4f9d5771..eef12013a53 100644 --- a/policyengine_us/tools/state_eitc_helpers.py +++ b/policyengine_us/tools/state_eitc_helpers.py @@ -31,9 +31,11 @@ def eitc_filing_status_eligible( """Apply IRC 32(d), optionally using a state's frozen exception switch. The switch enables the separated-spouse exception, rather than making - every separate filer eligible. Qualifying children are counted before - identification requirements: IRC 32(c)(3)(D) limits the credit schedule, - not the separated-spouse exception in IRC 32(d)(2)(B). + every separate filer eligible. Once enabled, the exception also governs + separated head-of-household filers, who are married under IRC 7703(a). + Qualifying children are counted before identification requirements: + IRC 32(c)(3)(D) limits the credit schedule, not the separated-spouse + exception in IRC 32(d)(2)(B). """ if separate_filer_eligible is None: @@ -41,11 +43,23 @@ def eitc_filing_status_eligible( period ).gov.irs.credits.eitc.eligibility.separate_filer filing_status = tax_unit("filing_status", period) - not_separate = filing_status != filing_status.possible_values.SEPARATE + statuses = filing_status.possible_values + separate = filing_status == statuses.SEPARATE if not separate_filer_eligible: - return not_separate + return ~separate person = tax_unit.members + # From 2021, IRC 32(d)(2)(A) determines marital status under IRC 7703(a) + # only, so a separated filer whom IRC 7703(b) lets file as head of + # household is still married for the EITC and needs the same exception. + # is_separated on the head or spouse is the model's married-but-apart + # indicator; head_of_household_eligible reads it for the 7703(b) route. + separated_head_of_household = ( + filing_status == statuses.HEAD_OF_HOUSEHOLD + ) & tax_unit.any( + person("is_tax_unit_head_or_spouse", period) & person("is_separated", period) + ) + needs_exception = separate | separated_head_of_household has_resident_qualifying_child = tax_unit.any( person("is_eitc_qualifying_child", period) & person("eitc_child_lived_with_taxpayer_more_than_half_year", period) @@ -53,7 +67,9 @@ def eitc_filing_status_eligible( meets_separation_conditions = tax_unit( "eitc_spouses_lived_apart_last_six_months", period ) | tax_unit("eitc_legal_separation_and_apart_at_year_end", period) - return not_separate | (has_resident_qualifying_child & meets_separation_conditions) + return ~needs_exception | ( + has_resident_qualifying_child & meets_separation_conditions + ) def calculate_eitc_demographic_eligibility( @@ -73,7 +89,13 @@ def calculate_eitc_demographic_eligibility( eitc_parameters.eligibility.age.min, ) max_age = eitc_parameters.eligibility.age.max - return has_child | tax_unit.any((age >= min_age) & (age <= max_age)) + # IRC 32(c)(1)(A)(ii)(II) applies the childless age test to the filer or, + # on a joint return, either spouse, never to a dependent, matching the + # federal eitc_demographic_eligible. + is_filer_or_spouse = ~person("is_tax_unit_dependent", period) + return has_child | tax_unit.any( + is_filer_or_spouse & (age >= min_age) & (age <= max_age) + ) def calculate_eitc_phase_out_start( diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py index 24216058cff..f8bbf650fc6 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py @@ -14,8 +14,11 @@ class eitc_marital_eligible(Variable): documentation = """ Married separate filers must have a qualifying child residing with them for more than half the year and satisfy either statutory separation - route. The exception is enabled from 2021. Child identification rules - do not determine whether the marital exception applies. + route. The exception is enabled from 2021. From then, IRC 32(d)(2)(A) + determines marriage under IRC 7703(a) only, so a separated head of + household filer (is_separated on the head or spouse) faces the same + test. Child identification rules do not determine whether the marital + exception applies. """ def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py index b8bd3643513..8bfcb4f5d72 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_spouses_lived_apart_last_six_months.py @@ -6,11 +6,22 @@ class eitc_spouses_lived_apart_last_six_months(Variable): entity = TaxUnit label = "Spouses had different principal residences for the last six months" definition_period = YEAR - default_value = False reference = "https://www.law.cornell.edu/uscode/text/26/32#d_2_B_iii_I" documentation = """ Whether the filer and spouse had different principal places of abode - throughout the last six months of the tax year. A current separation - indicator alone does not establish this condition. Defaults to false - when this separation fact is not supplied. + throughout the last six months of the tax year, for the separated-spouse + exception in IRC 32(d)(2)(B)(iii)(I). Defaults to true when the tax unit + head or spouse is separated (is_separated), the model's existing + living-apart indicator, which the head-of-household, filing-status and + child and dependent care credit rules also read. Set this input directly + to override the default. It does not establish the legal-separation + route in IRC 32(d)(2)(B)(iii)(II), which requires a separation + instrument and separate households at year end. """ + + def formula(tax_unit, period, parameters): + person = tax_unit.members + return tax_unit.any( + person("is_tax_unit_head_or_spouse", period) + & person("is_separated", period) + ) From 354e4dddb1099b45c8bb9f6662c7cc943da8fb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:04:47 +0000 Subject: [PATCH 4/7] Treat decree-separated filers as unmarried for the EITC; keep NJ's age fallback within the federal marital test IRC 7703(a)(2) makes a filer legally separated under a decree of separate maintenance unmarried, so IRC 32(d)(1) does not reach the unit. The new is_separated_under_decree input (default false, since survey data cannot identify a decree) lifts the exception requirement in both the separate and the separated head-of-household branches of the shared predicate. New Jersey's childless fallback waives only the federal age limits, so it now requires eitc_marital_eligible instead of excluding only separate returns; a married separated head of household with no qualifying child no longer reaches it. Updates the two partner cases whose new values the partner-test owner approved on the PR, and adds YAML witnesses and Hypothesis invariants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6GNhVT4m1wugwntS23 --- .../eitc-mfs-separation-conditions.fixed.md | 2 +- .../earned_income/eitc_marital_eligible.yaml | 96 +++++++ .../nj/tax/income/credits/eitc/nj_eitc.yaml | 55 ++++ .../federal/tax_credits/cdcc/federal.yaml | 8 +- .../federal/tax_credits/eitc/federal.yaml | 22 +- ...est_eitc_marital_eligibility_properties.py | 271 ++++++++++++++++++ policyengine_us/tools/state_eitc_helpers.py | 14 +- .../eitc/nj_childless_eitc_age_eligible.py | 9 +- .../marital_unit/is_separated_under_decree.py | 18 ++ 9 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py diff --git a/changelog.d/eitc-mfs-separation-conditions.fixed.md b/changelog.d/eitc-mfs-separation-conditions.fixed.md index d0bfb64784b..3841fd20a00 100644 --- a/changelog.d/eitc-mfs-separation-conditions.fixed.md +++ b/changelog.d/eitc-mfs-separation-conditions.fixed.md @@ -1 +1 @@ -Require a resident qualifying child and the statutory separation conditions for married separate filers, and for separated head-of-household filers from 2021, to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. A head or spouse marked as separated meets the six-month living-apart condition by default. Washington's Working Families Tax Credit no longer meets the childless age test through a dependent's age. +Require a resident qualifying child and the statutory separation conditions for married separate filers, and for separated head-of-household filers from 2021, to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. A head or spouse marked as separated meets the six-month living-apart condition by default. A new `is_separated_under_decree` input (default false) treats a head or spouse separated under a decree of separate maintenance as unmarried for the EITC, under IRC 7703(a)(2). New Jersey's childless age fallback now requires the federal marital condition. Washington's Working Families Tax Credit no longer meets the childless age test through a dependent's age. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml index 207180905c2..bac2d411d81 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml @@ -298,3 +298,99 @@ filing_status: HEAD_OF_HOUSEHOLD eitc_spouses_lived_apart_last_six_months: false eitc_marital_eligible: true + +- name: Separated head of household under a decree of separate maintenance is unmarried for the EITC + # IRC 7703(a)(2) treats a filer separated under a decree of separate + # maintenance as unmarried, so IRC 32(d)(1) doesn't apply and no + # qualifying child is needed for the marital test. + # 2025, no qualifying child: phase-in 0.0765 x 10_000 = 765, capped at the + # 649 maximum; earnings 10_000 are below the 10_620 phase-out start, so + # the reduction is 0 and the EITC is 649. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 10_000 + is_separated: true + is_separated_under_decree: true + mother: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, mother] + filing_status: HEAD_OF_HOUSEHOLD + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [head, mother] + state_code: TX + output: + eitc_child_count: 0 + eitc_marital_eligible: true + eitc_eligible: true + eitc: 649 + +- name: Separated head of household without a decree stays married and needs a qualifying child + # Same household with is_separated_under_decree false (the default): the + # filer is married under IRC 7703(a), the exception in IRC 32(d)(2)(B) + # needs a resident qualifying child, and there is none, so the EITC is 0. + period: 2025 + input: + people: + head: + age: 35 + employment_income: 10_000 + is_separated: true + is_separated_under_decree: false + mother: + age: 70 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, mother] + filing_status: HEAD_OF_HOUSEHOLD + eitc_spouses_lived_apart_last_six_months: true + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [head, mother] + state_code: TX + output: + eitc_child_count: 0 + eitc_marital_eligible: false + eitc_eligible: false + eitc: 0 + +- name: Separate filer under a decree needs no living-apart fact + # IRC 7703(a)(2): the decree makes the unit unmarried, so the exception + # isn't needed and no separation route is checked. Both routes are false. + # 2026, one child: 4_427 - 0.1598 x (30_000 - 23_890) = 3_450.62, the same + # schedule as the separated MFS parent above. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 30_000 + is_separated_under_decree: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: false + eitc_legal_separation_and_apart_at_year_end: false + households: + household: + members: [head, child] + state_code: TX + output: + eitc_marital_eligible: true + eitc_eligible: true + eitc: 3_450.62 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml index be6996b945b..487136a018f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml @@ -138,3 +138,58 @@ state_code: NJ output: nj_eitc: 0 + +- name: Married separated head of household without a qualifying child gets no NJ age fallback + # The filer is married under IRC 7703(a) (7703(b) gives only HoH status), + # lived apart six months (is_separated default), but has no EITC + # qualifying child, so IRC 32(d) denies the federal credit. The NJ + # fallback waives only the age limits, so the marital failure denies it + # too: federal 0, NJ 0. Before this fix NJ paid 0.4 x 649 = 259.60. + period: 2025 + input: + people: + head: + age: 55 + employment_income: 12_000 + is_separated: true + adult_child: + age: 30 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, adult_child] + filing_status: HEAD_OF_HOUSEHOLD + households: + household: + members: [head, adult_child] + state_code: NJ + output: + eitc_spouses_lived_apart_last_six_months: true + eitc_child_count: 0 + eitc_marital_eligible: false + eitc: 0 + nj_childless_eitc_age_eligible: false + nj_eitc: 0 + +- name: Unmarried 22-year-old without a qualifying child still gets the NJ age fallback + # Below the federal minimum age of 25 but above NJ's minimum of 18. + # 2025: NJ match 0.4 x federal zero-child maximum 649 = 259.60. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 22 + employment_income: 8_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: NJ + output: + eitc_marital_eligible: true + eitc: 0 + nj_childless_eitc_age_eligible: true + nj_eitc: 259.60 diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/cdcc/federal.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/cdcc/federal.yaml index 2f7593050cc..559455b06e8 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/cdcc/federal.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/cdcc/federal.yaml @@ -845,6 +845,8 @@ # joint return, so cdcc = 0 (deliberate filing-status-ineligibility pin). # The gate applies in cdcc_potential (pre-liability-cap), which is what # state credits consume, so both are pinned at 0. +# The same facts deny the EITC: without six months apart or a separation +# instrument, IRC 32(d) bars a married separate filer, so eitc = 0. - name: cdcc_separate_filer_ineligible (federal) period: 2026 absolute_error_margin: 0.1 @@ -895,15 +897,15 @@ output: tax_units: tax_unit: - eitc: 3_450.62 - eitc_eligible: true + eitc: 0.0 + eitc_eligible: false ctc: 2_200.0 ctc_value: 2_200.0 refundable_ctc: 780.0 cdcc_potential: 0.0 cdcc: 0.0 adjusted_gross_income: 30_000.0 - income_tax: -4_230.62 + income_tax: -780.0 # Same separate-filing parent, but living apart (is_separated) while # maintaining a home for the qualifying child: IRC 21(e)(4) treats them as # unmarried, restoring eligibility. AGI 30_000 -> first phase-out removes diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/eitc/federal.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/eitc/federal.yaml index 04973206659..cb7eaca3056 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/eitc/federal.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/tax_credits/eitc/federal.yaml @@ -869,7 +869,7 @@ cdcc: 0.0 adjusted_gross_income: 10_000.0 income_tax: -664.0 -- name: eitc_married_filing_separately_eligible_post_arpa (federal) +- name: eitc_married_filing_separately_same_household_ineligible (federal) period: 2026 absolute_error_margin: 0.1 input: @@ -931,22 +931,24 @@ output: tax_units: tax_unit: - # ARPA Sec. 9623 permanently amended 26 USC ยง 32(d): separate filers - # ARE eligible from 2021 on (eitc.eligibility.separate_filer: true), - # so this pins eligible/positive โ€” NOT the pre-2021 exclusion. - # Non-joint phase-out start = 23_890 (no joint bonus); earnings - # 20_000 < 23_890 -> plateau eitc = 4_427. + # ARPA Sec. 9623 opened 26 USC ยง 32(d) from 2021 only to "certain + # separated spouses": a separate filer needs a qualifying child + # living with them for over half the year and one 32(d)(2)(B) + # condition. This couple shares a household with no separation + # facts, so neither condition holds (no six months apart, no + # separation instrument with separate households at year end): + # eitc = 0, eitc_eligible = false. # income_tax: taxable = 20_000 - 16_100 MFS standard deduction = # 3_900 -> 390 tax, absorbed by non-refundable CTC; net = - # -(4_427 + 1_700) = -6_127. - eitc: 4_427.0 - eitc_eligible: true + # -1_700 refundable CTC. + eitc: 0.0 + eitc_eligible: false ctc: 2_200.0 ctc_value: 2_090.0 refundable_ctc: 1_700.0 cdcc: 0.0 adjusted_gross_income: 20_000.0 - income_tax: -6_127.0 + income_tax: -1_700.0 - name: eitc_negative_self_employment_income_offsets_wages (federal) period: 2026 absolute_error_margin: 0.1 diff --git a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py index 09dc0ab118f..0918cef93b0 100644 --- a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py +++ b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py @@ -18,10 +18,17 @@ from hypothesis import strategies as st from policyengine_core.parameters import ParameterNode +from policyengine_us.tools.section_911 import SECTION_911_LEAF_INPUTS from policyengine_us.tools.state_eitc_helpers import eitc_filing_status_eligible from policyengine_us.variables.gov.irs.credits.earned_income.eligibility.eitc_marital_eligible import ( eitc_marital_eligible, ) +from policyengine_us.variables.gov.states.nj.tax.income.credits.eitc.nj_childless_eitc_age_eligible import ( + nj_childless_eitc_age_eligible, +) +from policyengine_us.variables.gov.states.nj.tax.income.credits.eitc.nj_eitc import ( + nj_eitc, +) ELIGIBILITY = ParameterNode( @@ -87,6 +94,7 @@ def __init__(self, cases): filer = np.r_[True, self.member_groups[1:] != self.member_groups[:-1]] self.person_values["is_tax_unit_head_or_spouse"] = filer self.person_values["is_separated"] = np.zeros_like(filer) + self.person_values["is_separated_under_decree"] = np.zeros_like(filer) # Expose the existing SSN-restricted count as well, so accidentally # using it instead of pre-identification children fails the properties. identified_children = ( @@ -186,3 +194,266 @@ def test_state_frozen_exception_switch_overrides_current_year(year, frozen_switc ) assert not actual[1:, 0].any() assert actual[:, 1].all() + + +# IRC 7703(a)(2): a filer legally separated under a decree of separate +# maintenance is not married, so IRC 32(d) needs no exception for the unit. +DECREE_STATUSES = ("SEPARATE", "HEAD_OF_HOUSEHOLD", "JOINT", "SINGLE") +DECREE_HOLDERS = ("nobody", "head", "dependent") + + +class DecreeUnits: + """Units over status, head separation, separation facts and decree holder. + + Each generated child list is crossed with every status, head is_separated + value, separation-fact pair and decree holder. Members are the head, then + one dependent who never counts as a qualifying child, then the children. + """ + + def __init__(self, cases): + member_groups, person_rows, unit_rows = [], [], [] + for case_index, case in enumerate(cases): + for status, separated, (apart, legal), holder in product( + DECREE_STATUSES, (False, True), SEPARATION_FACTS, DECREE_HOLDERS + ): + unit_index = len(unit_rows) + unit_rows.append( + ( + case_index, + status, + separated, + apart, + legal, + DECREE_HOLDERS.index(holder), + ) + ) + # (head_or_spouse, separated, decree, qualifies, resident) + members = [ + (True, separated, holder == "head", False, True), + (False, False, holder == "dependent", False, True), + *[(False, False, False, q, r) for q, r in case], + ] + member_groups.extend([unit_index] * len(members)) + person_rows.extend(members) + self.member_groups = np.asarray(member_groups, dtype=int) + self.count = len(unit_rows) + columns = np.asarray(person_rows, dtype=bool).T + self.person_values = dict( + zip( + ( + "is_tax_unit_head_or_spouse", + "is_separated", + "is_separated_under_decree", + "is_eitc_qualifying_child", + "eitc_child_lived_with_taxpayer_more_than_half_year", + ), + columns, + ) + ) + case_index, status, separated, apart, legal, holder = zip(*unit_rows) + self.case_index = np.asarray(case_index) + self.status = np.asarray(status) + self.separated = np.asarray(separated) + self.holder = np.asarray(holder) + self.values = { + "filing_status": np.asarray(status).view(FilingStatuses), + "eitc_spouses_lived_apart_last_six_months": np.asarray(apart), + "eitc_legal_separation_and_apart_at_year_end": np.asarray(legal), + } + + def __call__(self, variable, period): + return self.values[variable] + + def members(self, variable, period): + return self.person_values[variable] + + def any(self, values): + result = np.zeros(self.count, dtype=bool) + np.logical_or.at(result, self.member_groups, values) + return result + + +def head_rule_without_decree(units, has_resident_child, year): + """IRC 32(d) as this PR read it before the decree input existed. + + Written from the statute rather than copied from the formula: before 2021 + a separate return is barred; from 2021 a separate filer, or a separated + head of household, needs a resident qualifying child and either route. + """ + separate = units.status == "SEPARATE" + if year < 2021: + return ~separate + needs_exception = separate | ( + (units.status == "HEAD_OF_HOUSEHOLD") & units.separated + ) + routes = ( + units.values["eitc_spouses_lived_apart_last_six_months"] + | units.values["eitc_legal_separation_and_apart_at_year_end"] + ) + return ~needs_exception | (has_resident_child[units.case_index] & routes) + + +@settings(max_examples=50, deadline=None, derandomize=True) +@given( + cases=st.lists( + st.lists(st.tuples(st.booleans(), st.booleans()), max_size=3), + min_size=1, + max_size=6, + ) +) +@example(cases=[[], [(True, True)], [(True, False)]]) +def test_decree_of_separate_maintenance_invariants(cases): + units = DecreeUnits(cases) + has_resident_child = np.asarray([any(q and r for q, r in case) for case in cases]) + nobody, head, dependent = ( + units.holder == DECREE_HOLDERS.index(h) for h in DECREE_HOLDERS + ) + for year in (2020, 2021, 2025): + actual = eitc_marital_eligible.formula(units, f"{year}-01-01", parameters) + expected = head_rule_without_decree(units, has_resident_child, year) + + # With the decree input false everywhere, results equal the rule + # before the input existed, and a decree on a dependent changes + # nothing: only the head or spouse can be separated under a decree. + np.testing.assert_array_equal(actual[nobody], expected[nobody]) + np.testing.assert_array_equal(actual[dependent], expected[dependent]) + # Turning the decree input on never removes marital eligibility. + assert np.all(actual[nobody] <= actual[head]) + if year < 2021: + # The frozen pre-ARPA path ignores the input. + np.testing.assert_array_equal(actual[head], actual[nobody]) + else: + # A decree makes the head unmarried, whatever the other facts. + assert actual[head].all() + # Not vacuous: every case has a separate filer with no separation + # route who fails without the decree and passes with it. + gained = actual[head] & ~actual[nobody] + assert set(units.case_index[head][gained]) == set(range(len(cases))) + + # States that freeze the exception off keep barring separate returns. + frozen = eitc_filing_status_eligible( + units, "2025-01-01", parameters, separate_filer_eligible=False + ) + np.testing.assert_array_equal(frozen, units.status != "SEPARATE") + + +NJ_EITC = ParameterNode( + "eitc", + directory_path=str( + Path(__file__).resolve().parents[1] + / "parameters/gov/states/nj/tax/income/credits/eitc" + ), +) +FEDERAL_EITC = ParameterNode( + "eitc", + directory_path=str( + Path(__file__).resolve().parents[1] / "parameters/gov/irs/credits/eitc" + ), +) + + +def nj_parameters(period): + return SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace(credits=SimpleNamespace(eitc=FEDERAL_EITC(period))), + states=SimpleNamespace( + nj=SimpleNamespace( + tax=SimpleNamespace( + income=SimpleNamespace( + credits=SimpleNamespace(eitc=NJ_EITC(period)) + ) + ) + ) + ), + ) + ) + + +class NewJerseyUnits: + """One-person New Jersey units with federal EITC results as inputs.""" + + def __init__(self, rows, marital): + ( + age, + child_count, + income_eligible, + other_federal_eligible, + phased_in, + reduction, + takes_up, + ) = (np.asarray(column) for column in zip(*rows)) + marital = np.asarray(marital, dtype=bool) + # eitc_eligible includes the marital condition. + eligible = np.asarray(other_federal_eligible, dtype=bool) & marital + self.count = len(rows) + self.person_values = {"age": age} + zeros = np.zeros(self.count) + self.values = { + "eitc_marital_eligible": marital, + "eitc_child_count": child_count, + "nj_eitc_income_eligible": np.asarray(income_eligible, dtype=bool), + "eitc_eligible": eligible, + "eitc_phased_in": phased_in, + "eitc_reduction": reduction, + "takes_up_eitc": np.asarray(takes_up, dtype=bool), + "eitc": np.maximum(phased_in - reduction, 0) * eligible * takes_up, + "foreign_earned_income_exclusion": zeros, + "section_911_excluded_income": zeros, + } + for variable in SECTION_911_LEAF_INPUTS: + self.values[variable] = zeros + + def __call__(self, variable, period): + return self.values[variable] + + def members(self, variable, period): + return self.person_values[variable] + + def any(self, values): + return np.asarray(values, dtype=bool) + + +def nj_eitc_for(rows, marital, year): + units = NewJerseyUnits(rows, marital) + period = f"{year}-01-01" + units.values["nj_childless_eitc_age_eligible"] = ( + nj_childless_eitc_age_eligible.formula(units, period, nj_parameters) + ) + return nj_eitc.formula(units, period, nj_parameters) + + +nj_rows = st.tuples( + st.integers(15, 80), + st.integers(0, 2), + st.booleans(), + st.booleans(), + st.floats(0, 8_000), + st.floats(0, 8_000), + st.booleans(), +) + + +@settings(max_examples=50, deadline=None, derandomize=True) +@given( + rows=st.lists(nj_rows, min_size=1, max_size=20), + marital=st.lists(st.booleans(), min_size=20, max_size=20), +) +@example( + rows=[(22, 0, True, False, 612.0, 0.0, True)] * 2, + marital=[False, True] + [False] * 18, +) +def test_nj_eitc_requires_federal_marital_eligibility(rows, marital): + marital = marital[: len(rows)] + for year in (2021, 2025): + actual = nj_eitc_for(rows, marital, year) + # A unit that fails the federal marital test gets no NJ EITC, either + # through the federal match or the age fallback. + assert np.all(actual[~np.asarray(marital)] == 0) + # Not vacuous: with the marital test passed, the age fallback pays a + # childless filer aged 18 or more who meets the income tests. + passed = nj_eitc_for(rows, [True] * len(rows), year) + age, children, income_ok, _, phased_in, reduction, _ = ( + np.asarray(column) for column in zip(*rows) + ) + fallback = (age >= 18) & (children == 0) & income_ok & (phased_in > reduction) + assert np.all(passed[fallback] > 0) diff --git a/policyengine_us/tools/state_eitc_helpers.py b/policyengine_us/tools/state_eitc_helpers.py index eef12013a53..c44617ba3d2 100644 --- a/policyengine_us/tools/state_eitc_helpers.py +++ b/policyengine_us/tools/state_eitc_helpers.py @@ -32,7 +32,8 @@ def eitc_filing_status_eligible( The switch enables the separated-spouse exception, rather than making every separate filer eligible. Once enabled, the exception also governs - separated head-of-household filers, who are married under IRC 7703(a). + separated head-of-household filers, who are married under IRC 7703(a) + unless separated under a decree (IRC 7703(a)(2)). Qualifying children are counted before identification requirements: IRC 32(c)(3)(D) limits the credit schedule, not the separated-spouse exception in IRC 32(d)(2)(B). @@ -59,7 +60,16 @@ def eitc_filing_status_eligible( ) & tax_unit.any( person("is_tax_unit_head_or_spouse", period) & person("is_separated", period) ) - needs_exception = separate | separated_head_of_household + # IRC 7703(a)(2): "an individual legally separated from his spouse under + # a decree of divorce or of separate maintenance shall not be considered + # as married." Such a unit is unmarried for IRC 32(d)(2)(A), so neither + # the separate-return nor the separated head-of-household branch needs + # the exception. + separated_under_decree = tax_unit.any( + person("is_tax_unit_head_or_spouse", period) + & person("is_separated_under_decree", period) + ) + needs_exception = (separate | separated_head_of_household) & ~separated_under_decree has_resident_qualifying_child = tax_unit.any( person("is_eitc_qualifying_child", period) & person("eitc_child_lived_with_taxpayer_more_than_half_year", period) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py index 5704b213abf..b75876133ac 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py @@ -18,9 +18,10 @@ class nj_childless_eitc_age_eligible(Variable): def formula(tax_unit, period, parameters): # Return True if all federal EITC conditions are met, except with modified age paramaters and household has no children. - # Check if filing status is separate. - filing_status = tax_unit("filing_status", period) - separate = filing_status == filing_status.possible_values.SEPARATE + # P.L.2021, c.130 waives only the federal minimum and maximum ages, + # so the unit must still meet the federal marital condition in + # IRC 32(d), whatever its filing status. + marital_eligible = tax_unit("eitc_marital_eligible", period) # Check if tax unit has any EITC qualifying children. person = tax_unit.members no_qualifying_children = tax_unit("eitc_child_count", period) == 0 @@ -37,7 +38,7 @@ def formula(tax_unit, period, parameters): claims_section_911 = elects_section_911_exclusion(tax_unit, period) return ( - ~separate + marital_eligible & no_qualifying_children & tax_unit.any(age_eligible) & tax_unit("nj_eitc_income_eligible", period) diff --git a/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py b/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py new file mode 100644 index 00000000000..a42103c9dd2 --- /dev/null +++ b/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class is_separated_under_decree(Variable): + value_type = bool + entity = Person + label = "Legally separated under a decree of separate maintenance" + documentation = """ + Whether the person is legally separated from their spouse under a decree + of divorce or of separate maintenance. IRC 7703(a)(2) treats such a + person as not married. A written separation agreement without a decree + does not count. Defaults to false because survey data cannot tell a + decree from an informal separation, so is_separated alone keeps the + married reading. + """ + definition_period = YEAR + default_value = False + reference = "https://www.law.cornell.edu/uscode/text/26/7703#a_2" From b235d3f71c65eee1e2ea2b136a316b7db134c68e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:47:07 +0000 Subject: [PATCH 5/7] Pin decree-on-spouse and SSN-less-child NJ paths; scope the decree input's docs Round 2 review notes: a test where only the spouse carries the decree (kills a head-only mutant on an assertion), a New Jersey case where a separate filer meets the separated-spouse exception through a child without an SSN and reaches the age fallback, and documentation saying only the EITC marital test reads is_separated_under_decree. Co-Authored-By: Claude Opus 5.5 --- .../earned_income/eitc_marital_eligible.yaml | 31 +++++++++++++++++++ .../nj/tax/income/credits/eitc/nj_eitc.yaml | 29 +++++++++++++++++ .../marital_unit/is_separated_under_decree.py | 10 +++--- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml index bac2d411d81..2cfe4b9e7d4 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml @@ -394,3 +394,34 @@ eitc_marital_eligible: true eitc_eligible: true eitc: 3_450.62 + +- name: Decree on the spouse alone makes a separate filer unmarried for the EITC + # IRC 7703(a)(2) reads the decree from either spouse, so the marital test + # passes with both separation routes false. 2026, one child: + # 4_427 - 0.1598 x (30_000 - 23_890) = 3_450.62. Without the decree the + # filer needs the IRC 32(d)(2)(B) exception and gets 0. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 30_000 + spouse: + age: 35 + is_separated_under_decree: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: false + eitc_legal_separation_and_apart_at_year_end: false + households: + household: + members: [head, spouse, child] + state_code: TX + output: + eitc_marital_eligible: true + eitc: 3_450.62 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml index 487136a018f..3c32ffe81d9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml @@ -193,3 +193,32 @@ eitc: 0 nj_childless_eitc_age_eligible: true nj_eitc: 259.60 + +- name: Separate filer who meets the separated-spouse exception through a child without an SSN gets the NJ age fallback + # The child counts for IRC 32(d)(2)(B) but not for the federal schedule + # (32(c)(3)(D)), so eitc_child_count is 0. The filer is 22, below the + # federal minimum age, and meets the marital test: NJ pays 0.4 x 649 = 259.60. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 22 + employment_income: 8_000 + child: + age: 5 + ssn_card_type: NONE + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: true + households: + household: + members: [head, child] + state_code: NJ + output: + eitc_marital_eligible: true + eitc_child_count: 0 + nj_childless_eitc_age_eligible: true + nj_eitc: 259.60 diff --git a/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py b/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py index a42103c9dd2..195938028cf 100644 --- a/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py +++ b/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py @@ -4,14 +4,16 @@ class is_separated_under_decree(Variable): value_type = bool entity = Person - label = "Legally separated under a decree of separate maintenance" + label = "Legally separated under a decree of divorce or separate maintenance" documentation = """ Whether the person is legally separated from their spouse under a decree of divorce or of separate maintenance. IRC 7703(a)(2) treats such a person as not married. A written separation agreement without a decree - does not count. Defaults to false because survey data cannot tell a - decree from an informal separation, so is_separated alone keeps the - married reading. + does not count. + Defaults to false because survey data cannot tell a decree from an + informal separation, so is_separated alone keeps the married reading. + Only the EITC marital test (IRC 32(d)) reads this input; filing status + and other credits do not yet apply IRC 7703(a)(2). """ definition_period = YEAR default_value = False From 8f2c888071b4c59b4337e05930d40b95fd9fe736 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:28:46 +0000 Subject: [PATCH 6/7] Bar married separate filers from New Jersey's EITC under 54A:4-7a.(3) N.J.S.A. 54A:4-7a.(3) and N.J.A.C. 18:35-4.3(g)(3) require a married claimant to file jointly, except a head of household or surviving spouse. The NJ-1040 line 58 instructions add an exception for a separate filer with a child and six months apart; the statute governs. nj_eitc_separate_filer_eligible passes a filer who doesn't file separately or is separated under a decree (IRC 7703(a)(2)), and both the federal match and the age fallback now read it. Record the conflict in candidates.yaml, pin each route in nj_eitc.yaml, and cut the property file to invariants that can fail on their own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KcYmGRGLN8zAqaGY3 --- .../nj/tax/income/credits/eitc/nj_eitc.yaml | 151 ++++- ...est_eitc_marital_eligibility_properties.py | 608 +++++++----------- .../eitc/nj_childless_eitc_age_eligible.py | 5 +- .../nj/tax/income/credits/eitc/nj_eitc.py | 3 + .../eitc/nj_eitc_separate_filer_eligible.py | 31 + source_conflicts/candidates.yaml | 33 + 6 files changed, 454 insertions(+), 377 deletions(-) create mode 100644 policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc_separate_filer_eligible.py diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml index 3c32ffe81d9..6e59297a498 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_eitc.yaml @@ -194,12 +194,13 @@ nj_childless_eitc_age_eligible: true nj_eitc: 259.60 -- name: Separate filer who meets the separated-spouse exception through a child without an SSN gets the NJ age fallback +- name: Separate filer who meets the federal exception through a child without an SSN gets no NJ age fallback # The child counts for IRC 32(d)(2)(B) but not for the federal schedule - # (32(c)(3)(D)), so eitc_child_count is 0. The filer is 22, below the - # federal minimum age, and meets the marital test: NJ pays 0.4 x 649 = 259.60. + # (32(c)(3)(D)), so eitc_child_count is 0 and the filer, at 22, is below + # the federal minimum age. The filer meets the federal marital test, but + # N.J.S.A. 54A:4-7a.(3) requires a married claimant to file jointly, so + # the age fallback pays 0. period: 2025 - absolute_error_margin: 0.01 input: people: head: @@ -220,5 +221,147 @@ output: eitc_marital_eligible: true eitc_child_count: 0 + nj_eitc_separate_filer_eligible: false + nj_childless_eitc_age_eligible: false + nj_eitc: 0 + +- name: Separate filer on the separation-instrument route gets the federal EITC but no NJ EITC + # IRC 32(d)(2)(B)(ii)(II): a written separation instrument and living apart + # at year end meet the federal exception. 2026, one child: phase-in + # 0.34 x 20,000 = 6,800 caps at the 4,427 maximum, and 20,000 is below the + # 23,890 phase-out start, so the federal EITC is 4,427. N.J.S.A. + # 54A:4-7a.(3) requires a married claimant to file jointly, so NJ pays 0. + period: 2026 + input: + people: + head: + age: 35 + employment_income: 20_000 + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [head, child] + state_code: NJ + output: + eitc: 4_427 + nj_eitc_separate_filer_eligible: false + nj_eitc: 0 + +- name: Separate filer on the separation-instrument route with a child without an SSN gets no NJ age fallback + # The instrument route meets the federal marital test, and the child + # without an SSN leaves eitc_child_count at 0, so the 22-year-old reaches + # only the age fallback. N.J.S.A. 54A:4-7a.(3) bars a married separate + # filer from it. + period: 2025 + input: + people: + head: + age: 22 + employment_income: 8_000 + child: + age: 5 + ssn_card_type: NONE + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + eitc_legal_separation_and_apart_at_year_end: true + households: + household: + members: [head, child] + state_code: NJ + output: + eitc_marital_eligible: true + nj_childless_eitc_age_eligible: false + nj_eitc: 0 + +- name: Separate filer with a child and six months apart gets no NJ EITC, against the NJ-1040 line 58 instructions + # The 2025 NJ-1040 instructions allow this filer the credit; the statute, + # N.J.S.A. 54A:4-7a.(3), and N.J.A.C. 18:35-4.3(g)(3) require a married + # claimant to file jointly, and the model follows them. Federal 2026, one + # child: 4,427 - 0.1598 x (30,000 - 23,890) = 3,450.62. The instructions' + # reading would pay 0.4 x 3,450.62 = 1,380.25. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 30_000 + is_separated: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + households: + household: + members: [head, child] + state_code: NJ + output: + eitc_spouses_lived_apart_last_six_months: true + eitc: 3_450.62 + nj_eitc_separate_filer_eligible: false + nj_eitc: 0 + +- name: Separate filer under a decree with a child gets the NJ match + # IRC 7703(a)(2): a filer legally separated under a decree is not married, + # so neither the federal marital test nor New Jersey's joint-filing rule + # applies, with both separation routes false. 2025, one child: phase-in + # 0.34 x 20,000 = 6,800 caps at 4,328, so NJ pays 0.4 x 4,328 = 1,731.20. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 20_000 + is_separated_under_decree: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: SEPARATE + eitc_spouses_lived_apart_last_six_months: false + eitc_legal_separation_and_apart_at_year_end: false + households: + household: + members: [head, child] + state_code: NJ + output: + eitc: 4_328 + nj_eitc_separate_filer_eligible: true + nj_eitc: 1_731.20 + +- name: Separate filer under a decree without a child gets the NJ age fallback + # The decree makes the 22-year-old unmarried (IRC 7703(a)(2)), so the + # marital tests pass with no child. Below the federal minimum age of 25, + # the filer reaches the age fallback: 2025, 0.4 x 649 = 259.60. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 22 + employment_income: 8_000 + is_separated_under_decree: true + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + households: + household: + members: [head] + state_code: NJ + output: + eitc: 0 nj_childless_eitc_age_eligible: true nj_eitc: 259.60 diff --git a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py index 0918cef93b0..8a640b47a71 100644 --- a/policyengine_us/tests/test_eitc_marital_eligibility_properties.py +++ b/policyengine_us/tests/test_eitc_marital_eligibility_properties.py @@ -1,11 +1,9 @@ -"""IRC 32(d) invariants over vectorized tax units and member populations. - -Invoke the production marital-eligibility formula with small entity proxies -and the actual exception parameter. Hypothesis varies qualifying-child, -residence and identification facts, while each unit is evaluated with all -four separation-fact combinations as both a separate and a joint filer. -YAML tests cover model integration; these generated populations check -monotonicity and aggregation without constructing or copying full models. +"""IRC 32(d) and New Jersey invariants over vectorized tax units. + +The tests call the production formulas on small entity proxies with the +real parameters, so they build no model. Each invariant can fail on its +own and carries its reason. The tests compare results across inputs and +do not restate a formula. YAML tests pin the dollar amounts. """ from itertools import product @@ -29,237 +27,53 @@ from policyengine_us.variables.gov.states.nj.tax.income.credits.eitc.nj_eitc import ( nj_eitc, ) +from policyengine_us.variables.gov.states.nj.tax.income.credits.eitc.nj_eitc_separate_filer_eligible import ( + nj_eitc_separate_filer_eligible, +) - -ELIGIBILITY = ParameterNode( - "eligibility", - directory_path=str( - Path(__file__).resolve().parents[1] - / "parameters/gov/irs/credits/eitc/eligibility" - ), +PARAMETERS = Path(__file__).resolve().parents[1] / "parameters/gov" +FEDERAL_EITC = ParameterNode( + "eitc", directory_path=str(PARAMETERS / "irs/credits/eitc") +) +NJ_EITC = ParameterNode( + "eitc", directory_path=str(PARAMETERS / "states/nj/tax/income/credits/eitc") ) -SEPARATION_FACTS = list(product((False, True), repeat=2)) def parameters(period): return SimpleNamespace( gov=SimpleNamespace( - irs=SimpleNamespace( - credits=SimpleNamespace( - eitc=SimpleNamespace(eligibility=ELIGIBILITY(period)) + irs=SimpleNamespace(credits=SimpleNamespace(eitc=FEDERAL_EITC(period))), + states=SimpleNamespace( + nj=SimpleNamespace( + tax=SimpleNamespace( + income=SimpleNamespace( + credits=SimpleNamespace(eitc=NJ_EITC(period)) + ) + ) ) - ) + ), ) ) class FilingStatuses(np.ndarray): possible_values = SimpleNamespace( - SEPARATE="SEPARATE", JOINT="JOINT", HEAD_OF_HOUSEHOLD="HEAD_OF_HOUSEHOLD" + SEPARATE="SEPARATE", + JOINT="JOINT", + HEAD_OF_HOUSEHOLD="HEAD_OF_HOUSEHOLD", + SINGLE="SINGLE", ) -class TaxUnits: - """Provide entity access and group aggregation, without policy logic.""" - - def __init__(self, cases): - self.member_groups = [] - member_values = [] - statuses, six_months, legal_separation = [], [], [] - for case in cases: - for status in ("SEPARATE", "JOINT"): - for apart, legal in SEPARATION_FACTS: - unit_index = len(statuses) - statuses.append(status) - six_months.append(apart) - legal_separation.append(legal) - # Every unit has an adult as well as its generated members. - members = [(False, True, True), *case] - self.member_groups.extend([unit_index] * len(members)) - member_values.extend(members) - self.member_groups = np.asarray(self.member_groups, dtype=int) - self.count = len(statuses) - self.person_values = dict( - zip( - ( - "is_eitc_qualifying_child", - "eitc_child_lived_with_taxpayer_more_than_half_year", - "meets_eitc_identification_requirements", - ), - np.asarray(member_values, dtype=bool).T, - ) - ) - # The first member of each unit is the filer; nobody is separated, so - # the separated head-of-household route is not exercised here. - filer = np.r_[True, self.member_groups[1:] != self.member_groups[:-1]] - self.person_values["is_tax_unit_head_or_spouse"] = filer - self.person_values["is_separated"] = np.zeros_like(filer) - self.person_values["is_separated_under_decree"] = np.zeros_like(filer) - # Expose the existing SSN-restricted count as well, so accidentally - # using it instead of pre-identification children fails the properties. - identified_children = ( - self.person_values["is_eitc_qualifying_child"] - & self.person_values["meets_eitc_identification_requirements"] - ) - self.values = { - "filing_status": np.asarray(statuses).view(FilingStatuses), - "eitc_spouses_lived_apart_last_six_months": np.asarray(six_months), - "eitc_legal_separation_and_apart_at_year_end": np.asarray(legal_separation), - "eitc_child_count": np.bincount( - self.member_groups, - weights=identified_children, - minlength=self.count, - ), - } - - def __call__(self, variable, period): - return self.values[variable] - - def members(self, variable, period): - return self.person_values[variable] +class Units: + """Entity access and group aggregation, with no policy logic.""" - def any(self, values): - result = np.zeros(self.count, dtype=bool) - np.logical_or.at(result, self.member_groups, values) - return result - - -member_facts = st.tuples(st.booleans(), st.booleans(), st.booleans()) - - -@settings(max_examples=50, deadline=None, derandomize=True) -@given(cases=st.lists(st.lists(member_facts, max_size=5), min_size=1, max_size=12)) -@example( - cases=[ - [], - [(True, True, False)], - [(True, False, True), (False, True, True)], - ] -) -def test_marital_eligibility_invariants(cases): - tax_units = TaxUnits(cases) - has_resident_child = np.asarray( - [ - any(qualifies and resident for qualifies, resident, _ in case) - for case in cases - ] - ) - for year in (2020, 2021, 2025): - actual = eitc_marital_eligible.formula( - tax_units, f"{year}-01-01", parameters - ).reshape(len(cases), 2, len(SEPARATION_FACTS)) - separate, joint = actual[:, 0], actual[:, 1] - - # Joint filers satisfy the marital test for every child/separation fact. - assert joint.all() - # No child, or only a nonresident child, can never enable the exception. - assert not separate[~has_resident_child].any() - # Adding either true separation fact cannot remove eligibility. - assert np.all(separate[:, 0] <= separate[:, 1]) - assert np.all(separate[:, 0] <= separate[:, 2]) - assert np.all(separate[:, 1] <= separate[:, 3]) - assert np.all(separate[:, 2] <= separate[:, 3]) - - if year < 2021: - assert not separate.any() - else: - assert not separate[:, 0].any() - # Both legal routes work with a resident qualifying child, even - # when that child's identification fails the federal credit test. - assert separate[has_resident_child, 1:].all() - - -@pytest.mark.parametrize("year,frozen_switch", [(2025, False), (2020, True)]) -def test_state_frozen_exception_switch_overrides_current_year(year, frozen_switch): - period = f"{year}-01-01" - assert ELIGIBILITY(period).separate_filer != frozen_switch - tax_units = TaxUnits( - [ - [(True, True, False)], - [], - [(True, False, True)], - ] - ) - actual = eitc_filing_status_eligible( - tax_units, - period, - parameters, - separate_filer_eligible=frozen_switch, - ).reshape(3, 2, len(SEPARATION_FACTS)) - - # A frozen switch controls when the exception is available, while the - # resident-child and separation requirements still apply to both routes. - np.testing.assert_array_equal( - actual[0, 0], [False, frozen_switch, frozen_switch, frozen_switch] - ) - assert not actual[1:, 0].any() - assert actual[:, 1].all() - - -# IRC 7703(a)(2): a filer legally separated under a decree of separate -# maintenance is not married, so IRC 32(d) needs no exception for the unit. -DECREE_STATUSES = ("SEPARATE", "HEAD_OF_HOUSEHOLD", "JOINT", "SINGLE") -DECREE_HOLDERS = ("nobody", "head", "dependent") - - -class DecreeUnits: - """Units over status, head separation, separation facts and decree holder. - - Each generated child list is crossed with every status, head is_separated - value, separation-fact pair and decree holder. Members are the head, then - one dependent who never counts as a qualifying child, then the children. - """ - - def __init__(self, cases): - member_groups, person_rows, unit_rows = [], [], [] - for case_index, case in enumerate(cases): - for status, separated, (apart, legal), holder in product( - DECREE_STATUSES, (False, True), SEPARATION_FACTS, DECREE_HOLDERS - ): - unit_index = len(unit_rows) - unit_rows.append( - ( - case_index, - status, - separated, - apart, - legal, - DECREE_HOLDERS.index(holder), - ) - ) - # (head_or_spouse, separated, decree, qualifies, resident) - members = [ - (True, separated, holder == "head", False, True), - (False, False, holder == "dependent", False, True), - *[(False, False, False, q, r) for q, r in case], - ] - member_groups.extend([unit_index] * len(members)) - person_rows.extend(members) + def __init__(self, member_groups, person_values, values): self.member_groups = np.asarray(member_groups, dtype=int) - self.count = len(unit_rows) - columns = np.asarray(person_rows, dtype=bool).T - self.person_values = dict( - zip( - ( - "is_tax_unit_head_or_spouse", - "is_separated", - "is_separated_under_decree", - "is_eitc_qualifying_child", - "eitc_child_lived_with_taxpayer_more_than_half_year", - ), - columns, - ) - ) - case_index, status, separated, apart, legal, holder = zip(*unit_rows) - self.case_index = np.asarray(case_index) - self.status = np.asarray(status) - self.separated = np.asarray(separated) - self.holder = np.asarray(holder) - self.values = { - "filing_status": np.asarray(status).view(FilingStatuses), - "eitc_spouses_lived_apart_last_six_months": np.asarray(apart), - "eitc_legal_separation_and_apart_at_year_end": np.asarray(legal), - } + self.count = int(self.member_groups.max()) + 1 + self.person_values = person_values + self.values = values def __call__(self, variable, period): return self.values[variable] @@ -273,156 +87,202 @@ def any(self, values): return result -def head_rule_without_decree(units, has_resident_child, year): - """IRC 32(d) as this PR read it before the decree input existed. - - Written from the statute rather than copied from the formula: before 2021 - a separate return is barred; from 2021 a separate filer, or a separated - head of household, needs a resident qualifying child and either route. - """ - separate = units.status == "SEPARATE" - if year < 2021: - return ~separate - needs_exception = separate | ( - (units.status == "HEAD_OF_HOUSEHOLD") & units.separated +STATUSES = ("SEPARATE", "HEAD_OF_HOUSEHOLD", "JOINT", "SINGLE") +HOLDERS = ("nobody", "head", "dependent") +GRID = list(product(STATUSES, (False, True), (False, True), (False, True), HOLDERS)) +# Child facts: (qualifying child, resident more than half the year, identified). +child = st.tuples(st.booleans(), st.booleans(), st.booleans()) + + +def federal_units(cases): + """Cross each child list with every status, head is_separated value, + separation-fact pair and decree holder. Members are the head, a + dependent who is never a qualifying child, then the children.""" + groups, people, units = [], [], [] + for case_index, case in enumerate(cases): + for status, separated, apart, legal, holder in GRID: + members = [ + (True, separated, holder == "head", False, True, True), + (False, False, holder == "dependent", False, True, True), + *[(False, False, False, *facts) for facts in case], + ] + groups += [len(units)] * len(members) + people += members + units.append((case_index, status, separated, apart, legal, holder)) + names = ( + "is_tax_unit_head_or_spouse", + "is_separated", + "is_separated_under_decree", + "is_eitc_qualifying_child", + "eitc_child_lived_with_taxpayer_more_than_half_year", + "meets_eitc_identification_requirements", ) - routes = ( - units.values["eitc_spouses_lived_apart_last_six_months"] - | units.values["eitc_legal_separation_and_apart_at_year_end"] + person_values = dict(zip(names, np.asarray(people, dtype=bool).T)) + case_index, status, separated, apart, legal, holder = map(np.asarray, zip(*units)) + identified = ( + person_values["is_eitc_qualifying_child"] + & person_values["meets_eitc_identification_requirements"] ) - return ~needs_exception | (has_resident_child[units.case_index] & routes) + values = { + "filing_status": status.view(FilingStatuses), + "eitc_spouses_lived_apart_last_six_months": apart, + "eitc_legal_separation_and_apart_at_year_end": legal, + # The identification-restricted count, exposed so that reading it in + # place of pre-identification children fails an invariant below. + "eitc_child_count": np.bincount(groups, weights=identified), + } + facts = SimpleNamespace( + case=case_index, + status=status, + separated=separated, + apart=apart, + legal=legal, + holder=holder, + resident_child=np.asarray([any(q and r for q, r, _ in case) for case in cases])[ + case_index + ], + ) + return Units(groups, person_values, values), facts -@settings(max_examples=50, deadline=None, derandomize=True) -@given( - cases=st.lists( - st.lists(st.tuples(st.booleans(), st.booleans()), max_size=3), - min_size=1, - max_size=6, - ) -) -@example(cases=[[], [(True, True)], [(True, False)]]) -def test_decree_of_separate_maintenance_invariants(cases): - units = DecreeUnits(cases) - has_resident_child = np.asarray([any(q and r for q, r in case) for case in cases]) - nobody, head, dependent = ( - units.holder == DECREE_HOLDERS.index(h) for h in DECREE_HOLDERS +def counterpart(facts, field, old, new): + """Return (i, j) index arrays pairing units that differ only in field.""" + keys = np.rec.fromarrays( + [ + facts.case, + facts.status, + facts.separated, + facts.apart, + facts.legal, + facts.holder, + ], + names="case,status,separated,apart,legal,holder", ) + lookup = {tuple(k): n for n, k in enumerate(keys.tolist())} + position = keys.dtype.names.index(field) + i, j = [], [] + for n, key in enumerate(keys.tolist()): + if key[position] == old: + other = list(key) + other[position] = new + i.append(n) + j.append(lookup[tuple(other)]) + return np.asarray(i), np.asarray(j) + + +@settings(max_examples=40, deadline=None, derandomize=True) +@given(cases=st.lists(st.lists(child, max_size=3), min_size=1, max_size=5)) +@example(cases=[[], [(True, True, False)], [(True, False, True)]]) +def test_federal_marital_invariants(cases): + units, f = federal_units(cases) + separate = f.status == "SEPARATE" + no_decree = f.holder != "head" for year in (2020, 2021, 2025): - actual = eitc_marital_eligible.formula(units, f"{year}-01-01", parameters) - expected = head_rule_without_decree(units, has_resident_child, year) - - # With the decree input false everywhere, results equal the rule - # before the input existed, and a decree on a dependent changes - # nothing: only the head or spouse can be separated under a decree. - np.testing.assert_array_equal(actual[nobody], expected[nobody]) - np.testing.assert_array_equal(actual[dependent], expected[dependent]) - # Turning the decree input on never removes marital eligibility. - assert np.all(actual[nobody] <= actual[head]) + ok = eitc_marital_eligible.formula(units, f"{year}-01-01", parameters) + + # 32(d) reaches only married filers who do not file jointly. + assert ok[(f.status == "JOINT") | (f.status == "SINGLE")].all() + # 32(d)(2)(B)(i) needs a qualifying child resident over half the year. + assert not ok[separate & no_decree & ~f.resident_child].any() + # 7703(a)(2) reaches the taxpayer only, so a dependent's decree is inert. + i, j = counterpart(f, "holder", "nobody", "dependent") + np.testing.assert_array_equal(ok[i], ok[j]) + # Each separation route and the head's decree only relieve the test. + for field, old, new in ( + ("apart", False, True), + ("legal", False, True), + ("holder", "nobody", "head"), + ): + i, j = counterpart(f, field, old, new) + assert np.all(ok[i] <= ok[j]) + if year < 2021: - # The frozen pre-ARPA path ignores the input. - np.testing.assert_array_equal(actual[head], actual[nobody]) - else: - # A decree makes the head unmarried, whatever the other facts. - assert actual[head].all() - # Not vacuous: every case has a separate filer with no separation - # route who fails without the decree and passes with it. - gained = actual[head] & ~actual[nobody] - assert set(units.case_index[head][gained]) == set(range(len(cases))) - - # States that freeze the exception off keep barring separate returns. - frozen = eitc_filing_status_eligible( - units, "2025-01-01", parameters, separate_filer_eligible=False + # Before ARPA 2021, 32(d) barred every separate return. + np.testing.assert_array_equal(ok, ~separate) + continue + # The exception needs one of the two separation routes. + assert not ok[separate & no_decree & ~f.apart & ~f.legal].any() + # Either route passes with a resident child, identified or not: + # 32(c)(3)(D) limits the credit schedule, not 32(d)(2)(B). + assert ok[separate & f.resident_child & (f.apart | f.legal)].all() + # 7703(a)(2): a head separated under a decree is not married. + assert ok[f.holder == "head"].all() + # 32(d)(2)(A) uses 7703(a) only, so a separated head of household + # faces the separate filer's test. + i, j = counterpart(f, "status", "HEAD_OF_HOUSEHOLD", "SEPARATE") + hoh_separated = f.separated[i] + np.testing.assert_array_equal(ok[i][hoh_separated], ok[j][hoh_separated]) + # An unseparated head of household is unmarried and passes. + assert ok[i][~hoh_separated].all() + + +@pytest.mark.parametrize("year", [2020, 2025]) +def test_state_frozen_switch_sets_only_when_the_exception_applies(year): + units, f = federal_units([[(True, True, False)], [], [(True, False, True)]]) + period = f"{year}-01-01" + off = eitc_filing_status_eligible( + units, period, parameters, separate_filer_eligible=False ) - np.testing.assert_array_equal(frozen, units.status != "SEPARATE") - - -NJ_EITC = ParameterNode( - "eitc", - directory_path=str( - Path(__file__).resolve().parents[1] - / "parameters/gov/states/nj/tax/income/credits/eitc" - ), -) -FEDERAL_EITC = ParameterNode( - "eitc", - directory_path=str( - Path(__file__).resolve().parents[1] / "parameters/gov/irs/credits/eitc" - ), -) - - -def nj_parameters(period): - return SimpleNamespace( - gov=SimpleNamespace( - irs=SimpleNamespace(credits=SimpleNamespace(eitc=FEDERAL_EITC(period))), - states=SimpleNamespace( - nj=SimpleNamespace( - tax=SimpleNamespace( - income=SimpleNamespace( - credits=SimpleNamespace(eitc=NJ_EITC(period)) - ) - ) - ) - ), - ) + on = eitc_filing_status_eligible( + units, period, parameters, separate_filer_eligible=True + ) + # A state that freezes the exception off bars every separate return. + np.testing.assert_array_equal(off, f.status != "SEPARATE") + # Switching it on gives the current federal test, whatever the year. + np.testing.assert_array_equal( + on, eitc_marital_eligible.formula(units, "2025-01-01", parameters) ) -class NewJerseyUnits: +def nj_units(rows, marital_eligible): """One-person New Jersey units with federal EITC results as inputs.""" - - def __init__(self, rows, marital): - ( - age, - child_count, - income_eligible, - other_federal_eligible, - phased_in, - reduction, - takes_up, - ) = (np.asarray(column) for column in zip(*rows)) - marital = np.asarray(marital, dtype=bool) - # eitc_eligible includes the marital condition. - eligible = np.asarray(other_federal_eligible, dtype=bool) & marital - self.count = len(rows) - self.person_values = {"age": age} - zeros = np.zeros(self.count) - self.values = { - "eitc_marital_eligible": marital, - "eitc_child_count": child_count, - "nj_eitc_income_eligible": np.asarray(income_eligible, dtype=bool), - "eitc_eligible": eligible, - "eitc_phased_in": phased_in, - "eitc_reduction": reduction, - "takes_up_eitc": np.asarray(takes_up, dtype=bool), - "eitc": np.maximum(phased_in - reduction, 0) * eligible * takes_up, - "foreign_earned_income_exclusion": zeros, - "section_911_excluded_income": zeros, - } - for variable in SECTION_911_LEAF_INPUTS: - self.values[variable] = zeros - - def __call__(self, variable, period): - return self.values[variable] - - def members(self, variable, period): - return self.person_values[variable] - - def any(self, values): - return np.asarray(values, dtype=bool) - - -def nj_eitc_for(rows, marital, year): - units = NewJerseyUnits(rows, marital) + ( + status, + decree, + age, + child_count, + income_eligible, + other_federal, + phased_in, + reduction, + takes_up, + ) = map(np.asarray, zip(*rows)) + marital = np.asarray(marital_eligible, dtype=bool) + eligible = other_federal.astype(bool) & marital + zeros = np.zeros(len(rows)) + values = { + "filing_status": status.view(FilingStatuses), + "eitc_marital_eligible": marital, + "eitc_child_count": child_count, + "nj_eitc_income_eligible": income_eligible.astype(bool), + "eitc_eligible": eligible, + "eitc_phased_in": phased_in, + "eitc_reduction": reduction, + "takes_up_eitc": takes_up.astype(bool), + "eitc": np.maximum(phased_in - reduction, 0) * eligible * takes_up, + "foreign_earned_income_exclusion": zeros, + "section_911_excluded_income": zeros, + **{variable: zeros for variable in SECTION_911_LEAF_INPUTS}, + } + person_values = { + "age": age, + "is_tax_unit_head_or_spouse": np.ones(len(rows), dtype=bool), + "is_separated_under_decree": decree.astype(bool), + } + return Units(np.arange(len(rows)), person_values, values) + + +def nj_eitc_for(rows, marital_eligible, year): + units = nj_units(rows, marital_eligible) period = f"{year}-01-01" - units.values["nj_childless_eitc_age_eligible"] = ( - nj_childless_eitc_age_eligible.formula(units, period, nj_parameters) - ) - return nj_eitc.formula(units, period, nj_parameters) + for variable in (nj_eitc_separate_filer_eligible, nj_childless_eitc_age_eligible): + units.values[variable.__name__] = variable.formula(units, period, parameters) + return nj_eitc.formula(units, period, parameters) -nj_rows = st.tuples( +nj_row = st.tuples( + st.sampled_from(STATUSES), + st.booleans(), st.integers(15, 80), st.integers(0, 2), st.booleans(), @@ -433,27 +293,31 @@ def nj_eitc_for(rows, marital, year): ) -@settings(max_examples=50, deadline=None, derandomize=True) +@settings(max_examples=40, deadline=None, derandomize=True) @given( - rows=st.lists(nj_rows, min_size=1, max_size=20), + rows=st.lists(nj_row, min_size=1, max_size=20), marital=st.lists(st.booleans(), min_size=20, max_size=20), ) @example( - rows=[(22, 0, True, False, 612.0, 0.0, True)] * 2, - marital=[False, True] + [False] * 18, + rows=[ + ("SEPARATE", False, 22, 0, True, False, 612.0, 0.0, True), + ("SEPARATE", True, 22, 0, True, False, 612.0, 0.0, True), + ], + marital=[True] * 20, ) -def test_nj_eitc_requires_federal_marital_eligibility(rows, marital): - marital = marital[: len(rows)] +def test_nj_eitc_marital_invariants(rows, marital): + marital = np.asarray(marital[: len(rows)]) + status = np.asarray([row[0] for row in rows]) + decree = np.asarray([row[1] for row in rows]) + relaxed_rows = [("JOINT", *row[1:]) for row in rows] for year in (2021, 2025): actual = nj_eitc_for(rows, marital, year) - # A unit that fails the federal marital test gets no NJ EITC, either - # through the federal match or the age fallback. - assert np.all(actual[~np.asarray(marital)] == 0) - # Not vacuous: with the marital test passed, the age fallback pays a - # childless filer aged 18 or more who meets the income tests. - passed = nj_eitc_for(rows, [True] * len(rows), year) - age, children, income_ok, _, phased_in, reduction, _ = ( - np.asarray(column) for column in zip(*rows) - ) - fallback = (age >= 18) & (children == 0) & income_ok & (phased_in > reduction) - assert np.all(passed[fallback] > 0) + # P.L.2021, c.130 waives only the federal ages, so a unit that fails + # 32(d) gets nothing through the match or the age fallback. + assert np.all(actual[~marital] == 0) + # N.J.S.A. 54A:4-7a.(3) bars a married separate filer, even one the + # federal exception admits; a decree makes the filer unmarried. + assert np.all(actual[(status == "SEPARATE") & ~decree] == 0) + # Neither rule can raise the credit when it applies. + relaxed = nj_eitc_for(relaxed_rows, np.ones(len(rows), dtype=bool), year) + assert np.all(actual <= relaxed) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py index b75876133ac..70c7251cd86 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py @@ -20,8 +20,10 @@ def formula(tax_unit, period, parameters): # Return True if all federal EITC conditions are met, except with modified age paramaters and household has no children. # P.L.2021, c.130 waives only the federal minimum and maximum ages, # so the unit must still meet the federal marital condition in - # IRC 32(d), whatever its filing status. + # IRC 32(d), and New Jersey's own bar on married separate filers in + # section 1a.(3). marital_eligible = tax_unit("eitc_marital_eligible", period) + nj_separate_filer_eligible = tax_unit("nj_eitc_separate_filer_eligible", period) # Check if tax unit has any EITC qualifying children. person = tax_unit.members no_qualifying_children = tax_unit("eitc_child_count", period) == 0 @@ -39,6 +41,7 @@ def formula(tax_unit, period, parameters): return ( marital_eligible + & nj_separate_filer_eligible & no_qualifying_children & tax_unit.any(age_eligible) & tax_unit("nj_eitc_income_eligible", period) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc.py b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc.py index 86c3d553299..890f4fc07d9 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc.py @@ -32,10 +32,13 @@ def formula(tax_unit, period, parameters): "eitc_reduction", period ) federal_eitc_takes_up = tax_unit("takes_up_eitc", period) + # N.J.S.A. 54A:4-7a.(3) bars a married separate filer, without the + # federal separated-spouse exception. federal_eitc_eligible = ( federal_eitc_eligible_assuming_meets_income_test & federal_eitc_meets_income_test & federal_eitc_takes_up + & tax_unit("nj_eitc_separate_filer_eligible", period) ) childless_eitc_age_eligible = tax_unit("nj_childless_eitc_age_eligible", period) childless_eitc_eligible = ( diff --git a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc_separate_filer_eligible.py b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc_separate_filer_eligible.py new file mode 100644 index 00000000000..0561a0473b9 --- /dev/null +++ b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc_separate_filer_eligible.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class nj_eitc_separate_filer_eligible(Variable): + value_type = bool + entity = TaxUnit + label = "Meets New Jersey's EITC rule for a married separate filer" + documentation = ( + "N.J.S.A. 54A:4-7a.(3) and N.J.A.C. 18:35-4.3(g)(3) require a married " + "claimant to file jointly, except one who files as a head of household " + "or surviving spouse. They have no exception for a separate filer with " + "a qualifying child who lived apart from the spouse." + ) + definition_period = YEAR + reference = ( + # P.L.2021, c.130, section 1a.(3). + "https://pub.njleg.state.nj.us/Bills/2020/PL21/130_.HTM", + "https://www.law.cornell.edu/regulations/new-jersey/N-J-A-C-18-35-4-3", + ) + defined_for = StateCode.NJ + + def formula(tax_unit, period, parameters): + filing_status = tax_unit("filing_status", period) + separate = filing_status == filing_status.possible_values.SEPARATE + person = tax_unit.members + # IRC 7703(a)(2): a filer separated under a decree is not married. + under_decree = tax_unit.any( + person("is_tax_unit_head_or_spouse", period) + & person("is_separated_under_decree", period) + ) + return ~separate | under_decree diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 6b1dbcbedc4..e59be87270a 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -6087,6 +6087,39 @@ candidates: note: 'Retain as a disputable gap-fill; verify whether repayments are statutory losses and separate this issue from unsupported spouse attribution. Code not yet on main: policyengine_us/variables/gov/states/nj/tax/income/adjusted_gross_income/claim_of_right/nj_claim_of_right_repayment_category.py, gov/states/nj/tax/income/adjusted_gross_income/nj_claim_of_right_offset.yaml, nj_claim_of_right_offset, nj_claim_of_right_repayment_category.' +- id: us-nj:eitc-married-separate-filer + program: income-tax + years: + from: 2021 + type: tax + question: Can a married New Jersey resident who files a separate return claim the NJEITC when the federal + separated-spouse exception applies? + sources: + - kind: statute + citation: N.J.S.A. 54A:4-7a.(3), as amended by P.L.2021, c.130 + url: https://pub.njleg.state.nj.us/Bills/2020/PL21/130_.HTM + - kind: regulation + citation: N.J.A.C. 18:35-4.3(g)(3) + url: https://www.law.cornell.edu/regulations/new-jersey/N-J-A-C-18-35-4-3 + - kind: instructions + citation: 2025 Form NJ-1040 instructions, line 58 + url: https://www.nj.gov/treasury/taxation/pdf/current/1040i.pdf#page=44 + readings: + statute: A married claimant who does not file federally as a head of household or surviving spouse must file + jointly, with no exception for a separated filer. N.J.A.C. 18:35-4.3(g)(3) says the same. + other: A married filer not filing jointly can claim the credit with a qualifying child who lived with the filer + for more than half the year and six months apart from the spouse. + example: In 2026, a separate filer with a child, $30,000 of wages and six months apart gets $1,380.25 under the + instructions and $0 under the statute. + pe_us: + follows: statute + code: + - policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_eitc_separate_filer_eligible.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10307 + state: ruled + note: A filer separated under a decree of divorce or separate maintenance is not married (IRC 7703(a)(2)) and can + claim the credit on a separate return. - id: us-nj:liheap-income-ceiling program: liheap years: From 0262579ff1af875370d4cff3b09b671c741c2473 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:42:47 +0000 Subject: [PATCH 7/7] Require residence facts for separated head of household; fix decree docs IRC 7703(b)(1) needs the child in the home for more than half the year and 7703(b)(3) needs the spouse out of the household for the last six months, so head_of_household_eligible now reads both inputs. A separated filer who fails either files separately, which keeps Washington's state-only credit ($660 in the witness). The decree and marital documentation now name every reader, and the changelog states New Jersey's tightening. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KcYmGRGLN8zAqaGY3 --- .../eitc-mfs-separation-conditions.fixed.md | 2 +- .../earned_income/eitc_marital_eligible.yaml | 10 +++---- .../wa_working_families_tax_credit.yaml | 28 +++++++++++++++++++ .../eligibility/eitc_marital_eligible.py | 4 ++- .../marital_unit/is_separated_under_decree.py | 7 +++-- .../tax_unit/head_of_household_eligible.py | 13 +++++++-- 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/changelog.d/eitc-mfs-separation-conditions.fixed.md b/changelog.d/eitc-mfs-separation-conditions.fixed.md index 3841fd20a00..1556c9f36f8 100644 --- a/changelog.d/eitc-mfs-separation-conditions.fixed.md +++ b/changelog.d/eitc-mfs-separation-conditions.fixed.md @@ -1 +1 @@ -Require a resident qualifying child and the statutory separation conditions for married separate filers, and for separated head-of-household filers from 2021, to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. A head or spouse marked as separated meets the six-month living-apart condition by default. A new `is_separated_under_decree` input (default false) treats a head or spouse separated under a decree of separate maintenance as unmarried for the EITC, under IRC 7703(a)(2). New Jersey's childless age fallback now requires the federal marital condition. Washington's Working Families Tax Credit no longer meets the childless age test through a dependent's age. +Require a resident qualifying child and the statutory separation conditions for married separate filers, and for separated head-of-household filers from 2021, to receive the federal EITC, CalEITC, and credits that follow the federal marital rule. A head or spouse marked as separated meets the six-month living-apart condition by default. A new `is_separated_under_decree` input (default false) treats a head or spouse separated under a decree of divorce or separate maintenance as unmarried for the EITC, under IRC 7703(a)(2). New Jersey's EITC, including its childless age fallback, now follows the federal marital condition and, under N.J.S.A. 54A:4-7a.(3), bars every married separate filer unless a decree applies. Head-of-household status for a separated filer now requires a child resident for more than half the year and six months apart from the spouse (IRC 7703(b)). Washington's Working Families Tax Credit no longer meets the childless age test through a dependent's age. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml index 2cfe4b9e7d4..53565609f00 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_marital_eligible.yaml @@ -231,7 +231,7 @@ eitc_spouses_lived_apart_last_six_months: true eitc_marital_eligible: true -- name: Separated head of household who did not live apart for six months fails from 2021 +- name: Separated filer who did not live apart for six months files separately and fails from 2021 period: 2025 input: people: @@ -250,7 +250,7 @@ members: [head, child] state_code: TX output: - filing_status: HEAD_OF_HOUSEHOLD + filing_status: SEPARATE eitc_marital_eligible: false eitc: 0 @@ -269,7 +269,6 @@ tax_units: tax_unit: members: [head, child] - eitc_spouses_lived_apart_last_six_months: false households: household: members: [head, child] @@ -396,8 +395,9 @@ eitc: 3_450.62 - name: Decree on the spouse alone makes a separate filer unmarried for the EITC - # IRC 7703(a)(2) reads the decree from either spouse, so the marital test - # passes with both separation routes false. 2026, one child: + # The predicate reads the decree from the head or the spouse: IRC 7703(a)(2) + # treats an individual legally separated under a decree as not married, so + # the marital test passes with both separation routes false. 2026, one child: # 4_427 - 0.1598 x (30_000 - 23_890) = 3_450.62. Without the decree the # filer needs the IRC 32(d)(2)(B) exception and gets 0. period: 2026 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml index a66e11371f4..8276a5ee687 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wa/tax/income/credits/wa_working_families_tax_credit.yaml @@ -1755,3 +1755,31 @@ # income $17,600: 325 - 0.13 x 1,509 = 128.83, rounded to 129. Measuring # from AGI ($17,618.27) would give 127. wa_working_families_tax_credit: 129 + +- name: Separated filer who did not live apart for six months files separately and keeps the state-only credit + # IRC 7703(b)(3) needs the spouse out of the household for the last six + # months, so the filer cannot file as head of household and files + # separately. The federal EITC is 0 (IRC 32(d)), and Washington's + # state-only path pays the 2025 one-child amount, $660, with no phase-out + # at $10,000 of earnings. + period: 2025 + input: + people: + head: + age: 35 + employment_income: 10_000 + is_separated: true + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + eitc_spouses_lived_apart_last_six_months: false + households: + household: + members: [head, child] + state_code: WA + output: + filing_status: SEPARATE + eitc: 0 + wa_working_families_tax_credit: 660 diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py index f8bbf650fc6..648e7afbaad 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_marital_eligible.py @@ -18,7 +18,9 @@ class eitc_marital_eligible(Variable): determines marriage under IRC 7703(a) only, so a separated head of household filer (is_separated on the head or spouse) faces the same test. Child identification rules do not determine whether the marital - exception applies. + exception applies. A head or spouse separated under a decree + (is_separated_under_decree) is not married under IRC 7703(a)(2) and + needs no exception. """ def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py b/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py index 195938028cf..d33d39b026a 100644 --- a/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py +++ b/policyengine_us/variables/household/demographic/marital_unit/is_separated_under_decree.py @@ -11,9 +11,10 @@ class is_separated_under_decree(Variable): person as not married. A written separation agreement without a decree does not count. Defaults to false because survey data cannot tell a decree from an - informal separation, so is_separated alone keeps the married reading. - Only the EITC marital test (IRC 32(d)) reads this input; filing status - and other credits do not yet apply IRC 7703(a)(2). + informal separation, so is_separated alone keeps the married reading + for the EITC. Only the EITC marital test (IRC 32(d)) and New Jersey's + separate-filer rule read this input, and the federal test reads it only + from 2021. Filing status and other rules do not read it yet. """ definition_period = YEAR default_value = False diff --git a/policyengine_us/variables/household/demographic/tax_unit/head_of_household_eligible.py b/policyengine_us/variables/household/demographic/tax_unit/head_of_household_eligible.py index 7a6fd30e58b..5dfe85ae38f 100644 --- a/policyengine_us/variables/household/demographic/tax_unit/head_of_household_eligible.py +++ b/policyengine_us/variables/household/demographic/tax_unit/head_of_household_eligible.py @@ -42,12 +42,21 @@ def formula(tax_unit, period, parameters): # IRC 7703(b)(1) further requires the taxpayer to file a separate # return: a JOINT filer who is separated cannot claim HoH because # the joint return forecloses the "considered unmarried" path. - treated_unmarried_qualifies = tax_unit.sum(is_qualifying_child) > 0 + # IRC 7703(b)(1) needs a child in the home for more than half the year, + # and 7703(b)(3) needs the spouse out of the household for the last + # six months. + resident_child = is_qualifying_child & person( + "eitc_child_lived_with_taxpayer_more_than_half_year", period + ) + treated_unmarried_qualifies = tax_unit.sum(resident_child) > 0 + lived_apart = tax_unit("eitc_spouses_lived_apart_last_six_months", period) surviving_spouse = tax_unit("surviving_spouse_eligible", period) unmarried_qualifies = has_qualifying_person & ~married & ~is_separated # ~married masks the JOINT-with-is_separated=true case: a JOINT # filer cannot use IRC 7703(b)(1) because they have not filed a # separate return. MFS filers (spouse in a different tax unit) have # married=false and continue to qualify via this branch. - separated_qualifies = treated_unmarried_qualifies & is_separated & ~married + separated_qualifies = ( + treated_unmarried_qualifies & is_separated & ~married & lived_apart + ) return (unmarried_qualifies | separated_qualifies) & ~surviving_spouse