Skip to content

CGT alignment: three clean parameters, and an elasticity that has to be cited (#97) - #102

Open
vahid-ahmadi wants to merge 1 commit into
mainfrom
uk/cgt-reform-lane
Open

CGT alignment: three clean parameters, and an elasticity that has to be cited (#97)#102
vahid-ahmadi wants to merge 1 commit into
mainfrom
uk/cgt-reform-lane

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Closes #97. Branches from main.

Aligning capital gains tax rates with income tax rates is the Budget's flagship broad-based option with the "big three" ruled out by the manifesto pledge. PolicyEngine-UK expresses it cleanly — three parameters, all resolved against a real 2.89.2, with the baselines recorded from the engine rather than from reporting:

parameter engine, 2026 aligned
gov.hmrc.cgt.basic_rate 0.18 0.20
gov.hmrc.cgt.higher_rate 0.24 0.40
gov.hmrc.cgt.additional_rate 0.24 0.45

The finding that reshaped the lane

I opened #97 expecting to say "PE-UK has a CGT behavioural response, so this lane can score both legs". Checking the engine says something more specific:

gov.simulation.capital_gains_responses.elasticity is zero from 2000-01-01, and capital_gains_behavioural_response short-circuits to zero when it is.

So a default PolicyEngine-UK score of this measure is static. The engine does not hand you a behavioural number — it hands you the machinery to compute one once you supply an elasticity. (It also requires simulation.baseline to be attached, so it exists only in a reform run.)

That makes the elasticity an assumption this repo makes, and the single parameter the whole comparison turns on — published estimates of this measure disagree mostly about the realisations response, not the arithmetic.

So the lane sweeps, and citations are enforced

It does not pick a value and publish a number. The validator refuses to mark a sweep point publishable until it carries a named citation, because an elasticity without one is an opinion with a decimal point.

publishable legs: ['static']
held pending citation: ['central', 'high_response']

The static leg is publishable because it asserts no behaviour. The other two are held with a note saying what they need — including which elasticity definition the citation must state: semi-elasticity vs elasticity, and with respect to which rate. Those are different numbers wearing the same word, and picking the wrong one silently rescales the whole response.

Comparability, enforced not narrated

An OBR costing of this measure will be post-behavioural. The static leg must never be presented against it — that is #67's finding one level up, and the spec says so in a field the validator checks. The static-vs-behavioural difference is published as its own quantity on the behavioural_elasticity axis rather than folded into a residual.

The validator also refuses a reform that restates the baseline (it would score zero and read as agreement), and warns loudly if the engine ever switches the response on by default — that would invalidate the static/behavioural framing this lane rests on.

Unexecuted, and says so

The compute needs the managed environment and the certified populace-uk bundle, exactly like #51 and #66; a local Microsimulation() refuses without an explicit dataset. Everything below the engine boundary — reform values, paths, baselines, the sweep contract — is verified and tested here.

Verification

Suite 273 passed, ruff format --check clean, validator green with and without the engine.

Reviewers

@MaxGhenis @DTrim99 — the call worth testing: I've made the citation requirement a hard gate rather than a convention, which means this lane currently cannot publish a behavioural number at all. That is deliberate, but it does mean the lane ships less than it could. If you'd rather it carried a documented default elasticity with the caveat attached, that is a reasonable alternative and I'd take the argument.

…asticity (#97)

Aligning capital gains tax rates with income tax rates is the Budget's
flagship broad-based option with the "big three" ruled out, and
PolicyEngine-UK expresses it cleanly: 18/24/24 today to 20/40/45, three
parameters, all resolved against a real 2.89.2 with the baselines
recorded from the engine rather than from reporting.

The finding that reshaped the lane, verified rather than assumed:
gov.simulation.capital_gains_responses.elasticity is ZERO from
2000-01-01, and capital_gains_behavioural_response short-circuits to
zero when it is. So a default PolicyEngine-UK score of this measure is
STATIC. The engine does not hand you a behavioural number — it hands you
the machinery to compute one once you supply an elasticity.

That makes the elasticity an assumption THIS REPO makes, and the single
parameter the whole comparison turns on: published estimates of this
measure disagree mostly about the realisations response, not the
arithmetic. So the lane does not pick a value and publish a number. It
sweeps, and the validator refuses to mark a sweep point publishable
until it carries a named citation — an elasticity without one is an
opinion with a decimal point. Today exactly one leg is publishable, the
static one, because it asserts no behaviour; the central and
high-response legs are held with a note saying what they need, including
which elasticity DEFINITION the citation must state (semi-elasticity vs
elasticity, and with respect to which rate — different numbers wearing
the same word).

Comparability is stated and enforced in the spec: an OBR costing of this
measure will be post-behavioural, and the static leg must never be
presented against it. That is #67's finding one level up, and the
static-vs-behavioural difference is published as its own quantity on the
behavioural_elasticity axis rather than folded into a residual.

The validator also refuses a reform that restates the baseline (it would
score zero and read as agreement), and warns loudly if the engine ever
switches the response on by default, because that would invalidate the
static/behavioural framing this lane rests on.

Unexecuted, and says so: the compute needs the managed environment and
the certified populace-uk bundle, exactly like #51 and #66 — a local
Microsimulation() refuses without an explicit dataset. Everything below
the engine boundary is verified and tested here.

Suite 284 passed, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Review request — @MaxGhenis @DTrim99.

This one is part of a batch; the whole queue, with a suggested merge order and what is blocked on whom, is in #104 so you can triage in one place rather than PR by PR.

@DTrim99 DTrim99 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.

Verdict: approve. All three CGT parameter paths, their baselines, and the aligned targets verify against policyengine-uk (2.89.2), and the elasticity is correctly held behind a hard citation gate.

Verified

  • gov.hmrc.cgt.basic_rate 0.18, higher_rate 0.24, additional_rate 0.24 — all paths exist and the 2026 values match exactly (set 2024-10-30 per Finance Act 2025 s.7).
  • Aligned targets 0.20 / 0.40 / 0.45 match the UK income-tax basic/higher/additional rates for 2025/26 and 2026/27 exactly.
  • capital_gains_responses.elasticity = 0 from 2000-01-01; the behavioural response short-circuits to 0 when elasticity is 0 and requires simulation.baseline. The "default score is static" framing is accurate.

Should (non-blocking)

  • The claim that the elasticity is "an assumption this repo makes, not a property of the engine" overstates it: elasticity.yaml already ships two authoritative citations (Agersnap & Zidar 2021; Advani, Lonsdale & Summers 2024) and a ~0.5–2.0 range. The default is 0 and the choice of a nonzero value is the repo's — but the central / high_response legs could cite those upstream sources directly rather than being left null. Suggest softening the wording and pre-populating the source candidates.

Suggestion

  • The engine exposes a second elasticity convention (mtr_elasticity) alongside the retention-rate elasticity (and raises if both are nonzero). The note's semi-elasticity warning is right; naming mtr_elasticity concretely would make the citation-definition check actionable.

🤖 review via Claude Code

vahid-ahmadi pushed a commit that referenced this pull request Aug 26, 2026
Conflicts were the two lanes.json copies, both append-vs-rename:
main renamed the BE lane's "Ledger facts" to "Chronicle facts" while
this branch appended the dwp-pension-participation lane. Took main's
wording and re-appended the lane — an 11-line addition against
origin/main, repo escaping preserved.

app/public/data/lanes.json is a literal copy of data/lanes.json, so it
is cp'd rather than written independently; writing both from their own
sides is what test_app_data_copies_match_committed_data exists to catch.

build_db.py and relationships.py auto-merged. Verified the step list
survived intact — a merge that splits a step tuple yields invalid
Python, and one that drops a step yields a silently missing lane; the
import-vs-runs guard covers the second.

284 tests, ruff clean, two builds agree on content_hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
vahid-ahmadi pushed a commit that referenced this pull request Aug 26, 2026
The previous commit guarded pipeline/validate_budget_2026_registry.py
and left validate_cgt_reform_spec.py (#102) failing the same way — a
validator that lands on its own branch, absent on this ref.

Folded both into one loop that skips a missing validator, announces
every skip via ::notice::, and reports how many actually ran. Each
starts enforcing the moment its PR merges, with no edit here.

Announcing the skip is the point. A gate that quietly validates
nothing reads as a gate that passed — the #74/#95 failure mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
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.

Capital gains tax reform scoring (mode 2): the Budget's flagship measure, and the one where the behavioural response IS the disagreement

2 participants