From a8b2b85356f7b5039bb2fa13e69599d9a45261cf Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Fri, 13 Mar 2026 11:31:32 -0400 Subject: [PATCH 1/2] Fix individual_eitc reform not responding to parameter changes - Restructure reform to check `in_effect` inside formula using `where()`, following modern reform patterns (like GA SB520, NJ budget reforms) - Rename `enabled` parameter to `in_effect` for consistency - Add `takes_up_eitc` multiplier that was missing from reform - Change AGI limit behavior: 0 now means "no limit" instead of "no EITC" - Update tests to set `in_effect: true` and use proper naming conventions - Add backward compatibility aliases for existing code Closes #7779 Co-Authored-By: Claude Opus 4.5 --- .../fix-individual-eitc-reform.fixed.md | 1 + .../individual_eitc/agi_eitc_limit.yaml | 9 +- .../{enabled.yaml => in_effect.yaml} | 8 +- policyengine_us/reforms/winship.py | 108 +++++++++++---- .../tests/policy/reform/winship.yaml | 126 ++++++++++++++---- 5 files changed, 197 insertions(+), 55 deletions(-) create mode 100644 changelog.d/fix-individual-eitc-reform.fixed.md rename policyengine_us/parameters/gov/contrib/individual_eitc/{enabled.yaml => in_effect.yaml} (58%) diff --git a/changelog.d/fix-individual-eitc-reform.fixed.md b/changelog.d/fix-individual-eitc-reform.fixed.md new file mode 100644 index 00000000000..0a3bfaf23e2 --- /dev/null +++ b/changelog.d/fix-individual-eitc-reform.fixed.md @@ -0,0 +1 @@ +Fixed individual_eitc (Winship) reform not responding to parameter changes in the app. diff --git a/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml b/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml index 9a8210021bd..99787df1ea6 100644 --- a/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml +++ b/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml @@ -1,8 +1,11 @@ -description: Tax filers with combined earned income over this cannot claim the EITC. This is only active if the Winship EITC reform is active. +description: >- + Tax filers with combined AGI at or above this amount cannot claim the individual-income EITC. + Set to 0 for no AGI limit (default behavior under the Winship proposal). values: - 2020-01-01: 0 + 2023-01-01: 0 + metadata: - label: EITC AGI limit + label: Individual-income EITC AGI limit unit: currency-USD period: year reference: diff --git a/policyengine_us/parameters/gov/contrib/individual_eitc/enabled.yaml b/policyengine_us/parameters/gov/contrib/individual_eitc/in_effect.yaml similarity index 58% rename from policyengine_us/parameters/gov/contrib/individual_eitc/enabled.yaml rename to policyengine_us/parameters/gov/contrib/individual_eitc/in_effect.yaml index 75a13f2cf9c..03181a6f35b 100644 --- a/policyengine_us/parameters/gov/contrib/individual_eitc/enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/individual_eitc/in_effect.yaml @@ -1,9 +1,11 @@ -description: A proposal by Scott Winship to assess EITC income at the individual level, regardless of filing status. +description: The individual-income EITC reform (Winship proposal) applies if this is true. values: - 2020-01-01: false + 0000-01-01: false + metadata: - label: Individual-income EITCs + label: Individual-income EITC reform in effect unit: bool + period: year reference: - title: Reforming the EITC to Reduce Single Parenthood and Ease Work-Family Balance href: https://ifstudies.org/blog/reforming-the-eitc-to-reduce-single-parenthood-and-ease-work-family-balance diff --git a/policyengine_us/reforms/winship.py b/policyengine_us/reforms/winship.py index 4e9c0d5962f..dfe89947ce4 100644 --- a/policyengine_us/reforms/winship.py +++ b/policyengine_us/reforms/winship.py @@ -1,20 +1,29 @@ from policyengine_us.model_api import * +from policyengine_core.periods import period as period_ -def create_eitc_winship_reform(parameters, period, bypass=False): - if not bypass and not parameters(period).gov.contrib.individual_eitc.enabled: - return None +def create_individual_eitc() -> Reform: + """Individual-income EITC reform (Winship proposal). + + Computes EITC separately for each spouse based on their individual + earnings, then sums the results. This differs from baseline EITC + which uses combined household earnings. + + Reference: https://ifstudies.org/blog/reforming-the-eitc-to-reduce-single-parenthood-and-ease-work-family-balance + """ + + class individual_eitc_base(Variable): + """Helper variable for computing EITC in simulation branches. - # Compute EITC under filer_adj_earnings = filer head adj earnings - # Then compute EITC under filer_adj_earnings = filer spouse adj earnings - # Then set EITC = sum of the two + This variable calculates EITC based on the current simulation's + filer_adjusted_earnings and adjusted_gross_income inputs. + Used by get_branch() to compute individual EITC for head/spouse. + """ - class original_eitc(Variable): value_type = float entity = TaxUnit definition_period = YEAR - label = "Original EITC" - reference = "https://www.law.cornell.edu/uscode/text/26/32#a" + label = "Individual EITC base calculation" unit = USD defined_for = "eitc_eligible" @@ -29,14 +38,28 @@ class eitc(Variable): value_type = float entity = TaxUnit definition_period = YEAR - label = "EITC" + label = "Federal earned income credit" + reference = "https://www.law.cornell.edu/uscode/text/26/32#a" unit = USD defined_for = "eitc_eligible" def formula(tax_unit, period, parameters): + p = parameters(period).gov.contrib.individual_eitc + takes_up_eitc = tax_unit("takes_up_eitc", period) + + # Check if reform is active + reform_active = p.in_effect + + # Baseline EITC calculation + maximum = tax_unit("eitc_maximum", period) + phased_in = tax_unit("eitc_phased_in", period) + reduction = tax_unit("eitc_reduction", period) + limitation = max_(0, maximum - reduction) + baseline_eitc = min_(phased_in, limitation) + + # Reform: compute EITC separately for head and spouse person = tax_unit.members simulation = tax_unit.simulation - agi = tax_unit("adjusted_gross_income", period) adj_earnings = person("adjusted_earnings", period) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) @@ -44,35 +67,68 @@ def formula(tax_unit, period, parameters): filer_earned_head_only = tax_unit.sum(adj_earnings * is_head) filer_earned_spouse_only = tax_unit.sum(adj_earnings * is_spouse) - head_only_branch = simulation.get_branch("head_only") - head_only_branch.set_input( + # Calculate head's individual EITC using branch + head_branch = simulation.get_branch("head_only") + head_branch.set_input( "filer_adjusted_earnings", period, filer_earned_head_only ) - # Phase out with respect to individual earnings instead of AGI - head_only_branch.set_input( + head_branch.set_input( "adjusted_gross_income", period, filer_earned_head_only ) - head_eitc = head_only_branch.calculate("original_eitc", period) + head_eitc = head_branch.calculate("individual_eitc_base", period) - spouse_only_branch = simulation.get_branch("spouse_only") - spouse_only_branch.set_input( + # Calculate spouse's individual EITC using branch + spouse_branch = simulation.get_branch("spouse_only") + spouse_branch.set_input( "filer_adjusted_earnings", period, filer_earned_spouse_only ) - spouse_only_branch.set_input( + spouse_branch.set_input( "adjusted_gross_income", period, filer_earned_spouse_only ) - spouse_eitc = spouse_only_branch.calculate("original_eitc", period) + spouse_eitc = spouse_branch.calculate("individual_eitc_base", period) + + individual_eitc = head_eitc + spouse_eitc + + # Apply AGI limit if set (0 = no limit) + agi_limit = p.agi_eitc_limit + agi = tax_unit("adjusted_gross_income", period) + agi_eligible = where(agi_limit > 0, agi < agi_limit, True) - agi_limit = parameters(period).gov.contrib.individual_eitc.agi_eitc_limit + reform_eitc = individual_eitc * agi_eligible - return (agi < agi_limit) * (head_eitc + spouse_eitc) + return where(reform_active, reform_eitc, baseline_eitc) * takes_up_eitc - class winship_eitc_reform(Reform): + class reform(Reform): def apply(self): - self.update_variable(original_eitc) + self.update_variable(individual_eitc_base) self.update_variable(eitc) - return winship_eitc_reform + return reform + + +def create_individual_eitc_reform(parameters, period, bypass: bool = False): + if bypass: + return create_individual_eitc() + + p = parameters.gov.contrib.individual_eitc + + reform_active = False + current_period = period_(period) + + for _ in range(5): + if p(current_period).in_effect: + reform_active = True + break + current_period = current_period.offset(1, "year") + + if reform_active: + return create_individual_eitc() + else: + return None + +individual_eitc_reform = create_individual_eitc_reform(None, None, bypass=True) -winship_reform = create_eitc_winship_reform(None, None, bypass=True) +# Backward compatibility aliases +winship_reform = individual_eitc_reform +create_eitc_winship_reform = create_individual_eitc_reform diff --git a/policyengine_us/tests/policy/reform/winship.yaml b/policyengine_us/tests/policy/reform/winship.yaml index e171047a219..02adc7dc37b 100644 --- a/policyengine_us/tests/policy/reform/winship.yaml +++ b/policyengine_us/tests/policy/reform/winship.yaml @@ -1,72 +1,152 @@ -- name: Winship reform gives a bonus +# Individual-income EITC (Winship) reform tests +# Tests that EITC is calculated separately for head and spouse earnings + +- name: Case 1, individual EITC gives bonus for dual-earner couple. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: + gov.contrib.individual_eitc.in_effect: true gov.contrib.individual_eitc.agi_eitc_limit: 100_000 people: - head: + person1: employment_income: 20_000 - spouse: + is_tax_unit_head: true + person2: employment_income: 30_000 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: - eitc: 6210 + 6604 # The sum of the EITCs for the two tests below. - original_eitc: 2_000 + # Sum of individual EITCs: head (20k income) + spouse (30k income) + # = 6604 + 6210 = 12814 + eitc: 12_814 -- name: EITC for 30k/0k income, to inform the first test. +- name: Case 2, EITC for 30k head income only. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: + gov.contrib.individual_eitc.in_effect: true gov.contrib.individual_eitc.agi_eitc_limit: 100_000 people: - head: + person1: employment_income: 30_000 - spouse: + is_tax_unit_head: true + person2: employment_income: 0 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: eitc: 6_210 -- name: EITC for 20k/0k income, to inform the first test. +- name: Case 3, EITC for 20k head income only. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: + gov.contrib.individual_eitc.in_effect: true gov.contrib.individual_eitc.agi_eitc_limit: 100_000 people: - head: + person1: employment_income: 20_000 - spouse: + is_tax_unit_head: true + person2: employment_income: 0 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: eitc: 6_604 -- name: EITC for 20k/0k income, to inform the first test. +- name: Case 4, AGI limit blocks EITC when AGI exceeds limit. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: - gov.contrib.individual_eitc.agi_eitc_limit: 19_000 + gov.contrib.individual_eitc.in_effect: true + gov.contrib.individual_eitc.agi_eitc_limit: 19_000 people: - head: + person1: employment_income: 20_000 - spouse: + is_tax_unit_head: true + person2: + employment_income: 0 + is_tax_unit_spouse: true + person3: + age: 5 + person4: + age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] + output: + # AGI (20k) >= limit (19k), so no EITC + eitc: 0 + +- name: Case 5, reform disabled returns baseline EITC. + period: 2023 + absolute_error_margin: 10 + reforms: policyengine_us.reforms.winship.winship_reform + input: + gov.contrib.individual_eitc.in_effect: false + people: + person1: + employment_income: 20_000 + is_tax_unit_head: true + person2: + employment_income: 30_000 + is_tax_unit_spouse: true + person3: + age: 5 + person4: + age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] + output: + # Baseline EITC for combined 50k income with 2 children + # Should be much lower than individual EITC + eitc: 2_000 + +- name: Case 6, no AGI limit when set to 0. + period: 2023 + absolute_error_margin: 10 + reforms: policyengine_us.reforms.winship.winship_reform + input: + gov.contrib.individual_eitc.in_effect: true + gov.contrib.individual_eitc.agi_eitc_limit: 0 + people: + person1: + employment_income: 100_000 + is_tax_unit_head: true + person2: employment_income: 0 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: + # AGI limit of 0 means no limit - should still get individual EITC + # Head with 100k income is past EITC phase-out, so 0 eitc: 0 From a727a7a8b154100d70c673e9126fc32045337ad9 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Fri, 13 Mar 2026 12:09:58 -0400 Subject: [PATCH 2/2] Fix individual EITC reform by computing EITC directly Instead of using simulation branches (which had caching issues), compute the EITC formula directly for each spouse's individual earnings using the EITC parameters. Co-Authored-By: Claude Opus 4.5 --- policyengine_us/reforms/winship.py | 74 +++++++++++------------------- 1 file changed, 28 insertions(+), 46 deletions(-) diff --git a/policyengine_us/reforms/winship.py b/policyengine_us/reforms/winship.py index dfe89947ce4..0d22a926537 100644 --- a/policyengine_us/reforms/winship.py +++ b/policyengine_us/reforms/winship.py @@ -12,28 +12,6 @@ def create_individual_eitc() -> Reform: Reference: https://ifstudies.org/blog/reforming-the-eitc-to-reduce-single-parenthood-and-ease-work-family-balance """ - class individual_eitc_base(Variable): - """Helper variable for computing EITC in simulation branches. - - This variable calculates EITC based on the current simulation's - filer_adjusted_earnings and adjusted_gross_income inputs. - Used by get_branch() to compute individual EITC for head/spouse. - """ - - value_type = float - entity = TaxUnit - definition_period = YEAR - label = "Individual EITC base calculation" - unit = USD - defined_for = "eitc_eligible" - - def formula(tax_unit, period, parameters): - maximum = tax_unit("eitc_maximum", period) - phased_in = tax_unit("eitc_phased_in", period) - reduction = tax_unit("eitc_reduction", period) - limitation = max_(0, maximum - reduction) - return min_(phased_in, limitation) - class eitc(Variable): value_type = float entity = TaxUnit @@ -59,33 +37,38 @@ def formula(tax_unit, period, parameters): # Reform: compute EITC separately for head and spouse person = tax_unit.members - simulation = tax_unit.simulation adj_earnings = person("adjusted_earnings", period) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) - filer_earned_head_only = tax_unit.sum(adj_earnings * is_head) - filer_earned_spouse_only = tax_unit.sum(adj_earnings * is_spouse) - - # Calculate head's individual EITC using branch - head_branch = simulation.get_branch("head_only") - head_branch.set_input( - "filer_adjusted_earnings", period, filer_earned_head_only - ) - head_branch.set_input( - "adjusted_gross_income", period, filer_earned_head_only - ) - head_eitc = head_branch.calculate("individual_eitc_base", period) - - # Calculate spouse's individual EITC using branch - spouse_branch = simulation.get_branch("spouse_only") - spouse_branch.set_input( - "filer_adjusted_earnings", period, filer_earned_spouse_only - ) - spouse_branch.set_input( - "adjusted_gross_income", period, filer_earned_spouse_only - ) - spouse_eitc = spouse_branch.calculate("individual_eitc_base", period) + head_earnings = tax_unit.sum(adj_earnings * is_head) + spouse_earnings = tax_unit.sum(adj_earnings * is_spouse) + + # Get EITC parameters for direct computation + child_count = tax_unit("eitc_child_count", period) + eitc_params = parameters(period).gov.irs.credits.eitc + + eitc_maximum = eitc_params.max.calc(child_count) + phase_in_rate = eitc_params.phase_in_rate.calc(child_count) + phase_out_rate = eitc_params.phase_out.rate.calc(child_count) + # Use joint phase-out start (matching original behavior) + phase_out_start = eitc_params.phase_out.start.calc( + child_count + ) + eitc_params.phase_out.joint_bonus.calc(child_count) + + # Compute head's individual EITC + head_phased_in = min_(eitc_maximum, head_earnings * phase_in_rate) + head_phase_out = max_(0, head_earnings - phase_out_start) + head_reduction = phase_out_rate * head_phase_out + head_limitation = max_(0, eitc_maximum - head_reduction) + head_eitc = min_(head_phased_in, head_limitation) + + # Compute spouse's individual EITC + spouse_phased_in = min_(eitc_maximum, spouse_earnings * phase_in_rate) + spouse_phase_out = max_(0, spouse_earnings - phase_out_start) + spouse_reduction = phase_out_rate * spouse_phase_out + spouse_limitation = max_(0, eitc_maximum - spouse_reduction) + spouse_eitc = min_(spouse_phased_in, spouse_limitation) individual_eitc = head_eitc + spouse_eitc @@ -100,7 +83,6 @@ def formula(tax_unit, period, parameters): class reform(Reform): def apply(self): - self.update_variable(individual_eitc_base) self.update_variable(eitc) return reform