Skip to content

Make branch-measured marginal tax rates match fresh re-simulation - #9698

Open
MaxGhenis wants to merge 9 commits into
mainfrom
fix-mtr-branch-divergence
Open

MaxGhenis wants to merge 9 commits into
mainfrom
fix-mtr-branch-divergence

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9697.

Summary

Marginal-rate branches now perturb the inputs that programs actually read, preserve moved wages, hours and capital gains, and clear cached monthly values before applying the increase. The earnings, health-benefit and component rates share the same adult loop; the capital-gains rate raises long-term gains so preferential rates apply. For the reported Ohio parent earning $28,000, the corrected earnings rate is 0.5271.

Without behavioral responses, branch rates match finite differences between fresh simulations. Under dynamic reforms, they match fresh simulations with the parent's earnings, capital-gains and hours responses held at their simulated values. Setting responses to zero only in the raised branch would change behavior between the two sides of the comparison; freezing the same responses preserves the marginal-rate interpretation.

Both simulation wrappers synchronize input_variables after moving inputs and backfilling geography, then rebuild the reform's baseline with those inputs. This also changes exported input names: datasets exported through input_variables retain pre-response variables instead of stale aggregate columns, including long_term_capital_gains.

Round 3 fixes

  • Restore the baseline policy, rebind its holders to that policy, remove reform-only holders and inputs, and restore baseline tracing isolation. The existing UserReform regression must calculate $4,016 of baseline income tax while the reform calculates $0; reform-only inputs must not leak into baseline cache invalidation.
  • Neutralize the hours response alongside earnings and capital-gains responses in behavioral measurement branches. A cold-cache regression enables both labor-supply and capital-gains elasticities and queries the two responses in both orders, preventing capital-gains-first measurement recursion.

Validation

Python tests run one file at a time on macOS using Python 3.13.9 and compatible Core 3.32.15. Local wall times include setup and reflect heavy host contention; they are not Linux CI benchmarks. Python API coverage is required for branch ownership, tracing, cold-cache query order and shared measurements. The new tracing case stays in the existing SPM group; the new combined-response file routes into the existing Rest/core group. No CI runners were added. The failed-probe timings stop at different assertions and are not cost deltas for the new tests.

New head: 37bb63197728f0d481b0782609321872f7bd0a29 (fast-forward from 792e7d4dbb; no merge in this round).

  • Baseline regression before fix: 4 failures at 792e7d4: both wrappers return $0 instead of $4,016; restored policy owns the discarded baseline; reform-only input remains in baseline metadata (479.73 s end to end).
  • Baseline regression after fix: 4 passed, including the new tracing case (534.33 s end to end; 3.46 GiB peak RSS).
  • Both response query orders before/after: CG-first fails with CycleError before the fix (203.38 s; 3.28 GiB). After the fix both isolated query orders and the nonzero order-comparison pass: 3 tests, four one-person simulations (1,414.56 s end to end; 3.51 GiB peak RSS).
  • Single-file checks and runtimes: existing behavioral-response measurement file: 14 passed (860.44 s; 3.73 GiB peak RSS). Five dynamic MTR/baseline comparisons: 5 passed, 7 deselected (2,104.41 s; 5.69 GiB peak RSS). Each file ran in a separate, sequential process; no statewide sweep or population microsimulation was rerun.
  • make format: passed before each commit; formatting and lint pass.
  • gh pr checks on the new head: confirmed on new head 37bb631; CI started and the initial three guard checks are queued (run 37630379186). Full-suite CI is pending, not yet confirmed green.

Historical differential sweep, reported by earlier rounds and not rerun in round 3:

Rate 2.15.16 disagreements Prior PR sweep
Earnings MTR 551 / 816 0 / 816
MTR including health benefits 562 / 816 0 / 816
State MTR 23 / 816 0 / 816
Federal and FICA MTRs 0 / 816 0 / 816
Capital-gains MTR 52 / 102 0 / 102

Impact

pending (hub), after round 3.

Earlier impact batches never ran: the private zsh runner aborted on its read-only status variable. No microsimulations were run in round 3. The baseline correction affects reform comparisons, so the hub must run all eight paired batches on the default dataset, comparing merge base 47ab53e0fb13eb2e7dfe91025f94f6f2c4310c89 against 37bb63197728f0d481b0782609321872f7bd0a29:

Batch 2025 2026
Base Required Required
Static tax reform Required Required
Labor-supply response Required Required
Capital-gains response Required Required

Use the existing top-rate reform (47%), CBO labor-supply elasticities (income −0.05, substitution 0.25), and capital-gains elasticity −0.62. Report person cliff counts/shares, mean cliff gap, mean MTR overall and among TANF recipients, static budget/poverty/distribution comparisons, and dynamic budget effects relative to the static reform within each version. Unreformed static budget, poverty and distribution outputs do not read marginal rates and are expected to be unchanged; population confirmation remains pending. Reform-versus-baseline comparisons, cliff measures and dynamic results require the hub runs after the baseline correction.

Remaining review points

axiom: n/a: simulation infrastructure; no statutory provision changes.

Round 4 fixes

Head: a729eecd02f9121f0a0a2712268d44e2af7fe952. The redo verified the two existing fast-forward commits, bc17da2fdfae1824170225571e7a925449594f66 (P1) and a729eecd02f9121f0a0a2712268d44e2af7fe952 (P2), and recovered their completed CI results. No merge, history rewrite, or additional source changes were needed in the redo.

  • P1: refresh only the four Ohio 2026 household-net-income snapshots to $23,294.93, $24,175.43, $41,073.76 and $41,546.66 after main Give North Carolina reduced-price school meal students the free tier #9756. The one school-age child's annual meal subsidy falls from $1,142.23816777 to $1,119.60. Removing January CPI uprating reduces it by $26.23816777; correcting paid lunch from $0.46 to $0.44 adds $3.60. This $22.63816777 decrease flows through free school meals, household benefits and net income equally in both earnings pairs. MTR expectations remain 0.1195 and 0.5271.
  • P2: register successfully stored post-construction set_input calls through the shared US simulation mixin. Each branch receives a separate input list; non-input calculated caches still clear, and neutralized or expired writes do not become inputs. Raised branches retain their measuring branch's explicit identification and other inputs, except the perturbed earnings/gains. The fix also reaches health-inclusive rates and both baseline/reform behavioral measurement arms.

Recovered failing evidence at reviewed head 275b87b040a39dfc3c0c246c6947622447d0256b, on macOS with Python 3.14.7 and Core 3.32.15:

  • Ohio YAML: 4 stale income assertions failed, 8 tests passed (113.64s pytest elapsed).
  • Texas $80,000 parent with children aged 4 and 8 and branch-local has_itin=[False, True, True]: federal MTR was -4.28 instead of 0.12, restoring $4,400 CTC in the raised branch. Health-inclusive MTR was -12.481421 instead of 0.1965. The generated input-preservation invariant also failed because raised has_itin was absent: 3 failed, 12 deselected (372.56s).
  • The retained combined-response regression failed because the baseline earnings measurement's raised branch lost identification: 1 failed, 3 deselected (6,113.17s pytest elapsed). A superseded full-policy behavioral case also failed with baseline MTR -4.2035 instead of 0.1965 (2,425.77s); it is supporting evidence, not additional retained CI coverage.

All four requested files passed individually and sequentially in CI run 37687411397, selective job 113055230778, associated with PR head a729eecd. GitHub tested merge commit 2dffe27e73ac39bc8b9e7238dcef529187b9a114, which includes six newer main changes. All round-4 changed files and the four requested test files are identical between that tree and the literal PR head; those incoming policy changes do not affect the new branch-input regressions. Linux used Python 3.14.7, pytest 8.4.2 and Core 3.32.21. These are completed CI runs, not fresh local full-file runs in the redo.

Requested file Result Pytest elapsed, including fixtures Command start to pytest summary
test_marginal_rate_branches.py 15 passed 636.93s 675.39s
test_combined_behavioral_responses.py 4 passed 249.37s 296.53s
test_spm_system.py 74 passed 378.07s 417.17s
household marginal_tax_rate.yaml 12 passed 76.33s 118.30s

Total: 105 passed across the four files. The Rest Python job explicitly names the federal, health, property and combined measurement regressions as passing. The generated invariant covers boolean, enum, string and money inputs; monthly, annual and eternal periods; nested branches; cache recomputation; and caller/sibling isolation. The combined regression observes real raised branches in all four baseline/reform earnings/capital-gains paths. Each selective file reports one plugin assertion-rewrite warning. All 37 checks, including lint, partner contracts and the full SPM/microsimulation CI job, are successful for this PR head.

New-case selective call times were 1.08s (federal), 2.16s (health), 2.99s (property) and 35.93s (combined measurement preservation), with 5.67s shared fixture setup for the identification cases. No per-file peak RSS is available. Existing CI resource artifacts report 9,351,764 kB for the 724-test Rest/core group and 5,361,316 kB for the 113-test SPM group; these are whole-group measurements. Local before timings reflect host contention and a different Core version, so they are not Linux before/after cost benchmarks. New API/branch tests remain in Rest/core; the unchanged SPM file stays in its existing CI group. No CI jobs or runner budgets were added. Repository dependency and lock files were not changed. Prior make format runs and CI lint both passed.

Additional local check at literal head a729eecd, using the same Python 3.14.7 / Core 3.32.15 environment as the recovered failures: the federal, health and generated input-preservation regressions all passed (3 passed, 12 deselected, 4 existing numerical divide warnings). Pytest reported 3,474.20s including collection/setup; end-to-end foreground invocation was 3,955.40s, with 45.46s user CPU, 12.57s system CPU and 1.14 GiB peak RSS. Shared fixture setup was 95.99s; case calls were 5.73s, 10.81s and 11.51s. These macOS measurements reflect severe host contention and are not Linux CI cost comparisons. The process finished normally; no interruption or additional local test process was needed. The four full-file results and behavioral regression passing evidence above come from the completed CI runs.

Impact: P1 is test-only. The P2 source audit finds no expected numeric change in the hub's recorded eight default-dataset batches (2025/2026 base, static top-rate, CBO labor supply and capital-gains response). Construction-time inputs remain equivalent; internal measurement setters copy already preserved primitives or unchanged zero defaults, and sibling input lists stay isolated. Market-income/tax-first queries start shared measurements at the top-level simulation before policy-choice branches. The running impact at 37bb631977 therefore remains applicable to P2. This conclusion is scoped to those recorded paths; direct cold dynamic queries inside policy override branches can now preserve their explicitly set overrides and change accordingly. Main's school-meal parameter update is already in the merged head and is separate from the test-only P1 refresh. No population microsimulations were run in this redo. Hub impact remains pending; CI for this PR head is green.

marginal_tax_rate and its relatives measure a rate by raising one adult's
earnings in a branch that keeps only the simulation's listed inputs. Two
defects made the branch disagree with a fresh simulation of the same
household:

- Simulation moves employment_income, self-employment income, weekly hours
  and long-term gains onto pre-response variables, and backfills state_code
  from state_code_str, after policyengine-core records input_variables.
  Only Microsimulation patched the list, so household branches erased the
  moved inputs. An Ohio parent earning $28,000 then drew $7,475 of Ohio
  Works First in the branch and reported a rate of -4.70; fresh
  simulations give 0.527.
- Branches raised the employment_income aggregate, which programs that
  read pre-response income (TANF's earned income) never see, and left the
  base's cached monthly values in place. Microsimulation rates therefore
  missed TANF phase-outs.

The moves and the input list now live in shared helpers used by both
wrappers, and one branch builder raises the variable that holds each input
for every branch-measured rate. marginal_tax_rate_on_capital_gains raised
the capital_gains aggregate, which taxed the increase at ordinary rates; it
now raises long-term gains.

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

codecov Bot commented Sep 30, 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 (a729eec).
⚠️ Report is 288 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #9698   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            4         2    -2     
  Lines           76       111   +35     
  Branches         2         7    +5     
=========================================
+ Hits            76       111   +35     
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 and others added 5 commits October 6, 2026 03:01
Conflict: tests/policy/baseline/household/marginal_tax_rate.yaml. Both
sides appended cases at the end of the file. Kept main's comment fix and
its three-roommate case from #9653, then this PR's six Ohio base/alt/rate
cases. The rate variables' docstrings from #9653 merged cleanly next to
this PR's formula changes.

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

Rates measured in a reformed simulation with labor supply or capital gains
elasticities now hold the responses at their simulated values. Since the
branches raise pre-response inputs (employment_income_before_lsr,
long_term_capital_gains_before_response), recomputing the responses there
scaled them with the raise: a Texas worker's $1,000 raise became $971 of
wages (rate 0.3166, fresh 0.2965), and a 20% capital gains rate read 0.3307.
create_perturbed_branch, which every earnings and capital gains rate uses,
now sets the three earnings responses, the capital gains response and the
weekly hours response to their values in the simulation; neutralized ones,
as in the behavioral response measurement branches, stay zero.

Core branches a reformed simulation's baseline while constructing it, before
the wrappers move inputs. That branch kept employment_income and
weekly_hours_worked as inputs with empty pre-response variables and the stale
input_variables, so its TANF counted no earnings and the baseline rate that
labor supply responses compare against erased pre-response wages: an Ohio
Works First parent got a -$416 response to a top-rate reform that does not
touch them. Both wrappers now branch the baseline again after the moves, as
core's subsample does.

Adds tests for both (invariants 5 and 6) and corrects the #9653 comment on
the component helper's adult loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 8, 2026
…9994)

* Bind a subsampled reform's rebuilt baseline arm to baseline policy

Core's subsample rebuilds the baseline arm as a branch of the reform and
then hands it the replaced arm's policy. Before core 3.32.21 it does not
rebind the branch's holders, so the baseline read the reform's neutralized
income_tax ($0 instead of $4,016), and the arm kept the replaced arm as its
own baseline. No core version points the policy back at the new arm or
primes its private parameter-tracing root, so a traced baseline arm wrote
its tracer onto the shared parameter tree (core 3.32.8).

Clear the arm's baseline, rebind its holders and isolate its tracing, as
construction does. Adds a regression test for both wrappers and their
clones and a Hypothesis property that subsampling never changes which
policy a holder belongs to.

Found in review of #9698 (P3-1).

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

* Drop holders a baseline arm cannot use, at construction and after subsampling

A reform's baseline arm is a branch of the reform, so its holders were
built for the reform's variables. Rebinding one to the baseline's variable
kept the holder's population and storage, so a reform that moved a variable
to another entity, or into or out of ETERNITY, left the baseline arm with
storage its own variable cannot use (core 3.32.8; core 3.32.21 drops these
holders itself). Drop them with their recorded inputs, as core 3.32.21's
binder does, on both baseline-arm paths. apply_reform keeps a simulation's
own inputs, as core's does. No shipped reform changes a variable's entity
or ETERNITY-ness.

Tests now also check that each holder suits its variable, and the property
test's structural reform redefines is_household_head as yearly. The
regression test reforms the supplied system instead of rebuilding policy
from source.

Addresses review r1 of #9994.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 11, 2026
…'s branch

Review r1 of #10058 found two ways the branch lost an input:

- A capital_gains total set through set_input after construction is not
  among the construction inputs the branch keeps, so the branch dropped it
  and rebuilt the total from long-term gains: 73.4 and 231.9 instead of
  0.188. The branch now raises the parent's total whenever set_input stored
  one for the period, at construction or later (tools.period_branch.has_input).
- A variable given for one period kept every period's array, so a 2026
  value calculated before branching survived when the input was for 2025
  (adjusted gross income for 2025 measured 0.000). The branch now drops what
  was calculated for periods the input was not given for
  (drop_inherited_values limited to those variables).

Also neutralize the hours response in the behavioral response measurement
branches, as #9698 does: a capital-gains-first request with labor supply
elasticities otherwise re-entered the measurements through hours and raised
a CycleError. In labor-first order those branches already returned zero for
it through the labor supply response's re-entry guard.

Tests: a late-set total (Python), an input for another year and New York's
$25 million threshold, which the scaled rise spreads over $24,985 (YAML).

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

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.

Branch-measured marginal tax rates disagree with fresh re-simulation

1 participant