diff --git a/packages/populace-build/src/populace/build/uk/country_package.json b/packages/populace-build/src/populace/build/uk/country_package.json index 0a0951c..9dbd799 100644 --- a/packages/populace-build/src/populace/build/uk/country_package.json +++ b/packages/populace-build/src/populace/build/uk/country_package.json @@ -2,5 +2,5 @@ "schema_version": 1, "country": "uk", "policy": "spec-only country package; Python execution lives in shared runtime modules", - "resources": [] + "resources": ["wealth_source_stages.json"] } diff --git a/packages/populace-build/src/populace/build/uk/wealth_source_stages.json b/packages/populace-build/src/populace/build/uk/wealth_source_stages.json new file mode 100644 index 0000000..2fdd080 --- /dev/null +++ b/packages/populace-build/src/populace/build/uk/wealth_source_stages.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "country": "uk", + "policy": "UK household-wealth source stages are manifest-defined. Wealth holdings are imputed onto the UK population from the ONS Wealth and Assets Survey (WAS) by a weighted donor model over the listed predictors, clipped to the donor's realized range. Cash ISA and stocks & shares (investment) ISA holdings are surfaced as standalone outputs; the investment-ISA component is also folded into corporate_wealth for back-compatibility with the legacy archived UK-data wealth imputation, which summed investment ISAs into corporate_wealth and never represented cash ISAs. Executable Python belongs only in shared Populace runtimes.", + "stages": [ + { + "stage": "household_wealth", + "survey": "ONS Wealth and Assets Survey, Round 8 (2018-2020)", + "source": "https://www.ons.gov.uk/peoplepopulationandcommunity/personalandhouseholdfinances/incomeandwealth/methodologies/wealthandassetssurveyqmi", + "grain": "household", + "artifacts": [ + { + "kind": "public_microdata", + "format": "tab", + "vintage": "round-8-2018-2020", + "locator": "ONS Wealth and Assets Survey, UK Data Service end-user licence" + } + ], + "operations": [ + { "kind": "read_table", "table": "was_household", "weight": "R8xshhwgt" }, + { + "kind": "derive", + "outputs": [ + "cash_isa", + "stocks_and_shares_isa", + "savings", + "corporate_wealth", + "gross_financial_wealth", + "net_financial_wealth", + "property_wealth", + "main_residence_value", + "other_residential_property_value", + "non_residential_property_value", + "owned_land", + "num_vehicles", + "student_loan_balance" + ] + }, + { + "kind": "fit_weighted_qrf", + "predictors": [ + "is_adult", + "is_child", + "region", + "employment_income", + "self_employment_income", + "private_pension_income", + "savings_interest_income", + "dividend_income", + "hbai_household_net_income", + "tenure_type", + "num_bedrooms", + "council_tax" + ] + }, + { "kind": "support_clip", "range": "donor_realized" }, + { "kind": "fold_into", "output": "corporate_wealth", "component": "stocks_and_shares_isa" } + ], + "outputs": [ + "cash_isa", + "stocks_and_shares_isa", + "savings", + "corporate_wealth", + "gross_financial_wealth", + "net_financial_wealth", + "property_wealth", + "main_residence_value", + "other_residential_property_value", + "non_residential_property_value", + "owned_land", + "num_vehicles", + "student_loan_balance" + ], + "nonnegative_outputs": [ + "cash_isa", + "stocks_and_shares_isa", + "savings", + "corporate_wealth", + "gross_financial_wealth", + "property_wealth", + "main_residence_value", + "other_residential_property_value", + "non_residential_property_value", + "owned_land", + "num_vehicles", + "student_loan_balance" + ], + "notes": "Household wealth holdings imputed from the ONS Wealth and Assets Survey (Round 8). WAS field mapping: cash_isa <- DVCISAVR8 (value of cash ISAs, not mapped in the legacy imputation); stocks_and_shares_isa <- DVIISAVR8_aggr (value of investment ISAs, previously folded silently into corporate_wealth); savings <- DVSaValR8_aggr; property_wealth <- DVPropertyR8; main_residence_value <- DVhvalueR8; gross/net financial wealth <- HFINWR8_SUM/HFINWNTR8_Sum; owned_land <- DVLUKValR8_sum; num_vehicles <- vcarnr8; student_loan_balance <- Tot_LosR8_aggr - Tot_los_exc_SLCR8_aggr. corporate_wealth aggregates non-DB pensions, employee shares/options, UK shares, investment ISAs and unit trusts. Imputed onto the UK population by a weighted donor model over the listed predictors and clipped to the donor's realized range. cash_isa and stocks_and_shares_isa are surfaced as standalone outputs; stocks_and_shares_isa is also folded into corporate_wealth for back-compatibility." + } + ] +} diff --git a/packages/populace-build/tests/test_uk_wealth.py b/packages/populace-build/tests/test_uk_wealth.py new file mode 100644 index 0000000..a480869 --- /dev/null +++ b/packages/populace-build/tests/test_uk_wealth.py @@ -0,0 +1,65 @@ +"""Contract tests for the UK household-wealth source manifest. + +The UK build package is spec-only: the wealth holdings are declared in +``uk/wealth_source_stages.json`` and loaded by the shared source-manifest +runtime. These tests assert the manifest is valid and, in particular, that it +surfaces the cash / stocks-and-shares ISA split (with the investment-ISA +component folded into corporate_wealth for back-compatibility). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from populace.build.source_manifest import load_source_manifest + +UK_PACKAGE = Path(__file__).resolve().parents[1] / "src/populace/build/uk" +WEALTH_MANIFEST_PATH = UK_PACKAGE / "wealth_source_stages.json" +ISA_OUTPUTS = {"cash_isa", "stocks_and_shares_isa"} + + +def _manifest(): + return load_source_manifest(WEALTH_MANIFEST_PATH) + + +class TestUkWealthManifest: + def test_manifest_is_uk_household_wealth(self) -> None: + manifest = _manifest() + assert manifest.country == "uk" + assert manifest.version >= 1 + assert {stage.stage for stage in manifest.stages} == {"household_wealth"} + + def test_isa_outputs_present_and_nonnegative(self) -> None: + stage = _manifest().stages[0] + assert ISA_OUTPUTS <= set(stage.outputs) + assert ISA_OUTPUTS <= set(stage.nonnegative_outputs) + + def test_stage_imputes_then_clips(self) -> None: + kinds = [op.kind for op in _manifest().stages[0].operations] + assert "fit_weighted_qrf" in kinds + assert "support_clip" in kinds + + def test_investment_isa_folded_into_corporate_wealth(self) -> None: + # Back-compat: investment ISAs remain part of corporate_wealth. + folds = [ + op + for op in _manifest().stages[0].operations + if op.kind == "fold_into" + ] + assert any( + op.parameters.get("output") == "corporate_wealth" + and op.parameters.get("component") == "stocks_and_shares_isa" + for op in folds + ) + + def test_donor_source_is_cited(self) -> None: + assert _manifest().stages[0].source.startswith("https://") + + +class TestUkCountryPackage: + def test_manifest_is_registered_as_a_resource(self) -> None: + country_package = json.loads( + (UK_PACKAGE / "country_package.json").read_text(encoding="utf-8") + ) + assert "wealth_source_stages.json" in country_package["resources"]