Skip to content

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

Open
MaxGhenis wants to merge 1 commit into
mainfrom
fix-branch-override-shadowing
Open

MaxGhenis wants to merge 1 commit into
mainfrom
fix-branch-override-shadowing

Conversation

@MaxGhenis

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.

  • CTC limit. ctc_limiting_tax_liability 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 (the chapter 1 tax, on taxable income after itemized deductions) plus the section 55(a) tax; 24(b)(3) was struck in 2013; Schedule 8812 Credit Limit Worksheet A line 1 is Form 1040 line 18. The variable now reads income_tax_before_credits directly. The cycle the branch once avoided is gone: salt_deduction reads state_withheld_income_tax, per-person withholding estimates that do not read federal tax or credits.
  • Comparison branches. New get_override_branch (policyengine_us/tools/override_branch.py) keeps one branch per name and period, creates it again for another period or other override values, and, when the parent already has a value for an overridden input at branch creation, 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, which is the usual case because the formula branches while the parent is still calculating the overridden variable. 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 (pinned parameters, own variable deletion) are created per period with get_branch_for_period.

This supersedes the per-period branch helper in #9738; that PR can keep its multi-year test and core bump.

Evidence

Audit of every formula branch (git grep get_branch policyengine_us/variables) on 27 household cases: each read a branch served from an inherited key was compared with a fresh simulation that sets the branch's inputs before calculating anything. On main, only the no_salt override is shadowed in single-year runs (always when nested in another branch; at top level depending on order); the itemization and state-choice branches are shadowed when the parent already has the input (e.g. tax_unit_itemizes given as input) and in year 2 of a multi-year simulation; MTR, capital-gains MTR, trust-fund-revenue and employer-payroll branches clear their arrays and are effective. Example, CA couple 2025: ctc_limiting_tax_liability is $43,038.46 when household net income is calculated first and $35,670.97 when income tax is; it is $35,670.97 in every order with this PR.

Impact (Enhanced CPS 2024 file, 2026, full sample, real runs)

Run Income tax Refundable CTC State income tax Itemizers Household net income
main, policyengine.py order (as published) $2,141.0998bn $31.4537bn $521.6485bn 15.6964m $14,462.0821bn
main, income tax calculated first $2,141.0853bn $31.4683bn $521.6485bn 15.6964m $14,462.0966bn
main, every branch override made effective (no SALT in every CTC limit) $2,141.0868bn $31.4668bn $521.6485bn 15.3753m $14,462.0951bn
this PR, any order $2,141.0853bn $31.4683bn $521.6485bn 15.6964m $14,462.0966bn
  • 39 records (323,532 weighted tax units) change, all itemizers with SALT whose CTC exceeds their liability: income tax −$14.57m, refundable CTC +$14.57m (per record −$1.85 to −$3,400, median −$648). State income tax, itemization and EITC unchanged.
  • main is order-dependent by the same amount; main with income tax calculated first is bitwise identical to this PR, which is bitwise identical across calculation orders.
  • With this PR, a run that drops every inherited array in every branch at creation is bitwise identical in all 47 output arrays: no branch override is shadowed on this file. (That run takes 11× as long, which is why the PR drops arrays only when the parent already has the overridden input.)
  • 2024 (New York pre-TCJA branch active): 39 records, income tax −$4.7m. 2027 (Medicaid SSI-supplement branch active): 42 records, income tax −$38.1m. State income tax $0 in every year.
  • Peak memory 7.3–8.0 GB vs 11.1–15.8 GB on main (three fewer whole-simulation clones).

Real runs only; scripts, logs and arrays are in the investigation folder (microsim_run.py, diff_ms.py, ms/). Dataset: the Enhanced CPS 2024 h5 used for the 2026-10-01 multi-year investigation (sha256 faffaf68…), SPM thresholds in national mode; the two SPM poverty flags are left out because the SPM calculator rejects some units in that file.

Invariants and tests

policyengine_us/tests/core/test_override_branches.py, on a seeded sample of 48 households across 10 states (itemizers, SALT, children, aged dependents):

  1. ctc_limiting_tax_liability = max(0, income tax before credits − other non-refundable credits); non-refundable + refundable CTC = CTC; the sample has itemizers with SALT whose limit binds.
  2. Every reported variable is identical whichever of six variables is calculated first.
  3. Differential: each comparison branch (itemizing, not itemizing, DE, VA, ID) equals a fresh simulation with the overridden input set first.
  4. Same when the parent already has the input, and when an earlier year was calculated first.
  5. get_override_branch reuse/recreation and drop_inherited_values keep inputs (including ancestor-branch inputs) and drop calculated values.

On main, 6 of these fail (1, three orders in 2, both cases in 4).

axiom: us:statutes/26/26 encoded-correct (caps non-refundable credits by income_tax_before_credits; us/statutes/26/26.test.yaml nonrefundable_credits_capped_by_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

❌ Patch coverage is 61.11111% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.11%. Comparing base (909176a) to head (cc2fdab).
⚠️ Report is 118 commits behind head on main.

Files with missing lines Patch % Lines
...caid/medicaid_enrolled_for_ssi_state_supplement.py 0.00% 1 Missing ⚠️
...eral_income_tax/al_federal_income_tax_deduction.py 0.00% 1 Missing ⚠️
...etaker/mo_tanf_if_non_parent_caretaker_excluded.py 0.00% 1 Missing ⚠️
...etaker/mo_tanf_if_non_parent_caretaker_included.py 0.00% 1 Missing ⚠️
...tates/ny/tax/income/credits/ctc/ny_ctc_pre_2024.py 0.00% 1 Missing ⚠️
...tax/income/credits/ctc/ny_ctc_pre_2024_eligible.py 0.00% 1 Missing ⚠️
...iables/gov/states/ny/tax/income/credits/ny_eitc.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##              main    #9741       +/-   ##
============================================
- Coverage   100.00%   64.11%   -35.89%     
============================================
  Files            4       16       +12     
  Lines           76      301      +225     
  Branches         2       15       +13     
============================================
+ Hits            76      193      +117     
- Misses           0      106      +106     
- Partials         0        2        +2     
Flag Coverage Δ
unittests 64.11% <61.11%> (-35.89%) ⬇️

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.

This branch has not been deployed

No deployments
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