From bfb7e81fc341dd1c8d722c6a96ba0f4ea1770a2a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:45:03 +0400 Subject: [PATCH 1/5] Record which estimated UK areas the disclosure output did not publish UKGeographyDisclosureOutput now keeps, for each level, the codes of the areas the release estimated: those with household weight, which is how policyengine.py's AreaWeights.from_labels builds the release's areas. withheld_codes(level) returns, sorted, the estimated areas that are not published. This covers an area whose relative change alone is withheld, which the release still reports an average for, and the case where withholding cannot resolve the findings and no area is published, which withholds every estimated area. An area with no household weight is neither published nor withheld. Co-Authored-By: Claude Opus 5.5 --- .../simulation_output_geographic.py | 16 +++- .../test_simulation_output_geographic.py | 89 +++++++++++++++++-- 2 files changed, 97 insertions(+), 8 deletions(-) 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/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: From 3a71ed3be299530b360fec915b97828149fa36e6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:52:31 +0400 Subject: [PATCH 2/5] Report each level's withheld UK areas in the local-area breakdown result LocalAreaBreakdownBuilder now fills constituency_withheld_codes and local_authority_withheld_codes from the disclosure output it built the records from: the sorted codes of the areas the release estimated but did not publish, [] when a level withheld none. The builder refuses to build without a disclosure output rather than report the lists as unknown. Co-Authored-By: Claude Opus 5.5 --- .../simulation_output_local_areas.py | 10 ++- .../test_simulation_output_uk_local_areas.py | 65 ++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) 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_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) From d482efeabd9051c6c50bb0076e6785db84bd17dc Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:53:39 +0400 Subject: [PATCH 3/5] Check the withheld-area lists in the beta local-area breakdown test assert_uk_local_area_breakdown now requires each level's withheld codes to be a list of area codes, sorted without repeats, holding no published area. The beta test runs against the executor the deployment just released, which reports them, so a missing list fails it. Co-Authored-By: Claude Opus 5.5 --- .../tests/simulation/test_calculate.py | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) 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( From 18d7fc96a76dacfc3eb84e3292f56bb5ff7f4296 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:54:19 +0400 Subject: [PATCH 4/5] Describe the withheld-area code lists in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 2ab341e29..308b9c8bd 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,6 +1,7 @@ - bump: patch changes: added: + - Added constituency_withheld_codes and local_authority_withheld_codes to LocalAreaBreakdownOutput, the result of a breakdown=local_areas run; 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; both fields are optional and null when a result does not report them, so results from older executors still validate and the job status body omits them; the beta local-area breakdown check requires both lists, sorted and without a published area - 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 From 022deac2a48f68c815e70f2ad09fe605495c9e72 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:29:52 +0400 Subject: [PATCH 5/5] Limit the changelog entry to the executor change The contract and gateway change, with its own changelog entry, moved to PolicyEngine/policyengine-sim-api#976, which deploys first. This entry now describes only the executor filling the two lists and the stricter beta check, and sits beside the local-area breakdown entry so that merging main later does not touch the same text. Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 308b9c8bd..25d5b9e74 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,10 +1,10 @@ - bump: patch changes: added: - - Added constituency_withheld_codes and local_authority_withheld_codes to LocalAreaBreakdownOutput, the result of a breakdown=local_areas run; 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; both fields are optional and null when a result does not report them, so results from older executors still validate and the job status body omits them; the beta local-area breakdown check requires both lists, sorted and without a published area - 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