Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1f0cd1b
Make head_is_dependent_elsewhere consumers independent of the head label
MaxGhenis Oct 8, 2026
3ecc675
Keep tax units without a filer eligible where only claimed filers are…
MaxGhenis Oct 9, 2026
8da8312
Address review: Hawaii worksheet earnings, NM same-filer age, childle…
MaxGhenis Oct 9, 2026
e838623
Allow a cent of rounding in the Hawaii self-employed joint pair
MaxGhenis Oct 9, 2026
41fb4a5
Address review round 2: worksheet earnings, minors' own status, input…
MaxGhenis Oct 9, 2026
527d47f
Merge remote-tracking branch 'upstream/main' into head-dependent-else…
MaxGhenis Oct 9, 2026
436805e
Merge the Hawaii deductions references after syncing main
MaxGhenis Oct 9, 2026
23c6229
Apply the federal rules for a filer who can be claimed as a dependent
MaxGhenis Oct 9, 2026
4597c55
Address the federal claimable-filer review
MaxGhenis Oct 10, 2026
e4b8f36
Separate dependency facts and fix Hawaii qualified-person counts
MaxGhenis Oct 10, 2026
c7f969f
Clarify claimability outside the current tax unit
MaxGhenis Oct 10, 2026
4c8ed81
Merge commit 'c7f969f872' into claimable-filer-federal
MaxGhenis Oct 10, 2026
3f88702
Read the split dependency inputs from #10024
MaxGhenis Oct 10, 2026
b2771d2
Follow the corrected exemption count in three Hawaii credits
MaxGhenis Oct 10, 2026
163fcd4
Address the second federal claimable-filer review
MaxGhenis Oct 10, 2026
3d0864f
Address the third federal claimable-filer review
MaxGhenis Oct 10, 2026
35a2cbc
Give the spouse without an SSN a card type in the recovery rebate test
MaxGhenis Oct 10, 2026
940a821
Merge main into claimable-filer-federal after #10024
MaxGhenis Oct 15, 2026
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/claimable-filer-federal.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Apply the federal rules for a filer who can be claimed as a dependent on another return: no child tax credit, credit for other dependents or under-13 care qualifying child (or that child's care expenses) on such a return (IRC 24, 21, 152(b)(1)), no personal exemption for that filer (IRC 151(d)(2)) in the exemption count Rhode Island, Delaware, Ohio, Wisconsin, Oregon, Hawaii and New Mexico read, no recovery rebate for that filer, including under the Armed Forces exception, no tuition and fees deduction (IRC 222(c)(3)), and no education credits or student loan interest deduction on a return with a claimed filer (IRC 25A(g)(3), 221(c)).
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class cdcc_relevant_expenses(Variable):
)

def formula(tax_unit, period, parameters):
expenses = tax_unit("tax_unit_childcare_expenses", period)
expenses = tax_unit("cdcc_qualifying_childcare_expenses", period)
cdcc_limit = tax_unit("cdcc_limit", period)
eligible_capped_expenses = min_(expenses, cdcc_limit)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,15 @@ def formula(tax_unit, period, parameters):
is_aoc_eligible = person(
"is_eligible_for_american_opportunity_credit", period
)
# This reform leaves the dependency rules of IRC 25A(g)(3) and
# 25A(f)(1)(A)(iii) in place.
barred_by_dependency = person(
"is_barred_from_education_credits_by_dependency", period
)
eligible_expenses = tax_unit.sum(
person("qualified_tuition_expenses", period) * ~is_aoc_eligible
person("qualified_tuition_expenses", period)
* ~is_aoc_eligible
* ~barred_by_dependency
)
capped_expenses = min_(llc.expense_limit, eligible_expenses)
maximum_amount = llc.rate * capped_expenses
Expand Down
7 changes: 1 addition & 6 deletions policyengine_us/reforms/states/ga/ctc/ga_ctc_reform.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,7 @@ def formula(tax_unit, period, parameters):
unused_credit = max_(potential - non_refundable, 0)
# Cap the refund at amount per eligible child (same child count as
# ga_ctc_potential: qualifying children under the age threshold).
baseline = parameters(period).gov.states.ga.tax.income.credits.ctc
person = tax_unit.members
age = person("age", period)
ctc_eligible_child = person("ctc_qualifying_child", period)
ga_child_age_eligible = age < baseline.age_threshold
eligible_children = tax_unit.sum(ctc_eligible_child & ga_child_age_eligible)
eligible_children = add(tax_unit, period, ["ga_ctc_eligible_child"])
refund_limit = p.refundable.amount * eligible_children
refundable_credit = min_(unused_credit, refund_limit)
# Only pay the refund in periods where the reform is in effect. The
Expand Down
8 changes: 2 additions & 6 deletions policyengine_us/reforms/states/ga/sb520/ga_sb520_reform.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,8 @@ def formula(tax_unit, period, parameters):

sb520_active = p_sb520.in_effect

person = tax_unit.members
age = person("age", period)
ctc_eligible_child = person("ctc_qualifying_child", period)
# SB 520 preserves baseline GA CTC age threshold (under 6)
ga_child_age_eligible = age < p.age_threshold
eligible_children = tax_unit.sum(ctc_eligible_child & ga_child_age_eligible)
# SB 520 preserves the baseline GA CTC child test (under 6)
eligible_children = add(tax_unit, period, ["ga_ctc_eligible_child"])

sb520_amount = p_sb520.credits.ctc.amount
baseline_amount = p.amount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ def formula(tax_unit, period, parameters):

person = tax_unit.members
age = person("age", period)
is_dependent = person("is_tax_unit_dependent", period)
# A return on which the filer (or, if joint, either spouse) can be
# claimed as a dependent has no dependents (IRC 152(b)(1)).
is_dependent = person("is_tax_unit_dependent", period) & ~person.tax_unit(
"head_or_spouse_is_dependent_elsewhere_without_filing_exception", period
)

# Apply age limit if in effect
if p.age_limit.in_effect:
Expand Down Expand Up @@ -49,7 +53,11 @@ class hi_older_dependents_count(Variable):

def formula(tax_unit, period, parameters):
person = tax_unit.members
is_dependent = person("is_tax_unit_dependent", period)
# A return on which the filer (or, if joint, either spouse) can be
# claimed as a dependent has no dependents (IRC 152(b)(1)).
is_dependent = person("is_tax_unit_dependent", period) & ~person.tax_unit(
"head_or_spouse_is_dependent_elsewhere_without_filing_exception", period
)
total_dependents = tax_unit.sum(is_dependent)
eligible_dependent_exemptions = tax_unit(
"hi_eligible_dependents_count", period
Expand All @@ -66,14 +74,16 @@ class hi_regular_exemptions(Variable):
defined_for = StateCode.HI

def formula(tax_unit, period, parameters):
exemptions_count = tax_unit("exemptions_count", period)
p = parameters(period).gov.states.hi.tax.income.exemptions

# Personal exemptions exclude the dependent portion. Over-age
# dependents fall back to the personal count.
dependents = tax_unit("tax_unit_dependents", period)
# Personal exemptions exclude the dependent portion: the filers'
# own exemptions (none for a filer who can be claimed as a
# dependent). Over-age dependents fall back to the personal count.
own_exemptions = tax_unit(
"head_spouse_count_not_dependent_elsewhere", period
)
older_dependents = tax_unit("hi_older_dependents_count", period)
personal_count = exemptions_count - dependents + older_dependents
personal_count = own_exemptions + older_dependents

# Aged heads and spouses get an extra base exemption (preserved
# exactly as the baseline computes it).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ def formula(tax_unit, period, parameters):

person = tax_unit.members
age = person("age", period)
is_dependent = person("is_tax_unit_dependent", period)
# A return on which the filer (or, if joint, either spouse) can be
# claimed as a dependent has no dependents (IRC 152(b)(1)).
is_dependent = person("is_tax_unit_dependent", period) & ~person.tax_unit(
"head_or_spouse_is_dependent_elsewhere_without_filing_exception", period
)

# Apply age limit if in effect
if p.age_limit.in_effect:
Expand Down Expand Up @@ -49,7 +53,11 @@ class wi_older_dependents_count(Variable):

def formula(tax_unit, period, parameters):
person = tax_unit.members
is_dependent = person("is_tax_unit_dependent", period)
# A return on which the filer (or, if joint, either spouse) can be
# claimed as a dependent has no dependents (IRC 152(b)(1)).
is_dependent = person("is_tax_unit_dependent", period) & ~person.tax_unit(
"head_or_spouse_is_dependent_elsewhere_without_filing_exception", period
)
total_dependents = tax_unit.sum(is_dependent)
eligible_dependent_exemptions = tax_unit(
"wi_eligible_dependents_count", period
Expand All @@ -68,12 +76,13 @@ def formula(tax_unit, period, parameters):
p_base = parameters(period).gov.states.wi.tax.income

# Personal exemptions exclude the dependent portion.
total_exemptions_count = tax_unit("exemptions_count", period)
dependents = tax_unit("tax_unit_dependents", period)
older_dependents = tax_unit("wi_older_dependents_count", period)
personal_exemptions_count = (
total_exemptions_count - dependents + older_dependents
# These are the filers' own exemptions (none for a filer who can
# be claimed as a dependent) plus over-age dependents.
own_exemptions = tax_unit(
"head_spouse_count_not_dependent_elsewhere", period
)
older_dependents = tax_unit("wi_older_dependents_count", period)
personal_exemptions_count = own_exemptions + older_dependents
personal_exemption_amount = (
personal_exemptions_count * p_base.exemption.base
)
Expand Down
10 changes: 9 additions & 1 deletion policyengine_us/reforms/treasury/repeal_dependent_exemptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ class exemptions_count(Variable):
def formula(tax_unit, period, parameters):
total_unit_size = tax_unit("tax_unit_size", period)
dependents = tax_unit("tax_unit_dependents", period)
return total_unit_size - dependents
# Only the filers' own exemptions remain; a filer who can be
# claimed as a dependent has none (IRC 151(d)(2)).
dependent_filer = tax_unit("head_or_spouse_is_dependent_elsewhere", period)
independent_filers = tax_unit(
"head_spouse_count_not_dependent_elsewhere", period
)
return where(
dependent_filer, independent_filers, total_unit_size - dependents
)

class reform(Reform):
def apply(self):
Expand Down
167 changes: 167 additions & 0 deletions policyengine_us/tests/core/test_claimable_filer_federal_invariants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Federal rules for a filer who can be claimed as a dependent elsewhere.

A filer who can be claimed as a dependent on another return is treated as
having no dependents (IRC 152(b)(1); on a joint return, if either spouse can be
claimed, "you and your spouse can't claim any dependents", Publication 501) and
has no personal exemption (IRC 151(d)(2)). So:

- `exemptions_count` counts only the filers who cannot be claimed, plus the
dependents when no filer can be claimed;
- `ctc_qualifying_child` and the credit for other dependents are zero on a
return with a claimable filer;
- a child under 13 is not a child and dependent care qualifying person on such
a return (a disabled person still is, IRC 21(b)(1)(B));
- recovery rebates leave out a claimable filer and, on such a return, the
dependents;
- the state amounts that read the exemption count follow it (Georgia's two
credits keep their own count).

For couples with either, both or neither spouse claimed, in 2021-2026:

1. Swap invariance: each output is the same under either head/spouse
labelling (Hypothesis batches and the seeded population).
2. Monotonicity: marking another filer as claimed never raises any of the
amounts or counts below (seeded population only).
3. Identities: `exemptions_count` equals the filers who cannot be claimed plus
the dependents when no filer can be claimed, and a return with a claimable
filer has no CTC-qualifying child and no credit for other dependents
(both populations).

The generated adults have no tuition or student loan interest, so the education
credit, tuition deduction and student loan interest gates are covered by YAML
cases only, as are the contributed reforms.
"""

import numpy as np
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

from policyengine_us import Simulation
from policyengine_us.tests.core.test_dependent_elsewhere_head_spouse_swap_invariance import (
CLAIM_PATTERNS,
TOLERANCE,
YEARS,
_calc,
_crafted_cases,
_monotone_variants,
_seeded_couples,
_situation,
_swap_variants,
adults,
dependents,
)

# Texas has no income tax; the others read the federal exemption count.
STATES = ["TX", "OR", "HI", "NM", "GA", "RI", "DE", "OH", "WI"]
# State amounts that read the federal exemption count; each is zero outside
# its state.
STATE_OUTPUTS = [
"ri_exemptions",
"de_personal_credit_potential",
"oh_personal_exemptions",
"wi_base_exemption",
"or_regular_exemptions",
"hi_regular_exemptions",
"nm_low_and_middle_income_exemption",
"nm_low_income_comprehensive_tax_rebate",
"ga_ctc_potential",
"ga_low_income_credit_potential",
]
# Amounts that marking another filer as claimed must never raise.
MONOTONE = [
"ctc_maximum",
"ctc_qualifying_children",
"capped_count_cdcc_eligible",
"cdcc_relevant_expenses",
"exemptions_count",
"rrc_arpa",
] + STATE_OUTPUTS
OUTPUTS = ["income_tax"] + MONOTONE


@st.composite
def couples(draw):
return {
"state": draw(st.sampled_from(STATES)),
"adults": [draw(adults()), draw(adults())],
"dependents": draw(st.lists(dependents(), max_size=2)),
"claimed": draw(st.sampled_from(CLAIM_PATTERNS)),
}


def check_swap(units, year, outputs, identities=None):
"""Each output is the same under either head/spouse labelling."""
sim = Simulation(situation=_situation(units, year, _swap_variants))
for name in outputs:
values = _calc(sim, name, year)
assert np.isfinite(values).all(), f"{name} in {year} is not finite"
drawn, swapped = values[0::2], values[1::2]
differs = ~(np.abs(swapped - drawn) <= TOLERANCE)
assert not differs.any(), (
f"{name} in {year} changes when the head and spouse labels are "
"exchanged: "
+ ", ".join(
f"{units[i]['state']} claimed={units[i]['claimed']} "
f"{drawn[i]:.2f} -> {swapped[i]:.2f}"
for i in np.flatnonzero(differs)[:10]
)
)
if identities is not None:
identities(sim, year)


def check_monotone(units, year, outputs, identities=None):
"""Marking another filer as claimed never raises an output."""
sim = Simulation(situation=_situation(units, year, _monotone_variants))
for name in outputs:
values = _calc(sim, name, year).reshape(-1, 4)
nobody, first, second, both = values.T
for more, fewer, label in (
(first, nobody, "first adult"),
(second, nobody, "second adult"),
(both, first, "both after first"),
(both, second, "both after second"),
):
rises = more > fewer + TOLERANCE
assert not rises.any(), (
f"{name} in {year} rises when the {label} is claimed "
"elsewhere: "
+ ", ".join(
f"{units[i]['state']} {fewer[i]:.2f} -> {more[i]:.2f}"
for i in np.flatnonzero(rises)[:10]
)
)
if identities is not None:
identities(sim, year)


def _federal_identities(sim, year):
claimed_filer = _calc(sim, "head_or_spouse_is_dependent_elsewhere", year) > 0
independent = _calc(sim, "head_spouse_count_not_dependent_elsewhere", year)
dependents_count = _calc(sim, "tax_unit_count_dependents", year)
expected = independent + np.where(claimed_filer, 0, dependents_count)
np.testing.assert_array_equal(_calc(sim, "exemptions_count", year), expected)
assert not _calc(sim, "ctc_qualifying_children", year)[claimed_filer].any()
odc = np.asarray(
sim.calculate("ctc_adult_individual_maximum", year, map_to="tax_unit")
)
assert not odc[claimed_filer].any()


@settings(
max_examples=4,
deadline=None,
derandomize=True,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
)
@given(st.sampled_from(YEARS), st.lists(couples(), min_size=6, max_size=15))
def test_claimable_filer_federal_rules_are_label_free(year, units):
check_swap(units, year, OUTPUTS, _federal_identities)


@pytest.mark.parametrize("year", YEARS)
def test_claimable_filer_federal_rules_seeded_population(year):
units = _seeded_couples(STATES, per_state=2) + _crafted_cases(STATES)
check_swap(units, year, OUTPUTS, _federal_identities)
check_monotone(units, year, MONOTONE, _federal_identities)
Loading
Loading