Skip to content

Limit the CTC by actual tax liability and make formula branches calculate under their overrides - #9741

Merged
MaxGhenis merged 9 commits into
mainfrom
fix-branch-override-shadowing
Oct 6, 2026
Merged

MaxGhenis merged 9 commits into
mainfrom
fix-branch-override-shadowing

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Formula branches copy every array their parent has cached when they are created, and set_input on a branch clears none of them (policyengine-core Simulation.clone → InMemoryStorage.clone; Holder.get_array falls back to the copied default key). A value the parent calculated from the overridden input therefore answers for the branch.

Rebased onto #9738 and #9742. Both landed after this PR was opened and overlap it. The branch now merges current main (#9753's credit-limit order included), and the PR re-applies its two changes on top of what those PRs built:

CTC limit by actual liability

ctc_tax_liability_after_preceding_credits (Credit Limit Worksheet A line 3) recomputed the liability without SALT in a no_salt branch. Whether that branch saw its override depended on which variable the caller asked for first. In policyengine.py's US output order it applied the no-SALT liability at the top level. Asking for income_tax first gave the liability with SALT. Inside the itemizing branch it always gave the liability with SALT.

The law limits the credit by the actual liability:

  • 26 U.S.C. 26(a) caps subpart A credits at regular tax liability plus the section 55(a) tax. Regular tax liability is the chapter 1 tax, on taxable income after itemized deductions.
  • 24(b)(3) was struck in 2013.
  • Schedule 8812 Credit Limit Worksheet A line 1 is Form 1040 line 18.

Line 3 now reads income_tax_before_credits directly, then subtracts the preceding_credits list as before (#9742's list, in #9753's order). Line 5 (ctc_limiting_tax_liability) is unchanged. The cycle the branch once avoided is gone. salt_deduction → salt → state_and_local_sales_or_income_tax reads state_withheld_income_tax. Each of the 42 state withholding estimates it can add reads only adjusted_gross_income_person (two also read geography), so none of them reads federal tax or credits. It also reads local_income_tax, whose adds list already leaves out the local taxes that depend on state tax. test_ctc_itemizing_branch_cycle.py passes without the branch.

Comparison branches

get_override_branch (policyengine_us/tools/period_branch.py) does three things:

  • It keeps one branch per name and period.
  • It creates the branch again for another period or other override values.
  • When the parent already has a value for an overridden input at branch creation, it drops every copied array except inputs.

Otherwise it shares the parent's cache. No cached value can have been calculated from an input value that did not yet exist. That is the usual case, because the formula branches while the parent is still calculating the overridden variable.

It is used by itemizing/not itemizing, Delaware and Virginia EITC refundability, Idaho aged or disabled credit/deduction, Missouri TANF non-parent caretaker and Medicaid for SSI state supplements. The Alabama 2020-IRC and New York pre-ARPA EITC / pre-TCJA CTC branches pin parameters and do their own variable deletion. They use get_branch_for_period.

Every other get_branch caller is left unchanged: MTR, capital-gains MTR, trust-fund revenue, employer payroll revenue and behavioral-response measurement. Each deletes its non-input arrays at creation or neutralizes what it changes.

Round 2 (head 53cdd80)

Review r2 found that clearing a branch's inherited values kept every array of a variable that was an input in any year. In its example, a CA couple with taxable_income supplied for 2025 only, the 2026 itemizing branch kept the parent's computed taxable income and gave $13,140 of income tax instead of $8,191.05.

drop_inherited_values now keeps an array only when its exact (variable, branch, period) key was set through set_input on that branch, an ancestor or default. Eternal variables are matched by (variable, branch). Three new tests fail on 39124ed's helper and pass on 53cdd80. One of them is r2's own case, which fails there with 13,140.0 against 8,191.047. A fourth, existing test now checks the input as it is stored (employment_income_before_lsr).

The local suite passed, one process at a time: 5,235 passed and 5 skipped across the branch, override, IRS credit, code-health and the AL, DE, ID, MO, NY, VA, CO, OK and HHS Medicaid folders. The 2026 microsimulation's changes are identical to round 1 on every variable (records, weighted counts and totals changed), because the dataset supplies inputs for a single year. The impact table above stands.

Evidence (2026-10-01, pre-rebase head)

Audit of every formula branch (git grep get_branch policyengine_us/variables) on 27 household cases. Each read that a branch served from an inherited key was compared with a fresh simulation that sets the branch's inputs before calculating anything. Findings on main:

  • In single-year runs, only the no_salt override was shadowed. It was always shadowed when nested in another branch, and at top level depending on order.
  • The itemization and state-choice branches were shadowed when the parent already had the input (e.g. tax_unit_itemizes given as input), and in year 2 of a multi-year simulation. Calculate each year's formula branches fresh so multi-year simulations match single-year ones #9738 has since fixed the year-2 case.
  • The MTR, capital-gains MTR, trust-fund-revenue and employer-payroll branches clear their arrays and are effective.

Example, CA couple 2025: the CTC-limiting liability was $43,038.46 when household net income was calculated first and $35,670.97 when income tax was. With this PR it is $35,670.97 in every order. With the fix, the same audit found 0 stale reads among 12,041 inherited reads in 177 branches across 26 cases.

Impact (real microsimulation)

Default dataset (populace_us_2024.h5@populace-us-2024-spm-20260909), policyengine-core 3.32.15. Main (9bbdec7d51) against main merged with this head (39124edc77), one run each, compared tax unit by tax unit.

Weighted change from main 2024 2026
ctc_limiting_tax_liability (Worksheet A line 5) −$32.7bn (7,280 tax units) −$74.5bn (8,324 tax units)
refundable_ctc +$95.4M (196 tax units, 147k weighted) +$167.5M (185 tax units, 137k weighted)
non_refundable_ctc −$95.4M (same units) −$167.5M (same units)
income_tax −$95.4M −$167.5M
household_net_income +$95.5M +$167.5M
tax_unit_itemizes, DE/VA/ID/NY/AL income tax, MO TANF unchanged unchanged
state_income_tax −$40k (2 units) $0 (1 unit)

The limit now reads actual liability, which is lower than the old no-SALT liability for filers who itemize state and local taxes. refundable_ctc is the increase in usable CTC that the earned-income phase-in adds on top of that limit, so a lower limit leaves more of the credit refundable, up to the per-child maximum. The non-refundable CTC these units lose was above their actual tax, and income_tax_capped_non_refundable_credits already kept it from being used. So their income tax falls by the full increase in the refundable credit. No tax unit's itemization choice changes.

Changed expectation

ok_child_care_child_tax_credit.yaml, "OK CTC includes federal non-refundable CTC used against unearned-income tax" (2025): ctc_value goes from 4,400 to 2,799. The case's tax before credits is $2,799.38, after $33,284 of SALT among its itemized deductions. Main's no-SALT limit was $6,793.40, which let $4,400 of non-refundable CTC count against $2,799 of tax. §26(a) limits it to actual liability. The same case's ok_federal_ctc was already $2,799 on main.

Invariants and tests

policyengine_us/tests/core/test_override_branches.py runs on a seeded sample of 48 households across 10 states (itemizers, SALT, children, aged dependents) and on random households (Hypothesis). It checks five invariants:

  1. Worksheet A from actual liability. Line 3 = max(0, income_tax_before_credits − preceding credits), and line 5 = max(0, line 3 − subsequent credits when Worksheet B applies). Non-refundable + refundable CTC = CTC. The sample has itemizers with SALT whose limit binds.
  2. Order independence. Every reported variable is identical whichever variable is calculated first: six fixed orders on the seeded sample, and four on random households.
  3. Differential. Each comparison branch (itemizing, not itemizing, DE, VA, ID) equals a fresh simulation with the overridden input set first.
  4. Same in harder states. Invariant 3 also holds when the parent already has the input, and when an earlier year was calculated first.
  5. Helper contracts. get_override_branch reuses a branch within a period and recreates it otherwise. get_branch_for_period is the no-input case and replaces a same-named branch it did not create. drop_inherited_values keeps inputs, including ancestor-branch inputs, and drops calculated values.

Also:

axiom: TheAxiomFoundation/rulespec-us#1426 queued (us:statutes/26/26, us:statutes/26/24/d; its case A is this actual-liability limit, which Axiom cannot adjudicate until 26(a) is encoded); the existing us/statutes/26/26.test.yaml nonrefundable_credits_capped_by_tax_before_credits already caps the aggregate by income_tax_before_credits

🤖 Generated with Claude Code

…tual liability

A policyengine-core branch starts as a copy of every array its parent has
cached, and set_input on it clears none of them, so a value the parent
calculated from the overridden input answers for the branch.

- ctc_limiting_tax_liability no longer recomputes the liability without SALT
  in a "no_salt" branch. The branch usually inherited the liability with
  SALT, so the CTC limit depended on which variable was calculated first.
  26 U.S.C. 26(a) limits the credit by the actual tax liability, which
  reflects the SALT deduction.
- get_override_branch (tools/override_branch.py) creates a comparison branch
  once per period and, when the parent has already calculated an overridden
  input, drops the arrays the branch copied except inputs. The itemization,
  Delaware and Virginia EITC refundability, Idaho aged or disabled, Missouri
  TANF caretaker and Medicaid SSI-supplement branches use it; the Alabama
  2020-IRC and New York pinned-parameter branches are created per period.

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

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@             Coverage Diff              @@
##              main    #9741       +/-   ##
============================================
- Coverage   100.00%   86.03%   -13.97%     
============================================
  Files            4       13        +9     
  Lines           76      179      +103     
  Branches         2        2               
============================================
+ Hits            76      154       +78     
- Misses           0       25       +25     
Flag Coverage Δ
unittests 86.03% <ø> (-13.97%) ⬇️

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 and others added 7 commits October 5, 2026 14:02
…e_branch)

Fold #9741's get_override_branch into the period_branch module #9738 added,
so there is one branch helper: get_override_branch keeps one branch per name
and period, creates it again for another period or other override values,
and drops the arrays a branch copied (keeping inputs) when the parent already
has a value for an overridden input. get_branch_for_period becomes the same
call with no inputs, for the Alabama 2020-IRC and New York pinned-parameter
branches.

Itemizing/not itemizing, Delaware and Virginia EITC refundability, Idaho aged
or disabled credit/deduction, Missouri TANF caretaker and the Medicaid SSI
state-supplement branch now set their inputs through get_override_branch.

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

ctc_tax_liability_after_preceding_credits (Worksheet A line 3, from #9742)
read income tax before credits from a no_salt branch, whose override was
shadowed or not depending on which variables were calculated first. 26 U.S.C.
26(a) limits the credit by regular tax liability (the chapter 1 tax, on
taxable income after itemized deductions) plus the 55(a) tax, and Worksheet A
line 1 is Form 1040 line 18. Read income_tax_before_credits directly. No cycle:
the SALT deduction counts state income tax through state_withheld_income_tax,
withholding estimated from each person's AGI.

Adds a hand-worked 2025 itemizer whose limit binds; the multi-year test no
longer expects a no_salt branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Order-independence (seeded sample and Hypothesis), differential tests of each
comparison branch against a fresh simulation (also when the parent already has
the input and after an earlier year), the Worksheet A identities from actual
liability, and the helper contracts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CTC limit reads the actual liability now, whatever the deduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The case's tax before credits is $2,799.38 after its itemized SALT; the
no-SALT limit this PR removes allowed $4,400 of non-refundable credit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This merge keeps the PR's earlier history (cc2fdab) and takes the reworked tree
(2d8202a): the actual-liability read now lives in Worksheet A line 3
(ctc_tax_liability_after_preceding_credits), and get_override_branch is
reconciled with main's get_branch_for_period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
drop_inherited_values kept every array of any variable in the
simulation's input_variables, whatever its period. A formula variable
supplied as an input for one year (e.g. taxable_income for 2025) then
kept the parent's calculated value for another year in the branch: the
2026 itemizing branch answered with taxable income calculated without
itemizing ($13,140 of income tax instead of $8,191.05, review r2).

Keep an array only if set_input stored it under that variable, branch
and period, on the branch or an ancestor; match eternal variables, which
store every period under one key, by branch. This also stops dropping
an eternal input set on an ancestor branch.

Adds review r2's case as a regression, a Hypothesis differential over
mixed input and formula years, and a Hypothesis property that the
helper keeps exactly the input keys. All three fail on 39124ed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis merged commit 9dc167b into main Oct 6, 2026
35 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged by the US + core hub on its merge gates. Audit:

  • Head: 53cdd80bbd421545699202f03de36864826821dd.
  • Methodology: Max ruled d799 on how CTC Worksheet A line 1 reads actual liability, and the PR follows that ruling.
  • Independent review (Opus 5.5; files in ~/reviews/us-hub/9741/):
    • r1 approved the rework 2d8202a978 and the merge 39124edc77.
    • r2 requested changes at 39124edc77 for one P2. Branch cache clearing kept calculated values for variables supplied as inputs in another year: a CA couple got $13,140 against $8,191.05 from a fresh itemizing simulation.
    • r3 (review-r3.md) approves fix 53cdd80bbd. drop_inherited_values now keeps exactly the (variable, branch, period) keys recorded through set_input on visible branches. r3 traced this through core 3.32.15 and reproduced both figures by hand. Its two P3 body-wording corrections are applied.
  • Tests: three new tests fail on the old helper and pass on the fix. The local suite at 53cdd80bbd passed 5,235, with 5 skipped, run one process at a time.
  • CI: every check passed at this head, and the PR is mergeable.
  • Impact (default dataset, 2026, unchanged from round 1):
    • refundable CTC +$167.45M across 185 records, matched by a non-refundable CTC reduction;
    • income tax −$167.45M;
    • ctc_value −$35.03M across 82 records;
    • itemization unchanged. 2024 is in the body.
  • Next: core#560 follows (d807).

@MaxGhenis
MaxGhenis deleted the fix-branch-override-shadowing branch October 6, 2026 06:34
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Brings in #9841 (28 percent rate gain and unrecaptured section 1250 gain
netted against losses), #9801, #9790, #9779, #9768, #9741 and #9630. Only
test_section_911_tax_stacking.py changed on both sides, in different
functions, and git merged it without conflict. #9841 passes Schedule D
lines 18 and 19 entered already netted through unchanged, and this
branch's capital_gains_tax, reforms and tests read the section 911
amounts and Schedule D inputs that #9841 keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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