Skip to content

Add disclosure control and uncertainty for UK geography outputs - #605

Merged
juaristi22 merged 3 commits into
mainfrom
uk-geography-disclosure-589
Oct 14, 2026
Merged

juaristi22 merged 3 commits into
mainfrom
uk-geography-disclosure-589

Conversation

@juaristi22

@juaristi22 juaristi22 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #589

Summary

UK results by region, constituency and local authority come from licensed survey microdata. This PR adds one layer that every published geography number goes through, as agreed in #589.

  • policyengine.disclosure (new package, array-first): the rules, the bootstrap, the rounding, the release check and the validator. It imports nothing from the rest of the library.
  • compute_uk_geography_disclosure (new output): a thin adapter from two completed simulations.
  • Passthrough: UK runs keep source_household_id and region on the household output.
  • Deprecation: compute_uk_constituency_impacts and compute_uk_local_authority_impacts warn, through the log too, that their output is not disclosure-controlled. Their behaviour is unchanged.

Rules

  • Count floor. At least 10 distinct survey households that contribute to a number. Clones count once, through source_household_id. For a share, both sides pass. The floor can be raised (30), never lowered.
  • Dominance (while noise is off). No household above half of a number. And the p% rule on signed parts: no reader, and no one household that knows its own part, can estimate any household's part to within 10%.
  • Effective sample. On data given as one area per record it sets reported or approximate. On weight-matrix data it is also a gate (10) and it sets the support band. Weights given as a matrix keep the matrix rules whatever they hold.
  • Tiers. reported, approximate (value and interval kept, tier as the flag) and withheld (no value, interval, direction or band).
  • Zero cells publish as zero.
  • Noise (off by default): one HMAC-SHA256 factor per survey household, 8 to 12% from 1, on the change only. It fails closed without a key, and the key is never stored on a model.
  • Uncertainty. Poisson bootstrap over survey households, 1,000 replicates, 90% interval. A ratio resamples numerator and denominator together.
  • Rounding to the order of the standard error, interval rounded outward.
  • Release check (reports only): child areas against their parent; a package against its components; for two levels that do not nest, each area against the areas of the other level inside it and around it; and prior_public. Reference estimates (publish=False) are checked like the release's own. to_public() refuses before a check and while a blocking finding remains.
  • validate_public and python -m policyengine.disclosure validate: a format check that needs no key.

Where this differs from the issue text

See my comment on #589. In short: approximate rows keep a value, zero cells publish as zero, withheld rows show no band, the bootstrap resamples denominators, and a remainder gets the rules of a cell, with no extra credit for noise or rounding.

Notes for review

  • pydantic in the package. UK geography outputs: disclosure control and uncertainty for region, constituency and local-authority impacts #589 says numpy and pandas only. The package also uses pydantic, so its public classes follow the repo rule on Pydantic models.
  • AreaEstimates is a plain class on purpose. It holds exact values, counts and noise factors. Its data attributes are private, and it cannot be pickled or copied.
  • The validator is a format check. It cannot tell whether a number rested on enough households. Only the release that made the file can.
  • Coalitions. The rules protect each household against any reader and any one other household, not against households that pool what they know.
  • Overlapping levels. Seats and local authorities released together are checked area by area against the areas inside and around them. On real geography that blocks many cells, so in practice release one of the two levels per dataset and run. The caller must also acknowledge unchecked_overlap, because other differences are not checked.
  • A dataset without source_household_id is refused unless the caller passes source_ids.

Tests

176 new tests on synthetic data only, plus the two deprecation tests and the passthrough assertions. They cover the cases listed in #589 and the probes from both reviews.

I also ran the layer on a private Microcosm file through policyengine-uk and the array API, as the dashboard will. The runs found defects that the synthetic tests had missed, each now fixed with a test.

Not in this PR

The modelled seat estimator, replicate weights, automatic secondary suppression, and the dashboard wiring (PolicyEngine/uk-autumn-budget-dashboard-2026#24).

🤖 Generated with Claude Code

Add policyengine.disclosure, a self-contained array-first layer: a floor on
distinct survey households, a dominance rule or keyed noise, bootstrap
intervals, precision-adaptive rounding, a release check for numbers a reader
could recover by subtraction, and a format validator for published files.

Add compute_uk_geography_disclosure as the Simulation adapter, keep
source_household_id and region on UK household outputs, and deprecate the two
unprotected constituency and local-authority functions.

Fixes #589

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

Copy link
Copy Markdown
Contributor

@juaristi22

Close, but not ready to publish from yet: four gaps in the rules, each shown below on synthetic data, then smaller fixes.

The structure matches what we agreed on #589, and the tests are strong. Everything below uses synthetic data only.

Must

1. The dominance rule misses two cases. support.py:44-54 and support.py:86

The rule compares the largest household's |wΔ| with Σ|wΔ|. That misses two cases:

  • Mixed signs. One household at +1,000, five at +110 and five at −110. Its share of Σ|wΔ| is 0.476, so the cell passes. The published total is 1,000, which is exactly that household's change. Packages with small offsetting gains and losses will produce this.
  • A few households carrying the whole total. Three households at +500 and 17 at +2 (or at about 1e-6 with a lower change_tolerance, see 5). The count floor passes and the top share is 0.33, so the cell is published, though three households make up about 98% of it.

Suggested fix: also test the top household against the signed total, in p% form. Withhold when |T − c_top| is small next to |c_top|, because the rest then barely moves the total away from one household's value. Add a top-two rule, or a p% rule, for the coalition case. Both are gaps in the rule we agreed, not a mistake in how it was coded.

2. from_matrix can turn weight-matrix data into label data and skip the n_eff gate. areas.py:121-129

A matrix with at most one non-zero per record is stored in the label layout. Label data skips the n_eff ≥ 10 gate and bands on the household count. On eFRS every household has weight in every seat of its nation. So a one-seat slice, or one seat per nation, meets the condition, and both are natural in a per-seat pipeline. Synthetic probe: 50 records, three of them heavy, n_eff 3.3. As a one-row matrix the cell is published as approximate with band 30+. Add a second row of 1e-9 weights and the same cell is withheld with band <10. Suggested fix: keep matrix rules for anything built through from_matrix. The label storage can stay as an internal fast path, with a flag saying the data came from a matrix.

3. The relation checks skip estimates added with publish=False. release.py:375, release.py:472, release.py:494

add(..., publish=False) says the rows that pass are public elsewhere. But a withheld cell in such an estimate never joins hidden, so its remainder is not checked. Probe: a package and a broad component are published, and a narrow component is added with publish=False. The narrow component reaches three households in one area and is withheld there. check() returns no findings, and package minus broad gives the narrow total exactly. If the narrow component is published instead, the same release gives recoverable_remainder. The same skip applies to a reference level that is the child in a nesting (line 375). Suggested fix: treat a withheld cell of any estimate in the release as hidden, whatever its publish flag. Run the package and nesting checks whenever the parent side is public, here or elsewhere.

4. Constituencies and local authorities: subtraction between the two levels is unchecked once acknowledged. release.py:572-613, docs/disclosure.md:118-122

This blocks by default, which is right. But acknowledge=["unchecked_overlap"] opens it with no check behind it. Probe on label data: LA L1 is seat S1 plus five households of seat S2. With S1 and L1 both published, L1 minus S1 gives those five households' total exactly. Real geography has this shape: the City of London and the Isles of Scilly each sit inside one seat. On a weight matrix the difference is a mix of every household, not a small set, so this is about one-area-per-record data. Before both levels are published for the same run on label data, I'd add a containment check. For each area of one level, take the areas of the other level that lie wholly inside it, and run the remainder through _passes as the nesting check does. Until then, unchecked_overlap should not be acknowledgeable for two label levels.

Should

5. The floors can be lowered, and change_tolerance has no floor. schema.py:125-145

DisclosureSettings(min_sources=1) is refused. But DisclosureSettings().model_copy(update={"min_sources": 1}) and model_construct(min_sources=1) are accepted, and a three-household cell then comes out of to_public(). That is standard pydantic behaviour and needs a deliberate step. But the docs say "you cannot lower them", so estimate_change and Release could re-validate (DisclosureSettings.model_validate(settings.model_dump())). Separately, change_tolerance is only gt=0 (line 136). At 1e-9, floating-point residue counts towards the 10 households: in my probe a three-household cell moved from withheld to approximate. A lower bound, for example ge=1.0, closes it.

6. The adapter quietly checks matrix levels against the nation, not the region. uk_geography_disclosure.py:173-185, docs/disclosure.md:94-96

With a weight matrix for constituency in area_weights= and no parent_of, the nesting falls back to ("constituency", "national"). With a single national area, every withheld seat goes into one remainder, and that nearly always passes. The docs say constituencies are always checked against their region. Since this is the eFRS path, I'd make a missing parent_of for a matrix level an error.

7. The deprecation warning won't reach anyone in a real run. uk_geography_impact.py:16-24

policyengine_core/tools/hugging_face.py:14 calls warnings.simplefilter("ignore") at import. Every warning is silenced in any process that has loaded a country model, which is every process that can call these functions. I checked: after importing the library, even a UserWarning from __main__ doesn't print. The tests pass because pytest resets the filters. A FutureWarning would be silenced too. I know you chose to keep the behaviour unchanged. A WARNING line through the policyengine logger keeps it unchanged, and the blanket filter doesn't touch it. A keyword the caller must pass (for example allow_unprotected=True) would be the stronger option.

8. AreaEstimates holds the noise factors in clear and can be pickled. estimate.py:54-80, estimate.py:220-224

The repr is safe: I checked repr, str, and every serialisation path of PublicRow and PublicTable on withheld rows, and none shows a value, interval, direction or band. But .table is a public attribute with exact values and counts, and estimates.table.to_csv(...) is one line away. contributions["share_gaining"] and ["share_losing"] hold each gainer's and loser's noise factor exactly. Within a dataset lineage those factors do the same job as the key. I'd use underscore attributes and a __getstate__ that refuses to pickle. The docs should also say the object is as sensitive as the key.

9. Docs and code disagree on noise for remainders. release.py:291-296, docs/disclosure.md:139-140

The docs, and your note on #589, say noise never protects a remainder. But _passes calls cell_tier(..., noise=estimates.noise), so with noise on the dominance rule is skipped for remainders too. Either choice can be defended: a remainder of noised cells is itself noised, and the factor only cancels against an un-noised copy of the same cell. Please pick one and make the docs and code agree.

Nits

10. Two rules have no test that fails without them: widening the interval to include the value (estimate.py:259-260), and rounding lo down rather than to nearest (estimate.py:311).

11. Zero cells. I found no leak from publishing zeros. Two small things, though. A zero change cell gets band <10 (from NO_SUPPORT), while a zero share gets its complement's band, often 30+. A reader may take <10 as the sample size. Also, a sampling zero is shown as [0, 0] with no_change. A docs line saying it means "no surveyed household in this area changed" would help.

12. Publishing average_change and relative_change together gives the area's baseline income total to within a few per cent (household count × average ÷ relative). That is the denominator, not the un-noised change, so I don't think it leaks the change. But the total isn't checked for dominance, so a docs line is worth adding.

13. validate_public could also check that direction agrees with lo and hi (validate.py:109-111).

What I checked

  • Fresh clone, worktree at 013f95456, new uv venv (Python 3.14), uv pip install -e ".[dev]". Read the whole diff.
  • pytest on the 11 touched test files: 173 passed. Full suite: 1,574 passed, 9 skipped, no Hugging Face login or data downloads.
  • ruff format --check . and ruff check .: clean. .github/check-changelog.sh main: passes. mypy (not blocking in CI) flags Optional access in uk_geography_disclosure.py:88-90, the same errors as in constituency_impact.py:51, and one no-any-return at areas.py:205. The adapter's model_construct then run() follows the other compute_* functions.
  • examples/uk_geography_disclosure.py runs, and its CSV passes validate_public.
  • Mutation run: I disabled 17 rules one at a time (floor, n_eff gate, dominance, both sides of a share, counting by source, the third share, bootstrap by source, nesting, package and prior checks, to_public refusing, withheld rows staying empty, noise sign, noise kept off denominators, matrix rules). The suite caught 15. The two it missed are in 10.
  • Synthetic probes for 1 to 8, 11 and 12. HMAC-SHA256 with a label prefix, no default key, a 16-byte minimum, and factors uniform 8 to 12% either side of 1 all match the code. So do Kish n_eff on weights summed by source, a Poisson bootstrap over sources with clones drawn together, 1,000 replicates, a fixed seed, and draws that don't depend on chunk size.

What I couldn't check:

  • Real datasets: whether eFRS and Microcosm carry source_household_id, and whether the ids match between the national and local files.
  • A real 650 × N matrix, for both run time and the nesting approximation between matrix seats and label regions.
  • The Quarto docs build.
  • Whether the UK Data Service accepts keyed noise in place of the dominance rule.

🤖 Generated with Claude Code

Add the p% rule on signed totals to the dominance check. Keep weight-matrix
rules for weights given as a matrix. Treat withheld cells of reference
estimates as hidden in the package and nesting checks. Check each area
against the areas of another level wholly inside it. Validate settings again
before use and bound the change tolerance. Require region parents for matrix
levels. Send the deprecation message through the log. Make AreaEstimates
private and unpicklable. Give zero cells the band of the households measured.
Check directions in validate_public.

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

Copy link
Copy Markdown
Contributor Author

Thanks Vahid, all 13 are addressed in the next commit. Your probes for 1 to 4 are now tests. Two things need your eye: what the new dominance rule still lets through (1), and which way I went on noise for remainders (9).

Must

1. Dominance. Added the p% rule on the signed total, as you suggested (support.py, new setting min_rest_share, default and floor 0.1). While noise is off, a number is withheld when what is left without its largest household, or without its two largest, is under 10% of the largest household's part.

  • Your first probe (+1,000 with offsetting ±110s) is now withheld.
  • Two households carrying a total between them are now withheld.
  • Your second probe still passes. With three households at +500 and 17 at +2, the rest after the top two is about the size of the largest part. A p% or top-two rule protects one or two households, which is the licence's absolute rule. Catching three would need a floor on effective contributors, and that withholds far more cells than the count floor we agreed. I've left it out and said so in the docs. Say if you see it differently.

2. from_matrix. Weights given as a matrix now keep the matrix rules whatever they hold. The label storage stays as an internal fast path. Your one-row probe is a test: withheld, band <10.

3. publish=False. A withheld cell of any estimate in the release is now hidden, whatever its flag. The package and nesting checks run whenever one side is published by this release. A suggestion names only a cell this release publishes, so for a reference child it suggests none and the fix is on the parent.

4. Seats and local authorities. Added the containment check. For two label levels on the same records, each area is checked against the public areas of the other level that lie wholly inside it, in both directions. Your L1 = S1 + five households probe is a test, and it blocks whether or not the overlap is acknowledged. unchecked_overlap stays acknowledgeable, since there is now a check behind it; its message says what is and isn't covered.

Should

5. Floors. estimate_change and Release validate their settings again, so model_copy and model_construct can't lower a floor. change_tolerance is now ge=1.0.

6. Matrix levels. A matrix level with no parent_of now raises when the household output has regions.

7. Deprecation. The message now also goes through the policyengine logger, which the blanket filter leaves alone. I checked that it prints in a process that has loaded the UK model. I kept the DeprecationWarning and didn't add a required keyword, since that would break callers.

8. AreaEstimates. The table, contributions and source index are underscore attributes, pickling and copying raise, and the docs say the object is as sensitive as the key.

9. Noise and remainders. I picked the code's behaviour and fixed the docs. A remainder gets the rules of a cell: the count floor always applies, and with noise on, noise stands in for the dominance rule, as it does for a cell. What subtraction recovers is the noised sum of the withheld cells. My note on #589 said noise never protects a remainder; what I meant is that it earns no extra credit.

Nits

10. Both rules now have tests, and I re-ran your two mutations: each fails the suite.

11. A zero change cell now takes the band of the households it was measured on, as a zero share does. The docs say what a zero means.

12. Added to the docs.

13. validate_public now checks the direction against lo and hi.

The things you couldn't check

  • Source ids. On Microcosm's national and local files the id is one namespace: every source id in the local file is in the national file, and a shared id has the same region in both. The eFRS has no source id, so the adapter refuses it unless the caller passes ids.
  • Docs build. The Quarto job passes in CI.
  • A real 650 × N matrix. Not run. I ran label data only: nation, regions and 650 seats on a private Microcosm file, through policyengine-uk and the array API.
  • UKDS and noise. Still open; noise stays off by default.

Also fixed: the no-any-return at areas.py:205.

@juaristi22
juaristi22 marked this pull request as ready for review October 14, 2026 11:39
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22

Needs changes before merge: 11 of the 13 are fixed, but the new p% rule still misses signed cases, and one seat and local authority shape is unchecked.

Thanks for the careful round. Everything below uses synthetic data only.

Round 1, point by point

  1. Dominance (must): partly fixed. Both shapes I sent are now withheld. The rule still misses some signed cases (finding 1). On three households, I agree with your call (see the end of finding 1).
  2. from_matrix (must): fixed. My one-row probe is now withheld, band <10, with or without the 1e-9 row.
  3. publish=False (must): fixed. The narrow reference component now gives recoverable_remainder, as it does when it is published.
  4. Seats and local authorities (must): fixed as I suggested. The L1 = S1 + five households probe blocks, with or without the acknowledgement. My suggestion was too narrow, though: see finding 2.
  5. Floors (should): fixed. model_copy and model_construct copies are refused by estimate_change and Release, and change_tolerance below 1 is refused. A related gap is in finding 3.
  6. Matrix levels without parent_of (should): fixed. It now raises.
  7. Deprecation (should): fixed. After importing policyengine_core.tools.hugging_face, the log line still prints.
  8. AreaEstimates (should): fixed. pickle, copy.copy and copy.deepcopy all raise. The docs say the object is as sensitive as the key.
  9. Noise for remainders (should): resolved, and the docs now match the code. My view is under finding 3.
  10. to 13. (nits): fixed. I re-ran both mutations from 10 against the new code, and the suite catches each one.

New findings

1. (must) The p% rule only pairs the largest household with the second-largest by size, and only protects the largest. support.py:59-76

For contributions of one sign, the second-largest is the best attacker, so the usual p% rule is right. With mixed signs, it isn't. Two synthetic cells, each with 20 contributors, pass both rules and are published:

  • Attacker that is not second-largest. The contributions are +1,000, +500, −400, and 17 at about −29 (about −495 in total). top_share is 0.42 and rest_share is 0.40, so the cell comes out approximate. The −400 household knows its own part. Total minus its part is 1,005, so it learns the +1,000 to within 0.5%.
  • Target that is not the largest. The contributions are +1,000, −990, +600, and 17 at ±2. The total is 612, which is the +600 household's change to within 2%. The two big parts cancel each other. This is the same shape as my round-1 mixed-signs probe, except that the cancelling pair is bigger than the target. rest_share is 0.39, and the cell is published.

Suggested fix: treat every household i as a possible target. Let R = total − cᵢ. Withhold when |R| < p·|cᵢ|, for any reader, or when |R − cⱼ| < p·|cᵢ| for some other household j, the attacker. With sorted contributions and searchsorted, this is O(n log n). It should withhold little extra. For a small cᵢ to trigger it, some other household's part must sit almost exactly at the total, and that cell is already risky. You can measure the extra withholding on your private label run. The docs line "These rules protect one or two households" (docs/disclosure.md:65) is true once this is in.

On the three-household case (3 × +500 and 17 × +2), I agree with leaving it out. p% protects against a single attacker. In that cell, two of the three together get the third to within about 7%, which is a coalition the standard rule doesn't cover, and the docs say so. Whether that is enough for the licence is a licence judgement, not a technical one.

2. (must) An area that lies wholly inside the union of two areas of the other level is not checked. release.py:659-700, docs/disclosure.md:143-148

The containment check takes one area and subtracts the areas of the other level that lie wholly inside it. It does not cover the mirror case. Probe on label data: S1 is 197 households of L1 and 3 of L2. S2 is 198 households of L1 and 2 of L2. S3 and S4 split between L2 and L3. No area of either level lies wholly inside an area of the other. With unchecked_overlap acknowledged, nothing blocks, every cell is published, and S1 + S2 − L1 gives the five sliver households' total exactly. On real geography this shape is common: a local authority covered by two seats, each spilling a few survey households into a neighbour.

Suggested fix: run the mirror check. For each area X, take the areas of the other level that meet X, and run (their union − X) through _passes, in both directions. Even with both checks, other differences are not covered. So until the mirror check is in, I'd keep unchecked_overlap non-acknowledgeable for two label levels.

3. (should) With noise on, noise_distance has no lower bound, but noise is what replaces the dominance rule. schema.py:145, schema.py:161-168

DisclosureSettings(noise=True, noise_distance=(1e-6, 2e-6)) is accepted, and it survives revalidated(). My round-1 +1,000 / ±110 cell is then approximate, with a noised value within 0.0003 of 1,000. This is item 5 again, for the setting that now does the dominance rule's job. A floor such as low >= 0.08 would close it. low >= min_rest_share would also work.

On (9), assuming that floor: I think your choice is safe relative to the cell rule. Each household has one factor, so a remainder is the noised sum of the withheld cells. It covers at least 10 households and sits over an un-noised denominator. That is exactly what a noised cell publishes, so a remainder is never weaker than a cell. The point a licence reviewer should see is this: the noise range is 8 to 12%, and p is 10%. So a dominated cell or remainder under noise gives the top household to within 8 to 12%, right at the line p% treats as disclosure. I'd state that next to the noise range in the docs.

Two limits still apply. First, an acknowledged noise_reference_public gives the factors away, and with them the protection on both cells and remainders. Second, since a household's factor is the same in every cell, the ratio of two cells that the same household dominates is exact, as the docs already say. Whether the UK Data Service accepts noise in place of the dominance rule is a licence judgement. It decides cells and remainders together, and noise staying off by default is right until it is settled.

4. (nit) Containment skips a withheld outer area. release.py:697

The nesting check has recoverable_parent for public children that add up to a withheld parent. The containment check ignores the empty flag, so public seats that tile a withheld local authority exactly are not flagged. I built that case: the nesting check against the region flagged it first, and after I withheld its suggestion, rounding hid the value. So I found no leak, but the gap could matter in other data.

5. (nit) changelog.d/589.changed.md says the old functions emit a DeprecationWarning. They now also log a warning, which is the part users will actually see.

What I checked

  • Fresh worktree at 051bd3b61, new uv venv (Python 3.14), uv pip install -e ".[dev]". Read the full diff against 013f95456.
  • pytest on the 11 touched test files: 182 passed. Full suite: 1,591 passed, 9 skipped, with no Hugging Face login or data downloads. ruff format --check and ruff check: clean. CI is green.
  • Old code against new tests: the new tests for points 1 to 8, 11 and 13 fail on 013f95456. Examples: both dominance tests in test_disclosure_support.py, test_weights_given_as_a_matrix_keep_the_matrix_rules, the two ..._hidden_too reference tests, test_an_area_minus_the_public_areas_inside_it_is_checked, test_settings_are_validated_again_before_use, test_a_direction_must_match_its_interval and test_a_zero_row_shows_the_households_it_was_measured_on. Many other estimate tests fail there only because of the _table rename.
  • Mutation run on the new code, 10 mutations, each caught by the suite: interval widening, lo rounding, p% off, each half of _rest_share, the matrix flag, re-validation, hiding withheld reference cells, containment, and the direction check.
  • Re-ran my round-1 probes for 1 to 8, plus new probes for findings 1 to 4. examples/uk_geography_disclosure.py runs, and its CSV validates.
  • Not checked: a real 650 × N matrix, any real dataset, and whether the UK Data Service accepts noise in place of dominance.

🤖 Generated with Claude Code

Apply the p% rule with every household as a target and any reader or any
one other household as the attacker. Check each area against the areas of
another level that meet it, and flag public areas that add up to a withheld
area. Count a withheld parent as recoverable only when the remainder holds no
records. Keep noise at least 8% from 1. Say in the changelog that the old
functions log a warning.

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

Copy link
Copy Markdown
Contributor Author

Thanks Vahid. All five are addressed in the next commit, and your probes are tests.

1. p% rule. Every household is now a target, and any reader or any one other household is an attacker, as you suggested. It uses searchsorted on the sorted parts, and a test checks it against a direct search over random cells. Both of your cells are withheld. I measured the cost on a private label run: with a reform where many households gain and many lose, it changed the tier of almost no seats, and with a one-sign reform, of none. I agree on three households. The docs now say the rules protect each household against any reader and any one other household, not against households that pool what they know.

2. Mirror check. Added. For each area, the public areas of the other level that meet it, minus the area, go through _passes, in both directions. Your probe is a test. unchecked_overlap stays acknowledgeable, and its message says what the two checks cover.

On real geography this check matters, as you expected. In a private label run with seats and local authorities released together, the checks blocked many local-authority cells, and resolving them by hand did not converge in a few hundred rounds. In practice that means one of the two levels per dataset and run. A later release of the other level from the same data has the same remainders, so it would need the first one in prior_public.

3. Noise floor. noise_distance now starts at 0.08 or above, and revalidated() enforces it. Your sentence is in the docs next to the noise range: noise puts a dominated cell or remainder at the 10% line, not safely past it. Noise stays off by default.

4. Withheld outer area. Added: public areas that add up to a withheld area of the other level now give recoverable_parent. While testing it I found a related bug. A withheld parent counted as recoverable when the remainder held records whose changes happened to be zero. A reader cannot know that they are zero, so only a remainder with no records gives a parent away now. That applies to nesting as well.

5. Changelog. It now says the old functions log a warning.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

@juaristi22

Good to merge once CI is green (it is now). All five are fixed; one docs point (should) and one test nit below.

Everything below uses synthetic data only.

Round 2, point by point

  1. p% rule (must): fixed. Both of my cells are now withheld: the attacker case has rest_share 0.005, the target case 0.017. My round-1 cells give the same result as before: the ±110 cell is withheld, and the 3 × +500 cell still passes, as agreed. I checked the searchsorted version against a brute-force search over every target and every attacker on 20,000 random cells. The cells included tied parts, zero parts, zero weights and several records per source household. There were no mismatches. Excluding the household's own slot with steps −2 to +1 is right on both sides of the slot. Dropping zero parts as targets is right too: a zero-part household as attacker gives the same error as any reader. It runs in O(n log n), about 0.2 s for a million parts.
  2. Mirror check (must): fixed. My S1 + S2 − L1 probe now gives recoverable_remainder in both directions, with or without the acknowledgement. The check sums every public area that meets X, so three or more seats covering one authority are caught too. For what it still misses, see finding 1.
  3. Noise floor (should): fixed. noise_distance=(1e-6, 2e-6) is now refused, and (0.05, 0.1) is in the "cannot be loosened" tests. The floor of 0.08 matches the default range. The docs sentence on the 10% line is clear.
  4. Withheld outer area (nit): fixed. My tiling probe now gives recoverable_parent from the containment check. On your related change from n_sources == 0 to not len(records) (nesting as well): it makes the check less strict, so I weighed it. When a withheld parent's hidden part has records that don't change, the public children do add up to the parent exactly. The only protection is that a reader can't tell those records didn't change. That is the standard reasoning, and I agree with it, but a licence reviewer should see that it rests on that.
  5. Changelog (nit): fixed.

New findings

1. (should) Two seats against two local authorities still leak, behind the acknowledgement. docs/disclosure.md:146-150

You asked whether a deeper difference can be built. It can, with several areas on both sides. Take S1 and S2 that together cover L1 and L2, with S2 also holding five households of L3. S3 spans L3 and L4, and S4 sits in L4. Both checks pass on every area: each area minus the public areas wholly inside it, and the public areas that meet an area minus that area, all hold 100 or more households. With unchecked_overlap acknowledged there are no blocking findings, every cell is reported, and S1 + S2 − L1 − L2 gives the five households' total exactly.

I wouldn't make this a must. It sits behind an explicit acknowledgement, the docs already say other differences go unchecked, and a full check means searching over sets of areas on both sides. Your private run also points to one level per dataset and run in practice. But "other differences" is too abstract for whoever has to decide on the acknowledgement. I'd name this shape in the docs (two or more areas of each level that cover the same households except a sliver), and say plainly that releasing both levels from one run is not fully covered. A cheap check for later: require every non-empty intersection of a seat and an authority to pass the cell rules. Any such difference is a sum of whole intersections, so this check catches slivers like the one above, at any depth. On real data it would block a lot, so it belongs in a later PR, not this one.

2. (nit) Two cases the tests don't pin down. tests/test_disclosure_support.py, tests/test_disclosure_release.py

  • test_the_rest_share_agrees_with_a_direct_search draws continuous normals, so it never has tied or zero parts. The code handles both (my fuzz above), but a side="right" or tie-handling regression could get through. Drawing some parts from a few integers would cover it.
  • Reverting not len(records) to n_sources == 0 is caught only by the updated containment test. A nesting test with a withheld parent whose hidden child has records that don't change would pin the new rule where it matters most.

What I checked

  • Fresh worktree at 5ffa150a6, new uv venv (Python 3.14), uv pip install -e ".[dev]". Read the full diff against 051bd3b61.
  • pytest on the touched disclosure and UK geography test files: 183 passed. Full suite: 1,599 passed, 9 skipped, with no Hugging Face login or data downloads. ruff format --check and ruff check: clean. CI is green on every job.
  • New tests against the round-2 code (051bd3b61 source, new tests): 8 fail. They are both p% tests, the brute-force comparison, the two noise-floor cases, the mirror test, the withheld-outer test, and the updated containment test.
  • Mutation run on the new code, 8 mutations. The suite catches 7: reverting the empty change, dropping steps −2 and +1, not excluding the household's own part, reader-only p%, the mirror check off, recoverable_parent in containment off, and the noise floor off. It misses one: removing the < 1e-9 zeroing at release.py:745. That is harmless on label data, where the subtraction is exact.
  • Re-ran my round-2 probes for findings 1 to 4, plus the two-by-two probe above.
  • Not checked: a real 650 × N matrix, any real dataset, or your private label run, so I can't confirm the cost figures for the new p% rule.

🤖 Generated with Claude Code

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.

UK geography outputs: disclosure control and uncertainty for region, constituency and local-authority impacts

2 participants