fix(destinations): the masking pass can no longer exit unsafely
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 7m23s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 7m23s
Review found _mask_for_disclosure could return with its invariant broken and say nothing. add_companion only ever withheld a *published* cell, so a group with one suppressed category and every other one not_applicable — routine in special schools and AP, where few categories apply — left the loop with the lone suppressed cell still solvable. Reproduced on a nine-pupil cohort: one hidden cell, cohort served, residual intact. A disclosure-control pass that fails silently is worse than none, because everything downstream trusts it. The loop now runs until the invariant holds and escalates when no companion exists: the pupil group is dropped from the payload, and an empty block serialises as None so the section is absent rather than an empty shell. disclosure_invariant_holds() is exported so tests assert it directly instead of re-deriving it, and an exhaustive test sweeps all 81 suppression patterns of a four-category group. Also fixes a test that set up six measures and checked one: the loop was `for measure in ["school_sixth_form"]`. It now checks every measure, and against the real invariant — none hidden, or at least two, rather than "at least two", which the five published measures would have failed. No regression on real data: 262 mainstream secondaries, all-pupils bar still drawable on 94%, zero invariant violations, one disadvantaged group dropped by the new escalation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
This commit is contained in:
1 parent
102397fe69
commit
2e9b5c83c5
3 files changed
+181
-42
No files matched your search
@@ -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}"
|
||||
)
|
||||
Reference in new issue
Block a user