Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/602-budget-contract.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updated the UK Budget reform contract to version 1.1.0. Its rate-blending case, `mid_year_blended_rate`, now holds capital gains realisations fixed (both elasticity conventions zero and no separate Business Asset Disposal Relief elasticity), so its static expected values hold whatever realisation response policyengine-uk applies by default. Added `cgt_retention_response`, which checks the response itself: with a retention-rate elasticity of 1, a 20% basic rate from 6 April 2027 scales realised gains by (1 - 20%) / (1 - 18%) on every route. Added `cgt_default_response`, the same reform with no response settings, so the report shows the default response policyengine-uk applies; its checks expect the retention-rate elasticity of 1 that policyengine-uk#2396 makes the default, and report as pending (without gating) until policyengine.py pins that release. Each case also checks that all three response settings reached the model. `capital_gains_response()` builds a case's elasticity changes.
1 change: 1 addition & 0 deletions changelog.d/release-lock-markers.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Re-locked `uv.lock` with the pinned uv 0.12.14. Two dependency markers written by another uv version made the release's version bump rewrite the lock, which the reviewed-lock check rejects, so no release has published since #571.
202 changes: 192 additions & 10 deletions src/policyengine/tax_benefit_models/uk/budget_reform_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,28 @@
Prior law after a bounded change
--------------------------------

Contract 1.0.0 expects prior law's projected path to resume after a bounded
change (``PRIOR_LAW_OPTION = "projected"``). Whether indexation should
The contract expects prior law's projected path to resume after a bounded
change (``PRIOR_LAW_OPTION = "projected"``, since contract 1.0.0). Whether indexation should
instead resume from a changed statutory amount (Income Tax Act 2007 s57
indexes "the amount ... for the previous tax year") is an open methodology
call (cos d1344). Checks that depend on it carry ``pending`` and never gate.

Behavioural responses
---------------------

A case about statutory arithmetic holds capital gains realisations fixed: it
sets both elasticity conventions to zero and the separate Business Asset
Disposal Relief elasticity off (:func:`capital_gains_response` with an
elasticity of 0), so its expected values hold whatever response
policyengine-uk applies by default. A case about the response sets its
elasticity, and expects realised gains scaled by
``((1 - t1) / (1 - t0)) ** elasticity``, where ``t0`` and ``t1`` are the
marginal rates on the gains without and with the reform. Contract 1.1.0
added both, and a case with no response settings that shows the default
response policyengine-uk applies. Its checks expect the retention-rate
elasticity of 1 that policyengine-uk#2396 makes the default, and carry
``pending`` until policyengine.py pins a release with it.

Report
------

Expand Down Expand Up @@ -90,14 +106,17 @@
import sys
import traceback
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from decimal import ROUND_CEILING, Decimal
from typing import Any, Optional, Union

CONTRACT_ID = "policyengine-uk/budget-reform-contract"
CONTRACT_VERSION = "1.0.0"
CONTRACT_VERSION = "1.1.0"
PRIOR_LAW_OPTION = "projected"
PRIOR_LAW_DECISION = "d1344"
# The capital gains response default: policyengine-uk#2396 makes it a
# retention-rate elasticity of 1. Its checks gate once policyengine.py pins it.
DEFAULT_RESPONSE_DECISION = "policyengine-uk#2396"
FLAT_STOP = datetime.date(2100, 12, 31)

COUNTRY_ROUTES = (
Expand Down Expand Up @@ -132,6 +151,10 @@
NEW_STATE_PENSION = "gov.dwp.state_pension.new_state_pension.amount"
CGT_BASIC_RATE = "gov.hmrc.cgt.basic_rate"
CGT_ANNUAL_EXEMPT_AMOUNT = "gov.hmrc.cgt.annual_exempt_amount"
CGT_RESPONSES = "gov.simulation.capital_gains_responses"
CGT_ELASTICITY = f"{CGT_RESPONSES}.elasticity"
CGT_MTR_ELASTICITY = f"{CGT_RESPONSES}.mtr_elasticity"
CGT_SEPARATE_BADR_ELASTICITY = f"{CGT_RESPONSES}.separate_badr_elasticity"
VAT_STANDARD_RATE = "gov.hmrc.vat.standard_rate"
VAT_COVERAGE = "gov.simulation.microdata_vat_coverage"
PROTECTED_LCWRA = "gov.dwp.universal_credit.rebalancing.protected_health_element"
Expand Down Expand Up @@ -342,8 +365,83 @@ def expected(context: Context) -> float:

GAINS_UPLIFT_ORACLE = (
"(£10,000 of gains - the annual exempt amount) x (20% - current rate) x "
"the share of the tax year at 20%; the gains sit wholly in the basic rate "
"band, and the exempt amount and current rate are read from current law"
"the share of the tax year at 20%, with realisations held fixed; the "
"gains sit wholly in the basic rate band, and the exempt amount and "
"current rate are read from current law"
)


def capital_gains_response(
start: datetime.date, elasticity: float = 0.0
) -> tuple[Change, ...]:
"""Changes fixing the capital gains realisation response from ``start`` on.

A retention-rate elasticity of ``elasticity``, no marginal tax rate
elasticity and no separate Business Asset Disposal Relief elasticity, so
every gain responds with ``elasticity``. An elasticity of 0 holds
realisations fixed, whatever response the model applies by default.
"""
return (
Change(CGT_ELASTICITY, start, elasticity),
Change(CGT_MTR_ELASTICITY, start, 0.0),
Change(CGT_SEPARATE_BADR_ELASTICITY, start, False),
)


def _response_settings_checks(
year: int, elasticity: float, oracle: str
) -> tuple[Check, ...]:
"""Checks that each response setting reached the built system: the
retention-rate elasticity, no marginal tax rate elasticity, and the
separate relief elasticity off (a switch reads as 0 when off)."""
return (
Check("parameter", CGT_ELASTICITY, year, elasticity, oracle),
Check("parameter", CGT_MTR_ELASTICITY, year, 0.0, oracle),
Check("parameter", CGT_SEPARATE_BADR_ELASTICITY, year, 0.0, oracle),
)


def _realised_gains(
year: int, rate: float, elasticity: float
) -> Callable[[Context], float]:
"""Gains after the realisation response to a basic rate of ``rate``.

The gains sit wholly in the basic rate band, so the marginal rates on
them without and with the reform are the two basic rates.
"""

def expected(context: Context) -> float:
current = context.baseline(CGT_BASIC_RATE, year)
return GAINS * ((1 - rate) / (1 - current)) ** elasticity

return expected


def _gains_response_uplift(
year: int, rate: float, elasticity: float
) -> Callable[[Context], float]:
"""Extra CGT at a basic rate of ``rate`` all year, after the response."""

def expected(context: Context) -> float:
exempt = context.baseline(CGT_ANNUAL_EXEMPT_AMOUNT, year)
current = context.baseline(CGT_BASIC_RATE, year)
realised = _realised_gains(year, rate, elasticity)(context)
return (realised - exempt) * rate - (GAINS - exempt) * current

return expected


REALISED_GAINS_ORACLE = (
"£10,000 of gains x ((1 - 20%) / (1 - current rate)) to the power of the "
"elasticity: the gains sit wholly in the basic rate band, so the marginal "
"rates on them are the basic rates; the current rate is read from "
"current law"
)

GAINS_RESPONSE_UPLIFT_ORACLE = (
"(realised gains - the annual exempt amount) x 20% - (£10,000 of gains - "
"the annual exempt amount) x current rate, with the realised gains above; "
"the exempt amount and current rate are read from current law"
)

DIVIDENDS = 10_000
Expand Down Expand Up @@ -543,7 +641,7 @@ def expected(context: Context) -> float:
PERSONAL_ALLOWANCE,
2034,
_baseline_value(PERSONAL_ALLOWANCE, 2034),
"contract 1.0.0: prior law's projected path resumes",
"prior law's projected path resumes (PRIOR_LAW_OPTION)",
),
Check(
"parameter",
Expand Down Expand Up @@ -663,8 +761,13 @@ def expected(context: Context) -> float:
),
Case(
id="mid_year_blended_rate",
title="CGT basic rate 20% from 30 October 2027",
changes=(Change(CGT_BASIC_RATE, _date("2027-10-30"), 0.20),),
title="CGT basic rate 20% from 30 October 2027, realisations held fixed",
changes=(
Change(CGT_BASIC_RATE, _date("2027-10-30"), 0.20),
# The case checks the blending of the rate, so the realisation
# response is off.
*capital_gains_response(_date("2025-01-01")),
),
# £20,000 of earnings and £10,000 of gains: the taxable gains sit
# wholly in the basic rate band.
household=adult(20_000, capital_gains=GAINS),
Expand All @@ -681,6 +784,7 @@ def expected(context: Context) -> float:
),
Check("parameter", CGT_BASIC_RATE, 2028, 0.20, "in force all year"),
Check("parameter", CGT_BASIC_RATE, 2026, 0.18, "before the change"),
*_response_settings_checks(2027, 0.0, "set by the case"),
Check("change", "capital_gains_tax", 2026, 0.0, "before the change", 0.005),
Check(
"change",
Expand All @@ -700,6 +804,83 @@ def expected(context: Context) -> float:
),
),
),
Case(
id="cgt_retention_response",
title=(
"CGT basic rate 20% from 6 April 2027, with a retention-rate "
"elasticity of 1"
),
changes=(
Change(CGT_BASIC_RATE, _date("2027-04-06"), 0.20),
*capital_gains_response(_date("2025-01-01"), elasticity=1.0),
),
# As above, the gains sit wholly in the basic rate band, before and
# after the response.
household=adult(20_000, capital_gains=GAINS),
checks=(
Check("parameter", CGT_BASIC_RATE, 2027, 0.20, "in force all year"),
*_response_settings_checks(2027, 1.0, "set by the case"),
Check("change", "capital_gains_tax", 2026, 0.0, "before the change", 0.005),
Check(
"variable",
"capital_gains",
2026,
float(GAINS),
"no rate change before 2027-28, so no response",
0.01,
),
Check(
"variable",
"capital_gains",
2027,
_realised_gains(2027, 0.20, 1.0),
REALISED_GAINS_ORACLE,
0.01,
),
Check(
"change",
"capital_gains_tax",
2027,
_gains_response_uplift(2027, 0.20, 1.0),
GAINS_RESPONSE_UPLIFT_ORACLE,
0.005,
),
),
),
Case(
id="cgt_default_response",
title=(
"CGT basic rate 20% from 6 April 2027, at policyengine-uk's "
"default response"
),
# No response settings, so the report shows the default. The expected
# values are cgt_retention_response's, the default policyengine-uk#2396
# sets; they report without gating until policyengine.py pins it.
changes=(Change(CGT_BASIC_RATE, _date("2027-04-06"), 0.20),),
household=adult(20_000, capital_gains=GAINS),
checks=tuple(
replace(check, pending=DEFAULT_RESPONSE_DECISION)
for check in (
*_response_settings_checks(2027, 1.0, "policyengine-uk's default"),
Check(
"variable",
"capital_gains",
2027,
_realised_gains(2027, 0.20, 1.0),
REALISED_GAINS_ORACLE + ", at the default elasticity of 1",
0.01,
),
Check(
"change",
"capital_gains_tax",
2027,
_gains_response_uplift(2027, 0.20, 1.0),
GAINS_RESPONSE_UPLIFT_ORACLE + ", at the default elasticity of 1",
0.005,
),
)
),
),
Case(
id="september_cpi_moves_indexed_thresholds",
title="A 5% September 2030 CPI rise indexes the 2031-32 thresholds",
Expand Down Expand Up @@ -1817,7 +1998,8 @@ def _sampled_change(rng: random.Random) -> Change:
def expected_annual(
change: Change, year: int, baseline: Callable[[str, int], float]
) -> float:
"""The annual value a single change gives, under contract 1.0.0.
"""The annual value a single change gives, under the contract's prior-law
option (``PRIOR_LAW_OPTION``).

Prior law is current law's annual value outside the change. Blended
parameters are sampled only from 2025 on, where current law holds one
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/uk_contract/evaluate_case_rows.json

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions tests/test_uk_budget_reform_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ def test_fiscal_year_average_is_day_weighted():
assert fiscal_year_average(pieces, 2028) == pytest.approx(0.20)


def test_the_response_oracle_is_the_static_one_at_an_elasticity_of_zero():
current_law = {
contract.CGT_ANNUAL_EXEMPT_AMOUNT: 3_000.0,
contract.CGT_BASIC_RATE: 0.18,
}
context = contract.Context(lambda path, year: current_law[path])
# (£10,000 - £3,000) x (20% - 18%).
assert contract._gains_uplift(2027, 1.0)(context) == pytest.approx(140.0)
assert contract._gains_response_uplift(2027, 0.20, 0.0)(context) == (
pytest.approx(140.0)
)
# At an elasticity of 1 the gains scale by (1 - 20%) / (1 - 18%).
realised = 10_000 * 0.8 / 0.82
assert contract._realised_gains(2027, 0.20, 1.0)(context) == pytest.approx(realised)
assert contract._gains_response_uplift(2027, 0.20, 1.0)(context) == (
pytest.approx((realised - 3_000) * 0.20 - 7_000 * 0.18)
)


def test_expected_annual_for_a_single_change():
def baseline(path, year):
return 0.20
Expand Down Expand Up @@ -101,6 +120,18 @@ def test_representations():
}


def test_the_rate_blending_case_holds_realisations_fixed_on_every_route():
case = next(case for case in CASES if case.id == "mid_year_blended_rate")
static = {
contract.CGT_ELASTICITY: [0.0],
contract.CGT_MTR_ELASTICITY: [0.0],
contract.CGT_SEPARATE_BADR_ELASTICITY: [False],
}
for route in contract.COUNTRY_ROUTES + contract.WRAPPER_ROUTES:
reform = contract.represent(case, route)
assert {path: list(reform[path].values()) for path in static} == static


def test_case_ids_are_unique_and_every_check_has_an_oracle():
ids = [case.id for case in CASES]
assert len(ids) == len(set(ids))
Expand Down Expand Up @@ -508,6 +539,54 @@ def test_the_april_rate_case_holds_on_the_country_routes():
)


def test_the_capital_gains_cases_hold_on_every_in_process_route():
pytest.importorskip("policyengine_uk.utils.dated_reforms")
routes = contract.COUNTRY_ROUTES + contract.WRAPPER_ROUTES
report = run_budget_reform_contract(
routes=list(routes),
cases=["mid_year_blended_rate", "cgt_retention_response"],
max_examples=0,
)
failures = [row for row in report["cases"] if row["status"] in ("fail", "error")]
assert failures == []
# The response is checked, and passes, on every route.
passed = {
row["route"]
for row in report["cases"]
if row["case_id"] == "cgt_retention_response"
and row["check"] == "change:capital_gains_tax:2027"
and row["status"] == "pass"
}
assert passed == set(routes)


def test_the_default_response_is_reported_without_gating():
"""The default case's checks all wait on policyengine-uk#2396, so it
reports the default response on every route without failing a run. The
golden rows cover its rows on every route under fake routes; one real
route checks that it runs and reports pending there."""
pytest.importorskip("policyengine_uk.utils.dated_reforms")
case = next(case for case in CASES if case.id == "cgt_default_response")
assert {check.pending for check in case.checks} == {
contract.DEFAULT_RESPONSE_DECISION
}
# No response settings: the case runs on whatever default is installed.
assert not any(change.path.startswith("gov.simulation.") for change in case.changes)

report = run_budget_reform_contract(
routes=["country.reform_dict"],
cases=["cgt_default_response"],
max_examples=0,
)
rows = [row for row in report["cases"] if row["case_id"] == case.id]
assert {row["status"] for row in rows} <= {"pass", "pending"}
(response,) = (
row for row in rows if row["check"] == "change:capital_gains_tax:2027"
)
assert response["status"] == "pending"
assert response["decision_id"] == contract.DEFAULT_RESPONSE_DECISION


def test_a_parameter_modifier_writes_the_annualised_parameters():
from policyengine_uk import Simulation

Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading