diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 2ab341e29..25d5b9e74 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -4,6 +4,7 @@ - Added the rule sim.dependencies.uv-version-pinned, checked by a new uv-version quality guard with proof fixtures taken from the unpinned setup-uv step and the ghcr.io/astral-sh/uv:latest copy on main; every astral-sh/setup-uv step, uv container image reference, Modal uv_sync or uv_pip_install call, uv install script or release download, pip, pipx or uv tool install of uv, and uv self update must pin the one uv release written in the new .uv-version file, 0.13.6 - Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced - Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput + - Made a breakdown=local_areas run fill constituency_withheld_codes and local_authority_withheld_codes on its LocalAreaBreakdownOutput, fields the contract and gateway already accept: each holds, sorted, the ONS codes of that level's areas that the run estimated (the areas with household weight) but disclosure control did not publish, including every estimated area when withholding cannot resolve the disclosure findings, and [] when none were withheld; an area the run never estimated, such as one with no households in the dataset, is in neither list; executors deployed for older policyengine.py versions keep leaving both out; the beta local-area breakdown check now requires both lists, sorted and without a published area - Declared local_area_breakdown version 1 in the routing bundle manifest of apps whose policyengine.py bundle routes both UK area region types to one local-area release dataset (regional_release_kind local_area; an area_coded_national bundle, or one routing the two types to different datasets, does not count as routed), and made the gateway refuse breakdown with 400, before spawning, for any route whose manifest does not declare it, including legacy routes and routes pinned to an older version, since older apps ignore the field and would return a national result - Made a routed local-area dataset fail as it loads, before any simulation, when it lacks any of the four household columns the area outputs read (constituency_code_oa, la_code_oa, region_code_oa and source_household_id), for breakdown, constituency and local-authority runs alike, in the runtime and in the Stage 12 worker; Stage 12 copies the same columns - Kept breakdown requests out of the Stage 12 shadow comparison, which skips them as unsupported_options, and added beta integration checks of a UK local-area breakdown and of a UK national result without area data diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py index 674992ace..b4185f12e 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py @@ -364,6 +364,14 @@ class LocalAreaBreakdownOutput(MacroOutputModel): policyengine.py release that computed it. Each list holds only its own level's records. Areas whose income change disclosure control withholds are left out of each list, so a list may be empty. + + ``constituency_withheld_codes`` and ``local_authority_withheld_codes`` + hold, sorted, the ONS codes of that level's areas that the run estimated + but disclosure control did not publish, so a client can tell a withheld + area from an absent one. An area the run never estimated, such as one with + no households in the dataset, is in neither list. An empty list means + that no area of the level was withheld. Null means that the result does + not report them, as results from older executors do not. """ kind: Literal["local_areas"] @@ -373,3 +381,5 @@ class LocalAreaBreakdownOutput(MacroOutputModel): data_version: str constituency_impact: list[ConstituencyImpactRecord] local_authority_impact: list[LocalAuthorityImpactRecord] + constituency_withheld_codes: list[str] | None = None + local_authority_withheld_codes: list[str] | None = None diff --git a/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py b/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py index cdd80eaf1..5ccc7a5f5 100644 --- a/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py +++ b/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py @@ -56,6 +56,14 @@ "data_version": "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", "constituency_impact": [CONSTITUENCY], "local_authority_impact": [LOCAL_AUTHORITY], + "constituency_withheld_codes": ["E14000530", "E14001172"], + "local_authority_withheld_codes": [], +} +# A result from an executor that does not report the withheld areas. +BREAKDOWN_WITHOUT_WITHHELD_CODES = { + key: value + for key, value in BREAKDOWN.items() + if not key.endswith("_withheld_codes") } _ZERO = {"baseline": 0.0, "reform": 0.0} _AGE_POVERTY = {"child": _ZERO, "adult": _ZERO, "senior": _ZERO, "all": _ZERO} @@ -115,6 +123,34 @@ def test_a_breakdown_result_carries_both_area_levels_and_their_provenance(): assert output.model_dump(mode="json") == BREAKDOWN +def test_a_breakdown_result_lists_the_withheld_areas_of_each_level(): + output = LocalAreaBreakdownOutput.model_validate(BREAKDOWN) + + assert output.constituency_withheld_codes == ["E14000530", "E14001172"] + # An empty list reports that disclosure control withheld no area. + assert output.local_authority_withheld_codes == [] + + +def test_a_breakdown_result_without_withheld_codes_still_validates(): + output = LocalAreaBreakdownOutput.model_validate(BREAKDOWN_WITHOUT_WITHHELD_CODES) + + # None means the result does not report which areas were withheld. + assert output.constituency_withheld_codes is None + assert output.local_authority_withheld_codes is None + response = JobStatusResponse( + status="complete", result=BREAKDOWN_WITHOUT_WITHHELD_CODES + ) + assert isinstance(response.result, LocalAreaBreakdownOutput) + + +@pytest.mark.parametrize( + "field", ["constituency_withheld_codes", "local_authority_withheld_codes"] +) +def test_withheld_codes_are_a_list_of_area_codes(field): + with pytest.raises(ValidationError): + LocalAreaBreakdownOutput.model_validate({**BREAKDOWN, field: "E14000530"}) + + def test_a_job_status_reads_a_breakdown_result_as_a_breakdown(): response = JobStatusResponse(status="complete", result=BREAKDOWN) diff --git a/projects/policyengine-apis-integ/tests/simulation/test_calculate.py b/projects/policyengine-apis-integ/tests/simulation/test_calculate.py index 105d7bcb6..391e9322e 100644 --- a/projects/policyengine-apis-integ/tests/simulation/test_calculate.py +++ b/projects/policyengine-apis-integ/tests/simulation/test_calculate.py @@ -247,6 +247,15 @@ def assert_congressional_district_results( _UK_AREA_RESULT_KEYS = ("constituency_impact", "local_authority_impact") +# For each UK area level: its records, their code field and its withheld codes. +_UK_AREA_LEVEL_KEYS = ( + ("constituency_impact", "constituency_code", "constituency_withheld_codes"), + ( + "local_authority_impact", + "local_authority_code", + "local_authority_withheld_codes", + ), +) _PUBLISHED_DISCLOSURE_TIERS = {"reported", "approximate"} @@ -263,7 +272,8 @@ def assert_uk_local_area_breakdown(breakdown_result: object) -> None: """Check a local-area breakdown came from the local-area dataset. Both area levels must publish at least one area, and every published area - must carry a disclosure tier. + must carry a disclosure tier. Each level must list the codes of the areas + disclosure control withheld, sorted and without a published area. """ breakdown = _as_mapping(breakdown_result, label="local-area breakdown") assert breakdown.get("kind") == "local_areas", ( @@ -274,15 +284,39 @@ def assert_uk_local_area_breakdown(breakdown_result: object) -> None: assert dataset.endswith("_dense"), ( f"Expected the local-area dataset, got {dataset!r}" ) - for key in _UK_AREA_RESULT_KEYS: + for key, code_key, withheld_key in _UK_AREA_LEVEL_KEYS: areas = breakdown.get(key) assert isinstance(areas, list), f"Expected {key} to be a list, got {areas!r}" assert areas, f"Expected {key} to publish at least one area" + published_codes: set[object] = set() for area in areas: record = _as_mapping(area, label=f"{key} record") assert record.get("disclosure_tier") in _PUBLISHED_DISCLOSURE_TIERS, ( f"Expected a published disclosure_tier in {key}, got {record}" ) + published_codes.add(record.get(code_key)) + assert_withheld_area_codes(breakdown, withheld_key, published_codes) + + +def assert_withheld_area_codes( + breakdown: Mapping, withheld_key: str, published_codes: set[object] +) -> None: + """Check one level's withheld codes are sorted strings and none is published.""" + withheld_codes = breakdown.get(withheld_key) + assert isinstance(withheld_codes, list), ( + f"Expected {withheld_key} to be a list, got {withheld_codes!r}" + ) + assert all(isinstance(code, str) for code in withheld_codes), ( + f"Expected {withheld_key} to hold area codes, got {withheld_codes!r}" + ) + assert withheld_codes == sorted(set(withheld_codes)), ( + f"Expected {withheld_key} sorted and without repeats, got {withheld_codes}" + ) + published_and_withheld = published_codes & set(withheld_codes) + assert not published_and_withheld, ( + f"Expected no published area in {withheld_key}, got " + f"{sorted(map(str, published_and_withheld))}" + ) class _GeneratedClientObject: @@ -359,6 +393,8 @@ def test_result_assertions_accept_generated_client_objects() -> None: "data_version": "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", "constituency_impact": [_BREAKDOWN_CONSTITUENCY], "local_authority_impact": [_BREAKDOWN_LOCAL_AUTHORITY], + "constituency_withheld_codes": ["E14000530", "E14001172"], + "local_authority_withheld_codes": [], } @@ -385,6 +421,14 @@ def test_breakdown_assertions_accept_generated_client_objects() -> None: }, "disclosure_tier", ), + # The executor under test reports the withheld areas of each level. + ({"constituency_withheld_codes": None}, "constituency_withheld_codes"), + ({"local_authority_withheld_codes": None}, "local_authority_withheld_codes"), + ( + {"constituency_withheld_codes": ["E14001172", "E14000530"]}, + "sorted", + ), + ({"local_authority_withheld_codes": ["E06000063"]}, "published"), ], ) def test_breakdown_assertions_reject_an_incomplete_breakdown(change, message) -> None: @@ -831,7 +875,8 @@ def test_calculate_uk_local_area_breakdown( Given a UK-wide request with breakdown=local_areas When the simulation is submitted and polled to completion Then the result is a local-area breakdown with published constituencies - and local authorities, each with a disclosure tier. + and local authorities, each with a disclosure tier, and each level lists + the codes of the areas disclosure control withheld. """ # Given request = SimulationRequest.from_dict( diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 060b6bb8c..b31f97ada 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -122,6 +122,20 @@ def test_local_area_breakdowns_are_public(): ]["properties"]["result"]["anyOf"] +def test_local_area_breakdowns_may_list_the_withheld_areas_of_each_level(): + breakdown = create_app().openapi()["components"]["schemas"][ + "LocalAreaBreakdownOutput" + ] + + for field in ("constituency_withheld_codes", "local_authority_withheld_codes"): + assert breakdown["properties"][field]["anyOf"] == [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ] + # Optional, so results from executors that do not report them validate. + assert field not in breakdown["required"] + + def test_route_table_is_frozen(): assert methods(cast("OpenAPIDocument", create_app().openapi())) == EXPECTED_ROUTES diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py index 4ce4a49b8..fd798ff49 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py @@ -322,15 +322,23 @@ def record_fields(self) -> dict[str, object]: class UKGeographyDisclosureOutput: """The published areas of each UK level, keyed by area code. - An area the disclosure check withholds is absent. + An area the disclosure check withholds is absent. ``estimated`` holds the + codes of each level's areas that the release estimated, those with + household weight as policyengine.py reads them; each one that is not + published was withheld. """ areas: Mapping[str, Mapping[str, UKAreaIncomeChange]] = field(default_factory=dict) + estimated: Mapping[str, frozenset[str]] = field(default_factory=dict) def level(self, level: str) -> Mapping[str, UKAreaIncomeChange]: """Return the published areas of one level.""" return self.areas.get(level, {}) + def withheld_codes(self, level: str) -> list[str]: + """Return the codes of one level's estimated areas not published, sorted.""" + return sorted(self.estimated.get(level, frozenset()) - set(self.level(level))) + def _require_uk_area_column( household: pd.DataFrame, column: str, breakdown: str @@ -636,13 +644,15 @@ def compute_uk_geography_disclosure_output( public = _resolved_public_table( impact, area_codes, year=year, inside=_uk_areas_inside(household) ) + estimated = {level: frozenset(codes) for level, codes in area_codes.items()} if public is None: - return UKGeographyDisclosureOutput() + return UKGeographyDisclosureOutput(estimated=estimated) return UKGeographyDisclosureOutput( areas={ level: _published_area_changes(public, level, area_codes[level]) for level in UK_DISCLOSURE_AREA_COLUMNS - } + }, + estimated=estimated, ) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_local_areas.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_local_areas.py index 9647ce091..deec1f22b 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_local_areas.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_local_areas.py @@ -86,7 +86,8 @@ def build(self) -> LocalAreaBreakdownOutput: No national impact analysis runs. Both simulations are computed first, baseline then reform, so a status poll moves through those stages before the outputs stage, and both levels then come from one - disclosure release over the pair. + disclosure release over the pair. Each level also lists the codes of + the areas the release estimated but did not publish. """ self.baseline.ensure() self.reform.ensure() @@ -120,6 +121,9 @@ def build(self) -> LocalAreaBreakdownOutput: if constituency_impact is None or local_authority_impact is None: msg = "UK area outputs returned no constituency or local-authority list" raise ValueError(msg) + if disclosure is None: + msg = "A UK local-area breakdown needs the disclosure-controlled output" + raise ValueError(msg) return LocalAreaBreakdownOutput( kind="local_areas", model_version=output_model_version( @@ -138,6 +142,10 @@ def build(self) -> LocalAreaBreakdownOutput: local_authority_impact=_area_records( local_authority_impact, LocalAuthorityImpactRecord ), + constituency_withheld_codes=disclosure.withheld_codes("constituency"), + local_authority_withheld_codes=disclosure.withheld_codes( + "local_authority" + ), ) def serialize(self) -> dict[str, Any]: diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_geographic.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_geographic.py index 84636b533..34905b884 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_geographic.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_geographic.py @@ -140,7 +140,9 @@ def test_complete_uk_geography_output_rejects_fallback_metadata( # the check then withholds one more seat so that subtraction from the region # or from its local authority cannot recover it. NEGATIVE has a negative total # baseline income, the Cardiff West case: its relative change is undefined, so -# the seat is left out although its average change passes. +# the seat is left out although its average change passes. UNWEIGHTED, when +# requested, has households of zero weight only, so the run estimates nothing +# for it. NORTH_WEST = "E12000002" CUMBERLAND = "E06000063" WESTMORLAND = "E06000064" @@ -153,10 +155,14 @@ def test_complete_uk_geography_output_rejects_fallback_metadata( } THIN = "E14000006" NEGATIVE = "E14000007" +UNWEIGHTED = "E14000008" +ESTIMATED_SEATS = {*SEATS, THIN, NEGATIVE} LAD23 = UKLocalAuthorityMetadata(boundary_version=UKLocalAuthorityBoundaryVersion.LAD23) -def _uk_disclosure_columns(*, with_source_ids: bool = True) -> dict[str, list]: +def _uk_disclosure_columns( + *, with_source_ids: bool = True, unweighted_seat: bool = False +) -> dict[str, list]: """Return the households' columns, with baseline and reformed incomes.""" rng = np.random.default_rng(7) rows: list[tuple[str, str, float, float]] = [] @@ -168,12 +174,16 @@ def _uk_disclosure_columns(*, with_source_ids: bool = True) -> dict[str, list]: for index in range(30): income = -40_000.0 if index < 20 else 30_000.0 rows.append((NEGATIVE, WESTMORLAND, income, -float(rng.uniform(50, 150)))) + if unweighted_seat: + rows.extend((UNWEIGHTED, WESTMORLAND, 30_000.0, -100.0) for _ in range(30)) columns: dict[str, list[object]] = { "household_id": list(range(len(rows))), "constituency_code_oa": [row[0] for row in rows], "la_code_oa": [row[1] for row in rows], "region_code_oa": [NORTH_WEST] * len(rows), - "household_weight": [float(rng.uniform(50, 150)) for _ in rows], + "household_weight": [ + 0.0 if row[0] == UNWEIGHTED else float(rng.uniform(50, 150)) for row in rows + ], } if with_source_ids: columns["source_household_id"] = list(range(len(rows))) @@ -183,9 +193,11 @@ def _uk_disclosure_columns(*, with_source_ids: bool = True) -> dict[str, list]: def _uk_disclosure_simulations( - *, with_source_ids: bool = True + *, with_source_ids: bool = True, unweighted_seat: bool = False ) -> tuple[SimpleNamespace, SimpleNamespace]: - columns = _uk_disclosure_columns(with_source_ids=with_source_ids) + columns = _uk_disclosure_columns( + with_source_ids=with_source_ids, unweighted_seat=unweighted_seat + ) baseline_income = columns.pop("baseline_income") reformed_income = columns.pop("reformed_income") baseline = weighted_entity_frame( @@ -270,6 +282,73 @@ def test_uk_disclosure_population_is_the_weighted_household_count( assert change.population == pytest.approx(expected[code]) +@pytest.mark.parametrize( + ("level", "estimated"), + [ + ("constituency", ESTIMATED_SEATS), + ("local_authority", {CUMBERLAND, WESTMORLAND}), + ], +) +def test_uk_disclosure_lists_each_estimated_area_it_does_not_publish( + uk_disclosure, level, estimated +) -> None: + withheld = uk_disclosure.withheld_codes(level) + published = set(uk_disclosure.level(level)) + + assert withheld == sorted(withheld) + assert not published & set(withheld) + # Every area the run estimated is either published or withheld. + assert published | set(withheld) == estimated + + +def test_uk_disclosure_lists_thin_undefined_and_complementary_seats( + uk_disclosure, +) -> None: + withheld = uk_disclosure.withheld_codes("constituency") + + assert {THIN, NEGATIVE} <= set(withheld) + # With THIN withheld, at least one more seat is withheld to protect it. + assert set(withheld) & set(SEATS) + + +def test_uk_disclosure_lists_no_area_the_run_never_estimated() -> None: + # Every household of UNWEIGHTED has zero weight, so the release has no + # estimate for it: it is neither published nor withheld. + baseline, reform = _uk_disclosure_simulations(unweighted_seat=True) + + output = simulation_output_geographic.compute_uk_geography_disclosure_output( + "uk", baseline, reform, year=2026 + ) + + assert output is not None + assert UNWEIGHTED not in output.level("constituency") + assert UNWEIGHTED not in output.withheld_codes("constituency") + published = set(output.level("constituency")) + assert published | set(output.withheld_codes("constituency")) == ESTIMATED_SEATS + + +def test_uk_disclosure_withholds_every_estimated_area_when_findings_remain( + monkeypatch, +) -> None: + # When withholding cells cannot resolve the findings, no area is published. + monkeypatch.setattr( + simulation_output_geographic, + "_resolved_public_table", + lambda *_args, **_kwargs: None, + ) + baseline, reform = _uk_disclosure_simulations() + + output = simulation_output_geographic.compute_uk_geography_disclosure_output( + "uk", baseline, reform, year=2026 + ) + + assert output is not None + assert output.level("constituency") == {} + assert output.level("local_authority") == {} + assert output.withheld_codes("constituency") == sorted(ESTIMATED_SEATS) + assert output.withheld_codes("local_authority") == [CUMBERLAND, WESTMORLAND] + + def test_uk_constituency_output_lists_only_published_seats( uk_disclosure, constituency_lookup ) -> None: diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_uk_local_areas.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_uk_local_areas.py index e4f0c4096..bb3e22de5 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_uk_local_areas.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_uk_local_areas.py @@ -686,13 +686,38 @@ def enter(self, stage): self.events.append(stage) -def _breakdown_builder(monkeypatch, *, events, runtime=None): +# The release estimated three seats and published one, the seat the faked +# constituency output lists; it estimated no local authority. +_PUBLISHED_SEAT = constituency_impact_record().constituency_code +_WITHHELD_SEATS = ["E14000529", "E14000531"] +_SEAT_CHANGE = simulation_output_geographic.UKAreaIncomeChange( + tier="reported", + average=10.0, + average_lower=8.0, + average_upper=12.0, + relative=0.01, + relative_lower=0.008, + relative_upper=0.012, + population=1000.0, +) +_BREAKDOWN_DISCLOSURE = simulation_output_geographic.UKGeographyDisclosureOutput( + areas={"constituency": {_PUBLISHED_SEAT: _SEAT_CHANGE}}, + estimated={ + "constituency": frozenset({_PUBLISHED_SEAT, *_WITHHELD_SEATS}), + "local_authority": frozenset(), + }, +) + + +def _breakdown_builder(monkeypatch, *, events, runtime=None, disclosure=None): """Return a UK breakdown builder whose area outputs are faked. The country module's impact analysis fails the test if it is called. - Each fake records into ``events``. + Each fake records into ``events``. The faked release returns + ``disclosure``, by default ``_BREAKDOWN_DISCLOSURE``. """ - disclosure = simulation_output_geographic.UKGeographyDisclosureOutput() + if disclosure is None: + disclosure = _BREAKDOWN_DISCLOSURE def compute(country, baseline_simulation, reform_simulation, *, year): events.append( @@ -762,9 +787,43 @@ def test_a_local_area_breakdown_publishes_both_levels_from_one_release(monkeypat "data_version": _LOCAL_AREA_REVISION, "constituency_impact": [constituency_impact_record().model_dump(mode="json")], "local_authority_impact": [], + "constituency_withheld_codes": _WITHHELD_SEATS, + # The release withheld no local authority, which is reported as such. + "local_authority_withheld_codes": [], } +def test_a_local_area_breakdown_lists_every_estimated_area_when_none_is_published( + monkeypatch, +): + events: list[object] = [] + disclosure = simulation_output_geographic.UKGeographyDisclosureOutput( + estimated={ + "constituency": frozenset({"E14000531", "E14000529"}), + "local_authority": frozenset({"E06000064", "E06000063"}), + } + ) + builder, _ = _breakdown_builder(monkeypatch, events=events, disclosure=disclosure) + + output = builder.serialize() + + assert output["constituency_withheld_codes"] == ["E14000529", "E14000531"] + assert output["local_authority_withheld_codes"] == ["E06000063", "E06000064"] + + +def test_a_local_area_breakdown_needs_the_disclosure_output(monkeypatch): + # Without the release, the breakdown could not say which areas it withheld. + builder, _ = _breakdown_builder(monkeypatch, events=[]) + monkeypatch.setattr( + "policyengine_simulation_executor.simulation_output_geographic." + "compute_uk_geography_disclosure_output", + lambda *_args, **_kwargs: None, + ) + + with pytest.raises(ValueError, match="disclosure-controlled"): + builder.build() + + def test_a_local_area_breakdown_enters_outputs_after_both_simulations(monkeypatch): events: list[object] = [] builder, _ = _breakdown_builder(monkeypatch, events=events) diff --git a/projects/policyengine-simulation-gateway/README.md b/projects/policyengine-simulation-gateway/README.md index 033836a95..9f837c8bb 100644 --- a/projects/policyengine-simulation-gateway/README.md +++ b/projects/policyengine-simulation-gateway/README.md @@ -104,6 +104,7 @@ instead of a `SingleYearMacroOutput`: | `model_version`, `policyengine_version` | The country model and the policyengine.py release that computed it. | | `dataset`, `data_version` | The local-area dataset's name and release revision. | | `constituency_impact`, `local_authority_impact` | The published areas of each level. Disclosure control leaves withheld areas out, so a list may be empty; each record carries a `disclosure_tier` (`reported` or `approximate`) and 90% interval bounds. | +| `constituency_withheld_codes`, `local_authority_withheld_codes` | The ONS codes, sorted, of each level's areas that the run estimated but disclosure control did not publish. An area the run never estimated, such as one with no households in the dataset, is in neither list, so an area absent from both the records and this list has no estimate. An empty list means that no area was withheld. Results from older executors don't report them: the fields are null, and the status body omits them. | Rules: diff --git a/projects/policyengine-simulation-gateway/tests/golden/openapi.json b/projects/policyengine-simulation-gateway/tests/golden/openapi.json index 715cd2c5a..ff61f5041 100644 --- a/projects/policyengine-simulation-gateway/tests/golden/openapi.json +++ b/projects/policyengine-simulation-gateway/tests/golden/openapi.json @@ -1779,6 +1779,34 @@ }, "type": "array", "title": "Local Authority Impact" + }, + "constituency_withheld_codes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Constituency Withheld Codes" + }, + "local_authority_withheld_codes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Local Authority Withheld Codes" } }, "additionalProperties": false, @@ -1793,7 +1821,7 @@ "local_authority_impact" ], "title": "LocalAreaBreakdownOutput", - "description": "Completed result of a UK local-area breakdown run.\n\nA request with ``breakdown: \"local_areas\"`` returns this instead of a\n``SingleYearMacroOutput``. The run covers the requested UK-wide or nation\nregion on the local-area dataset alone, and computes only the\nconstituency and local-authority impacts; every other figure comes from\nthe national run. ``kind`` is always ``local_areas``. ``dataset`` names the\nlocal-area dataset, ``data_version`` its release revision, and\n``model_version`` and ``policyengine_version`` the country model and the\npolicyengine.py release that computed it. Each list holds only its own\nlevel's records. Areas whose income change disclosure control withholds\nare left out of each list, so a list may be empty." + "description": "Completed result of a UK local-area breakdown run.\n\nA request with ``breakdown: \"local_areas\"`` returns this instead of a\n``SingleYearMacroOutput``. The run covers the requested UK-wide or nation\nregion on the local-area dataset alone, and computes only the\nconstituency and local-authority impacts; every other figure comes from\nthe national run. ``kind`` is always ``local_areas``. ``dataset`` names the\nlocal-area dataset, ``data_version`` its release revision, and\n``model_version`` and ``policyengine_version`` the country model and the\npolicyengine.py release that computed it. Each list holds only its own\nlevel's records. Areas whose income change disclosure control withholds\nare left out of each list, so a list may be empty.\n\n``constituency_withheld_codes`` and ``local_authority_withheld_codes``\nhold, sorted, the ONS codes of that level's areas that the run estimated\nbut disclosure control did not publish, so a client can tell a withheld\narea from an absent one. An area the run never estimated, such as one with\nno households in the dataset, is in neither list. An empty list means\nthat no area of the level was withheld. Null means that the result does\nnot report them, as results from older executors do not." }, "LocalAuthorityImpactRecord": { "properties": {