Skip to content

Make a retention-rate elasticity of 1 the default CGT realisation response - #2419

Merged
juaristi22 merged 19 commits into
mainfrom
cgt-retention-default-2396
Oct 15, 2026
Merged

juaristi22 merged 19 commits into
mainfrom
cgt-retention-default-2396

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2396

Blocked by: #2277 (merged) and PolicyEngine/policyengine.py#620 (merged and in a policyengine.py release; see the merge order below). This PR stays a draft until both are done.

PR set: CGT retention-rate default (#2396, PolicyEngine/policyengine.py#602)

Step PR What Depends on
0 #2277 Measurement branches (Max) —
1 PolicyEngine/policyengine.py#620 Budget contract: hold CGT realisations fixed in the rate-blending case; add response and default cases —
2 this PR Retention-rate elasticity of 1.0 as the model default step 0 merged; step 1 merged and released
3 PolicyEngine/policyengine.py#621 Record the response each UK run applies —
4 — This PR's release, and its compatibility claim for the certified policyengine-uk-data release (1.58.1) step 2
5 PolicyEngine/policyengine.py#633 (draft) policyengine.py adopts the release: certify, pin, default tests, docs steps 1, 3, 4
6 not opened yet policyengine-app-v2 methodology page step 5 released

Merge order. Merging publishes a release straight away. policyengine-sim-api's deploy gate runs policyengine.py's Budget reform contract, which fails on this default without PolicyEngine/policyengine.py#620. So sim-api needs a policyengine.py release with #620 before it takes this release. Merging and releasing #620 first keeps that order. It also means policyengine.py's automatic qualification (PolicyEngine/policyengine.py#574, still a draft) starts from a contract that holds under this default.
Tracking checklist: checklist on PolicyEngine/policyengine.py#602

Stacked on #2277. The branch merges #2277 (fix-lsr-baseline-2268, up to 03ec1e8), which the "stable across recalculation and order" criterion depends on, so the diff shows its commits until it merges. To review only this PR's commits, use the compare view fix-lsr-baseline-2268...cgt-retention-default-2396. #2277 now holds every behavioural response in marginal rate branches (hold_behavioural_responses), and this PR uses that in place of its own gains-only hold.

What

gov.simulation.capital_gains_responses.elasticity now defaults to 1.0: the central value Advani, Lonsdale and Summers (2024, pp. 36–37) use for the medium-term effect of their reform package, with 0.5 and 2.0 as sensitivities. mtr_elasticity stays 0 and separate_badr_elasticity false, so gains qualifying for Business Asset Disposal Relief take 1.0 too. A CGT reform with no elasticity set is no longer costed statically: for a marginal rate on gains rising from 24% to 45%, realised gains scale by 0.55 / 0.76 ≈ 0.7237.

Flipping the YAML alone wasn't safe, so the PR also does the following.

  1. Measure only where a reform changes the rules.
    • With a nonzero default, every simulation measured two marginal rates on gains per year: a simulation without a reform (its baseline is a copy of itself), a reform's own baseline, and every branch of them. Each measurement recalculates household net income in several branches, and the rates were always equal.
    • measure_capital_gains_mtrs now returns zero changes, measuring nothing, when the simulation applies its baseline's rules. same_rules (utils/rule_comparison.py) compares the two tax-benefit systems' parameter timelines and variable definitions, leaving out the behavioural-response settings, which only feed responses the measurement neutralises.
    • Rules changed after construction are measured, because the comparison looks at what the systems hold, not how they were built. That covers policyengine.py's modifiers, uk-cgt-reform's and apply_parameter_changes.
    • A population with no gains or losses doesn't measure either.
    • The comparison takes about 15 ms per simulation and year. It errs one way only: anything it can't compare counts as different, so it can cost a measurement but never skip one.
  2. Marginal tax rates hold realisations fixed.
    • The pay-rise branches of marginal_tax_rate and marginal_tax_rate_wrt_employer_cost measured the response again, comparing the reform's rate on gains at the higher pay with the baseline's at the old pay. At a band edge the £1,000 step moved the gains into the higher rate band and counted that move as part of the reform.
    • Take a gainer whose gains end £500 below the top of the basic rate band, under equalised rates. The branch's response was about eight times the reform's, and marginal_tax_rate came out at 864%.
    • The branches now hold the response at the simulation's own values, which gives 28%, the static rate at the realised gains. They use Measure labour supply responses against the reform's baseline #2277's hold_behavioural_responses, which holds the labour supply response too.
  3. Legacy MTR calls resolve with a warning.
    • An mtr_elasticity set while the retention-rate settings keep their defaults now applies alone in its periods, with a CapitalGainsResponseWarning. The warning is given once per tax-benefit system and period.
    • Retention-rate settings changed to a nonzero value still raise alongside it, with the same message prefixes as before.
    • An explicit retention elasticity equal to the default (1.0) can't be told apart from the default, so it resolves the same way. The docs say so.
  4. The assumption is reportable. capital_gains_response_assumption(simulation, year) returns the convention, the elasticities for main-rate and relief gains, the MTR elasticity, the separate switch, and each one's source (default, override or auto-resolved). Its to_dict() is JSON-serialisable. policyengine.py records it (Record the capital gains realisation response each UK run applies policyengine.py#621). The person-level capital_gains_elasticity and capital_gains_badr_elasticity read the same resolution.
  5. Docs and parameters.
    • The parameter descriptions state the assumption's qualifications:
    • New page: docs/book/assumptions/capital-gains-responses.md. It covers:
      • how the response works;
      • the assumption's limits, and large rate rises, where the elasticity can decide the sign of the revenue;
      • what the response moves: household incomes, and income tax band and allowance reforms;
      • when it is zero, including a reform to a band the last pound of gains doesn't fall in;
      • the other assumptions, the MTR convention, migration and runtime.
    • The simulations guide's revenue identity now points to gov_balance for reforms that move gains, and says household results count gains no longer realised as income lost.
  6. A cheaper marginal rate on gains.
    • marginal_tax_rate_on_capital_gains now measures the rise in capital_gains_tax per pound of marginal gains, instead of the fall in household net income. Its branches recalculate only what the tax depends on, so a measurement costs about a sixth of what it did.
    • Why the values hold. Realised gains reach household net income only as market income and through capital_gains_tax, so the two definitions agree. A new test traces a net income calculation and fails if any other variable starts reading capital_gains. Another checks the rate against a direct net-income difference for four household types.
    • On the full dataset, the new rate matches the old within 0.001 for every person with gains, with a weighted mean absolute difference below 1e-6.
    • One thing it leaves out: the private school attendance imputation, which ranks national-dataset households by an income that includes gains. A £1,000 step could move a household across a rank boundary, which is imputation noise rather than tax on gains.

Release

This ships as a minor release: 2396.changed.md for the new default, and 2396.added.md for capital_gains_response_assumption and CapitalGainsResponseWarning. A downstream pin that allows minor releases picks it up, and with it new costings for every CGT reform, moves in income tax band and allowance reforms, and a warning for mtr_elasticity-only calls. If such pins shouldn't pick it up silently, renaming the changed fragment to 2396.breaking.md makes it a major release.

What changes for users

  • CGT costings. Every CGT reform is scored with the response unless the static settings are set.
  • Other reforms. Reforms to income tax bands and allowances move realised gains, so costings that include CGT move too. Example: earnings of £30,000 and gains of £20,000, with the basic rate band cut to £20,000 or the personal allowance to £5,000. Realised gains fall by £1,463 (7.3%).
  • Household results. Household net income counts gains no longer realised as income lost, though they are deferred, not lost. Take £100,000 of earnings and £200,000 of gains, with the higher and additional rates at 45%. Net income falls £71,765 with the response and £41,370 without it. Revenue rises £16,502, not £41,370. Per-household impacts, income deciles and winners and losers show this. Whether distributional measures should use gains before the response is a methodology decision, not made here.
  • Large rate rises. Revenue from a single rate on gains peaks at 1/(1+e): 50% at the default, 40% at 1.5, 33% at 2.0, about 22% at 3.6. In the example above, the person's CGT rises £16,502 at the default and £6,777 at 1.5, and falls £20,536 at 3.6. Official costings of large rises can have the other sign.

These figures come from synthetic households, and the docs page and changelog state them.

Moving from the static default

To get Set
The old static costing elasticity, mtr_elasticity = 0 and separate_badr_elasticity = false for the years you run ("2000-01-01.2100-12-31" covers all)
CenTax's sensitivities elasticity = 0.5 or 2.0
The OBR elasticity comparison elasticity = 3.6, separate_badr_elasticity = true (badr_elasticity stays 1.4)
The MTR convention without the warning mtr_elasticity and elasticity = 0 for the same periods

Runtime

Method. Calculation time is the median of three runs, each in a fresh process. The measurement count is the number of marginal-rate measurements on gains.

  • Setup: one person with £100,000 of earnings and £200,000 of gains (or none), in 2026.
  • Columns: base is main with Measure labour supply responses against the reform's baseline #2277 (the static default); "default flip only" is base with just the YAML set to 1.0; the last column is this PR.
  • Noise: another workload shared the machine, so the seconds are indicative. The counts are exact.
Case Base Default flip only This PR
No reform 0.36 s (0) 1.52 s (2) 0.29 s (0)
No gains, CGT reform 0.17 s (0) 1.48 s (2) 0.13 s (0)
CGT reform 0.16 s (0) 1.44 s (2) 1.14 s (2)
Personal allowance reform 0.15 s (0) 1.47 s (2) 1.17 s (2)
marginal_tax_rate, no reform 0.43 s (0) 3.61 s (6) 0.41 s (0)
marginal_tax_rate, CGT reform 0.53 s (0) 3.73 s (6) 1.26 s (2)

Reading the table:

  • Flipping the YAML alone makes every calculation measure, roughly 4–9 times slower, with or without a reform or gains.
  • With this PR, runs without a reform or without gains cost what they did. A reform that moves someone's marginal rate on gains pays for the response itself: two measurements per simulation and year.
  • Marginal tax rates no longer measure again in each pay-rise branch, which is why they need two measurements instead of six.

The full dataset. On the enhanced FRS (2023-24, 1.40.3), household_net_income for 2026. Base and this PR ran back to back, two runs each:

Case Base Default flip only This PR
No reform 2.7–2.9 s (0) 15.0 s (2) 2.6–2.7 s (0)
CGT reform 2.6–3.0 s (0) 14.9 s (2) 4.2–5.1 s (2)
Personal allowance reform 2.0 s (0) 15.0 s (2) 5.0–5.7 s (2)

The "default flip only" column and base's personal allowance case come from an earlier run on the same machine.

What the response costs.

  • Every reformed simulation measures. A simulation whose rules differ from its baseline's measures two marginal rates on gains per year, CGT reform or not, because the rule comparison can't tell which rules can move them. Runs without a reform, which include every baseline, are unchanged.
  • The rate on gains is now cheaper. It comes from the rise in capital_gains_tax rather than in household net income (point 6 above). That cut the measurement from about 13 seconds to about 2 seconds per reformed simulation and year on the full dataset.
  • Memory. Peak memory varied from run to run on this machine (1.7–3.5 GB for the reformed runs), so I don't quote a figure. The first version of this PR, which measured through household net income, peaked about 2.3 GB above base.

Isolation and stability

  • No new state. The fast paths add none: same_rules reads values_list directly and writes no cache.
  • Branches. The held responses are set on branches only. Measurement still runs in Measure labour supply responses against the reform's baseline #2277's measurement branches, which clone the system and neutralise every response.
  • Warnings. Deduplication lives in a WeakKeyDictionary keyed by tax-benefit system, and warnings.filters is never touched.
  • Stability. The new tests check, with no elasticity set, that results are stable across recalculation, calculation order and computing the baseline first. They also check that the baseline, the response variable and the warning filters are unchanged after measuring.

Tests

  • New: test_capital_gains_response_default.py (37).
    • Default equals explicit 1.0 through reform= and scenario=, Simulation and Microsimulation, and on a dataset.
    • The 0.7237 factor, relief gains at 1.0, and a rate cut raising realisations.
    • 0.5, 1.3 and 2.0, and the static settings.
    • Legacy MTR: one warning, a new period warning again, the explicit zero silencing it, and per-period conflicts.
    • Dated settings.
    • No measurement for unreformed runs, a reform's baseline or populations without gains (a loss is measured).
    • Unreformed results bit-identical to static, marginal tax rates included.
    • The pay-rise and employer-cost reproducers.
    • Stability, isolation, changes after construction (three routes) and the helper's report.
  • New: test_rule_comparison.py (8):
    • copies;
    • edits at another date or in place;
    • an added parameter;
    • neutralised variables and replaced formulas;
    • the response settings left out;
    • separately loaded systems.
  • Updated: tests that encoded the static default now set what they need: test_capital_gains_responses.py, the residential MTR and equalisation tests in test_capital_gains_tax_components.py, and test_fiscal_year_parameters.py's override. The code-health read-only-cache test gains a default-response case.
  • After merging main (2.132.6) and the updated Measure labour supply responses against the reform's baseline #2277: the capital gains, rule comparison, labour supply, behavioural response, marginal rate, parameter and code-health tests give 2,509 passed and 1 skipped. uvx ruff format --check . and uvx ruff check . are clean. CI runs the full suite.
  • Local results before those merges:

Reproducer: policyengine_uk/tests/test_capital_gains_response_default.py::test_pay_rise_branches_hold_the_realisation_response

axiom: n/a: behavioural-response modelling assumption under gov.simulation; no statutory provision changes

Follow-ups (not in this PR)

  • For Give UK simulations a working subsample #2265: a UK subsample() must subsample the baseline too, or reform measurement now fails with a clear population-size error.
  • Faster elasticity-only scenarios: comparing formulas by code rather than identity would let separately built systems with the same rules (an elasticity-only scenario) skip measurement too. Today they measure and get an exact zero.

🤖 Generated with Claude Code

MaxGhenis and others added 8 commits October 10, 2026 17:43
relative_wage_change, relative_income_change and
employment_income_behavioral_response took the baseline from
simulation.get_branch("baseline"). policyengine_uk.Simulation keeps its
baseline as a separate simulation in simulation.baseline, so that call
made a branch of the reform simulation, with the reform's parameters,
and every response was zero (#2268).

The formulas now measure on one pair of branches, of the reform and of
simulation.baseline, opened by the response for each period. Both hold
the reform's earnings and gains before responses with the response
neutralised. The shared measurement_branch helper, which the capital
gains response now uses too, drops what the branched simulation had
already calculated, so neither response depends on calculation order
or changes on recalculation.

Fixes #2268

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the labour supply response working, each response's measurement
recomputed the other: the labour supply branches counted lower realised
gains as lost income, and the branches that step gains up to measure the
capital gains rate recomputed the labour supply response against a
baseline held at gains before the step. On a 16-person dataset with both
responses on, the capital gains response went from -150,569 to -554,679.

Every measurement branch now neutralises all three response variables,
so each response is measured from the position before any response and
does not change when the other is switched on. Tests state that, and
that every *_behavioural_response variable is in BEHAVIOURAL_RESPONSES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's unpinned ruff formats Python inside Markdown, and the
substitution elasticity line in the elasticity-based responses example
was over 88 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation that applies its baseline's rules (one built without a
reform, every branch of one, and a reform's own baseline) now skips the
marginal-rate measurement, whose answer would be zero, as does a
population without gains. The check compares the two tax-benefit
systems' parameter timelines and variable definitions, so rules edited
after construction, as policyengine.py's modifiers edit them, are still
measured.

Marginal tax rates on earnings now hold the realisation response at the
simulation's own values in their pay-rise branches. Measured again there,
the response compared the reform's rate at the higher pay with the
baseline's at the old pay, so at a band edge the pay rise counted as part
of the reform.

The response settings now resolve through
capital_gains_response_assumption, which lets an MTR elasticity apply
alone, with a warning, where the retention-rate settings keep their
defaults, and reports the response a simulation applies.

Part of #2396.

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

gov.simulation.capital_gains_responses.elasticity now defaults to 1.0,
the central value Advani, Lonsdale and Summers (2024, pp. 36-37) use,
with 0.5 and 2.0 as sensitivities; mtr_elasticity stays 0 and
separate_badr_elasticity false, so qualifying gains take 1.0 too. The
parameter descriptions state the assumption's qualifications and the
static recipe.

Tests that checked the static default now set the elasticity they need,
the MTR-convention tests set the retention-rate elasticity to 0, and new
tests cover the default through every route, the explicit alternatives,
legacy MTR settings, dated settings, the cases that never measure, the
pay-rise branches, stability and the assumption helper.

Fixes #2396.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new assumptions page describes how the response works, the default and
its qualifications, when there is no response, the other assumptions and
the marginal tax rate convention, the assumption helper, how to move
from the static default, and the runtime. The simulations guide's
revenue identity and the dynamics guide point to it.

Part of #2396.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marginal_tax_rate_on_capital_gains recalculated household net income in
each branch, all of whose programmes it has to reach. Realised gains
reach net income only as market income and through capital_gains_tax,
so the rise in that tax per pound of marginal gains is the same rate,
and its branches recalculate only what the tax depends on. On the
enhanced FRS a measurement now costs about a sixth of what it did, which
takes the response from about 13 to about 2 seconds per reformed
simulation and year; the rate matches the old one within 0.001 for every
person with gains.

A test traces a net income calculation and fails if any variable other
than capital_gains_tax and household_market_income reads capital_gains;
another checks the rate against a direct net-income difference.

Part of #2396.

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

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juaristi22 review of c6c77ef32 (this PR's own commits, b3831462b..c6c77ef32).

Verdict: the code is sound and well tested. Fine once the release type is settled (point 1). Before the app adopts it, the framing of 1.0 and the effect on household-level outputs need a user-facing note (points 2 to 4).

Findings

  1. must: a default change that re-scores every CGT reform ships as a patch. .github/bump_version.py:34-38 maps breaking to major, added/removed to minor and everything else to patch. With changelog.d/2396.changed.md and #2277's .fixed.md, this release is a patch. It changes the costing of every CGT reform, moves income tax reforms too (point 4), and makes mtr_elasticity-only calls warn. Any downstream ~= or caret pin picks that up silently, which is the same risk the PR body's merge-order note about PolicyEngine/policyengine.py#620 is guarding against. I'd make it 2396.breaking.md, or at least add an .added fragment for capital_gains_response_assumption (a genuine addition) so it's minor. Either way it should be a conscious choice, not the default bump.

  2. should: say plainly which way CenTax's own reasoning points for the reforms PolicyEngine usually scores. I read pp. 32-37 of the paper. Their elasticity is on the same base as this PR's: aggregate realisations against the retention rate. Their starting point is about 1.5 (the Agersnap and Zidar five-year estimate with controls). They then cut it to 1.0 for two features of their package: removing uplift at death and an exit charge, and counting gains that shift into income tax as revenue kept. The paper then says (p. 37): "A reform that lacked these base-broadening measures would surely result in a higher behavioural elasticity (implying lower revenues) than we have estimated." A rate-only reform in PolicyEngine has neither feature, and PolicyEngine adds no income tax back. So by their logic, 1.0 is at the low end for those reforms, and 1.5 is the unadjusted figure. The description at elasticity.yaml:1 and docs/book/assumptions/capital-gains-responses.md:43-51 say it "isn't an estimate for every reform". That's true but it doesn't say which way the error goes. I'd add one sentence saying that for rate changes without base reform, the source implies a higher elasticity, and therefore lower revenue. Whether to keep 1.0 or move to 1.5 is a methodology judgement for the maintainers. I'd keep 1.0 only with that sentence.

  3. should: 1.0 is a medium-term value, and the app scores single years. CenTax use 1.0 for the five-year effect. The study it rests on gives 1.6 at three to five years, and the Canadian study gives 1.8 in the first year for large gainers. Neither includes forestalling. So a year-one score will understate the response, and the gap from official costings will be largest in the first year. CenTax (p. 4) note that HMRC's ready reckoner has a 10pp rise in the top CGT rates losing £2bn against more than £5bn static. Under this functional form, the revenue-maximising rate is 1/(1+e): 50% at 1.0, 40% at 1.5, 33% at 2.0 and about 22% at 3.6. On a synthetic household (£100k earnings, £200k gains), main and additional CGT rates at 45% raise the person's CGT from £47,280 to £63,782 at the default. At 3.6 it falls to £26,744. So the default decides the sign of the headline for large rate rises against official figures. That divergence is legitimate, but it should be stated in the docs page and in the release note, with the horizon caveat.

  4. should: household-level outputs now count forgone realisations as lost income. Same household, same reform: household_net_income falls by £41,370 static and by £71,765 at the default. Meanwhile the government gains £16,502 instead of £41,370. The response lowers capital_gains, which feeds household_market_income, so decile charts, winners and losers, and per-household impacts will show a CGT rise costing households far more with behaviour than without. Gains not realised are deferred, not lost. This was already true for anyone who set an elasticity, but the default makes it the published number. docs/book/usage/simulations.md:362 redirects the revenue identity to gov_balance, which covers revenue only. Either decide that distributional outputs use gains before the response, or document it prominently before the app step. Related: non-CGT reforms move too. With earnings of £30k and gains of £20k, cutting the basic rate band to £20,000 or the personal allowance to £5,000 lowers realised gains by £1,463 (7.3%), because more of the gains fall in the higher CGT band. That's correct, but changelog.d/2396.changed.md only says CGT reforms are no longer static, so published income tax costings will shift without notice.

  5. nit: band-specific reforms give no response above the band. With the same household and only gov.hmrc.cgt.higher_rate raised to 45%, CGT rises from £47,280 to £65,642 but the response is exactly zero, because the marginal pound of gains is taxed at the unchanged additional rate. That's the right result for a marginal retention-rate design, but one line in "When there is no response" (capital-gains-responses.md:84) would save someone a bug report.

  6. nit: the base level. The response scales gains proportionally, so it can't fix the base-year level. On the current enhanced FRS, gains and CGT sit at roughly 45% of HMRC's 2024-25 outturn (my comment on #2274). So any pound comparison with the OBR elasticity setting (badr_elasticity.yaml:1) mixes the elasticity gap with the level gap. Worth one clause in the docs comparison. Also, carried interest moved to income tax from April 2026, so the carried interest schedule's share of the rate matters only for earlier years.

Points I checked that are fine

  • The baseline-equality shortcut doesn't skip non-CGT rules that move the CGT rate. same_rules (utils/rule_comparison.py:142) compares the whole parameter tree and every variable, leaving out only the response settings. So an income tax band or personal allowance reform is measured (the £1,463 above), and an elasticity-only reform is correctly skipped with a zero response. Anything it can't compare counts as different, so the only risk is cost. Input-only scenario changes get no response, and the docs (capital-gains-responses.md:95) say so.
  • The MTR pay-rise hold (marginal_tax_rate.py:34, marginal_tax_rate_wrt_employer_cost.py:38). Holding the response at the simulation's own values is right: the earnings MTR then includes the higher CGT band on the same gains but not a fresh response.
  • Rate from CGT alone. Outside the CGT folder, capital_gains is read only by household_market_income. That in turn feeds net income, gross income (unused) and the private school imputation, which you note. I found no means test or allowance taper that reads gains, so the narrower definition is equivalent apart from that imputation.
  • Legacy shim and reporter. An mtr_elasticity of -0.7 alone resolves to convention: "mtr" with one CapitalGainsResponseWarning, sources marked auto_resolved, and reverts to retention in years outside the override. to_dict() serialises.
  • Dependence on #2277. This is code-coupled, not just order-coupled: utils/capital_gains.py:14 imports measurement_branch, which exists only in #2277. Decoupling would mean moving that helper here, so keeping the stack is simpler. The merge order (PolicyEngine/policyengine.py#620, then #2277, then this) is right.

What I checked

  • Fresh worktree at c6c77ef32 with its own uv venv.
  • pytest on the seven touched test files (non-microsimulation): 171 passed. CGT YAML (tests/policy/baseline/gov/hmrc/capital_gains_tax): 48 passed. These build simulations without a baseline, so the default can't affect them. ruff format --check and ruff check: clean. CI green at this head.
  • Reverting source and re-running the new tests:
    • with the elasticity set back to 0: 20 of 37 fail;
    • with the pay-rise hold removed: 2 fail, both reproducers;
    • with the same-rules shortcut disabled: 3 fail.
  • Household probes for the numbers above, all synthetic. The docs example runs and prints £144,737.
  • Read the CenTax paper's pp. 4 and 32-40.
  • Not checked: the microsimulation tests and the full-dataset runtime and "within 0.001" claims, because I have no dataset credentials here. I also didn't check the OBR costing's own elasticity definition against the source.

🤖 Generated with Claude Code

Maria Juaristi and others added 8 commits October 15, 2026 11:59
From Vahid's review of #2419:

- The elasticity's description and the docs say that CenTax reach 1.0
  from about 1.5 by adjusting for their package's base changes and
  income shifting, and expect a higher elasticity without them (p. 37),
  so for rate changes alone the default overstates what a rate rise
  raises; and that 1.0 is a medium-term value, so first-year scores
  likely understate the response (pp. 34-35).
- A "Large rate rises" section gives the revenue-maximising rate
  1/(1+e) and shows the elasticity can decide the sign of a large rise,
  against the ready reckoner's figures (p. 33).
- "What the response moves": household net income counts gains no
  longer realised as income lost, and income tax band and allowance
  reforms move realised gains too. The changelog says both.
- "When there is no response" covers a reform to a band the last pound
  of gains doesn't fall in, and the OBR comparison notes that pounds mix
  the elasticity gap with any gap in the level of gains.
- An added fragment for capital_gains_response_assumption and
  CapitalGainsResponseWarning, so the release is a conscious minor bump
  rather than the default patch.

Every figure comes from synthetic households.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added test_labour_supply_parameters_are_read.py, whose fixture checks
that the formula path ran by looking for the lsr_measurement branch. This
PR no longer keeps measurement branches, so the fixture now checks that
the formula measured a nonzero response instead, which it also does only
after its early returns. The docs example in dynamics.md is wrapped to
pass ruff format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
marginal_tax_rate, marginal_tax_rate_wrt_employer_cost and
marginal_tax_rate_on_capital_gains step an input in a branch that shares
the simulation's tax-benefit system, and recalculate every formula there.
With the labour supply response now measured against the baseline, the
branch measured it again: at the stepped input, against a baseline without
the step, and with the step undone inside the measurement's own marginal
rate branches. On £40,000 of earnings and £22,000 of gains under a 30%
basic rate with elasticities set, the response inside the gains branch hit
the wage change bound (-£9,921 against -£1,406 on the reform) and the
capital gains marginal rate came out at 502% instead of 24%. With employer
NI price incidence on, the pay rise branches gave 94.2% and 94.9% instead
of 95.5% and 96.1%.

held_behavioural_responses reads the simulation's live responses once, and
hold_behavioural_responses sets them on each branch, generalising the
gains-only hold marginal_tax_rate_on_capital_gains already had. The same
pay-rise problem affected the capital gains response on main (830% and
1,164% against 28% and 48%), which this also fixes. Neutralised responses,
as in measurement branches, are not held.

A test now checks the measurement branch's purge directly: with core
3.33, a branch's held inputs already drop dependent values, so the
existing order tests pass without it, but a branch with no inputs would
still read its simulation's post-response net income.

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

A branch that shares its parent's tax-benefit system changes inputs, not
policy. The Pension Credit branch of the UC non-dependant exemption fixes
the UC housing element at zero, and measured the labour supply response
again for the whole population with that element held on the reform's
side only: for a renting UC family with a £22,000 earner, +£5,743 inside
the branch against -£754 on the reform. Every reform with elasticities
then opened two measurement pairs per period instead of one, which on
300 synthetic households doubled the reform's household_net_income time.

response_source walks up to the nearest ancestor with other rules (the
simulation itself, or a measurement branch), and the response formula
returns that simulation's response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
income_elasticity_lsr and substitution_elasticity_lsr multiply a measured
relative change by their elasticity, so with the elasticity at zero they
are zero. They still measured the change, which opens a measurement pair:
policyengine.py's UK labour supply output asks for both terms on every
simulation, so on current law each produced its zeros from a pair of
branches. With one elasticity set, the other term no longer computes its
relative change (the wage change needs marginal rates in both branches).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2277 (fix-lsr-baseline-2268) now holds every behavioural response in the
branches of the three marginal rate variables, through
held_behavioural_responses and hold_behavioural_responses. This branch
used its own gains-only pair for the same purpose; the conflicts in the
three variables resolve onto #2277's helpers, keeping this branch's
tax-only rate on gains, and the gains-only pair is removed.

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

Copy link
Copy Markdown
Collaborator Author

Thanks @vahid-ahmadi. Every household figure in your review reproduces on the branch. Changes are in 0e6cfaa, followed by merges of main and of the updated #2277.

  1. Release type. Added 2396.added.md for capital_gains_response_assumption and CapitalGainsResponseWarning, so this releases as a minor version. The body has a new "Release" section that says so. If minor pins downstream shouldn't pick it up silently, renaming the changed fragment to .breaking makes it a major release, a one-file change. Left as minor for now.

  2. Which way 1.0 errs. I checked this against the paper: pp. 34-37, plus p. 33 for the ready reckoner. elasticity.yaml and the docs now say the following:

    • CenTax reach 1.0 from about 1.5, adjusting for their package's base changes (uplift at death, the emigration charge) and for income shifting.
    • They expect a higher elasticity without those changes, and so lower revenue (p. 37).
    • So for a reform to rates alone, their reasoning points to a higher value, and the default overstates what a rate rise raises.
    • Whether to move to 1.5 is left as a methodology call.
  3. Horizon and sign.

    • The limits now say 1.0 is a medium-term value. The studies behind it find 1.6 at three to five years (A&Z) and 1.8 in the first year for large gainers (L&T), and forestalling isn't modelled, so a first-year score is likely to understate the response.
    • A new "Large rate rises" section gives the revenue-maximising rates at 1/(1+e) and your 45% example at 1.0, 1.5 and 3.6: CGT rises £16,502, rises £6,777, and falls £20,536.
    • It also gives the ready reckoner's figures for a 10pp rise: over £5bn static, and £2bn lost a year by year three. Both are on p. 33; p. 4 has only the £2bn.
    • The changelog says the default can give the opposite sign to official costings for a large rise.
  4. Household outputs. A new "What the response moves" section near the top of the page covers both effects:

    • Net income counts gains no longer realised as income lost, with your £71,765 against £41,370. Those gains are deferred, not lost, and gov_balance or the tax variables give revenue.
    • Income tax band and allowance reforms move realised gains too, with your £1,463 (7.3%).

    The changelog and the simulations guide's revenue note say both. Whether distributional measures should use gains before the response is a methodology decision, and this PR doesn't make it.

  5. Band-specific reforms. "When there is no response" now covers a reform to a band the last pound of gains doesn't fall in, with your higher-rate-only example (£47,280 to £65,642, no response).

  6. The base level. The docs' OBR comparison and badr_elasticity's description now say that a comparison in pounds mixes the gap between the elasticities with any gap in the level of gains and CGT. I didn't quote a level figure. On carried interest: agreed. As far as I can see, the model still charges carried interest gains at 32% after April 2026, which is a separate issue from this PR.

Also since your review:

🤖 Generated with Claude Code

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juaristi22 round 2, at 8502e5c6c. I looked at this PR's own diff (276cfcdeb..8502e5c6c, the compare view against #2277) and at what changed since c6c77ef32.

Verdict: all six round-1 points are fixed. Fine to merge in the planned order once its blockers are done. One pre-existing bug in the CGT band split (point 7) is outside this diff but worth a separate fix, because the default now turns it into a response.

Round-1 points

  1. Release type: fixed. With 2396.added.md, 2396.changed.md and #2277's .fixed.md in changelog.d, .github/bump_version.py's infer_bump returns minor, so 2.132.6 goes to 2.133.0. policyengine.py pins policyengine-uk==2.130.1 exactly, so it won't pick this up silently either way. The "Release" section in the body is clear, so minor is a reasonable call.
  2. Which way 1.0 errs: fixed. elasticity.yaml:1, the docs (capital-gains-responses.md:44-53) and the changelog now say that for a rate-only reform the source points above 1.0, so the default overstates what a rate rise raises. I checked this against the paper: the 1.5 starting point and the p. 37 "surely ... higher" sentence are both there. The default stays at 1.0, with 1.5 left as a methodology call.
  3. Horizon and sign: fixed. The 1.6 at three to five years and the 1.8 first-year figure match the paper. I re-ran the 45% example. CGT changes by +£16,502 at 1.0, +£6,777 at 1.5 and -£20,536 at 3.6, so the "Large rate rises" figures are right. The 1/(1+e) peaks are right too. You're right that p. 33 has both the £2bn and the "in excess of £5bn" figures, and p. 4 only the £2bn. My round-1 page reference was wrong.
  4. Household outputs: fixed (documented). I reproduced the £71,765 against £41,370 drop in net income, and the £1,463 (7.3%) for both the basic-rate-band and personal-allowance cuts. "What the response moves", the simulations note and the changelog all state them. The pre-response question is left open as a methodology decision.
  5. Band-only reforms: fixed. The higher-rate-only example reproduces: £47,280 to £65,642, with a response of exactly 0. But see point 7: that £65,642 depends on a pre-existing band bug.
  6. Base level: fixed. badr_elasticity.yaml:1 and capital-gains-responses.md:191-194 now say that a comparison in pounds mixes the gap between the elasticities with the gap in levels.

New findings

  1. should (pre-existing, not in this diff): the CGT split between the higher and additional rates ignores income already in the higher band. capital_gains_tax.py:71 sets higher_rate_band = higher_rate_limit - basic_rate_limit, and line 172 fills it from gains above the basic rate band. Nothing subtracts the taxable income that already sits above the basic rate limit. So every gainer gets the full higher band (about £87k) for gains, wherever their income ends. Synthetic checks, static (elasticity 0):

    • £200,000 earnings, £50,000 gains, gov.hmrc.cgt.additional_rate at 45%: CGT stays at £11,280. With higher_rate at 45% instead, it rises to £21,150. An additional-rate taxpayer's gains are all charged at the higher rate.
    • £120,000 earnings, £30,000 gains: all of the gains are charged at the higher rate, although about £19k of them sits above the £125,140 limit.

    With equal higher and additional rates (current law), this changes nothing. The docstring (lines 35-37) says the split exists only so that reforms can set an additional rate, and for those reforms it charges the wrong rate. Under this PR's default it also drives the response. For the £120k case with only the higher rate at 45%, realised gains fall by £8,289 at 1.0, where the corrected split would give no response at the margin. An additional-rate-only reform gives no response to anyone whose gains above the basic rate band are under about £87k, however high their income. The fix is to fill from max_(higher_rate_limit - max_(taxable_income, basic_rate_limit), 0), but I'd do that in its own PR, not here. Once it's fixed, the £65,642 in capital-gains-responses.md:149 becomes about £55,200. I make that £47,280 plus £37,710 times 21%, but please check it rather than take my arithmetic. The "no response" statement still holds.

  2. nit: the £5bn figure belongs to the paper, not the ready reckoner. capital-gains-responses.md:105-108 says the ready reckoner had a 10pp rise "raising over £5bn before behaviour". On p. 33 the ready reckoner gives the £2bn cost. The "in excess of £5bn" static figure is the authors' own remark ("Given the large static revenue..."). My round-1 wording ran the two together, so this one is partly mine. I'd write "HMRC's ready reckoner had a 10pp rise in the top rates losing £2bn a year by the third year, against a static gain the authors put at over £5bn".

What I checked

  • Fresh worktree at 8502e5c6c with its own uv venv.
  • pytest on the touched test files (default response, CGT responses, CGT components, rule comparison, fiscal-year parameters, the marginal-rate hold, measurement branches, labour supply baseline, code-health cached arrays): 180 passed. ruff format --check and ruff check: clean. CI is green at this head.
  • Ran infer_bump on the branch's changelog.d.
  • Re-ran every synthetic figure quoted in the docs, changelog and body: they all reproduce. Re-read pp. 4 and 33-37 of the paper.
  • Probed the CGT band split as above. All households are synthetic.
  • Not checked:
    • the full-dataset runtime and the policyengine.py#574 gate results, as I have no dataset credentials here;
    • #2277's own changes, which are reviewed separately.

🤖 Generated with Claude Code

Maria Juaristi and others added 3 commits October 15, 2026 14:27
… a fragment

From Vahid's review of #2277:

- dynamics.md says simulation.baseline is current law whatever policy a
  comparison treats as its baseline, so with elasticities on the reform
  side only, the reform's response also answers the baseline policy (a
  £30,000 earner in 2026: -£187 for a 22% basic rate, -£193 with a £13,000
  personal allowance too, against the -£6 the allowance adds), and that
  setting the elasticities on both sides isolates the reform's own.
- It also says only earnings and gains are held: other inputs a scenario
  changes keep each simulation's value and count as policy in the
  relative income change.
- An added fragment for the new measurement_branches helpers, so the
  release is a minor one: anyone who sets the labour supply elasticities
  sees outputs move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter the response

From Vahid's review of #2277: since response_source gives a branch that
shares its parent's rules the parent's labour supply response, the
marginal rate tests' labour supply cases fail only with both that and the
hold removed. A new test uses a branch with its own copy of the rules,
emptied as the marginal rate variables empty theirs: it measures the
response again unless the responses are held, and fails without the hold.
The marginal rate tests' docstring says which mechanism each case covers.

The zero-elasticity test now calculates the response before the terms, as
the response formula does: policyengine-core 3.32, the floor, refuses a
term requested before the response. It still fails without the shortcut.
The measurement, hold, order and purge tests pass on core 3.32.27 as well
as 3.33.2. On 3.32.27, disabling the purge fails the four order and
recalculation tests and the direct purge test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in c156a1f and 03ec1e8 from fix-lsr-baseline-2268: the docs on
what the labour supply response is measured against, the added fragment
for the measurement_branches helpers, and the test that isolates the
behavioural response hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 marked this pull request as ready for review October 15, 2026 15:36
@juaristi22
juaristi22 added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 6a3ac01 Oct 15, 2026
17 of 18 checks passed
@juaristi22
juaristi22 deleted the cgt-retention-default-2396 branch October 15, 2026 17:19
MaxGhenis added a commit that referenced this pull request Oct 15, 2026
…ards

Brings in #2277, #2391, #2402, #2404, #2405, #2407, #2411, #2413, #2415,
#2418, #2419 and #2420. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 15, 2026
…2277) into uc-hb-split-shared-cap-lha-rules

No conflicts; no benefit cap, LHA, UC or HB file changed on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Make CenTax’s retention-rate elasticity of 1.0 the default for UK CGT modelling

3 participants