From 6c2c934361f968d1f0363c7d63a1b37673201211 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Wed, 24 Jun 2026 13:28:18 +0100 Subject: [PATCH 1/2] Add UK household-wealth imputation stage with cash/S&S ISA split Declare a WAS-donor `household_wealth` source stage (manifest + plan module, mirroring the bus imputation pattern) that surfaces `cash_isa` and `stocks_and_shares_isa` as standalone outputs, with the investment-ISA component also folded into `corporate_wealth` for back-compatibility. Addresses the gap from the archived UK data wealth imputation, where investment ISAs were summed into corporate_wealth (not separable) and cash ISAs were never represented. The matching model-side input variables are in PolicyEngine/policyengine-uk#1791. Draft: the executable WAS donor transform is injected by the build runtime (not in this manifest by design); requesting review on stage placement given the UK build module (bus, geography) currently lives on uk-bus-fare-imputation-calibration. Closes #180 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/populace/build/uk/__init__.py | 24 ++++ .../populace/build/uk/wealth_imputation.py | 105 ++++++++++++++++++ .../build/uk/wealth_source_stages.json | 91 +++++++++++++++ .../populace-build/tests/test_uk_wealth.py | 81 ++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 packages/populace-build/src/populace/build/uk/__init__.py create mode 100644 packages/populace-build/src/populace/build/uk/wealth_imputation.py create mode 100644 packages/populace-build/src/populace/build/uk/wealth_source_stages.json create mode 100644 packages/populace-build/tests/test_uk_wealth.py diff --git a/packages/populace-build/src/populace/build/uk/__init__.py b/packages/populace-build/src/populace/build/uk/__init__.py new file mode 100644 index 0000000..cc76f42 --- /dev/null +++ b/packages/populace-build/src/populace/build/uk/__init__.py @@ -0,0 +1,24 @@ +"""UK build helpers. + +Currently exposes the household-wealth imputation plan (WAS holdings, including +the cash / stocks-and-shares ISA split). Other UK build stages (bus spending, +local geography) are added alongside as they land on ``main``. +""" + +from populace.build.uk.wealth_imputation import ( + UK_WEALTH_DONORS, + UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS, + UK_WEALTH_SOURCE_MANIFEST, + UK_WEALTH_SOURCE_STAGE_SPECS, + UK_WEALTH_STAGE_NAMES, + uk_wealth_plan, +) + +__all__ = [ + "UK_WEALTH_DONORS", + "UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS", + "UK_WEALTH_SOURCE_MANIFEST", + "UK_WEALTH_SOURCE_STAGE_SPECS", + "UK_WEALTH_STAGE_NAMES", + "uk_wealth_plan", +] diff --git a/packages/populace-build/src/populace/build/uk/wealth_imputation.py b/packages/populace-build/src/populace/build/uk/wealth_imputation.py new file mode 100644 index 0000000..9af1e78 --- /dev/null +++ b/packages/populace-build/src/populace/build/uk/wealth_imputation.py @@ -0,0 +1,105 @@ +"""UK household-wealth imputation plan (WAS holdings, incl. ISA split). + +Declares the source stage and donor graph that impute household wealth +holdings onto the UK population from the ONS Wealth and Assets Survey, and +assembles them into a :class:`~populace.build.plan.StagePlan`. The executable +stage transforms are injected by the build caller — there are no stubs or +fallbacks, exactly as the US and bus plans work. + +The stage surfaces ``cash_isa`` and ``stocks_and_shares_isa`` as standalone +outputs (the legacy archived UK data imputation folded investment ISAs into +``corporate_wealth`` and never represented cash ISAs); +``stocks_and_shares_isa`` is also folded into ``corporate_wealth`` for +back-compatibility. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from importlib.resources import files + +from populace.build.plan import DonorSpec, Stage, StagePlan +from populace.build.source_manifest import ( + SourceManifest, + SourceStageSpec, + load_source_manifest, +) +from populace.frame import Frame + + +def _load_uk_wealth_source_manifest() -> SourceManifest: + return load_source_manifest( + files(__package__).joinpath("wealth_source_stages.json") + ) + + +UK_WEALTH_SOURCE_MANIFEST: SourceManifest = _load_uk_wealth_source_manifest() +UK_WEALTH_SOURCE_STAGE_SPECS: tuple[SourceStageSpec, ...] = ( + UK_WEALTH_SOURCE_MANIFEST.stages +) +UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS: frozenset[str] = frozenset( + output + for stage in UK_WEALTH_SOURCE_STAGE_SPECS + for output in stage.nonnegative_outputs +) + +UK_WEALTH_STAGE_NAMES: tuple[str, ...] = tuple( + stage.stage for stage in UK_WEALTH_SOURCE_STAGE_SPECS +) + +UK_WEALTH_DONORS: Mapping[str, DonorSpec] = { + stage.stage: DonorSpec(survey=stage.survey, source=stage.source, notes=stage.notes) + for stage in UK_WEALTH_SOURCE_STAGE_SPECS +} + + +def uk_wealth_plan( + implementations: Mapping[str, Callable[[Frame], Frame]], +) -> StagePlan: + """Assemble the UK household-wealth imputation plan. + + Mirrors ``us_plan`` / ``uk_bus_plan``: every declared stage needs an + injected transform; there are no stubs or fallbacks by design. + + Args: + implementations: ``stage name -> transform(frame) -> frame`` for every + stage in :data:`UK_WEALTH_STAGE_NAMES`. + + Raises: + ValueError: If an implementation is missing for a declared stage, or an + unknown stage name is supplied. + """ + missing = [ + name for name in UK_WEALTH_STAGE_NAMES if name not in implementations + ] + if missing: + raise ValueError( + f"uk_wealth_plan needs an implementation for every declared stage; " + f"missing {missing}. There are no stubs or fallbacks by design." + ) + unknown = sorted(set(implementations) - set(UK_WEALTH_STAGE_NAMES)) + if unknown: + raise ValueError( + f"Unknown stage implementation(s) {unknown}; declared stages " + f"are {list(UK_WEALTH_STAGE_NAMES)}." + ) + stage_map = UK_WEALTH_SOURCE_MANIFEST.stage_map() + return StagePlan( + Stage( + name=name, + transform=implementations[name], + produces=stage_map[name].outputs, + donor=UK_WEALTH_DONORS[name], + ) + for name in UK_WEALTH_STAGE_NAMES + ) + + +__all__ = [ + "UK_WEALTH_DONORS", + "UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS", + "UK_WEALTH_SOURCE_MANIFEST", + "UK_WEALTH_SOURCE_STAGE_SPECS", + "UK_WEALTH_STAGE_NAMES", + "uk_wealth_plan", +] 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..ae8cb57 --- /dev/null +++ b/packages/populace-build/tests/test_uk_wealth.py @@ -0,0 +1,81 @@ +"""Contract tests for the UK household-wealth imputation plan. + +Mirrors ``test_uk_bus.py`` (plan assembly + donor citations), for the +WAS-anchored wealth holdings — in particular the cash_isa / +stocks_and_shares_isa split and the back-compat fold into corporate_wealth. +""" + +from __future__ import annotations + +import pytest + +from populace.build.uk import ( + UK_WEALTH_DONORS, + UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS, + UK_WEALTH_SOURCE_MANIFEST, + UK_WEALTH_STAGE_NAMES, + uk_wealth_plan, +) + +EXPECTED_STAGES = {"household_wealth"} +ISA_OUTPUTS = {"cash_isa", "stocks_and_shares_isa"} + + +def _noop_implementations() -> dict: + return {name: (lambda frame: frame) for name in UK_WEALTH_STAGE_NAMES} + + +class TestUkWealthManifest: + def test_manifest_is_uk(self) -> None: + assert UK_WEALTH_SOURCE_MANIFEST.country == "uk" + assert UK_WEALTH_SOURCE_MANIFEST.version >= 1 + assert set(UK_WEALTH_STAGE_NAMES) == EXPECTED_STAGES + + def test_isa_outputs_present_and_nonnegative(self) -> None: + outputs = { + output + for stage in UK_WEALTH_SOURCE_MANIFEST.stages + for output in stage.outputs + } + assert ISA_OUTPUTS <= outputs + assert ISA_OUTPUTS <= UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS + + def test_stage_imputes_then_clips(self) -> None: + for stage in UK_WEALTH_SOURCE_MANIFEST.stages: + kinds = [op.kind for op in stage.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 stage in UK_WEALTH_SOURCE_MANIFEST.stages + for op in stage.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 + ) + + +class TestUkWealthPlan: + def test_plan_assembles_with_donor_citations(self) -> None: + plan = uk_wealth_plan(_noop_implementations()) + assert tuple(stage.name for stage in plan.stages) == UK_WEALTH_STAGE_NAMES + donor_stages = dict(plan.donors()) + assert set(donor_stages) == set(UK_WEALTH_DONORS) + for spec in donor_stages.values(): + assert spec.source.startswith("https://") + + def test_missing_stage_refuses_to_assemble(self) -> None: + with pytest.raises(ValueError, match="missing"): + uk_wealth_plan({}) + + def test_unknown_stage_refuses_to_assemble(self) -> None: + implementations = _noop_implementations() + implementations["not_a_stage"] = lambda frame: frame + with pytest.raises(ValueError, match="Unknown stage"): + uk_wealth_plan(implementations) From f8cf6f891896589bb0f2039848fb7d78e5170074 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Wed, 24 Jun 2026 14:45:47 +0100 Subject: [PATCH 2/2] Make UK wealth stage spec-only (declarative manifest, no Python) The uk/ build package is a spec-only country package (JSON resources listed in country_package.json; runtime Python lives in shared modules). The first cut added a plan module and __init__.py into uk/, violating the spec-only contract. - Drop uk/wealth_imputation.py and uk/__init__.py. - Register wealth_source_stages.json in uk/country_package.json resources. - Rewrite test_uk_wealth.py to load the manifest via the shared load_source_manifest runtime and assert the cash/S&S ISA split, the fit+clip operations, and the fold into corporate_wealth. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/populace/build/uk/__init__.py | 24 ---- .../populace/build/uk/country_package.json | 2 +- .../populace/build/uk/wealth_imputation.py | 105 ------------------ .../populace-build/tests/test_uk_wealth.py | 82 ++++++-------- 4 files changed, 34 insertions(+), 179 deletions(-) delete mode 100644 packages/populace-build/src/populace/build/uk/__init__.py delete mode 100644 packages/populace-build/src/populace/build/uk/wealth_imputation.py diff --git a/packages/populace-build/src/populace/build/uk/__init__.py b/packages/populace-build/src/populace/build/uk/__init__.py deleted file mode 100644 index cc76f42..0000000 --- a/packages/populace-build/src/populace/build/uk/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""UK build helpers. - -Currently exposes the household-wealth imputation plan (WAS holdings, including -the cash / stocks-and-shares ISA split). Other UK build stages (bus spending, -local geography) are added alongside as they land on ``main``. -""" - -from populace.build.uk.wealth_imputation import ( - UK_WEALTH_DONORS, - UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS, - UK_WEALTH_SOURCE_MANIFEST, - UK_WEALTH_SOURCE_STAGE_SPECS, - UK_WEALTH_STAGE_NAMES, - uk_wealth_plan, -) - -__all__ = [ - "UK_WEALTH_DONORS", - "UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS", - "UK_WEALTH_SOURCE_MANIFEST", - "UK_WEALTH_SOURCE_STAGE_SPECS", - "UK_WEALTH_STAGE_NAMES", - "uk_wealth_plan", -] 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_imputation.py b/packages/populace-build/src/populace/build/uk/wealth_imputation.py deleted file mode 100644 index 9af1e78..0000000 --- a/packages/populace-build/src/populace/build/uk/wealth_imputation.py +++ /dev/null @@ -1,105 +0,0 @@ -"""UK household-wealth imputation plan (WAS holdings, incl. ISA split). - -Declares the source stage and donor graph that impute household wealth -holdings onto the UK population from the ONS Wealth and Assets Survey, and -assembles them into a :class:`~populace.build.plan.StagePlan`. The executable -stage transforms are injected by the build caller — there are no stubs or -fallbacks, exactly as the US and bus plans work. - -The stage surfaces ``cash_isa`` and ``stocks_and_shares_isa`` as standalone -outputs (the legacy archived UK data imputation folded investment ISAs into -``corporate_wealth`` and never represented cash ISAs); -``stocks_and_shares_isa`` is also folded into ``corporate_wealth`` for -back-compatibility. -""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping -from importlib.resources import files - -from populace.build.plan import DonorSpec, Stage, StagePlan -from populace.build.source_manifest import ( - SourceManifest, - SourceStageSpec, - load_source_manifest, -) -from populace.frame import Frame - - -def _load_uk_wealth_source_manifest() -> SourceManifest: - return load_source_manifest( - files(__package__).joinpath("wealth_source_stages.json") - ) - - -UK_WEALTH_SOURCE_MANIFEST: SourceManifest = _load_uk_wealth_source_manifest() -UK_WEALTH_SOURCE_STAGE_SPECS: tuple[SourceStageSpec, ...] = ( - UK_WEALTH_SOURCE_MANIFEST.stages -) -UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS: frozenset[str] = frozenset( - output - for stage in UK_WEALTH_SOURCE_STAGE_SPECS - for output in stage.nonnegative_outputs -) - -UK_WEALTH_STAGE_NAMES: tuple[str, ...] = tuple( - stage.stage for stage in UK_WEALTH_SOURCE_STAGE_SPECS -) - -UK_WEALTH_DONORS: Mapping[str, DonorSpec] = { - stage.stage: DonorSpec(survey=stage.survey, source=stage.source, notes=stage.notes) - for stage in UK_WEALTH_SOURCE_STAGE_SPECS -} - - -def uk_wealth_plan( - implementations: Mapping[str, Callable[[Frame], Frame]], -) -> StagePlan: - """Assemble the UK household-wealth imputation plan. - - Mirrors ``us_plan`` / ``uk_bus_plan``: every declared stage needs an - injected transform; there are no stubs or fallbacks by design. - - Args: - implementations: ``stage name -> transform(frame) -> frame`` for every - stage in :data:`UK_WEALTH_STAGE_NAMES`. - - Raises: - ValueError: If an implementation is missing for a declared stage, or an - unknown stage name is supplied. - """ - missing = [ - name for name in UK_WEALTH_STAGE_NAMES if name not in implementations - ] - if missing: - raise ValueError( - f"uk_wealth_plan needs an implementation for every declared stage; " - f"missing {missing}. There are no stubs or fallbacks by design." - ) - unknown = sorted(set(implementations) - set(UK_WEALTH_STAGE_NAMES)) - if unknown: - raise ValueError( - f"Unknown stage implementation(s) {unknown}; declared stages " - f"are {list(UK_WEALTH_STAGE_NAMES)}." - ) - stage_map = UK_WEALTH_SOURCE_MANIFEST.stage_map() - return StagePlan( - Stage( - name=name, - transform=implementations[name], - produces=stage_map[name].outputs, - donor=UK_WEALTH_DONORS[name], - ) - for name in UK_WEALTH_STAGE_NAMES - ) - - -__all__ = [ - "UK_WEALTH_DONORS", - "UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS", - "UK_WEALTH_SOURCE_MANIFEST", - "UK_WEALTH_SOURCE_STAGE_SPECS", - "UK_WEALTH_STAGE_NAMES", - "uk_wealth_plan", -] diff --git a/packages/populace-build/tests/test_uk_wealth.py b/packages/populace-build/tests/test_uk_wealth.py index ae8cb57..a480869 100644 --- a/packages/populace-build/tests/test_uk_wealth.py +++ b/packages/populace-build/tests/test_uk_wealth.py @@ -1,57 +1,50 @@ -"""Contract tests for the UK household-wealth imputation plan. +"""Contract tests for the UK household-wealth source manifest. -Mirrors ``test_uk_bus.py`` (plan assembly + donor citations), for the -WAS-anchored wealth holdings — in particular the cash_isa / -stocks_and_shares_isa split and the back-compat fold into corporate_wealth. +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 pytest +import json +from pathlib import Path -from populace.build.uk import ( - UK_WEALTH_DONORS, - UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS, - UK_WEALTH_SOURCE_MANIFEST, - UK_WEALTH_STAGE_NAMES, - uk_wealth_plan, -) +from populace.build.source_manifest import load_source_manifest -EXPECTED_STAGES = {"household_wealth"} +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 _noop_implementations() -> dict: - return {name: (lambda frame: frame) for name in UK_WEALTH_STAGE_NAMES} +def _manifest(): + return load_source_manifest(WEALTH_MANIFEST_PATH) class TestUkWealthManifest: - def test_manifest_is_uk(self) -> None: - assert UK_WEALTH_SOURCE_MANIFEST.country == "uk" - assert UK_WEALTH_SOURCE_MANIFEST.version >= 1 - assert set(UK_WEALTH_STAGE_NAMES) == EXPECTED_STAGES + 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: - outputs = { - output - for stage in UK_WEALTH_SOURCE_MANIFEST.stages - for output in stage.outputs - } - assert ISA_OUTPUTS <= outputs - assert ISA_OUTPUTS <= UK_WEALTH_NONNEGATIVE_SOURCE_OUTPUTS + 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: - for stage in UK_WEALTH_SOURCE_MANIFEST.stages: - kinds = [op.kind for op in stage.operations] - assert "fit_weighted_qrf" in kinds - assert "support_clip" in kinds + 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 stage in UK_WEALTH_SOURCE_MANIFEST.stages - for op in stage.operations + for op in _manifest().stages[0].operations if op.kind == "fold_into" ] assert any( @@ -60,22 +53,13 @@ def test_investment_isa_folded_into_corporate_wealth(self) -> None: for op in folds ) + def test_donor_source_is_cited(self) -> None: + assert _manifest().stages[0].source.startswith("https://") -class TestUkWealthPlan: - def test_plan_assembles_with_donor_citations(self) -> None: - plan = uk_wealth_plan(_noop_implementations()) - assert tuple(stage.name for stage in plan.stages) == UK_WEALTH_STAGE_NAMES - donor_stages = dict(plan.donors()) - assert set(donor_stages) == set(UK_WEALTH_DONORS) - for spec in donor_stages.values(): - assert spec.source.startswith("https://") - def test_missing_stage_refuses_to_assemble(self) -> None: - with pytest.raises(ValueError, match="missing"): - uk_wealth_plan({}) - - def test_unknown_stage_refuses_to_assemble(self) -> None: - implementations = _noop_implementations() - implementations["not_a_stage"] = lambda frame: frame - with pytest.raises(ValueError, match="Unknown stage"): - uk_wealth_plan(implementations) +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"]