Repository navigation
Conversation
marginal_tax_rate_on_capital_gains raised capital_gains, the sum of short- and long-term gains. That reaches adjusted gross income but not net capital gain, so the rise was taxed as ordinary income: a Texas filer with $1 million of long-term gains measured 0.298 instead of 0.238, and raising the top capital gains rate from 20% to 28% left a top-bracket filer's rate, and so the realization response, unchanged. Raise long_term_capital_gains instead (the change PR #9698 makes inside its wider rework of marginal-rate branches), and scale the rise to 0.1% of the household's net income or the person's gains above $1 million, because a $1,000 rise resolves a $100 million household's float32 net income only to 0.008 of rate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…00 million of gains 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 #10058 +/- ##
===========================================
- Coverage 100.00% 96.55% -3.45%
===========================================
Files 4 2 -2
Lines 76 116 +40
Branches 2 7 +5
===========================================
+ Hits 76 112 +36
- Misses 0 2 +2
- Partials 0 2 +2
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:
|
capital_gains adds short- and long-term gains, and adjusted gross income reads it. A situation that gives the total keeps it as given, so raising only long_term_capital_gains never reached such a household's income and its rate measured 0.84 to 1.0 instead of 0.188. Raise the given total by the same amount, and cover it in the branch-versus-new-simulation property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s in heavy-a Fixed household examples (each 2026 Texas bracket, the alternative minimum tax band, $20M and $100M of gains, wages under gains, joint filers, and capital_gains totals given as inputs) are YAML cases now, per the repo's test design rules. The Python file keeps what YAML cannot do: the Hypothesis properties, reforms read through the measurement branches, and the differential against new simulations, whose reference now raises a stored capital_gains total for every adult when any adult gives one (it found a household where one spouse gave a zero total). The direct reform property runs three examples instead of four. The file took 246 s of the core Rest group in CI run 38056548883, so it moves to heavy-a, which had the lowest time and peak memory of the groups there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
Address the independent review of the semi-elasticity and net-of-tax forms: - Compare single-rate revenue to within a relative 1e-12 plus an absolute 1e-15, since 1 + expm1(x) is accurate only to about 2e-16 absolutely, and pin the review's two counterexamples as Hypothesis examples. - Reject a semi-elasticity or net-of-tax elasticity that is negative, above 50 or 5, or not finite, at the policy level and for person-level values, and raise if a new form's response is not finite. The maxima keep float32 dollar responses finite for gains below $10 quadrillion; a property test checks that across both domains. The log-rate form is unchanged. - Qualify the revenue peaks as single-rate results (beta > 1; e >= 0.003), document and test the net-of-tax cap's tail and float32 round trips, and state bounds for the multiplier 1 + f. - Move the household simulations next to the existing behavioral-response simulations (Rest heavy-b): fold the default-path differential into the existing channel test and drop the conflict simulation. Read defaults from the shared system. Compare differentials by bit pattern. - One reference entry per source with page lists in quoted titles; dated CRS attribution; Agersnap and Zidar's ranges; Yale wording; the measurement limitation now cites #10058. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'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>
…keep inputs by period - test_responses_do_not_depend_on_which_is_requested_first: with labor supply and capital gains elasticities set, requesting either response first gives both the same nonzero values. At the previous head the capital-gains-first request raised CycleError. - test_drop_inherited_values_limited_to_variables: limited to some variables, drop_inherited_values leaves every other array alone, and has_input reports the periods set_input stored, from the situation or later. - Correct comments: SNAP reads hours before the labor supply response, so Texas child care is the program named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #10092, which drops main's stale il_aabd entry from the person-adds ratchet that failed this branch's Rest job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 12, 2026
…n responses (#10054) * Add semi-elasticity and net-of-tax forms for capital gains responses The capital gains realization response scaled realizations as the rate to the power of the elasticity, so revenue had no interior maximum for any elasticity above -1, and its 0.001 rate floor removed nearly all gains for anyone starting at a 0% rate. Add a semi-elasticity form (revenue peaks at 1 / semi_elasticity; defined at a 0% rate) and a net-of-tax form, each selected by setting its parameter. The log-rate elasticity form is unchanged and remains what a reform setting only `elasticity` gets. Document the forms, their floors and clips, and calibration to JCT's 1990 coefficient (3.51, a 28.5% peak) and the 3.1 CRS reports JCT now uses. Add unit, Hypothesis property and differential tests, including a frozen copy of main's formula that the log-rate path matches bit for bit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Validate capital gains response parameters and fix the revenue tests Address the independent review of the semi-elasticity and net-of-tax forms: - Compare single-rate revenue to within a relative 1e-12 plus an absolute 1e-15, since 1 + expm1(x) is accurate only to about 2e-16 absolutely, and pin the review's two counterexamples as Hypothesis examples. - Reject a semi-elasticity or net-of-tax elasticity that is negative, above 50 or 5, or not finite, at the policy level and for person-level values, and raise if a new form's response is not finite. The maxima keep float32 dollar responses finite for gains below $10 quadrillion; a property test checks that across both domains. The log-rate form is unchanged. - Qualify the revenue peaks as single-rate results (beta > 1; e >= 0.003), document and test the net-of-tax cap's tail and float32 round trips, and state bounds for the multiplier 1 + f. - Move the household simulations next to the existing behavioral-response simulations (Rest heavy-b): fold the default-path differential into the existing channel test and drop the conflict simulation. Read defaults from the shared system. Compare differentials by bit pattern. - One reference entry per source with page lists in quoted titles; dated CRS attribution; Agersnap and Zidar's ranges; Yale wording; the measurement limitation now cites #10058. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Qualify the state-rate revenue peak and drop the Hypothesis deadline Address the second review of the capital gains response forms: - The analytical federal peak at 1 / semi_elasticity with a fixed state rate holds only before the clip binds; state that it is the maximum only when beta * (1 - s) - ln(beta) > 1 (a state rate below about 31% to 36% for beta of 3.1 to 3.51), and add beta > 1 to the changelog. - Remove Hypothesis's 200 ms deadline from the two float32 array properties, which once took 253 ms to generate on a loaded machine. - Say a new form's response can also stop being finite when gains exceed the documented $10 quadrillion bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make capital-gains round-trip precision checks portable --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
What was wrong
marginal_tax_rate_on_capital_gainsmeasured a person's rate by raisingcapital_gainsby $1,000 in a branch and differencing household net income.capital_gainsadds short- and long-term gains. Overriding it reaches adjusted gross income (as a source inirs_gross_income) but notlong_term_capital_gainsor net capital gain, so the extra $1,000 was taxed as ordinary income, except where taxable income was below net capital gain and the preferential computation took the rise anyway (main is right for the $100,000 and $300,000 filers below). The capital gains realization response scales with the change in this rate between baseline and reform policy, so it mostly did not see the rates on gains.Household runs for 2026, Texas, single, on main
75cdd801and on this branch:For the $20M filer, measured through the behavioral response branches under a reform that sets the top capital gains rate (
gov.irs.capital_gains.rates.3):So on main a rise in the top rate from 20% to 28% produced no realization response for this filer.
The same formula also lost precision for large households.
household_net_incomeis a float32, which resolves $8 at $100 million, so a $1,000 rise resolved that household's rate only to 0.008.Fix
long_term_capital_gains. This is the change Make branch-measured marginal tax rates match fresh re-simulation #9698 makes inside its wider rework of marginal-rate branches; this PR lands it alone, on main's current branch code. The branch sets the aggregate rather thanlong_term_capital_gains_before_response, so it reaches the gains whether they are stored on the aggregate or derived from the pre-response input. A reform simulation's baseline is branched by core before this package moveslong_term_capital_gainsto the pre-response input, so it keeps the situation's value onlong_term_capital_gains, and a rise in the pre-response input never reaches it (the baseline rate then measures 1.0, which is whatrevenue_curve_patched.pyin the evidence folder ran into). The override also holds any behavioral response at its value in the simulation, the convention d1021 kept.capital_gainstotal that was given as an input.capital_gainsadds short- and long-term gains, and adjusted gross income reads it. A situation can give the total directly (15 test files do, 4 of them API-partner contract files), and so can a laterset_input. It is then stored as given, for every person in the simulation (zero for those it was not given for), rather than added up again. The branch raises the parent's total by the same amount wheneverset_inputstored one for the period, which core records for the situation, a dataset and later calls alike (tools.period_branch.has_input). Without this a household that gave the total at construction measured 0.84 to 1.0 instead of 0.188, and one that set it later measured 73.4 or 231.9 (supplied.py,review_repro.py). An unpushed local commit from 10/6 (183cecb919, branchcg-mtr-reads-capital-gains) handled the construction-time case; this takes that part of it.drop_inherited_values(already on main, intools/period_branch.py) over those variables, so each keeps only the periods it was given for.drop_inherited_valuesgains an optional list of variables for this.tx_ccs_work_requirement_eligible, and 23 other state programs): with both elasticities set, requesting the capital gains response first raisedCycleErroron main for a single Texas filer with wages and gains. Labor-first requests already got zero there, so nothing that ran before changes.capital_gains_mtr_rise). Households under $1 million keep $1,000. At the seven sizes I ran from $1 million to $5 billion of gains, measured rates are within 0.0001 of statute; the property test holds them within 0.00025.Things that differ from the brief
long_term_capital_gains, the baseline and reform measurement branches of a reform simulation both see it on today's main. Measure a branch's behavioural responses against baseline policy, with its own cache #9913 fixes something else: a branch or early clone of a reform simulation measuring its responses against reform policy. Both PRs stay open with the US hub.limit_to_tax_at_main_ratestakes the smaller of that and the tax on all taxable income at the ordinary rates (Schedule D Tax Worksheet line 47). With a 40% top capital gains rate, a filer with $20M of gains faces 37% + 3.8% = 0.408, while a filer with $1M, whose tax at the capital gains rates is still the smaller, faces 0.438. The ordinary-rate tax becomes the smaller at a top rate of 37.43% for $20M of gains and 37.08% for $100M. The 0.408 the evidence file reported for 40%, 60% and 80% was this cap, not part of the defect. A reform that taxes gains above 37% needs more than this parameter.ny_supplemental_taxtaxes all income at the top rate, and measures 6.345 with the scaled $24,985 rise; with $1,000 the same filer measures 0.342, and a filer within $1,000 of the threshold 150.3. So the scaled rise puts more filers in the window (0.1% of income instead of $1,000), each with a spike that much smaller. Below $1 million, where the benefit cliffs are, the rise is $1,000 as before. A YAML case pins the New York example.Invariants
r, a top-bracket filer's rate isr+ NIIT while the tax at the capital gains rates is the smaller regular tax, and 37% + NIIT after that. Through the measurement branches, reform rate − baseline rate equals that less 20%.capital_gainstotal at construction or later, whether or not it equals their short- plus long-term gains. The new simulations use the same rise function, so this checks the branch, not the choice of rise.Where invariant 1's or 2's reference does not apply, the tests stay out:
Input contract
The rate reaches the gains wherever the situation or dataset gives them:
long_term_capital_gainsorlong_term_capital_gains_before_response, and acapital_gainstotal at construction or set later. Three limits, all as on main:user_set_variableskeeps laterset_inputcalls only for IRA contributions, because formulas also callset_input.long_term_capital_gainsset after construction on a reform simulation does not reach its baseline, which core branched earlier; the behavioral response measurements copy only the pre-response inputs into it.Tests
policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml, 12 cases): each 2026 Texas bracket (15%, 15% + NIIT, 20% + NIIT, the AMT band at 0.338), $20M and $100M of gains, $60,000 of wages under $200,000 of gains, joint filers (both spouses), acapital_gainstotal given with and without long-term gains, adjusted gross income given for 2025 only (0.188 for 2026; 0.000 before), and the New York threshold example (6.345).policyengine_us/tests/core/test_capital_gains_mtr_measurement.py, 9 tests), for what YAML cannot do:capital_gainstotal, with the given-total households pinned as examples, plus totals set after construction (invariant 3);test_override_branches.py: one test thatdrop_inherited_valueslimited to some variables leaves the rest alone and thathas_inputreports the periodsset_inputstored.Against the previous head the order test raises
CycleErrorand the late-total test fails (73.4 and 231.9). Against main's formula atfdd7062905(a copy of the Python file with the rise helper stubbed in, since main lacks it, and shrinking off), the 7 tests that run a simulation fail and the 2 that do not pass (on_main.log). On this branch all pass, and so do the 14 tests intest_behavioral_response_measurements.py. The differential property found one counterexample in its own reference while I wrote it: when one spouse gives acapital_gainstotal and the other does not, the simulation stores a zero total for the other, which the reference has to raise too. It is pinned as an@example.CI cost. The Python file is simulation-heavy (each reformed household simulation builds a reformed system; the measurement-branch cases also compute the earnings marginal rate in both branches), so it runs in the
heavy-aRest group, notcore; the Makefile comment records why, andcode_health/test_rest_python_groups.pyandtest_make_test_matches_ci.pypass. No job, group or runner was added. From the Rest job's JUnit and GNU time reports:heavy-awall, peak RSSa926b5209a, incore)core: 13:58, 7.1 GB with itd692879443)2881f93749)heavy-bpeaks at 8.5 GB andpolicyandremainingat 13.8 and 14.7 GB on the 16 GB runner.core's peak went from 7.1 GB to 13.3 GB between the first two runs, but its only module changes were this file leaving and four test files main added (test_ssi_resource_deeming_invariants, 85 s, the largest), so that rise is not from this PR. Locally (macOS, a shared host under load) the file took 117 s of test time and 100 s of CPU atd692879443(pytest6.log); a separate, slower run of it peaked at 5.2 GB RSS (rss_run.log). The new test intest_override_branches.pytakes 1.2 s.Impact on model outputs
marginal_tax_rate_on_capital_gainsitself changes wherever it is requested, baseline simulations included. Scores change only through the capital gains realization response, which is zero unless a reform setsgov.simulation.capital_gains_responses.elasticity(default 0), so static scores do not move. With a nonzero elasticity, a person's response changes wherever their measured baseline or reform rate changes:Paired runs of main
75cdd801and this branch ata926b5209afor 2026, on the 10,000-household subsample of the default dataset that the repo's heavy behavioral tests use (sim.subsample(10_000), the same households in every run). Each cell is one simulation. These are subsample estimates of federal income tax, not full-population scores: full-data dynamic runs are gated on the shared host (the US hub's for #9698 have waited since 10/8), so none was made.Scripts and outputs:
impact.py,impact_main.jsonl,impact_fix.jsonlin the evidence folder. The same runs at main065d0e9560and this branch at59ddba8718, after the round-1 review fixes, are queued on the shared host and will replace this table when they finish.Relation to other PRs
create_perturbed_branch. When it rebases, its version of the loop replaces this one; it should keepcapital_gains_mtr_rise, the stored-rise divisor and the raise of a givencapital_gainstotal, which it does not have. Its hub has the resolution recipe.semi_elasticityparameter. Both are written and pass on a tree with both PRs (on that tree, with β = 1/0.30, a Texas filer with $20M of gains pays the most federal income tax at a top rate of 25.25% on a quarter-point grid, against 25.23% solved from the closed form). They go into whichever PR merges second:docs_rewrite.patchandrevenue_peak_test_section.pyin the evidence folder.Evidence (scripts and outputs):
~/reviews/crfb-m4a-2026-10/cg-mtr-measurement/.axiom: n/a: simulation machinery (how the behavioral response measures a marginal rate); no policy provision changes
🤖 Generated with Claude Code