Skip to content

Prepare for policyengine-uk's CGT retention-rate default - #633

Merged
juaristi22 merged 5 commits into
mainfrom
cgt-602-adopt-retention-default
Oct 19, 2026
Merged

juaristi22 merged 5 commits into
mainfrom
cgt-602-adopt-retention-default

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Part of #602.

Merges before the pin, and has none. Since #635, a policyengine-uk release reaches main as a qualified candidate pair. The candidates from 2.134.5 to 2.137.0 (#639–#664) pair each release with uk-data 1.58.2, and every one carries the default. So this PR lands first, and the default goes live when a candidate is promoted. A follow-up then makes the default cases gating. The order is recorded on #602.

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 merged, policyengine-uk 2.134.0
3 #621 Record the response each UK run applies merged, policyengine.py 6.5.0
4 this PR Docs, default tests and the relief-gains case, before the pin ready for review
5 a candidate pair (newest: #664, policyengine-uk 2.137.0 with uk-data 1.58.2) The pin, through the qualification pipeline (#635) awaiting review
6 not opened yet Make the default cases gating, name the release in the docs, results-change note, parity check after step 5
7 not opened yet policyengine-app-v2 methodology page, with a user-facing line with policyengine-api's bump to step 5's release

Tracking checklist: checklist on #602

In this PR

All of it is ready for review now. It holds on the current pin (policyengine-uk 2.130.1) and on the newest candidate's (2.137.0).

  • Contract 2.1.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 the realised gains, the relief gains' own response and the change in CGT, at the default elasticity of 1, against the share-weighted rate. The tax check also fixes which gains the annual exempt amount comes off. Like cgt_default_response, its checks wait on policyengine-uk#2396.

  • 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.

  • The record test in tests/test_uk_cgt_responses.py checks both sides of the pin: no resolved assumption before 2.134.0, and the retention convention at 1.0 from 2.134.0.

  • 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 policyengine-uk's caveats:

      • the default is low for rate changes alone;
      • it's a medium-term value;
      • large rate rises can flip the sign;
      • household results count deferred gains as income lost;
      • the model applies the elasticity as a power to each person's marginal retention rate, unlike CenTax's aggregate elasticity.

      It also notes that until Stack gains on taxable income when splitting the higher and additional CGT bands policyengine-uk#2433 lands, a reform should change the higher and additional rates together.

    • The dynamic field's description says None keeps the model's defaults.

  • Changelog: 602-cgt-default.added.md. Results don't change until policyengine.py pins policyengine-uk 2.134.0 or later.

Why the default cases don't gate yet

A gating default case fails on any model without the default, including main's current pin. policyengine-sim-api's deploy gate also runs the contract's cases on its own model. So they stay pending, reported with whether they hold on every route, until the pin has the default. The candidates' contract gates already run them: #664 reports 19 pending checks, and on 2.137.0 they all hold, as do the relief case's 15.

What the promotion brings (step 5, not this PR)

The default's own effect on unreformed results is nil. On uk-data 1.58.1, none of the aggregates compared moves between 2.133.0 and 2.134.0, for 2025–2027, 2029 or 2030. They cover household net income, taxes, spending and the balance, the main taxes and benefits, and poverty before and after housing costs. The rest of the drift from 2.130.1 is attributed by the candidate's metrics_review and replay gates.

policyengine-uk 2.131.0 to 2.137.0, from its changelog

Changes that move unreformed results:

  • 2.131.2: HBAI income after housing costs deducts ground rent and service charges.
  • 2.132.4: negative HBAI net income resets to zero, so bottom-decile figures move.
  • 2.134.1: property stocks are no longer charged as purchases. The data doesn't yet carry additional-dwelling purchases; Certify UK candidate 2.137.0-1.58.2-837869be0b9d #664's budget_inputs gate flags these, and Impute additional-dwelling and non-residential purchases, calibrated to HMRC's HRAD statistics policyengine-uk-data#556 tracks them.
  • 2.134.2, 2.134.4 and 2.134.6: the LCWRA freeze, LCWRA after 2029-30, and the Universal Credit Act 2025 standard allowance uplifts.
  • 2.134.3: the Scottish 48% threshold.
  • 2.134.5, 2.134.8 and 2.135.6: pension relief, adjusted net income, and the annual allowance taper.
  • 2.135.0: the benefit cap split per scheme, removing benefit_cap and is_benefit_cap_exempt*.
  • 2.135.1 and 2.136.1: Pension Credit's child amount, and UC capital.
  • 2.135.7: the VAT grossing split, and War Widow's Pension.

Changes to reforms, branches and interfaces:

  • 2.131.0: trading-loss relief.
  • 2.131.1: bare-year reform keys on growth rates, indices and household parameters name the calendar year.
  • 2.132.0: scenario sums keep each modifier's timing, and labour_supply_responses.obr_elasticities is removed.
  • 2.132.3: the dataset cache no longer couples a macro scenario to other simulations.
  • 2.133.0: measurement branches (Measure labour supply responses against the reform's baseline policyengine-uk#2277).
  • 2.134.0: the default; marginal_tax_rate_on_capital_gains measured from the rise in CGT; marginal rates on earnings hold gains fixed.
  • 2.134.7, 2.134.9, 2.135.3 and 2.135.5: dated reforms and modifiers, indexation resuming after a dated reform, and clones applying reforms.
  • 2.136.0 and 2.137.0: the aggregate-impact tool.

Step 6, after the promotion

  1. Make both default cases gating, and run them on every in-process route in the CGT integration test.
  2. Remove the skip guard in test_uk_cgt_default.py, and the pre-2.134.0 branch of the record test.
  3. Name the policyengine.py release in the docs, with a results-change fragment that points to policyengine-uk's changelog.
  4. The dashboard parity check against uk-cgt-reform. It will name the uk-cgt-reform commit, the policyengine-uk version, the outputs compared and the tolerance.
  5. Aggregate-only evidence:
    • 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.

Checks at 1e32c19

  • On the current pin (2.130.1): test_uk_budget_reform_contract.py, test_uk_cgt_responses.py, test_uk_cgt_default.py and test_uk_cgt_response_records.py give 187 passed. The default module is skipped, as intended.
  • On 2.137.0 (policyengine-core 3.33.0): the same files give 193 passed, with the default module unskipped.
  • Default cases: every pending check in both cases holds on all five in-process routes, on 2.134.0, 2.135.0 and 2.137.0.
  • Qualification gate: the full-run fixture tests/fixtures/uk_contract/full_released_uk_2.135.0.json is re-recorded for contract 2.1.0. It covers every route and case, seed 0 and 20 examples, on policyengine-uk 2.135.0 and the certified enhanced_frs_2024_25. Result: 905 pass, 34 pending and 120 unsupported. Against the 2.0.0 run it only adds the relief-gains case's 40 rows. test_uk_candidate_gates.py gives 300 passed.
  • Golden rows: main's 1,693 rows become 1,751. All 58 are added (48 for the case, 10 for its tax check); none are removed or changed, and the old order is kept.
  • Lint: ruff format --check . and ruff check . are clean.

🤖 Generated with Claude Code

@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
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 Round 2 review at 4bd46e2c.

There have been no commits or replies since round 1 (16 Oct), so all seven round 1 points are still open: (1) blockers in the body, (2) the list of drift from 2.130.1, (3) the changelog's "unchanged" sentence, (4) the band wording, with PolicyEngine/policyengine-uk#2433 still open, (5) the relief case's tax check, (6) the bare-year reason, and (7) the MIXED_RATIO comment. Everything below is new.

Blocking

  1. The branch conflicts with main. It conflicts in budget_reform_contract.py and tests/fixtures/uk_contract/evaluate_case_rows.json. Main's contract is 2.0.0 (Check statutory resumption after a freeze: Budget reform contract 2.0.0 (cos d1344) #627), but this branch sets CONTRACT_VERSION = "1.2.0" (budget_reform_contract.py:116), and the changelog fragment says "(1.2.0)". Rebase, give the new case a version above 2.0.0, and rebuild the golden rows on top of main's.

  2. The pin route has changed. Pin step 1 runs certify-data against the claim in uk-data 1.58.1's manifest. Main now promotes through the qualification pipeline (Turn on automatic UK promotion under d1344 #635). The candidate PRs record an exact qualified pair on uk-data 1.58.2. For example, Certify UK candidate 2.137.0-1.58.2-837869be0b9d #664 pairs pe-uk 2.137.0 with 1.58.2, and its verdict is needs_review. So "the publisher's compatibility claim in 1.58.1" no longer blocks anything. Rewrite pin steps 1–3 around a candidate PR, and say which one.

  3. The order against the candidate PRs. Every open candidate from 2.134.5 to 2.137.0 (Certify UK candidate 2.134.5-1.58.2-996a156cc6a2 #639–Certify UK candidate 2.137.0-1.58.2-837869be0b9d #664) carries the 1.0 default. On main, cgt_default_response is still pending, and Certify UK candidate 2.137.0-1.58.2-837869be0b9d #664's contract gate passes with "19 pending". So merging any candidate ships the default before this PR's docs, its default tests and its "results change" note, and with the default case not gating. This isn't urgent: the candidates' Test jobs fail on certification-evidence tests, and promotion is a reviewed merge until d1344 is ruled. Decide one of two orders, and write it on Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602:

    • this PR lands first, without the pin, and the pin goes in the candidate PR;
    • or this PR is the one that takes the candidate's pair.

Should-fix

  1. The PR body. It still says "This PR stays a draft until both exist", but the PR was marked ready on 16 Oct with no new commit. Either put it back to draft, or say what's ready to review now.

  2. The drift to attribute is now 2.130.1 → 2.137.0, not 2.134.x. The releases since round 1 add these changes:

    • 2.135.0: the benefit cap split per scheme, and the removal of benefit_cap and is_benefit_cap_exempt*;
    • 2.135.7: the VAT grossing split, so VAT now matches receipts instead of overshooting by 24%;
    • 2.134.5, 2.134.8 and 2.135.6: pension relief, adjusted net income, and the annual allowance taper;
    • 2.134.4: LCWRA after 2029-30;
    • 2.134.9: indexation resuming after a dated reform;
    • 2.135.1 and 2.136.1: Pension Credit's child amount, and UC capital.

    None of them touches CGT, but the VAT and benefit cap changes move headline unreformed numbers. pe-uk 2.136.0's python -m policyengine_uk.utils.aggregate_impact compares base with head on the certified data. Running it for 2.133.x against 2.134.0 isolates the default. Running it for 2.134.0 against the pin gives the rest.

  3. The web app stops being static. policyengine-api/pyproject.toml:50 pins policyengine[models]==6.5.0, and I found no CGT elasticity or dynamic override in policyengine_api/. So when the API takes the new pin, UK CGT reforms in the app realise gains at e = 1. Meanwhile app-v2's behavioural-responses.md still says the "web app assumes no behavioural responses" and gives −0.7 as the default. Step 6 is planned for "after this PR is released", which leaves that page wrong in the meantime. Land step 6 with the API bump, and add a user-facing line. Can you confirm the app passes nothing?

  4. The functional form, in docs/methodology/uk-capital-gains-responses.md:12-20,33-36 and docs/reforms.md:120. policyengine-uk applies a constant-elasticity power to each person's marginal retention rate. CenTax apply the elasticity linearly to an aggregate effective retention rate. These differ in two ways:

    • the form: the power and the linear form agree at e = 1, but not at the 0.5 and 2.0 sensitivities the docs offer;
    • the base: a per-person marginal rate differs from an aggregate effective rate at any e.

    Add a sentence saying so, so that "CenTax's 1.0" isn't read as reproducing their calculation.

  5. tests/test_uk_cgt_responses.py:267. if record["resolved"] is not None: is vacuous on 2.130.1. The only other test of resolved monkeypatches resolve (tests/test_uk_cgt_response_records.py:133). Add "make this unconditional" to pin step 4.

Questions

  1. Saved outputs across data releases. saved_record_problem (capital_gains_responses.py) checks the schema, the pe-uk version and source, and the settings digest. It doesn't check the uk-data release or the dataset hash. The year files are named {dataset_stem}_year_{year}.h5 (uk/datasets.py:318), with no release in the name either. The pin moves the data from 1.58.1 to 1.58.2, so a caller with an explicit id could reuse outputs across the two releases. Does this PR add the data identity, or should a follow-up be filed?

  2. The dashboard parity check. It isn't in the PR yet. When it lands, please name the uk-cgt-reform commit, the pe-uk version it ran, which outputs you compared, and the tolerance. Without those it doesn't show parity.

What I checked: a fresh clone at 4bd46e2c, and a uv venv with pe-uk overridden to 2.137.0 (core 3.33.0).

  • test_uk_cgt_default.py, test_uk_cgt_responses.py and test_uk_cgt_response_records.py give 54 passed, with the default module unskipped.
  • test_uk_budget_reform_contract.py gives 121 passed.
  • run_budget_reform_contract on both default cases, on every in-process route, with max_examples=1. Every pending check holds: 19 of 19 for cgt_default_response and 10 of 10 for the relief case, with no failures. So the default cases can be made gating at 2.137.0 too.
  • A trial merge with origin/main conflicts in the two files in point 1.

I didn't run anything on the real dataset or Hugging Face, the snapshots, api.sim_api, or the parity check.

Verdict: not ready. Rebase onto contract 2.0.0 and settle the order against the candidate PRs (points 1–3) first. Round 1's points 1–7 are still open.

Maria Juaristi and others added 4 commits October 19, 2026 10:59
Everything policyengine.py needs for policyengine-uk's default capital
gains realisation response (policyengine-uk#2396), apart from the pin,
which arrives with a qualified candidate pair:

- Contract 2.1.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.

Part of #602.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The case checked the realised gains and the relief gains' response but
not the tax, so it pinned neither the revenue effect for mixed gains nor
which gains the annual exempt amount comes off. The new check, pending
like the others, expects 45% on the main-rate gains after the exempt
amount, which comes off them first, plus the relief rate on the
qualifying gains, with both shares scaled by the share-weighted
retention ratio. Had the exempt amount come off the relief gains, the
change would be £630 more. The golden rows add 10 rows for it and change
none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The static-costing example sets both the higher and the additional
  rate, as the contract cases do. Until policyengine-uk#2433 lands, the
  model can put gains in the higher band where the law puts them in the
  additional band, so a reform to one of the two rates can respond where
  it shouldn't. The methodology page says so.
- The bare-year reason says what policyengine-uk does from 2.131.1:
  fiscal year 2027 for most parameters, calendar year 2027 for growth
  rates, the indices built from them and household parameters.
- Both pages say policyengine-uk applies the elasticity as a power to
  each person's marginal retention rate, where CenTax apply one aggregate
  elasticity to the whole base, so adopting their 1.0 reproduces neither
  their estimates nor their sensitivities.
- The docs name policyengine-uk 2.134.0 as the release with the default,
  which policyengine.py has from the first release that pins it.
- The changelog fragment says results don't change until that pin, and
  becomes an added fragment, since this release adds docs, tests and a
  contract case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record test skipped its resolved-assumption asserts whenever the
record had none, so on policyengine-uk 2.130.1 they never ran. It now
checks that the record has no resolved assumption before 2.134.0, and the
retention convention at 1.0 from 2.134.0, so it can't pass vacuously on
either side. Also corrects the MIXED_RATIO comment (40% of the gains
qualify, not 20%) and the skip guard's comment, now that the pin comes
with a candidate pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@juaristi22
juaristi22 force-pushed the cgt-602-adopt-retention-default branch from 4bd46e2 to 0b83d34 Compare October 19, 2026 10:00
@juaristi22 juaristi22 changed the title Adopt policyengine-uk's CGT retention-rate default Prepare for policyengine-uk's CGT retention-rate default Oct 19, 2026
@juaristi22

Copy link
Copy Markdown
Contributor Author

@vahid-ahmadi thanks for both rounds. I've rebased onto main and pushed 0b83d34 in four commits: the rebased one, then one each for the tax check, the docs and the tests. The body is rewritten for the new order.

Round 2

  1. Rebased. The contract is 2.1.0, which adds the relief-gains case on top of 2.0.0, still pending. The golden rows are rebuilt on main's 1,693 rows: 58 added (48 for the case, 10 for its new tax check), none removed or changed, and the old order kept.

  2. and 3. This PR lands first, without the pin. The pin comes from whichever candidate pair is promoted. I've written this on Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 and updated the checklist there. Pin steps 1–3 are gone from the body.

    • After the promotion, a follow-up makes both default cases gating, removes the skip guard, names the release in the docs and adds the results-change note.
    • They can't gate before then: a gating default case fails on any model without the default, which includes main's pin and sim-api's deploy gate.
    • Until then, each candidate's contract gate runs them as pending. On 2.137.0 all 19 hold, and so do the relief case's 15.
  3. The body now says what's ready: all of it, to merge on the current pin.

  4. The drift list is in the body, from both your lists plus 2.134.6 (the UC standard allowance uplifts) and the dated-reform fixes. It belongs to the promotion, whose metrics_review and replay gates attribute it. The default's own share is nil: on uk-data 1.58.1, none of the aggregates I compared moves between 2.133.0 and 2.134.0, in any of 2025–2027, 2029 or 2030. I used my own comparison over the full file rather than aggregate_impact.

  5. Confirmed, by code search on the default branches:

    • policyengine-api and policyengine-api-v2 set no capital_gains_responses parameter and no dynamic;
    • app-v2's code sets none either. Its UK dynamics page lists gov.dynamic.* only, and its one hit is a 2024 blog notebook.

    So the app takes the model default once the API bumps. Agreed on step 6: it's now step 7, landing with the API's bump with a user-facing line, not after this PR's release.

  6. Both pages now say that policyengine-uk applies the elasticity as a power to each person's marginal retention rate, while CenTax apply one aggregate elasticity to the whole base (pp. 32 and 37). So adopting 1.0 reproduces neither their estimates nor their sensitivities. The report doesn't state its functional form, so the pages say what a linear reading would give, without attributing one to CenTax.

  7. The record test now asserts on both sides of the pin: no resolved assumption before 2.134.0, and the retention convention at 1.0 from 2.134.0. So it can't pass vacuously on either side. The follow-up drops the pre-2.134.0 branch.

  8. Neither. Tie UK year files to the source dataset and package versions they were cut from #601 (for Tie UK year files to the source dataset and package versions they were cut from #600: year files tied to the source dataset's revision) and Stamp saved UK outputs with the year file they were calculated from #608 (for Saved UK outputs are reused after their year file is re-cut #606: saved outputs stamped with their year file) cover it.

  9. Will do, in the follow-up. It runs on the promoted pair, and its post will name the uk-cgt-reform commit, the policyengine-uk version, the outputs compared and the tolerance.

Round 1

  1. 2.134.0 is out, and no claim is needed now: the pipeline records an exact qualified pair on 1.58.2. The table says so.
  2. The list is in the body. The two-stage refresh now falls to the promotion. The default's own diff is the empty 2.133.0 → 2.134.0 step above.
  3. The fragment is now 602-cgt-default.added.md.
    • It says "The default leaves results without a reform unchanged", and that results don't change until policyengine.py pins 2.134.0 or later.
    • The line about the pin bringing 2.131 onwards moves to the follow-up, since this release doesn't move the pin.
    • It's an added fragment because this release adds docs, tests and a contract case. The guide's own "unchanged" sentence got the same wording.
  4. The guide's static-costing example sets both rates. The methodology page says to change both together until Stack gains on taxable income when splitting the higher and additional CGT bands policyengine-uk#2433 lands.
  5. Added change:capital_gains_tax:2027, pending. The oracle takes the annual exempt amount off the main-rate gains first and scales both shares by the share-weighted ratio.
    • It gives £2,921.79 on every in-process route, on 2.134.0, 2.135.0 and 2.137.0.
    • Had the exempt amount come off the relief gains, the change would be £630 more, so the check tells the two apart.
  6. The bare-year reason now gives policyengine-uk's reading from 2.131.1: fiscal year 2027 for most parameters, calendar year 2027 for growth rates, the indices built from them and household parameters.
  7. Fixed: 40%.

Checks at 0b83d34

  • On the pin (2.130.1): the contract, CGT responses, CGT default and response-record tests give 187 passed. The default module is skipped, as intended.
  • On 2.137.0 (core 3.33.0): 193 passed, with the default module unskipped.
  • Lint: ruff format --check . and ruff check . are clean.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 Round 3 at 0b83d34e.

Thanks. All seven round 1 points and all ten round 2 points are closed. I checked each one against the diff. There's one new blocker, and I need to correct something I said in round 2.

  1. Blocking: Test fails in tests/test_uk_candidate_gates.py. All four Test jobs (3.11 to 3.14) fail, with 69 failures, all in that file. Every failure comes from the guard at tests/test_uk_candidate_gates.py:841: "the runner declares contract 2.1.0, the fixture 2.0.0: refresh tests/fixtures/uk_contract with a real full run of the runner". full_released_uk_2.135.0.json is the contract 2.0.0 full run from ed1ed2f (17 Oct). Main passes this file: 300 passed locally, and Push to main is green at 4773da7f. So the version bump causes the failures. The four files you ran don't include it.

    • The fix: a real full run of contract 2.1.0 on the certified enhanced_frs_2024_25, committed in place of the 2.0.0 fixture, with tests/fixtures/uk_contract/README.md updated as ed1ed2f did. That needs the private data. If you can't get to it, Max made the last one. The JSON alone won't be enough:
      • RELEASED_COUNTS (tests/test_uk_candidate_gates.py:873-879: 881 pass, 19 pending, 119 unsupported) has to take the new run's counts, which add the relief case's rows;
      • if the run uses another pe-uk than 2.135.0, the file name changes, and so do the 20 or so _fixture("full_released_uk_2.135.0.json") references.
    • It has to land in this PR. Candidates are certified on top of main, so they would fail the same tests and none could promote.
  2. Correction to my round 2, point 3. I wrote that "promotion is a reviewed merge until d1344 is ruled". That was wrong. d1344 was ruled on 15 Oct, Qualify UK model and data candidates automatically before promotion #574 merged on 16 Oct, and Turn on automatic UK promotion under d1344 #635 set promotion.automatic: true in uk.json. The candidate PR body still carries the old sentence (uk_qualification.py:1627-1629), and that's where I took it from. This is how promotion works now:

    • The two-hourly run fast-forwards main to the newest promotable candidate once every check on it passes, as long as main is still the commit the candidate was built on.
    • A pass receipt promotes with no human step.
    • A needs_review receipt promotes only after a named reviewer accepts that exact receipt, through the workflow's accept_pair_id dispatch. That dispatch then re-runs the candidate's failed checks.

    Nothing can promote today:

    Still, if someone accepts Certify UK candidate 2.136.0-1.58.2-7f2b5855131e #663's or Certify UK candidate 2.137.0-1.58.2-837869be0b9d #664's receipt before this PR merges, the default goes live with main's contract at 2.0.0 and without these docs and tests. The order on Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 is right, but whoever accepts receipts needs to know it. Could you or Max add a line to Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 saying that no UK candidate receipt is accepted until Prepare for policyengine-uk's CGT retention-rate default #633 is on main? Once this PR merges, main moves, so every open candidate is requalified on top of it.

  3. Your round 2 point 5, the default's own diff: accepted. In an unreformed run the baseline and reform rules are the same. So the two marginal rates are equal, the model doesn't measure them, and the response is zero. A nil 2.133.0 → 2.134.0 diff on the aggregates is what that predicts. 2.134.0's other changes, to how marginal rates on gains and on earnings are measured, move marginal-rate outputs, not the aggregates you compared. Please attach the comparison script and its output to the follow-up's parity post, so it can be rerun.

What I checked

  • Head and merge. The head is still 0b83d34e. It merges cleanly with main (4773da7f): git merge-tree reports no conflicts, and GitHub reports it mergeable.
  • On the pin (pe-uk 2.130.1, core 3.33.0). The contract, CGT responses, CGT default and response-record tests give 187 passed and 1 skipped (the default module).
  • On pe-uk 2.137.0. The same four files give 193 passed, with the default module unskipped.
  • The gate tests. test_uk_candidate_gates.py gives 69 failed and 231 passed on the branch, and 300 passed on main.
  • Lint. ruff check and ruff format --check (ruff 0.17.1) are clean.
  • Golden rows. They go from 1,693 to 1,751. All 58 new rows are cgt_default_response_relief_gains: 10 for each of its three checks, 20 isolation and unreformed rows, and 8 case-level rows. None are removed or changed, and the old order is kept.
  • The new tax oracle, by hand at the 2027 parameters. Before the reform: (30,000 − 3,000) × 24% + 20,000 × 18% = £10,080. After: (25,178.57 − 3,000) × 45% + 16,785.71 × 18% = £13,001.79. So the oracle gives £2,921.79, the figure you report. Taking the exempt amount off the relief gains instead gives £630 more. I checked the arithmetic only. I didn't rerun run_budget_reform_contract on the case this round.
  • The record test. On the pin it now takes the resolved is None branch, so it no longer passes vacuously.
  • Saved outputs. Tie UK year files to the source dataset and package versions they were cut from #601 and Stamp saved UK outputs with the year file they were calculated from #608 are open and cover the data-identity gap.
  • The API. policyengine-api's main still pins policyengine[models]==6.5.0, and policyengine_api/ sets no capital_gains_responses and no dynamic.
  • Not run: anything on the real dataset, api.sim_api, or the parity check.

Verdict: approve once the 2.1.0 full-run fixture is in (point 1). Then merge it soon, and accept no candidate receipt before it's on main.

🤖 Generated with Claude Code

…ualification gate

The gate tests read a real full run of the runner, and fail rather than
skip when the contract version moves past it, so contract 2.1.0 failed
69 of them against the 2.0.0 run. This run is contract 2.1.0 on
policyengine-uk 2.135.0 with core 3.33.0 and the certified
enhanced_frs_2024_25, every route and case, seed 0 and 20 examples: 905
pass, 34 pending policyengine-uk#2396 (both default cases), 120
unsupported, holds. Against the 2.0.0 run it adds the relief-gains
case's 40 rows and changes no other row. Its properties and population
rows are identical, so it has no count from 1 to 9 and its population
details still agree across routes. RELEASED_COUNTS and the full-run test
take the new counts, and the fixture README says what was run.

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

Copy link
Copy Markdown
Contributor Author

@vahid-ahmadi thanks.

  1. Fixed in 1e32c19. It records contract 2.1.0's full run the way ed1ed2f recorded 2.0.0's:

    • every route and case, seed 0 and max_examples=20;
    • policyengine-uk 2.135.0 with core 3.33.0;
    • population checks on the certified enhanced_frs_2024_25 (SHA-256 056e80fd…).

    The result:

    • Counts: 905 pass, 34 pending policyengine-uk#2396 (both default cases) and 120 unsupported. It holds.
    • Against the 2.0.0 run: it adds the relief-gains case's 40 rows (15 pending, 24 pass, and 1 unsupported on api.sim_api) and changes no other row.
    • Properties and population rows: identical to the 2.0.0 run. So there's no count from 1 to 9, and the population details still agree across routes.
    • Test changes: the file name is the same, so the _fixture(...) references don't move. RELEASED_COUNTS and test_w1_full_run_passes take the new counts, and the fixture README's entry says what was run.
    • Locally: test_uk_candidate_gates.py gives 300 passed on the pin, and ruff is clean.
  2. Thanks for the correction. The Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 checklist now says that no UK candidate receipt is accepted until Prepare for policyengine-uk's CGT retention-rate default #633 is on main. A comment there carries it to Max.

  3. Will do: the comparison script and its output will go with the follow-up's parity post, so the comparison can be rerun.

🤖 Generated with Claude Code

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22 Round 4 at 1e32c198.

Thanks. The round 3 blocker is fixed. The new full_released_uk_2.135.0.json is a real contract 2.1.0 run, and I reproduced its 40 new rows exactly on policyengine-uk 2.135.0 with core 3.33.0. The #602 line is in the checklist too. Nothing in this PR is still open. One thing outside it needs Max today.

  1. Order: Certify UK candidate 2.136.0-1.58.2-7f2b5855131e #663's receipt was accepted after the Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 line. Ledger commit 71172d67 (12:30:42Z, uk-qualification-ledger) records MaxGhenis accepting pair 7f2b5855… (Certify UK candidate 2.136.0-1.58.2-7f2b5855131e #663: policyengine-uk 2.136.0, uk-data 1.58.2) through accept_pair_id. The review file is uk/7f2b5855…/reviews/fd3bd651….json. That was 42 minutes after your Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 comment. The acceptance re-ran Certify UK candidate 2.136.0-1.58.2-7f2b5855131e #663's failed checks: run 39184015698 attempt 2 has all four Test jobs in progress. Attempt 1 failed only on ledger-evidence tests (test_execution_receipt, test_qualified_pair_certification, test_release_manifests), and the acceptance itself may be what those tests were missing. Certify runs are queued from 12:45 and 12:53. If attempt 2 goes green, one of them can fast-forward main to Certify UK candidate 2.136.0-1.58.2-7f2b5855131e #663 before this PR merges, while main is still 4773da7f. The default would then go live with contract 2.0.0 and without these docs and tests. This doesn't block Prepare for policyengine-uk's CGT retention-rate default #633, and the fixture isn't affected: its file name follows the run's pe-uk, not the pin. @MaxGhenis, one of these:

Verdict: Approve, once the Test jobs on 1e32c198 pass. Test (3.14) has passed. Tests 3.11, 3.12 and 3.13 (run 39250888908) are still running. The other nine checks are green.

What I checked

  • Commits since 0b83d34e: one, 1e32c198. It touches only the fixture, its README and tests/test_uk_candidate_gates.py. The runner is unchanged.
  • Fixture provenance: contract_version 2.1.0, policyengine-uk 2.135.0, core 3.33.0, policyengine 6.7.6. Dataset SHA-256 056e80fd…, the same as the 2.0.0 run. Seed 0, max_examples 20, holds: true, keys sorted, no count from 1 to 9. Like the 2.0.0 fixture, the file carries no run id or commit. The README records the runner head instead (0b83d34e on 4773da7f).
  • Diff against the 2.0.0 fixture, row by row: 998 to 1038 case rows. All 40 added rows are cgt_default_response_relief_gains: 24 pass, 15 pending, and 1 unsupported (api.sim_api). None removed, none changed, old order kept. Routes, the 2 property rows and the 19 population rows are identical.
  • Independent rerun: I ran the runner with cases=["cgt_default_response_relief_gains"] on policyengine-uk 2.135.0 and core 3.33.0. Its 40 rows equal the fixture's exactly.
  • Counts: 884 case passes, 2 properties and 19 population rows make 905 pass. 34 pending (19 for the default case, 15 for the relief case) and 120 unsupported. RELEASED_COUNTS (lines 873-879) and test_w1_full_run_passes match them.
  • README: the entry describes the 2.1.0 run and the diff against the 2.0.0 run.
  • Tests (test_uk_candidate_gates, test_uk_budget_reform_contract, test_uk_cgt_responses, test_uk_cgt_default, test_uk_cgt_response_records): 487 passed and 1 skipped on the 2.130.1 pin. The skip is the expected test_uk_cgt_default.py:35 guard, waiting on the default. On 2.137.0, 493 passed.
  • Ruff: ruff format --check and ruff check are clean. CI Lint and Mypy are green.
  • Main: origin/main is still 4773da7f, pinned to policyengine-uk 2.130.1, and is an ancestor of the head. It merges cleanly (MERGEABLE), and nothing has been promoted yet.
  • Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602: the checklist (comment 6177808092, edited 11:48Z) says "No UK candidate receipt is accepted until Prepare for policyengine-uk's CGT retention-rate default #633 is on main", and comment 6266496086 cc's Max.

🤖 Generated with Claude Code

@juaristi22
juaristi22 merged commit ce5e95a into main Oct 19, 2026
24 of 25 checks passed
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