Repository navigation
Add semi-elasticity and net-of-tax forms for capital gains realization responses - #10054
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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
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:
|
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>
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>
|
Hand-off to the US + core hub. Head: 79b38952427d ( Reviews. All by GPT-6.1 Sol through Subfleet; reports in
Head after review. 79b3895 is a59b526 plus a merge of main at 065d0e9 only. Checks.
Depends on. Nothing open. #10058 (capital gains MTR measurement) is independent, and the two touch different hunks of 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 |
|
US + core hub merge audit, us#10054 at
|
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.
gov.simulation.capital_gains_responses.*)τ·Rpeaks atelasticity(existing, log-rate)τ^εsemi_elasticity(new)exp(−β·τ); responseLTCG × (exp(−β·(τ1 − τ0)) − 1)τ = 1/βwhen β > 1net_of_tax_elasticity(new)(1 − τ)^e; responseLTCG × (exp(e·[ln(1−τ1) − ln(1−τ0)]) − 1)τ = 1/(1+e)when e ≥ 0.003The 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
elasticitygets exactly what it got before. Setting more than one raises aValueErrornaming them, since the forms are alternatives and combining them would apply the response more than once.The brief suggested a
formparameter 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
ValueErrorfor 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 ofcapital_gains_semi_elasticityandcapital_gains_net_of_tax_elasticity.1 + fis 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.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_gainsis1 − Δhousehold_net_income / $1,000.household_net_incomeincludes 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 multiplier1 + fwithin [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):
LN(RCG) = a + b × MTRwith b = −0.0351 per percentage point, i.e. this form with β = 3.51.CRS R41364 (2021, Table 1, "Coefficients currently used were provided by these agencies") reported these semi-log coefficients:
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:
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:f(τ, τ) == 0for every parameter and every rate in [−5, 20].1 + f ≥ 0for all forms.(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.τ(1 + f(τ0, τ))peaks at 1/β for β in [1.05, 50] and every τ0, rising below the peak and falling above it.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.elasticityset, the response variable equals main's formula on fixed measurements, and end to end throughSimulationin 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 thecapital_gainsinput (short- plus long-term). Gross income counts that input, but the preferential-rate computation reads the component-basednet_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: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_gainsinstead, 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 undersemi_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 freshCountryTaxBenefitSystem) in the new file, which the Makefile routes to the Restcoregroup. In CI on 750a53c (run 38052107644) those four cases took 162 s of the file's 166 s. Thecoregroup 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(Restcore) is now pure arithmetic: unit tests, Hypothesis properties and the frozen-formula differential. It reads defaults from the shared read-onlypolicyengine_us.system.systemrather than building a model.test_capital_gains_channel_is_live_end_to_end(Rest heavy-b), on theSimulationthat test already builds, so it costs no extra model.Simulationtest,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.test_capital_gains_response_forms.py, localtest_behavioral_response_measurements.py, localtest_capital_gains_response_forms.pytook 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 bytest_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 -lon single files. The CI rows come from the Rest job'srest-python-<group>-resources.txtartifacts. 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 + fand for f.#pagelists in quoted titles.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:
Report:
review_r2_report.mdin 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.qmddocuments 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