Skip to content

Use disclosure-controlled UK geography outputs for constituency and local authority impacts - #860

Open
PavelMakarchuk wants to merge 2 commits into
mainfrom
fix/uk-geography-disclosure
Open

PavelMakarchuk wants to merge 2 commits into
mainfrom
fix/uk-geography-disclosure

Conversation

@PavelMakarchuk

@PavelMakarchuk PavelMakarchuk commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #859

Summary

UK constituency and local-authority results now come from policyengine's disclosure-controlled compute_uk_geography_disclosure (PolicyEngine/policyengine.py#605, closing PolicyEngine/policyengine.py#589). The executor no longer calls the deprecated compute_uk_constituency_impacts or compute_longwise_uk_geography_impacts. The UK region output is not part of this contract, so nothing else used the deprecated path. The pinned policyengine 6.7.5 already has the disclosure API, so the pin does not change.

Why

The deprecated functions apply no minimum sample. They are also not disclosure-controlled for UK Data Service licensed microdata. Production examples (reform gov.hmrc.income_tax.rates.uk[0].rate 0.20 → 0.21, 2026):

  • Report 4684 (country/wales): Cardiff West shows −£192 average but +1372% relative, which implies a baseline mean income of about −£14. Caerphilly shows −5.6%, against −0.1% to −2.3% in the neighbouring seats.
  • Report 4693 (UK): Pendle and Clitheroe shows +1.8%, while every other English seat loses.

How it works

  1. compute_uk_geography_disclosure_output (new, simulation_output_geographic.py) runs one policyengine release for constituency and local_authority. policyengine nests both levels in regions and the nation. The two levels overlap without nesting, so the call acknowledges unchecked_overlap. The release still checks each area against the areas inside it and around it.
  2. The output publishes only the change family (average and relative change). The share families (gaining and losing) are withheld up front, so the check never asks to protect numbers that nobody publishes.
  3. Blocking findings are resolved in a bounded loop (at most 25 rounds). Each round withholds the suggested cell, or the finding's group cell when it is at a published level. A withheld area can be the sum of the public areas of the other level inside it, as Orkney and Shetland is the sum of Orkney and Shetland. In that case the loop withholds those inner areas, read from the household area pairs. Region and national reference cells are never withheld, because those numbers are public elsewhere. If findings remain, the run publishes no area at either level, and a warning names only the kinds of findings.
  4. The builder computes the release once and shares it between the constituency and local-authority segments.
  5. Bootstrap replicates: 200, the layer's minimum. Replicates set only the interval and the rounding, never whether a cell is published. With the default of 1,000, every UK run takes minutes longer.
  6. A dataset without source_household_id now raises a clear error. This matches the existing missing-area-code errors (Admit UK datasets without area codes only when local-area runs are routed, and guard the weight-matrix fallback #727).
  7. Stage 12 keeps region_code_oa and source_household_id in the household frame it stores, next to the two area codes.
  8. The local-authority breakdown now passes the simulations to policyengine and no longer strips weights. That removes _unweighted_household_frame, the last recorded exception to sim.microdata.weight-stripping-confined. Its baseline goes from 1 to 0 (rules/ change, code-owned).

Contract: suppressed cells and uncertainty

This change is backward compatible. Every existing field keeps its type.

  • A withheld area is left out of the list. This also applies to an area whose relative change is undefined (a non-positive baseline income, such as Cardiff West) when its average passes, because the contract needs both values. An empty list is now valid, because every area can be withheld.
  • Published records gain optional fields (default null, so stored results still validate):
    • disclosure_tier: reported or approximate. approximate means a thin effective sample: the value is kept, but the interval understates the uncertainty.
    • average_household_income_change_lower / _upper and relative_household_income_change_lower / _upper: the 90% bootstrap interval, rounded outward.
  • average_household_income_change and relative_household_income_change are now rounded to the order of their standard error. Both now leave out changes of £1 or less a year, as the layer does. population is still the weighted household count.
  • I did not use null values plus a flag, for two reasons. The app's hex map would colour a null as zero. The v2 constituency_impacts and local_authority_impacts tables in policyengine-api have non-null float columns.

The golden OpenAPI changes only by these optional fields and by the docstrings.

App follow-up (PolicyEngine/policyengine-app-v2)

societyWideOutputNormalizer.ts keys the rows by name and copies x, y and both changes. A missing area gives one entry less, and the new fields are ignored, so nothing crashes. An all-withheld level gives {}, and the map then shows "No constituency data available". Follow-ups:

  1. Draw withheld areas as "not shown: too few survey households" from the full area list, instead of leaving a gap in the hex map.
  2. Show disclosure_tier (approximate) and the intervals in the tooltip.

Known limits and risks

Tests

  • tests/test_simulation_output_geographic.py runs the real disclosure layer on weighted frames. It checks the following:
    • A seat with 3 households is withheld, and one more seat is withheld by complementary suppression.
    • The Cardiff West-style seat with negative total baseline income is left out.
    • Published values sit inside their intervals.
    • Population is the weighted household count.
    • The constituency and local-authority records carry the new fields and the lookup or boundary metadata.
    • An empty output gives empty lists, and a missing source_household_id or missing area codes raise.
    • The resolution loop withholds suggested or group cells, or the inner areas of a withheld sum. It never withholds region cells, and it publishes nothing when it cannot resolve a finding.
  • tests/test_simulation_output_builder.py checks that both segments share one computation, and that US runs never compute it.
  • tests/test_stage12_output_planning.py covers the two new retained columns.
  • libs/policyengine-simulation-contract/tests/test_macro_output_uk_geography.py checks that old records validate and that new fields validate. It also checks that withheld is not a valid record tier, that values stay required, and that an empty list is valid.
  • Local runs, all pass:
    • executor: 1567 passed;
    • entry: 652 passed;
    • gateway, with the golden OpenAPI: 260 passed;
    • contract: 254 passed;
    • Stage 12 persistence: 40 passed;
    • ruff@0.15.22 format and check;
    • the rules tool tests and scripts/run_quality_guards.py;
    • pyright on the changed executor modules (not run in CI).
  • Not run locally: the Docker build, the local integration tests and the Modal image smoke. CI runs them.

Reviewer test instructions

  1. cd projects/policyengine-simulation-executor && uv sync --extra test && uv run pytest tests/test_simulation_output_geographic.py tests/test_simulation_output_builder.py tests/test_stage12_output_planning.py tests/test_microdata_weight_boundaries.py -q
  2. cd libs/policyengine-simulation-contract && uv run pytest tests/test_macro_output_uk_geography.py -q
  3. cd projects/policyengine-simulation-gateway && uv run pytest tests/test_openapi_golden.py -q, then review the golden diff. It has only optional fields and docstrings.
  4. On beta, with real data, run a UK economy report for the reform above (country/wales and uk, 2026). Then check these points:
    • Cardiff West is absent or has a negative relative change.
    • No area shows a relative change with the opposite sign to its average.
    • The share of areas withheld is acceptable.
    • The extra run time and the peak memory are acceptable.
    • The logs have no UK geography disclosure findings remain warning.

🤖 Generated with Claude Code

PavelMakarchuk and others added 2 commits October 20, 2026 21:03
… local authorities

The executor built UK constituency and local-authority results with
policyengine's deprecated compute_uk_constituency_impacts and
compute_longwise_uk_geography_impacts. They apply no minimum sample, so a
small area with negative-income households shows an exploding relative
change (Cardiff West: -£192 average, +1372% relative), and their output is
not disclosure-controlled for licensed survey microdata.

Both levels now go through compute_uk_geography_disclosure in one release,
nested in regions and the nation, with the constituency/local-authority
overlap acknowledged. Blocking findings are resolved by withholding the
suggested published cells; region and national reference cells are never
withheld, and a release that cannot be resolved publishes no area. The
share families, which the output does not publish, are withheld up front.

Contract: existing fields keep their types. A withheld area, or one whose
relative change is undefined, is left out of the list. Published records
gain optional disclosure_tier and 90% interval fields. An empty list is
now valid. Stage 12 keeps region_code_oa and source_household_id.

The local-authority breakdown no longer strips weights, so the last
recorded weight-stripping exception is removed and its baseline is 0.

Fixes #859

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A containment finding without a suggested cell names only the outer area.
When that area is withheld and the public areas of the other level inside
it add up to it (Orkney and Shetland is Orkney plus Shetland), the loop
withheld the outer cell again, made no progress, and published no area at
all. It now withholds the inner areas that lie wholly inside the outer
one, read from the household area pairs as the release reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use disclosure-controlled UK geography outputs for constituency and local authority impacts

1 participant