Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fix-individual-eitc-reform.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed individual_eitc (Winship) reform not responding to parameter changes in the app.
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
138 changes: 88 additions & 50 deletions policyengine_us/reforms/winship.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,116 @@
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.

# 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
Reference: https://ifstudies.org/blog/reforming-the-eitc-to-reduce-single-parenthood-and-ease-work-family-balance
"""

class original_eitc(Variable):
class eitc(Variable):
value_type = float
entity = TaxUnit
definition_period = YEAR
label = "Original 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)
return min_(phased_in, limitation)
baseline_eitc = min_(phased_in, limitation)

class eitc(Variable):
value_type = float
entity = TaxUnit
definition_period = YEAR
label = "EITC"
unit = USD
defined_for = "eitc_eligible"

def formula(tax_unit, period, parameters):
# 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)

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(
"filer_adjusted_earnings", period, filer_earned_head_only
)
# Phase out with respect to individual earnings instead of AGI
head_only_branch.set_input(
"adjusted_gross_income", period, filer_earned_head_only
)
head_eitc = head_only_branch.calculate("original_eitc", period)

spouse_only_branch = simulation.get_branch("spouse_only")
spouse_only_branch.set_input(
"filer_adjusted_earnings", period, filer_earned_spouse_only
)
spouse_only_branch.set_input(
"adjusted_gross_income", period, filer_earned_spouse_only
)
spouse_eitc = spouse_only_branch.calculate("original_eitc", period)

agi_limit = parameters(period).gov.contrib.individual_eitc.agi_eitc_limit

return (agi < agi_limit) * (head_eitc + spouse_eitc)

class winship_eitc_reform(Reform):
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

# 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)

reform_eitc = individual_eitc * agi_eligible

return where(reform_active, reform_eitc, baseline_eitc) * takes_up_eitc

class reform(Reform):
def apply(self):
self.update_variable(original_eitc)
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
126 changes: 103 additions & 23 deletions policyengine_us/tests/policy/reform/winship.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading