Skip to content

Compute the CTC tax-liability limit from actual liability (fixes request-order dependence) - #9649

Closed
MaxGhenis wants to merge 1 commit into
mainfrom
ctc-limiting-order-independence
Closed

MaxGhenis wants to merge 1 commit into
mainfrom
ctc-limiting-order-independence

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

ctc_limiting_tax_liability gave different answers depending on which variable a caller requested first. For SALT itemizers whose CTC is limited by tax liability, that changed refundable_ctc, ctc_value and income_tax. This PR computes the limit from actual income_tax_before_credits, which includes the SALT deduction. That is what 26 USC 26(a) and Schedule 8812 use, and the result no longer depends on request order.

Reproduction

A married couple in New York with one earner ($50,000 wages), two children and $40,000 of property tax, 2026. The only difference between the columns is the first variable requested from a fresh Simulation:

income_tax first ctc_limiting_tax_liability first
income_tax_before_credits 960.00 960.00
salt_deduction 40,400.00 40,400.00
ctc_limiting_tax_liability 960.00 5,504.00
refundable_ctc 3,400.00 0.00
ctc_value 4,360.00 4,400.00
income_tax -6,748.30 -3,348.30

This reproduces on policyengine-us 2.2.1 / policyengine-core 3.32.5 and on 2.15.1 / 3.32.7. The law gives the left column: liability after the $40,400 SALT deduction is $960, so the refundable portion is min($4,400 − $960, 2 × $1,700, 15% × ($50,000 − $2,500)) = $3,400. In the right column, ctc_value ($4,400) also disagrees with the credit income_tax actually applies ($960).

Repro script
"""ctc_limiting_tax_liability (and income_tax) depend on which variable is requested first."""
from importlib.metadata import version

import numpy as np
from policyengine_us import Simulation

Y = lambda v: {"2026": v}  # noqa: E731
SITUATION = {  # married couple, NY, two children, $50k wages, $40k property tax
    "people": {
        "parent": {"age": Y(40), "employment_income": Y(50_000), "real_estate_taxes": Y(40_000)},
        "spouse": {"age": Y(40)},
        "child1": {"age": Y(5)},
        "child2": {"age": Y(8)},
    },
    "tax_units": {"tax_unit": {"members": ["parent", "spouse", "child1", "child2"]}},
    "households": {"household": {"members": ["parent", "spouse", "child1", "child2"], "state_code": Y("NY")}},
}
REFORM = {"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2026-12-31": 3000}}
SHOW = ["income_tax_before_credits", "salt_deduction", "tax_unit_itemizes", "ctc", "ctc_limiting_tax_liability",
        "refundable_ctc", "non_refundable_ctc", "ctc_value", "income_tax"]


def run(first, reform=None):
    sim = Simulation(situation=SITUATION, reform=reform)
    sim.calculate(first, 2026)  # the ONLY difference between columns is this first request
    return [float(sim.calculate(v, 2026)[0]) for v in SHOW]


print(f"policyengine-us {version('policyengine-us')}, policyengine-core {version('policyengine-core')}")
ORDERS = {"A: income_tax 1st": "income_tax", "B: ctc_limiting 1st": "ctc_limiting_tax_liability"}
cols = {name: run(first) for name, first in ORDERS.items()}
ref = Simulation(situation=SITUATION)  # explicit no-SALT reference for the documented behaviour
ref.set_input("salt_deduction", 2026, np.zeros(1))
ref.set_input("tax_unit_itemizes", 2026, np.ones(1, dtype=bool))
print(f"{'variable (2026, baseline)':28}" + "".join(f"{c:>21}" for c in cols))
for i, v in enumerate(SHOW):
    print(f"{v:28}" + "".join(f"{x[i]:>21,.2f}" for x in cols.values()))
print(f"no-SALT income_tax_before_credits (salt_deduction=0, itemizing): "
      f"{float(ref.calculate('income_tax_before_credits', 2026)[0]):,.2f}")
print("Reform: CTC base $2,200 -> $3,000 (2026)")
for name, first in ORDERS.items():
    d = dict(zip(SHOW, (r - b for r, b in zip(run(first, REFORM), cols[name]))))
    print(f"  order {name[0]}: delta income_tax {d['income_tax']:>+10,.2f}   delta ctc_value {d['ctc_value']:>+10,.2f}")

Root cause

  • The formula evaluated income_tax_before_credits on simulation.get_branch("no_salt") after set_input("salt_deduction", 0).
  • get_branch clones the parent through Holder.clone, which copies _memory_storage, so the branch inherits every array the parent has already cached. set_input on the branch replaces only salt_deduction and invalidates nothing derived from it. The two outcomes:
    • Parent already cached the liability (for example income_tax requested first): the branch returned the parent's SALT-inclusive value, and the override did nothing.
    • Nothing SALT-dependent cached yet: the branch recomputed. Because Break refundable_ctc ↔ income_tax cycle on branches (closes #8059) #8060 pins tax_unit_itemizes on the branch, an itemizer then lost both the SALT deduction and the standard deduction. That gives $5,504 above, where SALT was the only itemized deduction.
  • Requesting medicaid, household_net_income, spm_unit_net_income, refundable_ctc, ctc_value or non_refundable_ctc first lands in the second case. medicaid gets there through medicaid_uses_non_filer_rules → tax_unit_is_filer → eligible_for_refundable_credits → refundable_ctc. policyengine.py 6.1.1 requests medicaid before income_tax, so policyengine.py and a plain Microsimulation disagree.

Why no branch is needed now

The documentation said excluding SALT was "required to avoid circular dependencies". That no longer holds:

  • The income-tax part of state_and_local_sales_or_income_tax is state_withheld_income_tax plus local_income_tax. All 42 *_withheld_income_tax formulas estimate withholding from adjusted_gross_income_person.
  • The local taxes that feed SALT (NYC, Philadelphia, Kansas City, St. Louis, Wilmington) read no federal tax or CTC variable.
  • The state variables that do read the federal CTC or federal income tax (AL, CA, CO, IA, MT, NE, NM, NY, OK, OR) feed state liability. State liability does not feed SALT, so they sit downstream of it.
  • No reform redefines SALT in terms of computed state tax.
  • Empirically: no CycleError in any test here, or on all 57,240 households of populace_us_2024 run in both request orders (below).

Impact

On populace_us_2024 for 2026 (policyengine-us 2.2.1, 79,729 tax units), compared per tax unit between policyengine.py's request order and income_tax first:

  • Limit differs: ctc_limiting_tax_liability differs for 8,643 tax units (17.0M weighted). All are SALT itemizers.
  • Income tax differs: 194 units (170k weighted). policyengine.py's order overstates 2026 federal income tax by $158.8M, by understating refundable CTC; the mean error is $825 per affected unit and the maximum $4,359.
  • Reform cost: raising the CTC base amount to $3,000 costs $31.193B in policyengine.py's order and $31.269B with income_tax first. The whole $75.6M gap is this bug.
  • With this fix: policyengine-us 2.2.1 plus this change, run on 4 of the 24 household chunks (13,240 tax units, 31.6M weighted), before machine load stopped the run. Every CTC variable and income_tax is identical, to $0.00, in policyengine.py's request order and with income_tax first, in baseline and reform. The values equal the old income_tax-first results exactly. On those units the old code had 275 differing limits and 10 differing income taxes. Nothing raised a CycleError.

Callers that already requested income_tax first see no change. Callers whose first request reached refundable_ctc get the SALT-inclusive limit.

Invariants (tested)

  • Order independence: ctc_limiting_tax_liability, refundable_ctc, non_refundable_ctc, ctc_value, income_tax_before_credits, ctc and income_tax are identical whichever variable is requested first.
  • Section 26(a) limit: ctc_limiting_tax_liability == max(0, income_tax_before_credits − other non-refundable credits).
  • Consistency with income tax: ctc_value == min(non_refundable_ctc, that liability) + refundable_ctc. The reported CTC equals the CTC that income tax actually delivers.

Tests

  • policyengine_us/tests/test_ctc_limiting_tax_liability_order.py, with two tests:
    • An example test of the household above for eight first-request variables, checking the statutory values.
    • A Hypothesis property test over batches of random households in NY, NJ, CA, CO, OK, IA, AL, MT, OR, MD and TX. AL, CO, IA, MT, NY, OK and OR are included because their tax reads federal CTC or federal income tax variables. It checks order independence (three orders) and the two identities above.
  • A YAML case in ctc_limiting_tax_liability.yaml, listing ctc_limiting_tax_liability first. It returned 5,504 before this change.
  • test_ctc_itemizing_branch_cycle.py (refundable_ctc ↔ income_tax dependency cycle surfaces on itemizing branch #8059) still passes; I updated its docstring.
  • Adds hypothesis to the dev extra; uv.lock gains only hypothesis.

Local results on policyengine-us 2.15.1 (this branch) with policyengine-core 3.32.7:

  • pytest policyengine_us/tests/test_ctc_limiting_tax_liability_order.py policyengine_us/tests/test_ctc_itemizing_branch_cycle.py: 11 passed.
  • policyengine-core test policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ -c policyengine_us: 32 passed.
  • With the old formula restored, both new tests fail. The example test gets ctc_limiting_tax_liability 5,504 instead of 960. The YAML case fails with ctc_limiting_tax_liability@2026: [5504.] differs from 960.0.
  • An earlier prototype of the same change on 2.2.1 passed all 978 YAML tests under policyengine_us/tests/policy/baseline/gov/irs/.

axiom: TheAxiomFoundation/rulespec-us#1426 queued

rulespec-us's section 24(d) takes the section 26(a) credit aggregates as caller inputs, and 26/b#regular_tax_liability is deferred, so no module decides which liability limits the CTC. The composition bridge in axiom-oracles already uses SALT-inclusive income_tax_before_credits. #1426 is a dispatch-ready encoding request: the verbatim law, a pasteable review_finding, and six companion cases computed from the statute and Rev. Proc. 2025-32, including this PR's household. It is sequenced after rulespec-us#1395.

🤖 Generated with Claude Code

ctc_limiting_tax_liability evaluated income_tax_before_credits on a
"no_salt" branch. get_branch clones every array the parent has cached and
set_input on the branch invalidates nothing derived from salt_deduction,
so the result depended on request order: requesting income_tax first
returned actual liability, while requesting something that reaches
refundable_ctc first (medicaid, household_net_income, ...; policyengine.py
requests medicaid first) returned liability with neither SALT nor the
standard deduction. For a liability-limited SALT itemizer that swung
refundable_ctc and income_tax by up to $3,400.

The branch is no longer needed to avoid a cycle: the SALT deduction's
income tax component is state_withheld_income_tax (AGI-based) plus
local_income_tax, neither of which reads the federal CTC. Use actual
income_tax_before_credits, which is the section 26(a) limitation.

Adds an order-independence example test, a Hypothesis property test of
order independence and the section 26(a) / ctc_value identities, a YAML
case, and hypothesis to the dev extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (909176a) to head (cc80d79).
⚠️ Report is 72 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #9649   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            4         1    -3     
  Lines           76        15   -61     
  Branches         2         0    -2     
=========================================
- Hits            76        15   -61     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #9741, merged 2026-10-06 as 9dc167b, on the US + core hub's PR sweep.

#9741 reapplies this PR's fix (CTC Worksheet A line 1 read from actual liability) on top of the Worksheet A/B split that #9742 introduced, which made this branch's direct patch stale. It also corrects the Oklahoma test this branch failed: ok_child_care_child_tax_credit.yaml changes from 4,400 to 2,799. Its impact table supersedes the one here.

@MaxGhenis MaxGhenis closed this Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant