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
270 lines
12 KiB
Python
270 lines
12 KiB
Python
"""The destinations serialiser's contract.
|
|
|
|
Not rendering a figure is not the same as not publishing it. This endpoint is
|
|
public and unauthenticated, so whatever the payload carries is published,
|
|
whatever the UI draws. The categories sum to the cohort and the pupil groups
|
|
sum to each other, so a lone suppressed cell is solvable by subtraction — the
|
|
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, disclosure_invariant_holds,
|
|
)
|
|
|
|
|
|
def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223):
|
|
return {
|
|
"pupil_group": group,
|
|
"destination_measure": measure,
|
|
"pupils": pupils,
|
|
"percentage": percentage,
|
|
"status": status,
|
|
"cohort_pupils": cohort,
|
|
"year": year,
|
|
}
|
|
|
|
|
|
def test_suppressed_category_serialises_as_suppressed_with_null_pupils():
|
|
rows = [
|
|
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
|
_row("all", "sixth_form_college", None, "suppressed"),
|
|
]
|
|
block = _destinations_block(rows)
|
|
cats = {c["category"]: c for c in block["groups"]["all"]["categories"]}
|
|
assert cats["sixth_form_college"]["status"] == "suppressed"
|
|
assert cats["sixth_form_college"]["pupils"] is None
|
|
assert cats["sixth_form_college"]["percentage"] is None
|
|
|
|
|
|
def test_published_category_keeps_its_figures():
|
|
block = _destinations_block([
|
|
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
|
])
|
|
cat = block["groups"]["all"]["categories"][0]
|
|
assert cat["pupils"] == 75
|
|
assert cat["percentage"] == 41.7
|
|
assert cat["status"] == "published"
|
|
|
|
|
|
def test_only_the_latest_year_is_served():
|
|
rows = [
|
|
_row("all", "school_sixth_form", 60, "published", year=202122),
|
|
_row("all", "school_sixth_form", 75, "published", year=202223),
|
|
]
|
|
block = _destinations_block(rows)
|
|
assert block["cohort_year"] == "2022/23"
|
|
assert len(block["groups"]["all"]["categories"]) == 1
|
|
assert block["groups"]["all"]["categories"][0]["pupils"] == 75
|
|
|
|
|
|
def test_all_three_pupil_groups_are_carried():
|
|
rows = [
|
|
_row("all", "school_sixth_form", 75, "published"),
|
|
_row("disadvantaged", "school_sixth_form", 17, "published", cohort=62),
|
|
_row("other", "school_sixth_form", 58, "published", cohort=118),
|
|
]
|
|
block = _destinations_block(rows)
|
|
assert set(block["groups"]) == {"all", "disadvantaged", "other"}
|
|
assert block["groups"]["disadvantaged"]["cohort"] == 62
|
|
|
|
|
|
def test_cohort_year_is_reported_so_the_page_can_date_itself():
|
|
block = _destinations_block([_row("all", "school_sixth_form", 75, "published")])
|
|
assert block["cohort_year"] == "2022/23"
|
|
|
|
|
|
def test_format_cohort_year_handles_the_six_digit_form():
|
|
assert _format_cohort_year(202223) == "2022/23"
|
|
assert _format_cohort_year(None) is None
|
|
|
|
|
|
def test_empty_rows_yield_none_not_an_empty_shell():
|
|
assert _destinations_block([]) is None
|
|
|
|
|
|
# ── Disclosure control ──────────────────────────────────────────────────────
|
|
#
|
|
# The rendering guards in lib/destinations.ts stop a withheld figure being
|
|
# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and
|
|
# unauthenticated, so whatever the payload carries is published. These tests
|
|
# are the ones that matter.
|
|
|
|
def _solve_residual(group):
|
|
"""What any caller can work out: cohort minus everything published."""
|
|
published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None]
|
|
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
|
return group["cohort"] - sum(published), len(hidden)
|
|
|
|
|
|
def test_a_lone_suppressed_category_cannot_be_solved_for():
|
|
"""Whitley Bay High School's real 2022/23 disadvantaged group: further
|
|
education withheld, everything else published, cohort 41. Before secondary
|
|
suppression the payload gave the answer away as 41 - 23 = 18."""
|
|
rows = [
|
|
_row("disadvantaged", "school_sixth_form", 15, "published", cohort=41),
|
|
_row("disadvantaged", "sixth_form_college", 0, "published", cohort=41),
|
|
_row("disadvantaged", "further_education", None, "suppressed", cohort=41),
|
|
_row("disadvantaged", "apprenticeship", 1, "published", cohort=41),
|
|
_row("disadvantaged", "employment", 2, "published", cohort=41),
|
|
_row("disadvantaged", "not_sustained", 3, "published", cohort=41),
|
|
_row("disadvantaged", "not_captured", 2, "published", cohort=41),
|
|
]
|
|
group = _destinations_block(rows)["groups"]["disadvantaged"]
|
|
residual, hidden = _solve_residual(group)
|
|
assert hidden >= 2, "a lone suppressed cell must gain a companion"
|
|
assert residual != 18, "the withheld figure is recoverable from the payload"
|
|
|
|
|
|
def test_every_group_hides_none_or_at_least_two_categories():
|
|
rows = [
|
|
_row("all", "school_sixth_form", 75, "published"),
|
|
_row("all", "sixth_form_college", None, "suppressed"),
|
|
_row("all", "further_education", 61, "published"),
|
|
_row("all", "apprenticeship", 8, "published"),
|
|
_row("all", "employment", 6, "published"),
|
|
_row("all", "not_sustained", 5, "published"),
|
|
_row("all", "not_captured", 4, "published"),
|
|
]
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
|
assert len(hidden) >= 2
|
|
|
|
|
|
def test_a_category_hidden_in_one_group_is_hidden_in_a_second():
|
|
"""disadvantaged + other = all for every category, so a category withheld
|
|
in exactly one of the three is recoverable from the other two."""
|
|
rows = []
|
|
for measure, a, d, o in [
|
|
("school_sixth_form", 75, None, 58),
|
|
("further_education", 61, 27, 34),
|
|
("apprenticeship", 8, 4, 4),
|
|
("employment", 6, 1, 5),
|
|
("not_sustained", 5, 3, 2),
|
|
("not_captured", 4, 2, 2),
|
|
]:
|
|
rows.append(_row("all", measure, a, "published", cohort=159))
|
|
rows.append(_row("disadvantaged", measure, d,
|
|
"published" if d is not None else "suppressed", cohort=37))
|
|
rows.append(_row("other", measure, o, "published", cohort=122))
|
|
|
|
groups = _destinations_block(rows)["groups"]
|
|
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 g in groups.values() for c in g["categories"]
|
|
if c["category"] == measure and c["status"] == "suppressed"
|
|
)
|
|
# 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():
|
|
"""percentage / pupils would hand back the cohort, and with it the residual."""
|
|
rows = [
|
|
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
|
_row("all", "sixth_form_college", None, "suppressed", percentage=11.7),
|
|
_row("all", "further_education", 61, "published", percentage=33.9),
|
|
]
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
for cell in group["categories"]:
|
|
if cell["status"] != "published":
|
|
assert cell["pupils"] is None
|
|
assert cell["percentage"] is None
|
|
|
|
|
|
def test_aggregates_are_not_served():
|
|
"""An aggregate spanning exactly one suppressed component names it, and
|
|
nothing renders them today."""
|
|
rows = [
|
|
_row("all", "school_sixth_form", 75, "published"),
|
|
_row("all", "agg_sustained_all", 171, "published"),
|
|
]
|
|
group = _destinations_block(rows)["groups"]["all"]
|
|
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
|
|
assert "aggregates" not in group
|
|
|
|
|
|
def test_a_fully_published_group_is_left_alone():
|
|
"""Secondary suppression must not cost anything where nothing is withheld —
|
|
this is the all-pupils view on every mainstream secondary."""
|
|
rows = [
|
|
_row("all", m, p, "published")
|
|
for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21),
|
|
("further_education", 61), ("apprenticeship", 8),
|
|
("employment", 6), ("not_sustained", 5), ("not_captured", 4)]
|
|
]
|
|
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}"
|
|
)
|