Skip to content

Adopt policyengine-uk's CGT retention-rate default - #633

Open
juaristi22 wants to merge 1 commit into
mainfrom
cgt-602-adopt-retention-default
Open

juaristi22 wants to merge 1 commit into
mainfrom
cgt-602-adopt-retention-default

Conversation

@juaristi22

Copy link
Copy Markdown
Contributor

Part of #602.

Blocked by: the policyengine-uk release from PolicyEngine/policyengine-uk#2419 (in the merge queue; expected 2.134.0), and the publisher's compatibility claim for that release in policyengine-uk-data 1.58.1's release manifest. certify-data refuses an unclaimed model version. This PR stays a draft until both exist.

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

Step PR What Status
0 PolicyEngine/policyengine-uk#2277 Measurement branches merged, policyengine-uk 2.133.0
1 #620 Budget contract: static pin, response and default cases; release-lock fix merged, policyengine.py 6.5.0
2 PolicyEngine/policyengine-uk#2419 Retention-rate elasticity of 1.0 as the model default in the merge queue
3 #621 Record the response each UK run applies merged, policyengine.py 6.5.0
4 — Step 2's release, and its compatibility claim for policyengine-uk-data 1.58.1 after step 2
5 this PR Adopt the release: certify, pin, gating default cases, default tests, docs draft
6 not opened yet policyengine-app-v2 methodology page after this PR is released

Tracking checklist: checklist on #602

In this PR now

Everything adopting the default needs, apart from the pin. It passes on the current pin (policyengine-uk 2.130.1).

  • Contract 1.2.0. It adds cgt_default_response_relief_gains: 45% higher and additional rates for £50,000 of gains, £20,000 of them qualifying for Business Asset Disposal Relief. It checks both the realised gains and the relief gains' own response at the default elasticity of 1, against the share-weighted rate. Like Hold CGT realisations fixed in the Budget contract's rate-blending case, and check the response #620's cgt_default_response, its checks wait on policyengine-uk#2396. The golden rows add 48 rows for it and change none.

  • tests/test_uk_cgt_default.py.

    • No elasticity set gives the same result as an explicit retention-rate elasticity of 1. This holds through the population, household and managed routes, relief gains included.
    • An mtr_elasticity set alone applies on its own, with one CapitalGainsResponseWarning, and the record says so.
    • An unreformed run is unchanged by the default.
    • The pinned defaults are the central case.

    The module skips until the installed policyengine-uk has the default.

  • Docs.

    • The reforms guide gets a section on the response: who is affected, the static settings on each route, the sensitivities, the MTR convention, precedence, setting the same dynamic on both sides of a comparison, and the assumption's limits.
    • A new methodology page explains how the response is measured and when it is zero. It carries the caveats from policyengine-uk's review: the default is low for rate changes alone, it's a medium-term value, large rate rises can flip the sign, and household results count deferred gains as income lost.
    • The dynamic field's description says None keeps the model's defaults.
  • Changelog: 602-cgt-default.changed.md.

The pin commit, once step 4 is done

Following #617's recipe:

  1. scripts/bundle.py certify-data --country uk --data-producer populace --manifest-uri hf://model/policyengine/policyengine-uk-data-private@<claim commit>/releases/1.58.1/release_manifest.json --model-version <X>.
  2. Regenerate the bundle manifest and both TRO sidecars (bundle.py generate --include-tros), and check them with bundle.py check --published-spm --include-tros --strict-tros.
  3. Update the three policyengine-uk pins and uv.lock (uv 0.12.14, --upgrade-package policyengine-uk only), plus the test constants that pin the certified pair.
  4. Make the two default cases gating, run them on every in-process route in the CGT integration test, and remove the skip in test_uk_cgt_default.py.
  5. Fill in {X} (the policyengine-uk version) and {Y} (this release) in the docs and the fragment.
  6. Run make test with the HF token, refresh snapshots, and attribute each drift. The jump from 2.130.1 also brings in policyengine-uk 2.131–2.133: trading-loss relief, and #2277's labour supply measurement.

Evidence, aggregate-only:

  • the contract on the new pair;
  • runtime per route;
  • unreformed runs identical to static;
  • one CGT reform at 0, 0.5, 1 and 2 and the OBR comparison;
  • an income tax threshold reform's cross-effect;
  • the dashboard-against-Python parity check with uk-cgt-reform.

Checks so far

  • On the current pin: tests/test_uk_budget_reform_contract.py, tests/test_uk_cgt_responses.py and tests/test_uk_cgt_default.py give 142 passed. One module is skipped: the default tests, as intended. ruff format --check . and ruff check . are clean.
  • With Make a retention-rate elasticity of 1 the default CGT realisation response policyengine-uk#2419's code installed locally:
    • tests/test_uk_cgt_default.py gives 6 passed.
    • Every pending check in both default cases holds on all five in-process routes. So making them gating at the pin will pass.

🤖 Generated with Claude Code

Everything that adopting policyengine-uk's default capital gains
realisation response (policyengine-uk#2396) needs apart from the pin:

- Contract 1.2.0 adds cgt_default_response_relief_gains: 45% higher and
  additional rates for £50,000 of gains, £20,000 of them qualifying for
  Business Asset Disposal Relief, whose response is checked at the
  default elasticity of 1. Like cgt_default_response, its checks wait on
  policyengine-uk#2396 until the pin moves. The golden rows add 48 rows
  for it and change none.
- tests/test_uk_cgt_default.py checks the default through the
  population, household and managed routes, an MTR elasticity set alone,
  and that an unreformed run is unchanged. It skips until the installed
  policyengine-uk has the default.
- The reforms guide gains a section on the response, and a methodology
  page explains it, with the caveats from policyengine-uk's review: low
  for rate changes alone, a medium-term value, large rate rises, and
  household results counting deferred gains as income lost.
- The dynamic field's description says None keeps the model's defaults.

With policyengine-uk#2419's code installed, the default tests pass and
every pending check in both default cases holds on all five in-process
routes. The pin commit fills in {X} and {Y}, makes the default cases
gating, and removes the skip.

Part of #602.

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

Copy link
Copy Markdown
Contributor

@juaristi22 thanks, this is my review at 4bd46e2c.

The content before the pin is ready for the pin commit once the body, the fragment and the docs point (4) are fixed. The new case, the default tests and the docs check out, and they pass unskipped on policyengine-uk 2.134.0 and 2.134.3. The pin commit itself needs a look when it lands.

  1. should, PR body, "Blocked by" and the table. #2419 merged on 15 Oct at 17:19 UTC and 2.134.0 went to PyPI at 17:47. 2.134.1, 2.134.2 and 2.134.3 have followed since. So the model release is no longer a blocker, and step 2 isn't "in the merge queue" any more. The claim blocker still stands: main's manifest certifies 1.58.1 on an exact claim (==2.130.1, compatibility_basis: legacy_compatible_model_package), so a new claim is needed. It's worth deciding now which version the claim names. If it's 2.134.3, the pin brings three more changes (point 2).

  2. should, PR body, pin step 6. The list of what the jump from 2.130.1 brings is incomplete. Besides trading-loss relief (2.131.0) and #2277 (2.133.0), the changelog has:

    • 2.131.1: bare-year reform keys on growth rates, indices and household parameters now name the calendar year;
    • 2.131.2: HBAI income after housing costs deducts ground rent and service charges;
    • 2.132.0: scenario sums keep each modifier's timing, and labour_supply_responses.obr_elasticities is removed, so a reform that names it now fails;
    • 2.132.3: the dataset cache no longer couples a macro scenario to other simulations;
    • 2.132.4: negative HBAI net income resets to zero, so bottom-decile figures move;
    • in 2.134.0 itself, besides the default: marginal_tax_rate_on_capital_gains measured from the rise in CGT, and marginal rates on earnings holding gains fixed;
    • if the pin is 2.134.3: the SDLT purchase fix (2.134.1), the LCWRA freeze (2.134.2) and the Scottish 48% threshold (2.134.3).

    Several of these move unreformed results. As suggested on Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602, attributing each drift is easier if the snapshots are refreshed at 2.133.0 (or the last release before 2.134.0) first, then at the pin. Then the default's own effect is the second diff.

  3. should, changelog.d/602-cgt-default.changed.md. "Results without a reform are unchanged" is true of the default. I checked it: test_an_unreformed_run_is_unchanged_by_the_default passes on 2.134.0. But the same release moves unreformed results through the changes in point 2. Read as a release note, the sentence says more than it means. I'd write "The default leaves results without a reform unchanged", and add a line saying the pin also brings policyengine-uk 2.131 to {X}, with a pointer to its changelog.

  4. should, docs/methodology/uk-capital-gains-responses.md:68-70 and docs/reforms.md:154, and Stack gains on taxable income when splitting the higher and additional CGT bands policyengine-uk#2433, which is still open. The page says a change to one band's rate leaves alone someone "whose last pound of gains falls in another band". That holds for the model's bands, not the law's. On 2.134.0, someone with £200,000 of earnings and £50,000 of gains (synthetic) gets these results:

    • higher rate only at 45%: gains fall to £36,184 and CGT is £14,933;
    • additional rate only at 45%: no response, and CGT stays at £11,280.

    In law their gains all sit in the additional band, so it should be the other way round. The guide's static-costing example is a higher-rate-only reform, which is exactly the affected shape. I'd set both rates there, as the contract cases do. I'd also either add a one-line caveat that points to #2433, or land #2433 before the pin. No figure in these docs depends on #2433:

    • the 0.7237 and 0.6440 are pure rate arithmetic;
    • the new case's £80,000 earner stays under £125,140 with or without the fix;
    • the case sets both rates anyway.
  5. nit, budget_reform_contract.py:925-964. The relief case checks gains and the relief response but not change:capital_gains_tax, so it doesn't pin the revenue effect for mixed gains at the default. The parameter settings are covered by cgt_default_response, so that's fine. A tax check would also cover which schedule the annual exempt amount comes off.

  6. nit, docs/reforms.md:44, which this PR doesn't touch. The reason given for rejecting bare years ("policyengine-uk reads them as fiscal year 2027 only") stops holding for growth rates, indices and household parameters from 2.131.1. The wrapper still rejects them, so behaviour is fine. Only the reason needs updating at the pin.

  7. nit, tests/test_uk_cgt_responses.py:62, which this PR doesn't touch but whose MIXED_RATIO it now uses. The comment reads "20% of 50% gains qualify". The fixture is £20,000 of £50,000, so 40%, which is what the formula on line 64 uses.

What looks right

  • The new case's oracle. At the 2027 parameters, t0 = 0.4 × 18% + 0.6 × 24% = 21.6% and t1 = 0.4 × 18% + 0.6 × 45% = 34.2%. The ratio is 0.658 / 0.784 = 0.83929, so gains are £41,964.29 and the relief response is −£3,214.29. On 2.134.0 every in-process route gives £41,964.29 and −£3,214.29, all within tolerance. The case reads the relief rate for 2027, which is 18% (from 6 April 2026, Finance Act 2025 s. 8). At 14% the gains would be £42,125, so the check tells the two apart. It also tells apart a model that ignores the relief share (£36,184) and one using the separate 1.4.
  • policyengine-uk's response does share-weight across schedules (marginal_tax_rate_on_capital_gains adds the step to capital_gains), and capital_gains_behavioural_response includes the relief part. So the case's two checks are consistent with each other.
  • Golden rows: 1,583 to 1,631, with 48 added (all cgt_default_response_relief_gains), none removed or changed, and the old order kept.
  • The skip guard. It keys on capital_gains_response_assumption, which arrived in the same release as the default. A release with the function but another default fails test_the_pinned_model_defaults_to_the_central_case rather than skipping. Once the pin is at 2.134.0 or later, the module can't skip silently even if the guard stays.
  • Precedence. dynamic=None makes build_reform_dict return None, so nothing reaches the model and its defaults apply. The record shows every source as default. An mtr_elasticity set alone gives one CapitalGainsResponseWarning and the factor (0.45/0.24)^−0.7.
  • The docs match policyengine-uk 2.134.0's parameter descriptions:
    • 1.0 is CenTax's medium-term value, with 0.5 and 2.0 as the sensitivities;
    • for rate changes alone the default overstates revenue;
    • a first-year score understates the response;
    • the peaks at 1/(1+e) are 50%, 40%, 33% and 22%;
    • the OBR's 3.6 and 1.4 compare elasticities, not the OBR's costing, and a comparison in pounds also mixes in the gap in gain levels;
    • −0.7 is a different convention, and the 0.7237 and 0.6440 are right.
      The caveats read neutrally.
  • The pin recipe matches Certify policyengine-uk 2.130.1 with policyengine-uk-data 1.58.1 #617's, and uv 0.12.14 is what CI pins now. policyengine-uk 2.134.x needs policyengine-core 3.33.0 or later, which is the version the existing claim already covers.

What I checked

  • Fresh worktree at 4bd46e2 and a uv venv on the pin (2.130.1). The contract, CGT responses and CGT default tests give 142 passed, with the default module skipped.
  • Two more venvs, overriding policyengine-uk to 2.134.0 and to 2.134.3. The same three files give 148 passed on each, with the default module unskipped.
  • run_budget_reform_contract on both default cases, all five in-process routes, on 2.134.0 and 2.134.3. Every pending check holds, so flipping them to gating at the pin will pass.
  • The band probe in point 4 (synthetic household, 2.134.0).
  • ruff check and ruff format --check (ruff 0.17.1) are clean. mypy on the contract module reports nothing in the new lines.
  • Not checked: the 1.58.1 manifest on Hugging Face (private, not accessed), so I can't say whether a claim for 2.134.x exists yet. I also didn't run the api.sim_api route, or anything on the real dataset.

🤖 Generated with Claude Code

@juaristi22
juaristi22 marked this pull request as ready for review October 16, 2026 12:37

This branch has not been deployed

No deployments
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.

2 participants