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
26 changes: 26 additions & 0 deletions packages/populace-build/src/populace/build/uk_runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
"""UK build helpers for Populace-owned local-geography artifacts."""

from populace.build.uk_runtime.cgt_calibration import (
UK_CGT_ANNUAL_EXEMPT_AMOUNTS,
UK_CGT_GAINS_AMOUNT_COLUMN,
UK_CGT_SOURCE_COLUMN,
UK_CGT_TAXPAYER_COUNT_COLUMN,
UKCGTTargetMaterialization,
materialize_uk_cgt_calibration_frame,
uk_cgt_annual_exempt_amount,
)
from populace.build.uk_runtime.firm_generation import (
EMPLOYMENT_BANDS,
HMRC_BAND_COLUMNS,
Expand Down Expand Up @@ -32,6 +41,12 @@
validate_uk_firm_population,
write_uk_firm_population,
)
from populace.build.uk_runtime.fiscal_targets import (
UK_CGT_REQUIRED_COLUMNS,
UK_CGT_TARGET_COVERAGE_REQUIREMENTS,
UK_CGT_TARGET_SPECS,
UK_FISCAL_TARGET_REGISTRY,
)
from populace.build.uk_runtime.frs_hmrc_leaves import (
FRS_HMRC_INCPBEN_COLUMN,
FRS_HMRC_OSSBEN_IDENTIFIABLE_SUBSET_COLUMN,
Expand Down Expand Up @@ -299,6 +314,17 @@
)

__all__ = [
"UK_CGT_ANNUAL_EXEMPT_AMOUNTS",
"UK_CGT_GAINS_AMOUNT_COLUMN",
"UK_CGT_SOURCE_COLUMN",
"UK_CGT_TAXPAYER_COUNT_COLUMN",
"UKCGTTargetMaterialization",
"materialize_uk_cgt_calibration_frame",
"uk_cgt_annual_exempt_amount",
"UK_CGT_REQUIRED_COLUMNS",
"UK_CGT_TARGET_COVERAGE_REQUIREMENTS",
"UK_CGT_TARGET_SPECS",
"UK_FISCAL_TARGET_REGISTRY",
"AGE_BANDS",
"AREA_TYPE_TO_LEDGER_GEOGRAPHY_LEVEL",
"AREA_TYPES",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Materialize the declared UK capital gains constraint frame.

``uk_runtime.fiscal_targets`` declares two HMRC capital gains facts. This
module prepares the person-level measure columns those facts compile against
and assembles the constraint :class:`~populace.frame.Frame`, following the
shape of :mod:`populace.build.uk_runtime.hmrc_calibration`.

Unlike the HMRC SPI surface, no simulation is needed: ``capital_gains`` is a
persisted person-level input on the UK national dataset, so the measures read
straight off ``dataset.person``.

The CGT taxpayer indicator is ``capital_gains > annual_exempt_amount``. The AEA
is policy-dependent — it moved £12,300 -> £6,000 -> £3,000 across 2022-23 to
2024-25 — so the default is derived from ``dataset.time_period`` through the
explicit :data:`UK_CGT_ANNUAL_EXEMPT_AMOUNTS` mapping and an unmapped period
raises. Silently defaulting would change what "CGT taxpayer" means without
changing the declared target value.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd

from populace.build.uk_runtime.fiscal_targets import (
UK_CGT_REQUIRED_COLUMNS,
UK_CGT_TARGET_SPECS,
)
from populace.build.uk_runtime.national_build import UKNationalDataset
from populace.calibrate import TargetRegistry
from populace.frame import EntitySchema, Frame, Weights

__all__ = [
"UK_CGT_ANNUAL_EXEMPT_AMOUNTS",
"UK_CGT_GAINS_AMOUNT_COLUMN",
"UK_CGT_SOURCE_COLUMN",
"UK_CGT_TAXPAYER_COUNT_COLUMN",
"UKCGTTargetMaterialization",
"materialize_uk_cgt_calibration_frame",
"uk_cgt_annual_exempt_amount",
]

#: The persisted person-level input the measures are derived from.
UK_CGT_SOURCE_COLUMN = "capital_gains"

UK_CGT_GAINS_AMOUNT_COLUMN = "uk_cgt_measure_gains_amount"
UK_CGT_TAXPAYER_COUNT_COLUMN = "uk_cgt_measure_taxpayer_count"

#: CGT annual exempt amount by build period (tax year starting in that year).
#:
#: HMRC Capital Gains Tax rates and allowances:
#: https://www.gov.uk/government/publications/rates-and-allowances-capital-gains-tax
UK_CGT_ANNUAL_EXEMPT_AMOUNTS: dict[str, float] = {
"2022": 12_300.0,
"2023": 6_000.0,
"2024": 3_000.0,
"2025": 3_000.0,
"2026": 3_000.0,
}


@dataclass(frozen=True)
class UKCGTTargetMaterialization:
"""Person-level CGT constraint frame and the declared HMRC facts."""

frame: Frame
registry: TargetRegistry
annual_exempt_amount: float
taxpayer_rows: int
minimum_positive_support_rows: int


def uk_cgt_annual_exempt_amount(time_period: int | str) -> float:
"""Return the CGT annual exempt amount in force for ``time_period``.

Raises:
ValueError: If the period has no reviewed AEA. A wrong threshold
silently changes what the declared taxpayer-count fact means, so
an unmapped period is refused rather than defaulted.
"""

period = str(time_period)
try:
return UK_CGT_ANNUAL_EXEMPT_AMOUNTS[period]
except KeyError:
raise ValueError(
f"No reviewed CGT annual exempt amount for period {period!r}; "
"the AEA is policy-dependent (£12,300 -> £6,000 -> £3,000 across "
"2022-23 to 2024-25) and must be declared explicitly. Known "
f"periods: {sorted(UK_CGT_ANNUAL_EXEMPT_AMOUNTS)}."
) from None


def materialize_uk_cgt_calibration_frame(
dataset: UKNationalDataset,
*,
annual_exempt_amount: float | None = None,
) -> UKCGTTargetMaterialization:
"""Prepare the CGT measure columns and assemble the constraint frame.

Args:
dataset: UK national dataset carrying the persisted person-level
``capital_gains`` input and strictly positive household weights.
annual_exempt_amount: Override for the CGT threshold. Defaults to the
reviewed AEA for ``dataset.time_period``.

Returns:
The frame, the two declared facts as a :class:`TargetRegistry`, and
support diagnostics.

Raises:
ValueError: If ``capital_gains`` is absent or non-finite, if any
household prior weight is not strictly positive, or if either
target has no strictly positive-mass support.
"""

if annual_exempt_amount is None:
annual_exempt_amount = uk_cgt_annual_exempt_amount(dataset.time_period)
if not np.isfinite(annual_exempt_amount) or annual_exempt_amount < 0.0:
raise ValueError(
"CGT annual exempt amount must be finite and non-negative; got "
f"{annual_exempt_amount!r}."
)

person = dataset.person
household = dataset.household
if UK_CGT_SOURCE_COLUMN not in person:
raise ValueError(
"UK CGT target materialization requires the person-level "
f"{UK_CGT_SOURCE_COLUMN!r} input column; it is absent from the "
"dataset person table, and the declared HMRC capital gains facts "
"cannot be compiled without it."
)
capital_gains = pd.to_numeric(
person[UK_CGT_SOURCE_COLUMN], errors="coerce"
).to_numpy(dtype=float, na_value=np.nan)
if not np.isfinite(capital_gains).all():
raise ValueError(
f"UK CGT person {UK_CGT_SOURCE_COLUMN!r} values must be finite."
)

mapped_mass = person["person_household_id"].map(
household.set_index("household_id")["household_weight"]
)
if mapped_mass.isna().any() or not mapped_mass.gt(0.0).all():
raise ValueError(
"UK CGT calibration requires strictly positive prior household "
"mass for every person row."
)
positive_mass = mapped_mass.to_numpy(dtype=float) > 0.0

support = capital_gains > float(annual_exempt_amount)
measure_values = {
UK_CGT_TAXPAYER_COUNT_COLUMN: support.astype(float),
UK_CGT_GAINS_AMOUNT_COLUMN: np.where(support, capital_gains, 0.0),
}
if set(measure_values) != set(UK_CGT_REQUIRED_COLUMNS):
raise RuntimeError( # pragma: no cover - guarded by the declared facts
"UK CGT prepared columns diverged from the declared measures: "
f"{sorted(measure_values)} != {sorted(UK_CGT_REQUIRED_COLUMNS)}."
)

n_positive = int((support & positive_mass).sum())
if n_positive == 0:
raise ValueError(
"UK CGT targets have no strictly positive-mass support; refusing "
"to calibrate positive HMRC capital gains facts (£65.9bn, 378k "
"taxpayers) to zero constraint rows. Check the annual exempt "
f"amount ({annual_exempt_amount:,.0f}) against the gains input."
)

calibration_person = person[["person_id", "person_household_id"]].reset_index(
drop=True
)
calibration_person = calibration_person.assign(**measure_values)
frame = Frame(
{
"person": calibration_person,
"household": household[["household_id"]].copy(),
},
EntitySchema(group_entities=("household",)),
{
"household": Weights(
household["household_weight"].to_numpy(dtype=float),
dataset.household_weight_kind,
)
},
mass_log=dataset.mass_log,
)
return UKCGTTargetMaterialization(
frame=frame,
registry=TargetRegistry(UK_CGT_TARGET_SPECS, country="uk"),
annual_exempt_amount=float(annual_exempt_amount),
taxpayer_rows=int(support.sum()),
minimum_positive_support_rows=n_positive,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""UK fiscal target facts, declared natively in Populace.

The UK national calibration surface is currently ingested from
policyengine-uk-data via :func:`populace.calibrate.specs_from_pe_surface`, whose
docstring calls that "the transitional path from harness-extracted surfaces to
declared facts". This module starts the declared-fact side for the UK, following
``us_runtime.fiscal_targets``.

The first facts declared here are capital gains, because their absence is
measurable. The published ``populace-uk`` release (``populace-uk-2023-dd68c73``)
carries 149 targets and none of them constrain capital gains, so the calibrated
weights leave the gains distribution unanchored: reading ``capital_gains``
weighted by ``household_weight`` straight out of ``populace_uk_2023.h5`` gives
1.47m CGT taxpayers holding £96.0bn of gains at a £65,374 mean, against HMRC's
378k, £65.9bn and ~£174,000. The taxpayer count is 3.9x the administrative
figure and the mean gain is 62% below it — the imputation spreads gains across
far too many households in amounts that are individually far too small.

That is distributional error, not a level shift, so it survives any
revenue-side correction. It matters for any CGT analysis: on an uncalibrated
baseline the share of people affected by a rate reform comes out roughly three
times too high.

Two targets are declared:

``hmrc/capital_gains_total``
Total chargeable gains of CGT taxpayers.

``hmrc/cgt_taxpayers``
Number of CGT taxpayers.

Both are person-entity, following the UK convention established by
``uk_runtime.hmrc_calibration``: the measures are person-level, so the
constraint rows live on the person table while the calibrated weights stay on
the household table via the frame's household ``Weights``. Both therefore need
prepared columns on the person frame — the registry refuses callables so that
it can serialize, and count-like facts are documented to use prepared
indicator/count columns. See ``UK_CGT_REQUIRED_COLUMNS``, and
``uk_runtime.cgt_calibration`` for the materialization that prepares them.

Open question for reviewers: ``us_runtime.fiscal_targets`` has since moved to
value-free references whose values arrive from an external Ledger artifact at
build time. The values here are inline with citations, which is what the
registry's own ``TargetSpec`` contract describes ("a value, optionally a
standard error, and always a citation"). If the UK should follow the US onto
Ledger-sourced values instead, these specs are the shape to migrate.
"""

from __future__ import annotations

from populace.build.gates import TargetCoverageRequirement
from populace.calibrate import TargetRegistry, TargetSpec

__all__ = [
"UK_CGT_REQUIRED_COLUMNS",
"UK_CGT_TARGET_COVERAGE_REQUIREMENTS",
"UK_CGT_TARGET_SPECS",
"UK_FISCAL_TARGET_REGISTRY",
]

#: HMRC Capital Gains Tax statistics, tax year 2023-24 (published August 2025).
_HMRC_CGT_SOURCE = (
"HMRC Capital Gains Tax statistics, tax year 2023-24, table 1: "
"https://www.gov.uk/government/statistics/capital-gains-tax-statistics"
)

#: The reference period of the declared facts. HMRC's 2023-24 outturn is the
#: latest published tax year; build-side aging carries it to forecast years.
_HMRC_CGT_PERIOD = 2023

#: Prepared person columns the frame must expose for these facts to compile.
#:
#: ``uk_cgt_measure_gains_amount`` is each person's chargeable gains, zeroed on
#: people who are not CGT taxpayers.
#: ``uk_cgt_measure_taxpayer_count`` is the 0/1 indicator for people whose
#: gains exceed the annual exempt amount **in force for the period** — the AEA moved
#: £12,300 -> £6,000 -> £3,000 across 2022-23 to 2024-25, so a fixed threshold
#: would silently mean different things in different years.
UK_CGT_REQUIRED_COLUMNS: tuple[str, ...] = (
"uk_cgt_measure_gains_amount",
"uk_cgt_measure_taxpayer_count",
)

UK_CGT_TARGET_SPECS: tuple[TargetSpec, ...] = (
TargetSpec(
name="hmrc/capital_gains_total",
entity="person",
measure="uk_cgt_measure_gains_amount",
value=65_900_000_000.0,
period=_HMRC_CGT_PERIOD,
source=_HMRC_CGT_SOURCE,
family="hmrc",
notes=(
"Total chargeable gains of CGT taxpayers in 2023-24. Declared "
"unsigned: the fact is a positive aggregate. Note that a measure "
"carrying gains net of losses can be negative on individual "
"person records even though the total is positive, so a build "
"aggregating losses into this column should confirm the sign "
"handling it wants."
),
),
TargetSpec(
name="hmrc/cgt_taxpayers",
entity="person",
measure="uk_cgt_measure_taxpayer_count",
value=378_000.0,
period=_HMRC_CGT_PERIOD,
source=_HMRC_CGT_SOURCE,
family="hmrc",
notes=(
"Number of CGT taxpayers in 2023-24. The measure column is a "
"person-level indicator for people above the annual exempt amount in force for the period; "
"the AEA is policy-dependent and must track the period rather "
"than being hard-coded."
),
),
)

UK_CGT_TARGET_COVERAGE_REQUIREMENTS: tuple[TargetCoverageRequirement, ...] = (
TargetCoverageRequirement(
requirement_id="uk_capital_gains",
label="HMRC capital gains totals and taxpayer counts",
accepted_names=(
"hmrc/capital_gains_total",
"hmrc/cgt_taxpayers",
),
min_matches=2,
notes=(
"Without both facts the gains distribution is unanchored: the "
"published populace-uk release has 1.47m CGT taxpayers against "
"HMRC's 378k. A revenue-side target constrains what CGT raises, "
"not how gains are spread across households, so it does not "
"substitute for these."
),
),
)

UK_FISCAL_TARGET_REGISTRY = TargetRegistry(UK_CGT_TARGET_SPECS, country="uk")
Loading
Loading