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/552-cgt-family-wiring.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The UK capital gains family is wired required-at-build: the driver runs hmrc_cgt_gains after the SPI restoration with a pinned --cgt-ods input, cgt_source_stages.json declares the stage's artifacts and operations with the band-fact fence recorded in-manifest, and the release input-coverage manifest carries the family contract.
142 changes: 142 additions & 0 deletions packages/microcosm-build/src/microcosm/build/uk/cgt_source_stages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
{
"version": 1,
"country": "uk",
"policy": "The UK capital gains family is source-manifest-defined. The published joint distribution must be SHA-256 verified before it is read, the stage redraws amounts only (incidence and household weights pass through unchanged), policy amounts come from the policyengine-uk parameter tree through the uk extra's deferred import, and the published band facts are fenced from calibration as policy-endogenous and proxy-conditioned.",
"stages": [
{
"stage": "hmrc_cgt_gains",
"survey": "HMRC Capital Gains Tax statistics table 3 (size of gain by taxable income), 2020-21 to 2023-24",
"source": "https://assets.publishing.service.gov.uk/media/6878ac62760bf6cedaf5bd93/Table_3_2025_Size_of_gain_by_income.ods",
"grain": "person",
"base_candidate": {
"filename": "populace_uk_2023.h5",
"tier": "frs",
"revision": "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z",
"sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833",
"size_bytes": 1315880118,
"runtime_sha256_required": true
},
"artifacts": [
{
"role": "published_fact_surface",
"kind": "administrative_table",
"format": "ods",
"survey": "HMRC Capital Gains Tax statistics table 3",
"publication": "https://www.gov.uk/government/statistics/capital-gains-tax-statistics",
"vintage": "2023-24",
"tax_year_start": 2023,
"locator": "https://assets.publishing.service.gov.uk/media/6878ac62760bf6cedaf5bd93/Table_3_2025_Size_of_gain_by_income.ods",
"sha256": "8e75c00bab949348a7238fea6d995f626c85e5d02813b46606dd7fea85e9d0c3",
"size_bytes": 11996,
"mime_type": "application/vnd.oasis.opendocument.spreadsheet",
"sheets": [
"3_1_2023-24",
"3_2_2022-23",
"3_3_2021-22",
"3_4_2020-21"
],
"mapped_build_period": 2023,
"period_mapping": "tax_year_start",
"basis": "Individuals with a CGT liability only; gains after losses and attributed gains but before the annual exempt amount; taxable income after reliefs and the Personal Allowance; trusts excluded; counts publish in thousands and amounts in GBP millions.",
"runtime_sha256_required": true
},
{
"role": "policy_parameters",
"kind": "versioned_parameter_tree",
"dependency": "policyengine-uk>=2.88 via microcosm-build[uk]",
"parameters": [
"gov.hmrc.income_tax.allowances.personal_allowance.amount",
"gov.hmrc.income_tax.allowances.personal_allowance.maximum_ANI",
"gov.hmrc.income_tax.allowances.personal_allowance.reduction_rate",
"gov.hmrc.cgt.annual_exempt_amount"
],
"instant_rule": "raw dated parameter files evaluated at 1 June of the build period's tax year",
"runtime_sha256_required": false,
"dependency_discipline": "deferred inside uk_cgt_policy_parameters; the base package never imports policyengine-uk at import time"
}
],
"operations": [
{
"kind": "verify_certified_candidate",
"artifact": "base_candidate",
"runtime_sha256_required": true,
"fail_on_mismatch": true
},
{
"kind": "verify_pinned_cgt_ods",
"artifact_role": "published_fact_surface",
"require_before_source_read": true,
"runtime_sha256_required": true,
"fail_on_mismatch": true
},
{
"kind": "taxable_income_proxy",
"components": [
"employment_income",
"self_employment_income",
"state_pension_reported",
"private_pension_income",
"property_income",
"savings_interest_income",
"dividend_income",
"miscellaneous_income"
],
"components_semantics": "Persisted leaves of the model's total_income concept (ITA 2007 s.23); state_pension_reported stands in for social_security_income, whose other taxable benefits are not persisted; reliefs such as pension contributions and Gift Aid are not deducted.",
"allowance": "tapered Personal Allowance from the policy_parameters artifact",
"fail_on_missing_component": true
},
{
"kind": "rank_preserving_allocation",
"within": "income band",
"ordering": "existing gains descending, person_id ascending on ties",
"band_order": "highest gain band first",
"suppressed_cell_allocation": "count implied by the cell's published gains at the band-total mean",
"column_reconciliation": "every income column rescales onto its published All-row taxpayer total",
"shortfall_policy": "proportional scale-down when the population holds less gainer mass than published taxpayers",
"minimum_allocation_people": 1.0,
"weights": "household_weight mapped to persons; no person splits across bands"
},
{
"kind": "within_band_draws",
"bounded_band_family": "truncated exponential matched to the cell's published mean",
"open_band_family": "Pareto with alpha = mean / (mean - lower bound)",
"mean_repair_margin": 0.02,
"mean_repair_reason": "Published counts round to the nearest thousand and amounts to the nearest million; four cells of the 2023-24 table imply a mean outside their own band, and repaired means clamp just inside the violated boundary.",
"bottom_band_floor": "annual exempt amount plus one pound, since every allocated person is a taxpayer with a liability",
"seed_base": 552,
"seed_mixing": "seed combined with the build period; draws ordered by allocation rank",
"deterministic": true
},
{
"kind": "sub_aea_remainder",
"policy": "gainers beyond the published taxpayer mass keep their existing amounts capped at the annual exempt amount",
"rationale": "Table 3 covers only individuals with a CGT liability; remaining gainers are treated as sub-AEA gainers rather than invented into the liability distribution or deleted."
},
{
"kind": "record_mass_conservation_receipt",
"entity": "household",
"reason": "Amounts-only capital gains redraw: household weights pass through unchanged and total household mass is conserved.",
"declared_factor": 1.0,
"gate_coupling": "The terminal family gate requires a valid mass-conserving MassChangeRecord carrying exactly this reason."
},
{
"kind": "classify_cgt_band_facts_with_reviewed_fence",
"calibration_permitted": false,
"fact_fence_id": "cgt_band_facts_policy_endogenous_proxy_conditioned",
"fenced_fact_count": 76,
"fenced_fact_composition": "60 joint cells, 10 gain-band row totals, 6 income-column totals",
"classification_rationale": "The taxpayer count is endogenous to policy (liability requires gains above the annual exempt amount computed by the model under simulation), the income conditioning is an arithmetic proxy that can move a person one band, and the published surface needs rounding and suppression reconciliation before any per-band fact is exact. The SPI 208-fact fence is the precedent.",
"calibrated_facts_unchanged": "The two aggregate facts in UK_CGT_TARGET_SPECS (total gains, taxpayer count; table 1 basis including trusts) remain the only calibrated CGT facts.",
"promotion_path": "A separately reviewed target profile may lift specific band facts out of the fence after the rounding/suppression reconciliation and the taxable-income-proxy adequacy are adjudicated on their own evidence.",
"adjudication": "https://github.com/PolicyEngine/microcosm/issues/552"
}
],
"outputs": [
"capital_gains"
],
"notes": "Amounts-only replacement: who has gains comes from the candidate, how much they have comes from the published joint distribution, and household weights pass through unchanged, the weight kind carries through, and the stage appends the mass-conservation receipt the terminal family gate requires. The stage runs after hmrc_spi_income so the taxable-income proxy sees the restored income surface. Requires the microcosm-build uk extra at build time for the policy_parameters artifact.",
"output_weight_kind": "importance",
"output_weight_kind_rationale": "The stage preserves whatever kind it receives; it runs after hmrc_spi_income, whose prior allocation establishes importance-kind household weights, so the final frame carries importance."
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"country": "uk",
"policy": "spec-only country package; Python execution lives in shared runtime modules",
"resources": [
"cgt_source_stages.json",
"degenerate_reviewed_exclusions.json",
"efrs_parity_known_gaps.json",
"efrs_parity_reference.json",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,27 @@
"weight_source": "household_weight"
},
"family_coverage": {
"hmrc_cgt_gains": {
"base_candidate_sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833",
"base_candidate_tier": "frs",
"calibration_permitted": false,
"effective_mass_requirements": {},
"fact_fence_id": "cgt_band_facts_policy_endogenous_proxy_conditioned",
"fenced_fact_count": 76,
"output_weight_kind": "importance",
"outputs": [
"capital_gains"
],
"required_mass_change_reason": "Amounts-only capital gains redraw: household weights pass through unchanged and total household mass is conserved.",
"source_manifest": "cgt_source_stages.json",
"source_manifest_sha256": "76f40ca54535ef0b21ad28ba235b0efeade7f9b1e16502271e360d7f3197eb54",
"source_vintages": {
"hmrc_surface": "2023-24",
"mapped_build_period": "2023"
},
"stage": "hmrc_cgt_gains",
"status": "required_at_build"
},
"hmrc_spi_income": {
"band_measure": "hmrc_spi_assessable_income",
"base_candidate_sha256": "f17306ccb2aad7ff0130be3589b560afb2e2a12a943570911cd0c77f07934833",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@

The stage owns amounts, not incidence and not weights. Who has gains comes
from the candidate; how much they have comes from the published distribution;
household weights pass through untouched, so the weight kind and mass log
carry through unchanged. Whether the published band facts also become calibration targets is a
household weights pass through untouched, so the weight kind carries
through and the stage appends a mass-conservation receipt the terminal
family gate requires. Whether the published band facts also become calibration targets is a
separate adjudication (see the fence discussion on the issue), and nothing
here assumes it.

Expand Down Expand Up @@ -70,10 +71,11 @@
uk_time_period,
validate_uk_national_frame,
)
from microcosm.frame import Frame
from microcosm.frame import Frame, MassChangeRecord

__all__ = [
"UK_CGT_IMPUTATION_SEED",
"UK_CGT_MASS_CONSERVATION_REASON",
"UK_CGT_IMPUTATION_STAGE_NAME",
"UK_CGT_TAXABLE_INCOME_PROXY_COMPONENTS",
"UKCGTImputationSummary",
Expand All @@ -87,6 +89,15 @@

UK_CGT_IMPUTATION_STAGE_NAME = "hmrc_cgt_gains"

#: The reviewed mass-conservation receipt this stage records. The terminal
#: family gate requires a valid mass-conserving MassChangeRecord carrying
#: exactly this reason, so a build whose CGT stage silently moved household
#: mass — or never ran — fails by name.
UK_CGT_MASS_CONSERVATION_REASON = (
"Amounts-only capital gains redraw: household weights pass through "
"unchanged and total household mass is conserved."
)

#: Base seed for the stage's draws. Combined with the build period so two
#: periods draw differently while each build is reproducible.
UK_CGT_IMPUTATION_SEED = 552
Expand Down Expand Up @@ -513,15 +524,29 @@ def impute_uk_capital_gains(

new_person = person.copy()
new_person["capital_gains"] = new_gains
# Person-only replacement: mass is untouched, so the kind and mass log
# carry through unchanged; Frame construction re-runs linkage validation.
# Person-only replacement: mass is untouched and the kind carries
# through; the appended record is a conservation receipt, not a change —
# the terminal family gate requires it, so a build whose CGT stage moved
# mass or never ran fails by name.
household_mass = float(
pd.to_numeric(
frame.table("household")["household_weight"], errors="raise"
).sum()
)
receipt = MassChangeRecord(
entity="household",
old_total=household_mass,
new_total=household_mass,
declared_factor=1.0,
reason=UK_CGT_MASS_CONSERVATION_REASON,
)
result_frame = uk_national_frame(
person=new_person,
benunit=frame.table("benunit"),
household=frame.table("household"),
time_period=time_period,
weight_kind=uk_household_weight_kind(frame),
mass_log=frame.mass_log,
mass_log=(*frame.mass_log, receipt),
)
validate_uk_national_frame(result_frame)
return result_frame
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -944,8 +944,8 @@ def _family_build_state_diagnostics(
if not valid_matches:
failures.append(
f"{family_name}: final dataset lacks the reviewed, "
"mass-conserving household MassChangeRecord for its SPI "
"prior allocation."
"mass-conserving household MassChangeRecord carrying its "
f"declared reason: {required_reason!r}."
)

diagnostics[family_name] = details
Expand Down
1 change: 1 addition & 0 deletions packages/microcosm-build/tests/test_country_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ def test_uk_package_loads(self) -> None:
spec = load_country_spec("uk")
assert spec.country == "uk"
assert spec.resources == (
"cgt_source_stages.json",
"degenerate_reviewed_exclusions.json",
"efrs_parity_known_gaps.json",
"efrs_parity_reference.json",
Expand Down
10 changes: 9 additions & 1 deletion packages/microcosm-build/tests/test_uk_cgt_imputation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from microcosm.build.uk_runtime.cgt_imputation import (
UK_CGT_IMPUTATION_STAGE_NAME,
UK_CGT_MASS_CONSERVATION_REASON,
UK_CGT_TAXABLE_INCOME_PROXY_COMPONENTS,
UKCGTPolicyParameters,
_band_plans,
Expand Down Expand Up @@ -241,7 +242,14 @@ def test_non_gainers_stay_at_zero_and_weights_pass_through(self) -> None:
pd.testing.assert_frame_equal(
result.table("household"), frame.table("household")
)
assert result.mass_log == frame.mass_log
# The appended record is a conservation receipt for the terminal
# family gate, not a mass change.
assert result.mass_log[:-1] == frame.mass_log
receipt = result.mass_log[-1]
assert receipt.entity == "household"
assert receipt.reason == UK_CGT_MASS_CONSERVATION_REASON
assert receipt.old_total == receipt.new_total
assert receipt.declared_factor == 1.0

def test_remainder_keeps_existing_amounts_capped_at_the_aea(self) -> None:
# One income band holds far more gainer mass than the published
Expand Down
Loading
Loading