Repository navigation
Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602
Description
Requested behaviour
We have decided to adopt the central elasticity proposed in uk-cgt-reform#10: 1.0 with respect to the net-of-tax retention rate, with CenTax’s 0.5 and 2.0 available as sensitivities. Please make this the canonical default for UK CGT modelling through policyengine.py, so analysts receive the agreed assumption without copying dashboard-specific reform parameters.
For pre-response gains G₀ and baseline/reform marginal rates t₀ and t₁, the response is:
G₁ = G₀ × ((1 − t₁) / (1 − t₀)) ** e
e = 1.0 by default
This is a change in both convention and central assumption. The old marginal-tax-rate value −0.7 is not an interchangeable representation of retention elasticity 1.0 across finite rate changes. For example, moving from 24% to 45% gives a gains multiplier of 0.55/0.76 ≈ 0.723684 at retention elasticity 1.0, rather than (0.45/0.24)^−0.7 ≈ 0.644020.
Current implementation
Inspected on 13 October 2026: policyengine.py main at 48ba01a5, bundle 6.3.3, pins policyengine-uk==2.126.12. In that UK release, retention elasticity and MTR elasticity both default to zero. The wrapper passes the caller’s reform/dynamic values to the country model, so an ordinary CGT reform currently runs statically unless the caller supplies an elasticity.
The retention response already exists in the UK model; this does not require a new response formula. The distinction also needs correcting in the published behavioural-response methodology, which still describes −0.7 as PE’s default UK CGT assumption.
Implementation scope
The companion upstream issue PolicyEngine/policyengine-uk#2396 owns the parameter change, country-model override compatibility, response tests and model release. This .py issue owns adoption of that release and validation of the analysis interface.
-
Consume one model-owned default. Depend on the upstream release setting
gov.simulation.capital_gains_responses.elasticityto 1.0, withmtr_elasticity=0andseparate_badr_elasticity=false. Under the CenTax central case, qualifying BADR/Investors’ Relief gains take the same 1.0 elasticity as other gains, matching [1] - Add docstrings and type hints to all functions #10. Keep the response calculation and default in the country model; do not duplicate them in wrapper-specific code. -
Roll the released model into
.pyand verify every UK entry point. Update the UK dependency pin, lockfile and bundle/provenance metadata, with the required compatibility checks. Cover populationSimulation(..., policy=..., dynamic=...),pe.uk.managed_microsimulation(reform=...), andpe.uk.calculate_household(reform=...). The population runner and managed helper are in UK model.py; the household route is in household.py. -
Preserve explicit choices and define precedence. Support static calculations explicitly (both main conventions zero and the separate BADR switch off), retention sensitivities 0.5/2.0, custom retention values, and the OBR elasticity comparison (3.6 main, 1.4 qualifying BADR gains with the separate switch on). Explicit caller values must override defaults, including dated values and the existing policy/dynamic precedence. An omitted assumption must select the new central case; it must not silently select static behaviour.
-
Integrate the upstream legacy-MTR handling. The UK response helper rejects simultaneously nonzero retention and MTR elasticities. The companion UK issue must resolve the interaction between explicit MTR calls and the new retention default. Verify that each
.pyentry point preserves the released model’s behaviour and exposes its migration guidance. Do not reinterpret −0.7 as retention 1.0: the dashboard’s migration of named cases is not a numerical conversion for Python callers. -
Make the assumption reproducible and update the methodology. Record the resolved convention, main/BADR elasticities and source/default-versus-override status in serialisable result/run metadata. Ensure caches and saved outputs distinguish model releases and behavioural assumptions. Document the intentional change from static-by-default Python reform calculations, how to reproduce the previous static result, and how to request the legacy MTR case. Coordinate a correction to the published −0.7 methodology page.
Interpretation and related work
CenTax’s 2024 report, pp. 36–37, uses 1.0 for its medium-term reform package and considers 0.5–2.0. The central value reflects base reforms, including removal of uplift at death and a departure charge, and an adjustment for cross-base income shifting. Document 1.0 as PE’s adopted modelling default; do not describe it as an unconditional estimate for every rates-only reform.
Income shifting is separate work tracked in policyengine-uk#1982 and #1805, with dashboard approaches compared in uk-cgt-reform#11. Do not automatically add an income-tax recapture on top of the CenTax central assumption, which already incorporates a cross-base adjustment. Likewise, label 3.6/1.4 as an OBR elasticity comparison rather than a full replication of the OBR costing, which includes additional tax effects. Adopting the elasticity alone does not make PE’s revenue estimates identical to CenTax’s.
Acceptance criteria
- An otherwise identical UK CGT reform with no elasticity override matches an explicit retention-1.0 run through all three
.pyentry points; the central case applies 1.0 to BADR gains too. - Wrapper integration tests cover the hand-calculated central factor, explicit static behaviour, sensitivities and the OBR main/BADR split. The upstream issue owns the detailed response-formula, rate-cut and model-state regression suite.
- Tests cover legacy MTR calls, conflicting explicit conventions, policy/dynamic precedence and dated overrides.
- Unreformed baseline gains and liabilities remain unchanged through the wrapper; validate bundle compatibility and check the runtime impact on the analysis routes.
- Resolved assumptions survive JSON/run-record round trips and cannot reuse results from another assumption or model version.
- The released UK pin/bundle, changelog, examples and methodology describe the same default and migration path.
- A dashboard-versus-Python parity check uses the same model, data, year and reform specification. Different datasets or package versions must not be treated as an elasticity discrepancy.
Activity
juaristi22 commentedon Oct 14, 2026
Implementation: PRs and merge order
This checklist tracks the PRs for this issue and PolicyEngine/policyengine-uk#2396. It's edited as they open and merge. Each PR carries the same table and says what it depends on.
vahid-ahmadi commentedon Oct 15, 2026
@juaristi22
The plan's order is right. Five things need fixing or an owner before step 2 merges: the data version, the release number, the methodology wording, how users hear about the change, and the window between the release and step 5.
I've posted separate comments on #620 and #621. This one is about the checklist.
Ordering
must, step 4: the data version. The checklist says the claim goes into the policyengine-uk-data 1.58.0 manifest. Main was certified on 14 Oct against 1.58.1, with policyengine-uk 2.130.1 (
manifest.json: HF revision89be6b73,compatibility_basisfrom a publisher claim, data built with 2.122.2). The claim should target whatever release main certifies when step 5 opens, and the checklist shouldn't fix a number now. It's also worth checking whether 1.58.1's existingcompatible_model_packagesspecifier already covers the new release. If it's a range, no new claim is needed. If it's exact, one is.should, step 4: who owns the claim, and on what evidence.
validate_release_manifesttreats a publisher claim as an assertion "made good only by this repo's test suite passing on the pinned pair". So the claim itself isn't a check. For this release a claim is reasonable: an unreformed run should be unchanged, because the response is zero without a reform. But "should be unchanged" is the thing to show. I'd attach one baseline run of the new release on the target data to the claim: headline receipts,capital_gainsandcapital_gains_tax, equal to the certified pair within rounding. I'd also name who publishes the claim and records the HF revision on this checklist. Right now step 4 has no owner.should, step 2: the release number. The #2419 branch reports 2.131.0. PyPI is at 2.132.5 and main pins 2.130.1. So the release from step 2 will be at least 2.133, and step 5's pin move brings every change from 2.130.1 to there, not just the elasticity. The parity check and snapshot refresh in step 5 need to separate the two. One way is to certify the release before the default merges, then the one after it, and diff the two. Otherwise every moved number gets put down to the elasticity.
should, steps 0 and 2: if Measure labour supply responses against the reform's baseline policyengine-uk#2277 stalls. #2419's branch already contains #2277's commits, so merging #2419 first would land them without their own review. #2419 says the "stable across recalculation and order" tests depend on #2277's measurement branches. Is it only those branches, or the labour supply baseline fix as well? If it's only the branches, splitting them into a small PR that #2419 can stack on would decouple the CGT default from the rest of #2277. I haven't checked how separable they are. It would also stop the release from step 2 carrying a second behavioural change (the labour supply baseline fix,
changelog.d/fix-lsr-baseline-2268.fixed.mdis in #2419's diff), which users would otherwise see at the same moment and couldn't tell apart.should, the window between steps 2 and 5. Merging #2419 publishes at once. From then on:
So for a while, the Python package and anything floating on policyengine-uk give different CGT costings for the same reform. Could the checklist give step 5 a target date relative to step 2, and list the consumers that don't pin? I couldn't confirm which of policyengine-api, api-v2, sim-api and the dashboards pin it.
The −0.7 and the 1.0
must, step 6: the parameters aren't the same kind, and the current page misdescribes the old one. In the model,
mtr_elasticityis a constant elasticity with respect to the tax rate.relative_capital_gains_mtr_changeislog(t1) − log(t0), so gains scale by (t1/t0)^e. The new parameter is an elasticity with respect to the net-of-tax (retention) rate, so gains scale by ((1−t1)/(1−t0))^e. The methodology page instead calls −0.7 "the percent change in capital gains with respect to a 1p change in the capital gains tax rate". That's a semi-elasticity, which matches neither. The rewrite should state the formula for 1.0, and say plainly that −0.7 and 1.0 aren't conversions of each other. The issue's 24% to 45% example (0.7237 against 0.6440) does that well.should, the page also says −0.7 is PE's UK default. In policyengine-uk 2.126.12 both elasticities default to 0. So, as Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 says, Python reform calculations have been static by default. Before the page changes, it's worth confirming what the web app actually applied. If it passed −0.7, its published CGT numbers used the MTR convention. If not, they were static. The note on the page should say which, because it decides what users are comparing the new numbers against.
Telling users
should, communication beyond the methodology page. The default changes published numbers for any CGT reform, and for other reforms that move a gainer's marginal rate. That includes income tax band and allowance changes, because #2419 measures every reformed simulation. Step 6 corrects one page. I'd add these to the plan:
cgt-autumn-budget.mdsays realisations respond "with an elasticity of −0.7 with respect to the marginal tax rate". My suggestion is to leave the numbers and add a dated note, not rewrite them. That one is an editorial call.should, step 6 ordering. Step 6 merges after the policyengine.py release and closes Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602. But the web app only applies the new default once policyengine-api and api-v2 bump their pins, and the checklist leaves that open. If the page goes live first, it describes a default the web app doesn't use yet. Either tie step 6 to the API bumps, or have the page say from which version each surface applies 1.0. Those bumps are outside this repo, but they need an owner and a date on the checklist. Otherwise Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 closes while the web app is still static.
Reproducibility
should, earlier published results. After step 5 and Record the capital gains realisation response each UK run applies #621, a saved UK output from before the change is recalculated under the new default the next time it's loaded. Nothing in the plan says how to reproduce a number published before the change. I'd add it to step 5's migration docs:
Any Budget dashboard or scorecard that pins policyengine.py or policyengine-uk keeps its old numbers until it bumps. Its owner should know the bump changes CGT lines on purpose, so the change isn't taken for a regression or reported in the same run as other changes.
nit, the acceptance criteria. "Unreformed baseline gains and liabilities remain unchanged through the wrapper" is the claim that justifies step 4. It would be worth ticking with a named run, not left to the step 5 test suite.
What I checked
c6c77ef3). Results are on those PRs.provenance/certification.py, for the data version and the claim rule.relative_capital_gains_mtr_change, for the MTR convention.behavioural-responses.mdandcgt-autumn-budget.md, read through code search.🤖 Generated with Claude Code
Report the default CGT response without gating, and check every setting