Skip to content
1 change: 1 addition & 0 deletions changelog.d/626.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Contract 2.0.0 of the UK Budget reform contract resolves cos d1344: after a freeze ends, the personal allowance, basic rate limit and aligned NICs thresholds are checked against indexation from the frozen amount (ITA 2007 ss21 and 57), and the six pending checks become gating ones; the sampled property uses the same statutory oracle for the allowance.
170 changes: 126 additions & 44 deletions src/policyengine/tax_benefit_models/uk/budget_reform_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,30 @@
Prior law after a bounded change
--------------------------------

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.
Where the timeline states no later value, what prior law is after a change
ends depends on how the parameter's future is made. Contract 2.0.0
(``PRIOR_LAW_OPTION = "statutory"``) records the Budget CI workstream's
call under cos d1344, taken with independent review:

- A threshold indexed from its own previous year (the personal allowance,
the basic rate limit and the NICs thresholds aligned with them) resumes
indexation from the changed amount. Income Tax Act 2007 s57(3) Step 1
multiplies "the allowance ... for the previous tax year" and s21(3) Step 1
increases "the basic rate limit for the previous tax year"; freezes are
legislated by fixing the amount and disapplying the indexation for named
years (Finance Act 2021 s5), so the frozen amount is indexed after them.
Returning to the path the threshold would have taken without the change,
contract 1.0.0's reading, has no statutory basis.
- An amount uprated from an index (benefit and pension rates) returns to its
uprated path, as contract 1.0.0 expected. That reading is not settled:
freezes of such amounts resumed from the frozen amount (Welfare Reform and
Work Act 2016 s11), while a period-limited modification reverts (SI
2020/371 reg 3). It is open in policyengine-uk#2430; the contract's
checks on it stay as they were until that is decided.

``Check.pending`` remains for open calls. No check waits on d1344 now; the
default capital gains response's checks wait on policyengine-uk#2396 (see
Behavioural responses).

Behavioural responses
---------------------
Expand Down Expand Up @@ -111,9 +130,13 @@
from typing import Any, Optional, Union

CONTRACT_ID = "policyengine-uk/budget-reform-contract"
CONTRACT_VERSION = "1.1.0"
PRIOR_LAW_OPTION = "projected"
CONTRACT_VERSION = "2.0.0"
PRIOR_LAW_OPTION = "statutory"
# The cos decision under which the prior-law call was taken.
PRIOR_LAW_DECISION = "d1344"
# Finance Act 2021 s5, as amended by Finance Act 2026 s10, fixes the
# personal allowance and basic rate limit for 2022-23 to 2030-31.
LAST_FROZEN_TAX_YEAR = 2030
# 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"
Expand Down Expand Up @@ -335,18 +358,51 @@ def expected(context: Context) -> float:
return expected


def _statutory_resumption(path: str) -> Callable[[Context], float]:
def _resumed_after_freeze(path: str, year: int) -> Callable[[Context], float]:
"""A threshold frozen to 2033-34, indexed from the frozen amount after it.

ITA 2007 s57(3) for the personal allowance and s21(3) for the basic rate
limit, each year from the year before, with current law's September CPI
rise for that year.
"""

def expected(context: Context) -> float:
increase = context.baseline(SEPTEMBER_CPI_RISE, 2034)
allowance, limit = 12_570.0, 37_700.0
for each in range(2034, year + 1):
increase = context.baseline(SEPTEMBER_CPI_RISE, each)
allowance = s57_personal_allowance(allowance, increase)
limit = s21_basic_rate_limit(limit, increase)
if path == PERSONAL_ALLOWANCE:
return s57_personal_allowance(12_570, increase)
return s21_basic_rate_limit(37_700, increase)
return allowance
if path == BASIC_RATE_LIMIT:
return limit
if path == LOWER_PROFITS_LIMIT:
return allowance
if path == UPPER_PROFITS_LIMIT:
return allowance + limit
if path == PRIMARY_THRESHOLD:
return weekly(allowance)
if path == UPPER_EARNINGS_LIMIT:
return weekly(allowance + limit)
raise ValueError(path)

return expected


def _resumed_freeze_change(year: int) -> Callable[[Context], float]:
def expected(context: Context) -> float:
allowance = _resumed_after_freeze(PERSONAL_ALLOWANCE, year)(context)
frozen_out = context.baseline(PERSONAL_ALLOWANCE, year) - allowance
return context.baseline(BASIC_RATE, year) * frozen_out

return expected


def _baseline_value(path: str, year: int) -> Callable[[Context], float]:
return lambda context: context.baseline(path, year)
RESUMPTION_ORACLE = (
"indexed from the frozen amount after the freeze (ITA 2007 s57(3) and "
"s21(3), the previous tax year's amount; cos d1344), with current law's "
"September CPI rise"
)


GAINS = 10_000
Expand Down Expand Up @@ -635,30 +691,37 @@ def expected(context: Context) -> float:
),
)
)
+ (
Check(
"parameter",
PERSONAL_ALLOWANCE,
2034,
_baseline_value(PERSONAL_ALLOWANCE, 2034),
"prior law's projected path resumes (PRIOR_LAW_OPTION)",
),
Check(
"parameter",
PERSONAL_ALLOWANCE,
2034,
_statutory_resumption(PERSONAL_ALLOWANCE),
"ITA 2007 s57 from the frozen £12,570 with 2034's September CPI rise",
pending=PRIOR_LAW_DECISION,
),
Check(
"parameter",
BASIC_RATE_LIMIT,
2034,
_statutory_resumption(BASIC_RATE_LIMIT),
"ITA 2007 s21 from the frozen £37,700 with 2034's September CPI rise",
pending=PRIOR_LAW_DECISION,
),
+ tuple(
check
for year in (2034, 2035)
for check in (
*(
Check(
"parameter",
path,
year,
_resumed_after_freeze(path, year),
RESUMPTION_ORACLE,
)
for path in (
PERSONAL_ALLOWANCE,
BASIC_RATE_LIMIT,
LOWER_PROFITS_LIMIT,
UPPER_PROFITS_LIMIT,
PRIMARY_THRESHOLD,
UPPER_EARNINGS_LIMIT,
)
),
Check(
"change",
"income_tax",
year,
_resumed_freeze_change(year),
"basic rate x (current law's allowance - the allowance "
"indexed from the frozen amount)",
0.005,
),
)
),
),
Case(
Expand Down Expand Up @@ -2001,16 +2064,21 @@ 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 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
value through each fiscal year, so each day of the year takes the
change's value inside the change and prior law's outside it.
"""The annual value a single change gives, under contract 2.0.0.

For the personal allowance, prior law is statute: tax years the change
covers take its value, years to 2030-31 take current law (Finance Act
2021 s5 fixes them), and every later year is ITA 2007 s57(3) applied to
the year before. For the other sampled parameters prior law is current
law's annual value outside the change. Blended parameters are sampled
only from 2025 on, where current law holds one value through each fiscal
year, so each day of the year takes the change's value inside the change
and prior law's outside it.
"""
blend, _ = SAMPLED_PARAMETERS[change.path]
stop = change.stop or FLAT_STOP
if change.path == PERSONAL_ALLOWANCE:
return _statutory_allowance(change, year, baseline)
prior = baseline(change.path, year)
if not blend:
sample = datetime.date(year, 4, 30)
Expand All @@ -2026,6 +2094,20 @@ def expected_annual(
return total / days


def _statutory_allowance(
change: Change, year: int, baseline: Callable[[str, int], float]
) -> float:
sample = datetime.date(year, 4, 30)
if change.start <= sample <= (change.stop or FLAT_STOP):
return float(change.value)
if year <= LAST_FROZEN_TAX_YEAR:
return baseline(PERSONAL_ALLOWANCE, year)
return s57_personal_allowance(
_statutory_allowance(change, year - 1, baseline),
baseline(SEPTEMBER_CPI_RISE, year),
)


def _property_annual_values(
change: Change,
routes: Sequence[str],
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/uk_contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ Real reports from the UK Budget reform contract runner (workstream W1,
run on 11 October 2026. The qualification gate tests in `tests/test_uk_candidate_gates.py` read them.

- `pinned_uk_2.125.1_defects.json`: every case on policyengine-uk 2.125.1, the pinned model at the time, with `max_examples=2` and no dataset. The 66 failures and 35 errors are the dated-reform defects the contract was written to catch.
- `full_released_uk_2.130.1.json`: the full run of contract 1.1.0 (16 October 2026), the runner at policyengine.py#574's head merged with main 8a303636, on the certified pair policyengine-uk 2.130.1 with core 3.33.0: every route and case, seed 0, `max_examples=20`, population checks on the certified `enhanced_frs_2024_25` (uk-data 1.58.1, SHA-256 `056e80fd…`). Result: 792 pass, 25 pending a methodology call (d1344 and policyengine-uk#2396), 101 unsupported with reasons, both properties passing, 19 of 19 population rows; 29.6 minutes, peak RSS 3.5 GB. Its `versions` record policyengine 6.4.0, the stale metadata of the editable install that ran it; treat them as fixture metadata. The gate tests read it through the coverage the current runner module declares, and fail rather than skip when the runner's contract version moves past it. It replaces the 1.0.0 run on policyengine-uk 2.126.12.
- `full_released_uk_2.135.0.json`: the full run of contract 2.0.0 (17 October 2026, UTC), the runner at policyengine.py#627's head f1f2431d9 (main merged in, #643 included), on policyengine-uk 2.135.0, which holds the d1344 statutory resumption (policyengine-uk#2423), with core 3.33.0. It covers every route and case, seed 0 and `max_examples=20`, with population checks on the certified `enhanced_frs_2024_25` (SHA-256 `056e80fd…`, the dataset of the certified pair). Result: 881 pass, 19 pending a methodology call (policyengine-uk#2396, the default capital gains response), 119 unsupported with reasons, both properties passing, 19 of 19 population rows. #651 changed only how the household route imports the UK model; its review found the output unchanged row for row. Its `versions` record policyengine 6.4.3, the stale metadata of the editable install that ran it; treat them as fixture metadata. The gate tests read it through the coverage the current runner module declares, and fail rather than skip when the runner's contract version moves past it. It replaces the 1.1.0 run on policyengine-uk 2.130.1, whose freeze checks contract 2.0.0 replaced.
- `population_pass_max_examples_0.json`: population checks only, on the earlier certified `enhanced_frs_2024_25` (uk-data 1.58.0, SHA-256 `5ba399ea…`). It has no cases, and with `max_examples=0` its sampled properties did not run, so the gate treats it as an incomplete run.

The edits:
- `dataset.path` (a local Hugging Face cache path) was replaced with a placeholder in the two files that name a dataset, and every file was re-serialized with sorted keys.
- Population rows are computed on the survey dataset, so a count from 1 to 9 in their `detail` is a small cell, which the metrics tool's `privacy.py` rule never publishes. In `full_released_uk_2.130.1.json`, `2027_people_with_lower_tax` and `payments_lower` (three rows each) are replaced by `"fewer than 10"`. No gate rule reads `detail`, and the qualification ledger (`publishable_contract_report`) publishes none of a population detail's values beyond its top-level true/false findings, since rows of one check on different routes share a subsample and their differences would give a handful's figures back. Check any new fixture for such counts before committing it.
- Population rows are computed on the survey dataset, so a count from 1 to 9 in their `detail` is a small cell, which the metrics tool's `privacy.py` rule never publishes. Since policyengine.py#643 the runner suppresses them at source, so `full_released_uk_2.135.0.json` reads `"fewer than 10"` where they occur, and its population details agree across routes. No gate rule reads `detail`, and the qualification ledger (`publishable_contract_report`) publishes none of a population detail's values beyond its top-level true/false findings, since rows of one check on different routes share a subsample and their differences would give a handful's figures back. Check any new fixture for such counts before committing it.

The run of contract 1.0.0 on policyengine-uk 2.126.8 (`full_allpass_uk_2.126.8.json`) was removed once no test read it.
2 changes: 1 addition & 1 deletion tests/fixtures/uk_contract/evaluate_case_rows.json

Large diffs are not rendered by default.

Loading
Loading