Skip to content

Add semi-elasticity and net-of-tax forms for capital gains realization responses - #10054

Merged
MaxGhenis merged 8 commits into
mainfrom
cg-response-functional-form
Oct 12, 2026
Merged

MaxGhenis merged 8 commits into
mainfrom
cg-response-functional-form

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

The capital gains realization response had one functional form, exp(ε · [ln τ1 − ln τ0]) − 1. Realizations scale as τ^ε, so in a single-rate model revenue scales as τ^(1+ε). For any ε between −1 and 0 there is no revenue-maximizing rate below 100%; at ε = −0.7 revenue rises all the way to a 100% rate. Both rates are also floored at 0.001, so someone whose baseline rate is 0% gets a log change of ln(τ1/0.001). In a household run, a person in the 0% bracket facing a new 10% rate loses 96% of their gains at ε = −0.7.

This PR adds two forms alongside it and leaves the existing one exactly as it was.

Parameter (gov.simulation.capital_gains_responses.*) Realizations scale as Accepted values Single-rate revenue τ·R peaks at Rates used
elasticity (existing, log-rate) τ^ε any finite ε (unchanged) nowhere below 100% for −1 < ε ≤ 0 floored at 0.001 (unchanged)
semi_elasticity (new) exp(−β·τ); response LTCG × (exp(−β·(τ1 − τ0)) − 1) 0 < β ≤ 50 τ = 1/β when β > 1 clipped to [0, 1]
net_of_tax_elasticity (new) (1 − τ)^e; response LTCG × (exp(e·[ln(1−τ1) − ln(1−τ0)]) − 1) 0 < e ≤ 5 τ = 1/(1+e) when e ≥ 0.003 clipped to [0, 0.999]

The semi-elasticity form depends only on τ1 − τ0, so it is defined at τ0 = 0 with no floor. The same 0%-bracket person loses 29% of their gains at β = 3.4. These peaks are for one flat rate on all gains; a progressive schedule, the NIIT, the AMT, state taxes and benefit interactions move the model's revenue-maximizing statutory rate away from them.

Selecting a form

The form is the one whose parameter is nonzero. All three default to 0, so the response stays off by default, and a reform that sets only elasticity gets exactly what it got before. Setting more than one raises a ValueError naming them, since the forms are alternatives and combining them would apply the response more than once.

The brief suggested a form parameter with string values. policyengine-core rejects those (ALLOWED_PARAM_TYPES = (float, int, bool, type(None), typing.List), policyengine_core/parameters/config.py:23, core 3.32.8). An integer code would be opaque in reform dicts and the app. A separate selector would also add two silent failure modes, a form set with its parameter left at 0 or a parameter set without the form, and either gives a zero response with no error. Selecting by the nonzero parameter has neither. In the app, switching a reform from the log-rate form to another means zeroing the elasticity; the error says so.

Supported values

The new forms raise a ValueError for a parameter that is negative, above its maximum (50 for β, 5 for e) or not finite. This applies at the policy level, before any measurement, and to person-level overrides of capital_gains_semi_elasticity and capital_gains_net_of_tax_elasticity.

  • Sign. Both parameters are positive where realizations fall as the rate rises, the opposite of the log-rate elasticity's usual sign. So a semi-elasticity entered as −3.4 is an error, not a silent reversal.
  • Maxima. At the maxima the multiplier 1 + f is at most exp(50) ≈ 5.2e21 or 1000^5 = 1e15, so the dollar response stays finite in the model's float32 arithmetic for gains below $10 quadrillion. CRS R41364's nonzero semi-elasticities run from 1.0 to 4.1.
  • Non-finite responses. A new form's response that is NaN or infinite also raises. That can happen only if a measured rate or a person's gains is not finite, or the gains exceed the $10 quadrillion bound.

The log-rate form accepts any finite elasticity. Its arithmetic and its silence on a NaN measured rate are unchanged, and a test pins both.

Clipping

marginal_tax_rate_on_capital_gains is 1 − Δhousehold_net_income / $1,000. household_net_income includes benefits and refundable credits, so the rate can fall outside [0, 1] at a benefit cliff. The exponential forms would turn a cliff into an explosive factor: a reform that removes a cliff where τ0 = 10 would multiply realizations by exp(9.75β). So the new forms clip rates to the range a tax rate can take. That keeps the semi-elasticity multiplier 1 + f within [exp(−β), exp(β)], so the factor f lies in [exp(−β) − 1, exp(β) − 1]. Clipping is a modeling choice that bounds rate shocks; the calibration papers do not establish it.

The net-of-tax form also caps rates at 0.999, the mirror of the log-rate form's 0.001 floor. Above the cap realizations stop falling, so single-rate revenue rises again at the very top. For e below about 0.0027, whose analytical peak is above 99.7%, revenue at 100% exceeds that peak. That case is documented and tested as intended. The log-rate form keeps its 0.001 floor and has no cap.

Calibration

A target revenue-maximizing rate τ* maps to β = 1/τ*: 28.5% is 3.51, 29% is 3.45 and 30% is 3.33. I verified each source below against its PDF:

  • JCT 1990 (JCS-12-90):

    • Equation [3] (printed p. 54) is LN(RCG) = a + b × MTR with b = −0.0351 per percentage point, i.e. this form with β = 3.51.
    • The report says that implies a revenue-maximizing rate of 28.5% (printed pp. 41–42 and 59).
    • It notes that the log-log form "would predict no tax rate at which revenue is maximized" (Appendix B, eq. [5]).
    • It cautions that a revenue-maximizing rate is very sensitive to functional form (printed p. 5 and pp. 41–42).
  • CRS R41364 (2021, Table 1, "Coefficients currently used were provided by these agencies") reported these semi-log coefficients:

    • JCT 3.1 (peak 32.3%);
    • Treasury 3.25 (30.8%);
    • CBO's time-series forecasting model 1.76;
    • Dowd, McClelland and Muthitacharoen (2015) 4.1.

    Appendix A derives the 1/b peak. In 2025, R48562 said JCT "has used a coefficient of 3.1".

  • Agersnap and Zidar (2021) estimate responses to the log net-of-tax rate. Their federal revenue-maximizing rates run from 33% to 47% across their 10-year specifications, and from 38% to 47% over years 6 to 10, which they treat as the long run. This is the main counterpoint.

So the CRFB description ("JCT/CBO semi-elasticity, revenue-maximizing rate about 28.5–30%") matches JCT's published 1990 calibration. The coefficient CRS reported for JCT in 2021 and 2025 implies about 32%. I found no published CBO scoring equation; CBO's own forecasting coefficient implies a much higher peak. A literature run (GPT-6.1 Sol) also checked:

  • CBO 1988;
  • TPC (−0.7 at 20%, peak about 28%);
  • PWBM (33%);
  • the Yale Budget Lab.

Yale's dynamic realize-or-defer model specifies its response on the net-of-tax rate; I re-checked that myself, and the docs no longer call this constant-elasticity form Yale's. cbo.gov blocked scripted downloads, so CBO 1988 is not cited in the code. The parameter defaults stay at 0. The documentation lists 3.51, 3.1 and the 3.33–3.51 range as calibrations rather than choosing one.

Because only the change in the rate enters, β from JCT's federal-rate equation applies to PolicyEngine's household rate whenever a federal change passes through one-for-one. Without clipping, a fixed state rate that adds to the federal rate multiplies realizations by a constant, so the analytical peak of single-rate federal revenue t·A·exp(−β(t+s)) stays at a federal rate, including the net investment income tax, of 1/β. With the clip, realizations stop falling once t + s reaches 100%, so revenue at a 100% federal rate can win. The analytical peak is the unique maximum when β(1 − s) − ln β > 1 (at equality the peak and a 100% rate tie): a state rate below about 31% to 36% for β from 3.1 to 3.51, and any β above 1 when s = 0. I checked this numerically, including the review's counterexamples (β = 1.05, s = 10%; β = 3.4, s = 40%).

Invariants

Each is a Hypothesis property in policyengine_us/tests/core/test_capital_gains_response_forms.py, checked for every form within the stated domain:

  1. No change, no response. f(τ, τ) == 0 for every parameter and every rate in [−5, 20].
  2. Monotonicity. With ε ≤ 0 and the new forms' β, e ≥ 0, the multiplier is non-increasing in τ1 and non-decreasing in τ0 over [−5, 20]. It is strictly decreasing inside each form's unclipped range when the parameter is nonzero and the multiplier resolves a 1e-4 rate step. For a net loss, a smaller multiplier moves the loss toward zero, so dollar realizations fall only for positive gains.
  3. Sign preservation and finiteness.
    • 1 + f ≥ 0 for all forms.
    • In float32, as the model computes it, the new forms' dollar responses are finite and sign-preserving. This holds across their whole parameter domains, for rates in [−5, 20] and gains up to 2^53 (about $9 quadrillion) in magnitude.
  4. Path independence, numerically. (1 + f(t0, t1))(1 + f(t1, t2)) = 1 + f(t0, t2) holds to 1e-9 in float64 where each multiplier is at least 1e-6. Two float32 cases are pinned as documented, not as violations: a 0→100%→0 round trip multiplies gains by 1.000001 at β = 3.4, and by 0 at β = 20, where the outbound multiplier is too small to store.
  5. Single-rate revenue peaks.
    • Semi-elasticity: τ(1 + f(τ0, τ)) peaks at 1/β for β in [1.05, 50] and every τ0, rising below the peak and falling above it.
    • Net-of-tax: it peaks at 1/(1 + e) for e in [0.003, 20]. The tail case below 0.0027 is tested as intended.
    • Log-rate: with ε in (−1, 0], revenue is non-decreasing on [0.001, 1]. This is the defect being addressed, kept as a documented property.
    • Comparisons allow a relative 1e-12 plus an absolute 1e-15, because 1 + expm1(x) is accurate only to about 2.2e-16 absolutely. The review's two counterexamples (β = 50 at 63.05%/63.06%; e = 20 at 80%/80.01%) are pinned as @examples. They reverse by about 2e-18 and fail a relative-only comparison.
    • A grid test checks that β = 1/0.285, 1/0.29 and 1/0.30 peak at 28.5%, 29% and 30%.
  6. Bounds. The semi-elasticity factor lies in [exp(−β) − 1, exp(β) − 1].
  7. Differential: the old form is unchanged.
    • The log-rate factor equals a frozen copy of main's formula at 75cdd80, bit pattern for bit pattern (integer views, float32 and float64).
    • With only elasticity set, the response variable equals main's formula on fixed measurements, and end to end through Simulation in the existing heavy-b channel test.

Outside the test suite, I ran three log-form scenarios over eight households on main (75cdd80) and on this branch. The reviewer independently repeated this. All 120 output values have identical float64 bit patterns: the response, long-term gains, the relative rate change, income tax and household net income.

A defect this does not fix: how τ is measured

Checking the model's revenue curve turned up a pre-existing problem on main in marginal_tax_rate_on_capital_gains. It perturbs the capital_gains input (short- plus long-term). Gross income counts that input, but the preferential-rate computation reads the component-based net_capital_gain, so the measured rate can reflect ordinary rates, deductions and stacking rather than the long-term rate. Household runs for 2026, TX single:

Filer Measured MTR on main Long-term gains raised instead Why
$100M of gains 0.296 about 0.238 AMT
$60k wages + $200k of gains 0.308 0.188 ordinary rates

The $100M filer's measured reform rate stays at 0.296 as the top statutory rate rises from 20% to 28%, so no form responds in that range.

This affects every form, the existing one included, so it has its own PR and changelog: it changes existing scores. #10058 fixes it by raising long_term_capital_gains instead, with the rise scaled so float32 resolves the rate at high incomes. On a tree with both PRs, that session measured a TX filer with $20M of gains under semi_elasticity = 1/0.30: federal income tax peaks at a 25.25% top rate on a quarter-point grid. The implicit condition r + 3.8% = (1 − 545,500/A)/β, with A the post-response gains $20M × exp(−(r − 20%)/0.30), solves at 25.23%. These household examples do not establish the direction or size of the measurement error across a whole population or every reform. The methodology page states the limitation, and it gets rewritten when #10058 merges.

Separately, above a top capital gains rate of about 37%, 26 U.S.C. 1(h)(1) caps the tax on gains at the ordinary-rate tax (limit_to_tax_at_main_rates), so the measured rate plateaus at 37% + 3.8%. That is the law, not a measurement problem.

Test placement and CI cost

The first revision put four model builds (three household Simulations and a fresh CountryTaxBenefitSystem) in the new file, which the Makefile routes to the Rest core group. In CI on 750a53c (run 38052107644) those four cases took 162 s of the file's 166 s. The core group went from 9:32 and 6.86 GB peak RSS on the base run (38046495131) to 13:36 and 7.49 GB. Whole-group differences include runner variance.

This revision moves every model build out of that file:

  • test_capital_gains_response_forms.py (Rest core) is now pure arithmetic: unit tests, Hypothesis properties and the frozen-formula differential. It reads defaults from the shared read-only policyengine_us.system.system rather than building a model.
  • The default-path end-to-end differential is an added assertion in the existing test_capital_gains_channel_is_live_end_to_end (Rest heavy-b), on the Simulation that test already builds, so it costs no extra model.
  • One new Simulation test, test_semi_elasticity_capital_gains_response_end_to_end_from_a_zero_rate, sits beside it in heavy-b. That file already holds the behavioral-response simulations. The test covers what arithmetic tests cannot: the new parameter set through a reform dict, the person-level variable, and real measured rates from a 0% baseline.
  • The two-parameter conflict now has formula-level coverage only; its end-to-end test is gone.
Measurement Before After
test_capital_gains_response_forms.py, local 38 cases (r1), 4 model builds 62 cases in 5.1 s (20.7 s wall with import), 1.50 GB peak RSS, no model builds
test_behavioral_response_measurements.py, local 14 cases in 36.5 s (53.8 s wall), 4.42 GB peak RSS 15 cases in 70.9 s (89.4 s wall), 5.82 GB peak RSS
Rest heavy-b group, Linux CI 7:33, 8.77 GB peak RSS (base run 38046495131) 8:29, 8.85 GB (run 38142244518, head 93b5250); the two capital gains simulations took 54.1 s (existing) and 56.1 s (new)
Rest core group, Linux CI 9:32, 6.86 GB (base); 13:36, 7.49 GB (r1) 12:12, 14.11 GB (run 38142244518); test_capital_gains_response_forms.py took 4.7 s with no model builds. Since the base run the group also gained 21 cases from modules merged to main (105.5 s), led in time by test_ssi_resource_deeming_invariants (77.9 s). GNU time records one memory high-water mark per group, so which module set the 14.11 GB peak is an inference, not a measurement.

The local rows are macOS (Python 3.14t, policyengine-core 3.32.8), measured with /usr/bin/time -l on single files. The CI rows come from the Rest job's rest-python-<group>-resources.txt artifacts. Heavy-b's 8.8 GB base peak leaves about 7 GB of headroom on the 16 GB runner. No jobs, matrix entries or runners were added.

Review

GPT-6.1 Sol reviewed 750a53c and requested changes (blocking: the revenue tolerance; the rest should-fix and nits). This revision addresses every finding:

  1. Revenue tolerance: an absolute 1e-15 allowance plus the counterexamples pinned.
  2. Domain validation and finiteness checks, with float32 property coverage.
  3. Peaks qualified for domain, clipping and single-rate models; the net-of-tax tail documented and tested.
  4. Bounds stated for 1 + f and for f.
  5. Yale wording.
  6. Test placement and cost measured; the read-only shared system reused.
  7. One reference entry per source, #page lists in quoted titles.
  8. Dated CRS attribution, Agersnap and Zidar's ranges, the 0.001 floor, "a reform rate well below 99.9%", and the measurement-defect wording qualified.
  9. A defined as post-response gains in an implicit condition.

Report: ~/reviews/crfb-m4a-2026-10/cg-functional-form/review_r1_report.md.

Round 2 (93b5250) resolved the blocker and findings 2 and 4–9. It asked for three more changes, made in this head:

  • Qualify the fixed-state-rate peak for clipping; the changelog now says β > 1.
  • Remove Hypothesis's 200 ms deadline from the two float32 array properties. One example took 253 ms on a loaded machine.
  • Mention oversized gains in the non-finite wording.

Report: review_r2_report.md in the same folder. Round 3 (a59b526) approved; its two nonblocking nits are this body's attribution wording, now qualified, and the docs page's tie case at equality, which goes to the planned docs follow-up.

This head also merges main at f403afe. Main's Rest job currently fails test_no_group_variables_in_person_adds.py::test_known_violations_only_shrink, because #9897 and #9902 crossed. That is unrelated to this PR, and #10092 fixes it.

Docs

docs-quarto/methodology/capital-gains-responses.qmd documents the three forms, the accepted values, the floors and clips, single-rate peaks and their limits, the calibration table with sources, and the measurement limitation. It is linked from the methodology index and sidebar. The parameter files carry the same documentation and their references.

axiom: n/a: microsimulation behavioral-response machinery, not a policy provision

🤖 Generated with Claude Code

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>
@codecov

codecov Bot commented Oct 10, 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 (c9a2084).
⚠️ Report is 416 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main    #10054   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            4         2    -2     
  Lines           76       132   +56     
  Branches         2         9    +7     
=========================================
+ Hits            76       132   +56     
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 3 commits October 11, 2026 08:54
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>
MaxGhenis and others added 2 commits October 11, 2026 10:55
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>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hand-off to the US + core hub. Head: 79b38952427d (git rev-parse 79b3895).

Reviews. All by GPT-6.1 Sol through Subfleet; reports in ~/reviews/crfb-m4a-2026-10/cg-functional-form/.

  • r1 at 750a53c: REQUEST_CHANGES (review_r1_report.md).
  • r2 at 93b5250: REQUEST_CHANGES (review_r2_report.md).
  • r3 at a59b526: APPROVE (review_r3_report.md), with two nonblocking nits:
    • the PR body's CI attribution, now qualified in the body;
    • the docs page's tie case at equality, which goes to the docs follow-up below.

Head after review. 79b3895 is a59b526 plus a merge of main at 065d0e9 only. git diff a59b52602d 79b3895242 touches none of this PR's files: the forms module, response variable, three parameters, the two test files, the qmd page and the changelog fragment.

Checks.

Depends on. Nothing open. #10058 (capital gains MTR measurement) is independent, and the two touch different hunks of capital_gains_responses.py.

Holds and decisions. None. This adds forms that are off by default, and the default log-rate path is bit-identical to main.

Follow-ups owed (the #10058 session owns them; recorded in the session memory note):

Merge. Squash, as main does. If the only block is the required review on a maintainer's PR, use gh pr merge --admin --squash --match-head-commit <head> with an audit comment.

@MaxGhenis
MaxGhenis merged commit 6a3d2e1 into main Oct 12, 2026
28 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, us#10054 at c9a2084e1ebdab356f823bd5fdd8ab1a9d74ea2e (squash): add semi-elasticity and net-of-tax forms for capital gains realization responses.

  • Reviews: GPT-6.1 Sol round 3 APPROVE at a59b52602d. A Sol delta review APPROVES this head: since then the PR's own change is the precision test and the methodology page. The documented float32 round trip is now a bound (within 32 float32 epsilon of 1), not one platform's figure, and a deliberately degraded computation fails it. The main merge left all 11 PR-owned files unchanged.
  • CI fix: the earlier red runs failed that test on Linux (0.9999991 against a pinned 1.000001), and two older runs failed only on main's since-fixed person-adds ratchet.
  • Gates checked live at merge:
    • gh pr checks exits 0;
    • the latest Pull request run at the head concluded success;
    • MERGEABLE;
    • not a draft;
    • no reviewer's latest review requests changes;
    • the semantic-overlap guard;
    • head pinned.
  • Impact: none on microsimulations, by construction. All three response parameters default to zero, so no capital gains response runs unless a reform sets one. The description's household comparison found the log form identical to main, bit for bit.
  • Axiom: n/a, as the description states (behavioral-response machinery, not a policy provision).

@MaxGhenis
MaxGhenis deleted the cg-response-functional-form branch October 12, 2026 10:40
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