Repository navigation
Limit the CTC by actual tax liability and make formula branches calculate under their overrides - #9741
Merged
Conversation
…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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Oct 2, 2026
…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>
Contributor
Author
|
Merged by the US + core hub on its merge gates. Audit:
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Formula branches copy every array their parent has cached when they are created, and
set_inputon a branch clears none of them (policyengine-coreSimulation.clone→InMemoryStorage.clone;Holder.get_arrayfalls back to the copieddefaultkey). 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_tax_liability_after_preceding_credits(Worksheet A line 3) is where theno_saltbranch now lived, andctc_limiting_tax_liabilitybecame line 5 (line 3 less the Worksheet B credits). This PR keeps that structure and its parameter lists. It changes only line 1 of line 3, which now reads the actual liability.get_branch_for_periodinpolicyengine_us/tools/period_branch.py, which creates branches again for each period. That is one of the three thingsget_override_branchdoes. The two are now one module,period_branch.py, with one implementation.get_branch_for_periodis a thin wrapper,get_override_branch(simulation, name, period, {}), kept for the branches that pin parameters rather than inputs. Calculate each year's formula branches fresh so multi-year simulations match single-year ones #9738's multi-year tests still pass. They no longer expect ano_saltbranch, because none exists.CTC limit by actual liability
ctc_tax_liability_after_preceding_credits(Credit Limit Worksheet A line 3) recomputed the liability without SALT in ano_saltbranch. 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 forincome_taxfirst 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:
Line 3 now reads
income_tax_before_creditsdirectly, then subtracts thepreceding_creditslist 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_taxreadsstate_withheld_income_tax. Each of the 42 state withholding estimates it can add reads onlyadjusted_gross_income_person(two also read geography), so none of them reads federal tax or credits. It also readslocal_income_tax, whoseaddslist already leaves out the local taxes that depend on state tax.test_ctc_itemizing_branch_cycle.pypasses without the branch.Comparison branches
get_override_branch(policyengine_us/tools/period_branch.py) does three things: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_branchcaller 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_incomesupplied 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_valuesnow keeps an array only when its exact (variable, branch, period) key was set throughset_inputon that branch, an ancestor ordefault. 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:no_saltoverride was shadowed. It was always shadowed when nested in another branch, and at top level depending on order.tax_unit_itemizesgiven 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.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.ctc_limiting_tax_liability(Worksheet A line 5)refundable_ctcnon_refundable_ctcincome_taxhousehold_net_incometax_unit_itemizes, DE/VA/ID/NY/AL income tax, MO TANFstate_income_taxThe limit now reads actual liability, which is lower than the old no-SALT liability for filers who itemize state and local taxes.
refundable_ctcis 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, andincome_tax_capped_non_refundable_creditsalready 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_valuegoes 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'sok_federal_ctcwas already $2,799 on main.Invariants and tests
policyengine_us/tests/core/test_override_branches.pyruns on a seeded sample of 48 households across 10 states (itemizers, SALT, children, aged dependents) and on random households (Hypothesis). It checks five invariants: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.get_override_branchreuses a branch within a period and recreates it otherwise.get_branch_for_periodis the no-input case and replaces a same-named branch it did not create.drop_inherited_valueskeeps inputs, including ancestor-branch inputs, and drops calculated values.Also:
ctc_tax_liability_after_preceding_credits.yamladds a hand-worked 2025 Texas itemizer: three children, $90,000 of wages, $40,000 of mortgage interest and $15,000 of real estate tax. Line 1 is $3,723 with SALT; it would be $5,523 without SALT. The non-refundable CTC is $3,723 and the refundable CTC $2,877.credit_limit_worksheets_integration.yaml,test_ctc_credit_limit_worksheets.py,test_pinned_branch_credit_dependents.py, the 25D limit), Order federal credit limits as the IRS credit limit worksheets do #9753'stest_federal_credit_limit_order.pyand Calculate each year's formula branches fresh so multi-year simulations match single-year ones #9738'stest_multi_year_simulation.pypass on this head. Their docstrings no longer say the limit uses a no-SALT liability.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_creditsalready caps the aggregate byincome_tax_before_credits🤖 Generated with Claude Code