Skip to content
Closed
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/ctc-limiting-order-independence.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Compute the CTC tax-liability limit from actual income tax before credits, including the SALT deduction, instead of on a no-SALT branch whose result depended on which variables were requested first.
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,40 @@
non_refundable_ctc: 10
output:
ctc_limiting_tax_liability: 80

- name: SALT itemizer, the CTC is limited by actual tax liability after the SALT deduction.
# Married couple in New York, one earner with $50,000 of wages, two
# children, $40,000 of property tax. SALT is capped at $40,400, so taxable
# income is $9,600 and tax before credits is $960 (26 USC 26(a)). The
# refundable CTC is min($4,400 - $960, 2 x $1,700, 15% x ($50,000 -
# $2,500)) = $3,400 (26 USC 24(d)(1)). ctc_limiting_tax_liability is listed
# first: it used to be computed on a no_salt branch that returned $5,504
# when nothing SALT-dependent had been cached yet.
period: 2026
absolute_error_margin: 0.01
input:
people:
head:
age: 40
employment_income: 50_000
real_estate_taxes: 40_000
spouse:
age: 40
child1:
age: 5
child2:
age: 8
tax_units:
tax_unit:
members: [head, spouse, child1, child2]
households:
household:
members: [head, spouse, child1, child2]
state_code: NY
output:
ctc_limiting_tax_liability: 960
refundable_ctc: 3_400
ctc_value: 4_360
tax_unit_itemizes: true
salt_deduction: 40_400
income_tax_before_credits: 960
10 changes: 7 additions & 3 deletions policyengine_us/tests/test_ctc_itemizing_branch_cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
consumers (e.g. policyengine.py's household-impact integration tests)
on `policyengine-core >= 3.24`.

The fix in `ctc_limiting_tax_liability.py` propagates the parent's
`tax_unit_itemizes` value to the no_salt child branch so the
`tax_unit_itemizes` formula is never re-entered there.
The original fix propagated the parent's `tax_unit_itemizes` value to
the no_salt child branch so the `tax_unit_itemizes` formula was never
re-entered there. `ctc_limiting_tax_liability` no longer uses a branch
(see test_ctc_limiting_tax_liability_order.py); it reads
`income_tax_before_credits` on the simulation it runs in, where
`tax_unit_itemizes` is already an input on the itemizing / not_itemizing
branches. These tests keep guarding against the cycle.
"""

import numpy as np
Expand Down
191 changes: 191 additions & 0 deletions policyengine_us/tests/test_ctc_limiting_tax_liability_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""CTC outputs must not depend on the order in which variables are requested.

`ctc_limiting_tax_liability` used to evaluate `income_tax_before_credits`
on a "no_salt" branch with `salt_deduction` set to zero. `get_branch`
copies every array the parent simulation has already cached, and
`set_input` on the branch does not invalidate values derived from the
overridden variable. So the branch returned the parent's SALT-inclusive
liability when `income_tax` had been requested first, and a liability with
no SALT (and, because itemization was pinned, no deduction at all) when
something reached `refundable_ctc` first (`medicaid`,
`household_net_income`, `spm_unit_net_income`, ...). policyengine.py
requests `medicaid` before `income_tax`, so its outputs differed from a
plain `Microsimulation` for liability-limited SALT itemizers.

Invariants checked here, for every household:

- Order independence: the CTC variables and `income_tax` are the same
whichever variable is requested first.
- Section 26(a) limitation: `ctc_limiting_tax_liability` equals income tax
before credits less the other non-refundable credits, floored at zero.
- Consistency with income tax: `ctc_value` equals the non-refundable CTC
that actual liability absorbs plus the refundable CTC.
"""

from dataclasses import dataclass

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

from policyengine_us import Simulation

YEAR = 2026

CTC_OUTPUTS = [
"ctc_limiting_tax_liability",
"refundable_ctc",
"non_refundable_ctc",
"ctc_value",
"income_tax",
]

# Married couple in New York, one earner with $50,000 of wages, two
# children, $40,000 of property tax. SALT ($40,400 cap) exceeds the
# standard deduction, so taxable income is $9,600 and tax before credits
# is $960, well below the $4,400 CTC.
NY_SALT_ITEMIZER = {
"people": {
"head": {
"age": 40,
"employment_income": {YEAR: 50_000},
"real_estate_taxes": {YEAR: 40_000},
},
"spouse": {"age": 40},
"child1": {"age": 5},
"child2": {"age": 8},
},
"tax_units": {"tax_unit": {"members": ["head", "spouse", "child1", "child2"]}},
"households": {
"household": {
"members": ["head", "spouse", "child1", "child2"],
"state_code": "NY",
}
},
}

FIRST_REQUESTS = [
"income_tax",
"ctc_limiting_tax_liability",
"refundable_ctc",
"ctc_value",
"medicaid",
"household_net_income",
"spm_unit_net_income",
"state_income_tax",
]


def _outputs(situation, first_variable, variables=CTC_OUTPUTS):
sim = Simulation(situation=situation)
sim.calculate(first_variable, YEAR)
return {variable: sim.calculate(variable, YEAR) for variable in variables}


@pytest.mark.parametrize("first_variable", FIRST_REQUESTS)
def test_salt_itemizer_ctc_is_limited_by_actual_liability(first_variable):
out = _outputs(NY_SALT_ITEMIZER, first_variable)
# 26 USC 26(a): nonrefundable credits are limited to regular tax
# liability, here $960 after the SALT deduction.
assert out["ctc_limiting_tax_liability"][0] == pytest.approx(960)
# 26 USC 24(d)(1): min($4,400 - $960, 2 x $1,700, 15% x ($50,000 -
# $2,500)) = $3,400.
assert out["refundable_ctc"][0] == pytest.approx(3_400)
assert out["ctc_value"][0] == pytest.approx(960 + 3_400)


@dataclass(frozen=True)
class Household:
married: bool
children: int
wages: int
property_tax: int
state: str


# Includes states whose income tax reads federal CTC or federal income tax
# variables (AL, CO, IA, MT, NY, OK, OR), where a dependency cycle through the
# SALT deduction would surface as a CycleError.
households = st.builds(
Household,
married=st.booleans(),
children=st.integers(min_value=0, max_value=3),
wages=st.integers(min_value=0, max_value=250).map(lambda k: k * 1_000),
property_tax=st.integers(min_value=0, max_value=60).map(lambda k: k * 1_000),
state=st.sampled_from(
["NY", "NJ", "CA", "CO", "OK", "IA", "AL", "MT", "OR", "MD", "TX"]
),
)


def _situation(batch):
people, tax_units, spm_units, families, marital_units, hhs = ({} for _ in range(6))
for i, h in enumerate(batch):
head = f"head_{i}"
adults = [head]
people[head] = {
"age": 40,
"employment_income": {YEAR: h.wages},
"real_estate_taxes": {YEAR: h.property_tax},
}
if h.married:
adults.append(f"spouse_{i}")
people[f"spouse_{i}"] = {"age": 40}
children = [f"child_{i}_{k}" for k in range(h.children)]
for k, child in enumerate(children):
people[child] = {"age": 2 + 5 * k}
marital_units[f"marital_unit_{child}"] = {"members": [child]}
members = adults + children
marital_units[f"marital_unit_{i}"] = {"members": adults}
tax_units[f"tax_unit_{i}"] = {"members": members}
spm_units[f"spm_unit_{i}"] = {"members": members}
families[f"family_{i}"] = {"members": members}
hhs[f"household_{i}"] = {"members": members, "state_code": h.state}
return {
"people": people,
"tax_units": tax_units,
"spm_units": spm_units,
"families": families,
"marital_units": marital_units,
"households": hhs,
}


@settings(
max_examples=10,
deadline=None,
derandomize=True,
suppress_health_check=[HealthCheck.too_slow],
)
@given(st.lists(households, min_size=1, max_size=6))
def test_ctc_invariants_hold_in_every_request_order(batch):
situation = _situation(batch)
extra = ["income_tax_before_credits", "ctc"]
reference = _outputs(situation, "income_tax", CTC_OUTPUTS + extra)
for first_variable in ["refundable_ctc", "medicaid", "household_net_income"]:
other = _outputs(situation, first_variable, CTC_OUTPUTS + extra)
for variable in CTC_OUTPUTS + extra:
np.testing.assert_allclose(
other[variable],
reference[variable],
atol=0.01,
err_msg=f"{variable} differs when {first_variable} is requested first",
)

sim = Simulation(situation=situation)
credits = sim.tax_benefit_system.parameters(YEAR).gov.irs.credits.non_refundable
other_credits = sum(
sim.calculate(credit, YEAR)
for credit in credits
if credit != "non_refundable_ctc"
)
liability = np.maximum(0, reference["income_tax_before_credits"] - other_credits)
np.testing.assert_allclose(
reference["ctc_limiting_tax_liability"], liability, atol=0.01
)
delivered = (
np.minimum(reference["non_refundable_ctc"], liability)
+ reference["refundable_ctc"]
)
np.testing.assert_allclose(reference["ctc_value"], delivered, atol=0.01)
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,31 @@ class ctc_limiting_tax_liability(Variable):
entity = TaxUnit
label = "CTC-limiting tax liability"
unit = USD
documentation = "The tax liability used to determine the maximum amount of the non-refundable CTC. Excludes SALT from all calculations (this is an inaccuracy required to avoid circular dependencies)."
documentation = (
"The tax liability that limits the non-refundable CTC: income tax "
"before credits, less non-refundable credits other than the CTC."
)
definition_period = YEAR
reference = [
# Regular tax liability limits the aggregate of nonrefundable credits.
"https://www.law.cornell.edu/uscode/text/26/26#a",
"https://www.law.cornell.edu/uscode/text/26/26#b_1",
# The refundable portion is measured against the same limitation.
"https://www.law.cornell.edu/uscode/text/26/24#d_1",
# Schedule 8812 Credit Limit Worksheet A.
"https://www.irs.gov/instructions/i1040s8",
]

def formula(tax_unit, period, parameters):
simulation = tax_unit.simulation
no_salt_branch = simulation.get_branch("no_salt")
no_salt_branch.set_input("salt_deduction", period, np.zeros(tax_unit.count))
# Propagate the parent's itemization determination so the
# no_salt branch doesn't re-enter
# `tax_unit_itemizes` -> `tax_liability_if_itemizing` ->
# `income_tax` -> `refundable_ctc`, which forms a cycle
# (issue #8059). The parent's value has already been computed
# by the time we get here: either set as input on the
# itemizing / not_itemizing branch, or computed and cached on
# the top-level sim before `refundable_ctc` was reached (the
# `income_tax_before_credits` branch of
# `income_tax_before_refundable_credits` runs first).
itemizes = tax_unit("tax_unit_itemizes", period)
no_salt_branch.set_input("tax_unit_itemizes", period, itemizes)
tax_liability_before_credits = no_salt_branch.calculate(
"income_tax_before_credits", period
)
# Actual liability, including any SALT deduction. This used to be
# evaluated on a "no_salt" branch to avoid a circular dependency, but
# the SALT deduction's income tax component now comes from
# state_withheld_income_tax (an AGI-based estimate) and
# local_income_tax, neither of which depends on the federal CTC. The
# branch also inherited whatever the parent simulation had already
# cached, so its value depended on which variables were requested
# first.
tax_liability_before_credits = tax_unit("income_tax_before_credits", period)
non_refundable_credits = parameters(period).gov.irs.credits.non_refundable
non_refundable_credits_ex_ctc = [
x for x in non_refundable_credits if x != "non_refundable_ctc"
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ dev = [
"setuptools>=80.9.0",
"build>=1.2.2.post1",
"towncrier>=24.8.0",
"hypothesis>=6.100.0",
]

[tool.hatch.build.targets.wheel]
Expand Down
Loading
Loading