Skip to content
Draft
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_entry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}


Expand All @@ -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", (
Expand All @@ -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:
Expand Down Expand Up @@ -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": [],
}


Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions projects/policyengine-simulation-entry/tests/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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]:
Expand Down
Loading
Loading