diff --git a/backend/data_loader.py b/backend/data_loader.py index fc0529c..cad154e 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -839,10 +839,44 @@ def _format_cohort_year(year) -> str | None: return text -def _mask_for_disclosure(groups: dict) -> None: - """Add secondary suppression until no withheld figure can be recovered. +_PUPIL_GROUPS = ("disadvantaged", "other", "all") - Not rendering a number is not the same as not publishing it. This endpoint + +def _lone_hidden_groups(groups: dict) -> list: + """Pupil groups hiding exactly one category — solvable by subtraction.""" + return [ + key for key, group in groups.items() + if sum(1 for c in group["categories"] if c["status"] == "suppressed") == 1 + ] + + +def _lone_hidden_categories(groups: dict) -> list: + """Categories hidden in exactly one of several pupil groups.""" + lone = [] + categories = {c["category"] for g in groups.values() for c in g["categories"]} + for category in categories: + found = [ + c for g in groups.values() for c in g["categories"] + if c["category"] == category + ] + hidden = [c for c in found if c["status"] == "suppressed"] + if len(hidden) == 1 and len(found) > 1: + lone.append(category) + return lone + + +def disclosure_invariant_holds(groups: dict) -> bool: + """Every row and every column hides none, or at least two. + + Public so the tests can assert it directly rather than re-deriving it. + """ + return not _lone_hidden_groups(groups) and not _lone_hidden_categories(groups) + + +def _mask_for_disclosure(groups: dict) -> None: + """Withhold further cells until nothing suppressed can be solved for. + + Not rendering a figure is not the same as not publishing it. This endpoint is public and unauthenticated, so anything left in the payload is published, whatever the UI chooses to draw — the same reasoning the admission_distance field carries in app.py. @@ -855,17 +889,19 @@ def _mask_for_disclosure(groups: dict) -> None: category suppressed in exactly ONE of the three gives itself away. DfE's own answer is secondary suppression: withhold a second cell so the - residual spans two unknowns and identifies neither. This does the same, - iterating because each new suppression can break the other identity, and - terminating because cells are only ever added to the suppressed set. + residual spans two unknowns and identifies neither. - Mutates `groups` in place. + Where no companion can do that — a sparse cohort whose every other category + is `not_applicable`, which is common in special schools and alternative + provision — there is nothing left to withhold, so the pupil group is + DROPPED entirely. An earlier version simply gave up here and returned with + the violation intact and no signal, which is the one outcome this function + must never produce: a disclosure-control pass that fails silently is worse + than none, because everything downstream trusts it. + + Mutates `groups` in place. Guaranteed to return with + disclosure_invariant_holds(groups) true. """ - PAIRS = ("disadvantaged", "other", "all") - - def cells(group_key): - group = groups.get(group_key) - return group["categories"] if group else [] def suppress(cell): if cell["status"] == "published": @@ -875,14 +911,13 @@ def _mask_for_disclosure(groups: dict) -> None: return True return False - def add_companion(candidates): + def add_companion(candidates) -> bool: """Withhold a second cell so the residual spans two unknowns. The companion must carry pupils. Suppressing a zero looks like secondary suppression and protects nothing: the residual still equals - the original withheld figure exactly. Where every remaining cell is - zero there is no companion that helps, so the whole set goes — losing - real data, but that beats publishing what DfE withheld. + the original withheld figure exactly. Returns False when no cell can + do the job, which escalates to dropping the group. """ published = [c for c in candidates if c["status"] == "published"] useful = sorted( @@ -891,31 +926,47 @@ def _mask_for_disclosure(groups: dict) -> None: ) if useful: return suppress(useful[0]) - return any([suppress(c) for c in published]) + # Every remaining cell is zero or not applicable: withholding any of + # them leaves the residual equal to the original figure. + return False - changed = True - while changed: + # Fixpoint: each new suppression can break the other identity. Terminates + # because every pass either adds a suppression, drops a group, or stops. + while not disclosure_invariant_holds(groups): changed = False - # Column rule: a category must be suppressed in none of the three - # pupil groups, or in at least two of them. - categories = {c["category"] for key in PAIRS for c in cells(key)} - for category in categories: - found = [ - c for key in PAIRS for c in cells(key) if c["category"] == category + for category in _lone_hidden_categories(groups): + siblings = [ + c for g in groups.values() for c in g["categories"] + if c["category"] == category ] - hidden = [c for c in found if c["status"] == "suppressed"] - if len(hidden) == 1 and len(found) > 1: - if add_companion(found): - changed = True + if add_companion(siblings): + changed = True - # Row rule: within a group, none suppressed or at least two. - for key in PAIRS: - group_cells = cells(key) - hidden = [c for c in group_cells if c["status"] == "suppressed"] - if len(hidden) == 1: - if add_companion(group_cells): - changed = True + for key in _lone_hidden_groups(groups): + if add_companion(groups[key]["categories"]): + changed = True + + if changed: + continue + + # Nothing left to withhold. Drop the groups that are still solvable, + # and any category still solvable across the groups that remain. + for key in _lone_hidden_groups(groups): + del groups[key] + changed = True + + for category in _lone_hidden_categories(groups): + for group in groups.values(): + for cell in group["categories"]: + if cell["category"] == category and suppress(cell): + changed = True + + if not changed: + # Unreachable given the two escalations above, but a masking pass + # must never spin or exit unsafely. Withhold everything. + groups.clear() + return def _destinations_block(rows: list) -> dict | None: @@ -969,6 +1020,12 @@ def _destinations_block(rows: list) -> dict | None: _mask_for_disclosure(groups) + # Masking can empty the block entirely — a sparse cohort where no group + # could be made safe. Return None so the section is absent rather than + # rendering an empty shell. + if not groups: + return None + return {"cohort_year": _format_cohort_year(latest_year), "groups": groups} diff --git a/backend/tests/test_destinations_api.py b/backend/tests/test_destinations_api.py index a418408..462b7db 100644 --- a/backend/tests/test_destinations_api.py +++ b/backend/tests/test_destinations_api.py @@ -9,7 +9,9 @@ serialiser adds secondary suppression to prevent it. See docs/superpowers/specs/2026-08-28-destination-measures-design.md. """ -from backend.data_loader import _destinations_block, _format_cohort_year +from backend.data_loader import ( + _destinations_block, _format_cohort_year, disclosure_invariant_holds, +) def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223): @@ -148,13 +150,16 @@ def test_a_category_hidden_in_one_group_is_hidden_in_a_second(): rows.append(_row("other", measure, o, "published", cohort=122)) groups = _destinations_block(rows)["groups"] - for measure in ["school_sixth_form"]: + measures = {c["category"] for g in groups.values() for c in g["categories"]} + assert len(measures) == 6, "the fixture's six measures must all be checked" + + for measure in sorted(measures): hidden = sum( - 1 for key in ("all", "disadvantaged", "other") - for c in groups[key]["categories"] + 1 for g in groups.values() for c in g["categories"] if c["category"] == measure and c["status"] == "suppressed" ) - assert hidden >= 2, f"{measure} is solvable across the pupil groups" + # The invariant is "none, or at least two" — not "at least two". + assert hidden != 1, f"{measure} is solvable across the pupil groups" def test_a_suppressed_cell_never_keeps_its_percentage(): @@ -195,3 +200,70 @@ def test_a_fully_published_group_is_left_alone(): group = _destinations_block(rows)["groups"]["all"] assert all(c["status"] == "published" for c in group["categories"]) assert len(group["categories"]) == 7 + + +def test_the_invariant_is_asserted_directly_not_re_derived(): + """A group with one suppressed category and nothing else to withhold.""" + rows = [ + _row("all", "school_sixth_form", None, "suppressed", cohort=9), + _row("all", "sixth_form_college", None, "not_applicable", cohort=9), + _row("all", "further_education", None, "not_applicable", cohort=9), + ] + block = _destinations_block(rows) + assert block is None or disclosure_invariant_holds(block["groups"]) + + +def test_a_sparse_cohort_with_no_companion_drops_the_group(): + """Special schools and AP routinely have one suppressed category and every + other one not applicable. There is nothing left to withhold, so the group + goes — an earlier version returned here with the violation intact.""" + rows = [ + _row("all", "school_sixth_form", None, "suppressed", cohort=9), + _row("all", "sixth_form_college", None, "not_applicable", cohort=9), + _row("all", "further_education", None, "not_applicable", cohort=9), + _row("all", "apprenticeship", None, "not_applicable", cohort=9), + _row("all", "employment", None, "not_applicable", cohort=9), + _row("all", "not_sustained", None, "not_applicable", cohort=9), + _row("all", "not_captured", None, "not_applicable", cohort=9), + ] + block = _destinations_block(rows) + assert block is None or "all" not in block["groups"], ( + "a group that cannot be made safe must not be served" + ) + + +def test_zeros_are_not_treated_as_a_usable_companion(): + """Suppressing a zero protects nothing — the residual is unchanged. With + only zeros available the group must be dropped, not falsely 'fixed'.""" + rows = [ + _row("all", "school_sixth_form", None, "suppressed", cohort=5), + _row("all", "sixth_form_college", 0, "published", cohort=5), + _row("all", "further_education", 0, "published", cohort=5), + ] + block = _destinations_block(rows) + if block and "all" in block["groups"]: + group = block["groups"]["all"] + published = sum(c["pupils"] for c in group["categories"] + if c["pupils"] is not None) + hidden = [c for c in group["categories"] if c["status"] == "suppressed"] + assert len(hidden) != 1, "a zero companion leaves the figure solvable" + assert group["cohort"] - published != 5 + + +def test_masking_always_terminates_in_a_safe_state(): + """Exhaustive over every suppression pattern of a four-category group.""" + from itertools import product + MEASURES = ["school_sixth_form", "sixth_form_college", + "further_education", "apprenticeship"] + for statuses in product(["published", "suppressed", "not_applicable"], + repeat=len(MEASURES)): + rows = [ + _row("all", m, 3 if st == "published" else None, st, cohort=12) + for m, st in zip(MEASURES, statuses) + ] + block = _destinations_block(rows) + if block is None: + continue + assert disclosure_invariant_holds(block["groups"]), ( + f"invariant broken for {statuses}" + ) diff --git a/docs/superpowers/specs/2026-08-28-destination-measures-design.md b/docs/superpowers/specs/2026-08-28-destination-measures-design.md index 8fceb7f..492167a 100644 --- a/docs/superpowers/specs/2026-08-28-destination-measures-design.md +++ b/docs/superpowers/specs/2026-08-28-destination-measures-design.md @@ -69,7 +69,17 @@ identity, and terminates because cells are only ever added. The companion must carry pupils. Suppressing a zero looks like secondary suppression and protects nothing: the residual still equals the original -withheld figure. Where no non-zero companion exists, the whole set is withheld. +withheld figure. + +Where no companion can do the job — a sparse cohort whose every other category +is `not_applicable`, routine in special schools and alternative provision — the +pupil group is **dropped from the payload entirely**. A first version simply +returned at that point with the violation intact and no signal, which review +caught: a disclosure-control pass that fails silently is worse than none, +because everything downstream trusts it. The function now cannot terminate +except in a state where `disclosure_invariant_holds()` is true, and an +exhaustive test sweeps all 81 suppression patterns of a four-category group to +prove it. Measured cost on the 400-school sample: the all-pupils bar survives on **94%** of mainstream secondaries rather than 100%. That is the price of not