fix(destinations): withhold at the API, not just in the chart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m49s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m49s
Code review found the disclosure the whole design was meant to prevent.
R1 was written as a rendering rule and implemented as one: canRenderBar
stopped the bar being drawn, but GET /api/schools/{urn} still carried the
cohort and every published category. cohort - sum(published) returned
Whitley Bay's withheld further-education figure exactly — 18 pupils — to
any caller, and the RSC payload put it in the browser too.
app.py already stated the principle for admission_distance: this endpoint
is public and unauthenticated, so a field left in the payload is a
published field. The same reasoning applies here and did not get applied.
_mask_for_disclosure now closes both identities before serialisation —
categories sum to the cohort, and disadvantaged + other = all — by adding
secondary suppression until every row and column hides none or at least
two. My first attempt picked the smallest published cell as the companion
and a new test caught it choosing a zero, which protects nothing: the
residual still resolved to 18. The companion must carry pupils.
DfE's own aggregates are no longer served. Nothing rendered them, and one
spanning a single suppressed component names it.
Cost, measured over 262 mainstream secondaries: the all-pupils bar
survives on 94% rather than 100%. Zero lone-suppressed groups remain.
The e2e helper now tells a missing feature apart from missing data: it
fails if the API serves no destinations key at all, and skips if the key
is served but the annual DAG has not populated the marts. Failing on the
second would redden the staging gate for unrelated commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
This commit is contained in:
1 parent
68a192e430
commit
102397fe69
11 files changed
+318
-122
No files matched your search
+101
-13
@@ -839,17 +839,100 @@ 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.
|
||||
|
||||
Not rendering a number 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.
|
||||
|
||||
Two identities let a caller solve for a withheld cell:
|
||||
|
||||
* within a pupil group, the categories sum to the cohort, so a group with
|
||||
exactly ONE suppressed category gives it away as cohort - sum(rest);
|
||||
* across groups, disadvantaged + other = all for every category, so a
|
||||
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.
|
||||
|
||||
Mutates `groups` in place.
|
||||
"""
|
||||
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":
|
||||
cell["status"] = "suppressed"
|
||||
cell["pupils"] = None
|
||||
cell["percentage"] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_companion(candidates):
|
||||
"""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.
|
||||
"""
|
||||
published = [c for c in candidates if c["status"] == "published"]
|
||||
useful = sorted(
|
||||
(c for c in published if (c["pupils"] or 0) > 0),
|
||||
key=lambda c: c["pupils"],
|
||||
)
|
||||
if useful:
|
||||
return suppress(useful[0])
|
||||
return any([suppress(c) for c in published])
|
||||
|
||||
changed = True
|
||||
while changed:
|
||||
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
|
||||
]
|
||||
hidden = [c for c in found if c["status"] == "suppressed"]
|
||||
if len(hidden) == 1 and len(found) > 1:
|
||||
if add_companion(found):
|
||||
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
|
||||
|
||||
|
||||
def _destinations_block(rows: list) -> dict | None:
|
||||
"""Shape destination rows for one phase into the API's block.
|
||||
|
||||
Carries `status` through untouched and emits no computed totals. The only
|
||||
aggregates present are ones DfE published itself; whether showing one is
|
||||
safe depends on how many of its components are suppressed, which the
|
||||
frontend decides (lib/destinations.ts, rule R2).
|
||||
Applies secondary suppression before returning, so no caller of this public
|
||||
endpoint can solve for a figure DfE withheld. See _mask_for_disclosure.
|
||||
|
||||
Deliberately does NOT compute a residual, a "remaining pupils" figure, or
|
||||
any total that would close a gap left by a suppressed category — the
|
||||
categories sum to the cohort, so such a figure names the withheld cell.
|
||||
Aggregate measures are dropped entirely. DfE publishes them, and they would
|
||||
be useful for a "what is published for this group" fallback, but nothing
|
||||
renders them today and an aggregate spanning exactly one suppressed
|
||||
component names that component. An unused field that leaks is not a
|
||||
trade-off worth carrying — re-add them with their own guard if the fallback
|
||||
is ever built.
|
||||
|
||||
Deliberately computes no residual, no "remaining pupils" figure, and no
|
||||
total that would close a gap left by a suppressed category.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
@@ -864,23 +947,28 @@ def _destinations_block(rows: list) -> dict | None:
|
||||
for row in rows:
|
||||
group = groups.setdefault(
|
||||
row["pupil_group"],
|
||||
{"cohort": row.get("cohort_pupils"), "categories": [], "aggregates": {}},
|
||||
{"cohort": row.get("cohort_pupils"), "categories": []},
|
||||
)
|
||||
measure = row["destination_measure"]
|
||||
published = row.get("status") == "published"
|
||||
# Belt and braces: percentage is derived from the same source cell as
|
||||
# pupils, but publishing one without the other would hand back the
|
||||
# cohort (pupils / percentage) and with it the residual.
|
||||
cell = {
|
||||
"category": measure,
|
||||
"pupils": row.get("pupils"),
|
||||
"percentage": row.get("percentage"),
|
||||
"pupils": row.get("pupils") if published else None,
|
||||
"percentage": row.get("percentage") if published else None,
|
||||
"status": row.get("status"),
|
||||
}
|
||||
if measure in _AGGREGATE_MEASURES:
|
||||
group["aggregates"][measure[len("agg_"):]] = cell
|
||||
else:
|
||||
group["categories"].append(cell)
|
||||
continue
|
||||
group["categories"].append(cell)
|
||||
|
||||
if not groups:
|
||||
return None
|
||||
|
||||
_mask_for_disclosure(groups)
|
||||
|
||||
return {"cohort_year": _format_cohort_year(latest_year), "groups": groups}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""The destinations serialiser's contract: it carries suppression through, and
|
||||
never emits a total that closes a gap left by a suppressed category.
|
||||
"""The destinations serialiser's contract.
|
||||
|
||||
The destination categories sum to the cohort, so an aggregate that happens to
|
||||
equal the residual names the withheld figure exactly. See
|
||||
docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
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
|
||||
@@ -43,39 +46,6 @@ def test_published_category_keeps_its_figures():
|
||||
assert cat["status"] == "published"
|
||||
|
||||
|
||||
def test_no_closing_total_is_emitted_for_a_partially_suppressed_group():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "sixth_form_college", None, "suppressed"),
|
||||
_row("all", "further_education", 61, "published", percentage=33.9),
|
||||
_row("all", "apprenticeship", 8, "published", percentage=4.4),
|
||||
_row("all", "employment", 6, "published", percentage=3.3),
|
||||
_row("all", "not_sustained", 5, "published", percentage=2.8),
|
||||
_row("all", "not_captured", 4, "published", percentage=2.2),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
group = block["groups"]["all"]
|
||||
published = sum(c["pupils"] for c in group["categories"] if c["pupils"] is not None)
|
||||
residual = group["cohort"] - published
|
||||
for value in group["aggregates"].values():
|
||||
if value is None or value.get("pupils") is None:
|
||||
continue
|
||||
assert value["pupils"] != residual, (
|
||||
"an aggregate equal to the residual identifies the suppressed cell"
|
||||
)
|
||||
|
||||
|
||||
def test_aggregates_are_separated_from_categories():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "agg_sustained_all", 171, "published", percentage=95.0),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
group = block["groups"]["all"]
|
||||
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
|
||||
assert group["aggregates"]["sustained_all"]["pupils"] == 171
|
||||
|
||||
|
||||
def test_only_the_latest_year_is_served():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 60, "published", year=202122),
|
||||
@@ -110,3 +80,118 @@ def test_format_cohort_year_handles_the_six_digit_form():
|
||||
|
||||
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"]
|
||||
for measure in ["school_sixth_form"]:
|
||||
hidden = sum(
|
||||
1 for key in ("all", "disadvantaged", "other")
|
||||
for c in groups[key]["categories"]
|
||||
if c["category"] == measure and c["status"] == "suppressed"
|
||||
)
|
||||
assert hidden >= 2, 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
|
||||
@@ -42,17 +42,47 @@ Those are the precise numbers the `c` exists to hide, and in a random 400-school
|
||||
sample **22% of mainstream secondaries** have exactly one suppressed category in
|
||||
their disadvantaged group. This is the normal case, not an edge case.
|
||||
|
||||
Two rules follow, and everything else in this document is downstream of them.
|
||||
Three rules follow, and everything else in this document is downstream of them.
|
||||
|
||||
**R1 — Never render a derived remainder.** Not as a number, and not as a bar
|
||||
segment: a segment sized by the residual can be read straight off the axis. Where
|
||||
any category in a pupil group is suppressed, the page shows the published
|
||||
categories, says the rest are withheld, and stops.
|
||||
**R1 — Never *publish* enough to derive a remainder.**
|
||||
|
||||
**R2 — Never aggregate across a suppression boundary.** A group total is
|
||||
publishable only when DfE published that total itself, or when the aggregate
|
||||
spans **two or more** suppressed cells. Summing published components to fill a
|
||||
gap is R1 with extra steps.
|
||||
An earlier draft of this rule said "never *render* a derived remainder", and
|
||||
that was the defect code review caught in PR #137. Not drawing a number does
|
||||
nothing to stop it being computed: `GET /api/schools/{urn}` is public and
|
||||
unauthenticated, so anything in the payload is published whatever the UI
|
||||
chooses to draw. The rendering guards shipped; the payload still carried the
|
||||
cohort and every published category, and `cohort - sum(published)` returned
|
||||
Whitley Bay's withheld figure exactly.
|
||||
|
||||
The rule is therefore about the serialiser, and the UI guards are a second line
|
||||
of defence behind it. Two identities have to be closed:
|
||||
|
||||
- within a pupil group the categories sum to the cohort, so a group with
|
||||
exactly **one** suppressed category gives it away;
|
||||
- across groups, disadvantaged + other = all for every category, so a category
|
||||
suppressed in exactly **one** of the three gives itself away.
|
||||
|
||||
`_mask_for_disclosure` applies DfE's own answer — secondary suppression —
|
||||
withholding a companion cell until every row and every column hides either none
|
||||
or at least two. It iterates, because each new suppression can break the other
|
||||
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.
|
||||
|
||||
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
|
||||
republishing what DfE withheld.
|
||||
|
||||
**R2 — Never aggregate across a suppression boundary.** Summing published
|
||||
components to fill a gap is R1 with extra steps.
|
||||
|
||||
DfE's own aggregates (`Sustained education destination`, `Sustained education,
|
||||
employment & apprenticeships`) are ingested but **not served**. An aggregate
|
||||
spanning exactly one suppressed component names it, and nothing renders them
|
||||
today — an unused field that leaks is not a trade-off worth carrying. They can
|
||||
be re-added with their own guard if the fallback ladder is ever built.
|
||||
|
||||
**R3 — The three pupil groups are one disclosure surface, not three.**
|
||||
Disadvantaged and Not-known-to-be-disadvantaged partition All pupils, so
|
||||
@@ -116,12 +146,17 @@ the counts — the published percentages do not sum to 100.
|
||||
|
||||
Random 400-school sample, 2022/23, mainstream secondaries (n=262):
|
||||
|
||||
| View | Published | Consequence |
|
||||
|---|---|---|
|
||||
| All pupils, all categories | **100%** | Full bar works everywhere |
|
||||
| Disadvantaged, headline rate | 95% | Gap panel works |
|
||||
| Disadvantaged, three grouped cards | 68% | Degrades card by card |
|
||||
| Disadvantaged, all six categories | **20%** | Bar unusable for this group |
|
||||
| View | As published by DfE | After R1–R3 masking | Consequence |
|
||||
|---|---|---|---|
|
||||
| All pupils, all categories | 100% | **94%** | Bar works nearly everywhere |
|
||||
| Disadvantaged, headline rate | 95% | 95% | Gap panel works |
|
||||
| Disadvantaged, three grouped cards | 68% | 68% | Degrades card by card |
|
||||
| Disadvantaged, all six categories | 20% | **20%** | Bar unusable for this group |
|
||||
|
||||
The middle column is what the site actually serves. Masking costs the
|
||||
all-pupils bar on 6% of mainstream secondaries — those are schools where a
|
||||
category was suppressed in exactly one pupil group and no non-zero companion
|
||||
existed below the all-pupils row.
|
||||
|
||||
Special schools and alternative provision are far worse: 13% and 41% respectively
|
||||
have the whole cohort suppressed even for all pupils. The empty state is
|
||||
@@ -325,10 +360,12 @@ and the staging E2E gate runs post-merge.
|
||||
|
||||
## Risks
|
||||
|
||||
**A later change reintroduces the disclosure.** The likeliest route is someone
|
||||
applying `safe_numeric` to a destination column for consistency, or adding a
|
||||
`coalesce` in a mart. Mitigation is the dbt test plus the unit tests on
|
||||
`canAggregate()` — the rule has to be executable, not documentary.
|
||||
**A later change reintroduces the disclosure.** The likeliest routes are
|
||||
applying `safe_numeric` to a destination column for consistency, adding a
|
||||
`coalesce` in a mart, or — as happened in review — enforcing a disclosure rule
|
||||
at the rendering layer instead of the publishing layer. Mitigation is the dbt
|
||||
tests plus `backend/tests/test_destinations_api.py`, which reconstructs the
|
||||
residual the way an attacker would and asserts it no longer resolves.
|
||||
|
||||
**The two-year lag reads as staleness.** Mitigated by dating the cohort in the
|
||||
section header rather than only in a tooltip.
|
||||
|
||||
@@ -2415,11 +2415,20 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) =
|
||||
|
||||
// ── Destination measures ───────────────────────────────────────────────────
|
||||
//
|
||||
// These journeys need marts.fact_ks4_destinations to be populated, which only
|
||||
// happens after the annual EES DAG runs. Until then the helper below fails the
|
||||
// suite loudly rather than skipping: a silent skip here would let a genuine
|
||||
// regression in the sections ride along unnoticed, which is exactly what the
|
||||
// distance journeys were changed to avoid.
|
||||
// Two failure modes have to be told apart here, and conflating them is how
|
||||
// this suite would either hide a regression or block the promotion pipeline:
|
||||
//
|
||||
// * the backend does not serve the `destinations` field at all — a code
|
||||
// regression, or a deploy that did not land. FAILS.
|
||||
// * the field is served but every school is empty — the annual EES DAG has
|
||||
// not run on this environment yet. SKIPS, loudly.
|
||||
//
|
||||
// The second is a data-load precondition, not a defect, and it is true for
|
||||
// every commit between this merging and the DAG being triggered. Failing on it
|
||||
// would redden the staging gate for unrelated work. This is not the quiet skip
|
||||
// 4f01fbd removed from the distance journeys: that one hid a broken feature
|
||||
// behind a flag check, whereas the assertion that the code is deployed and
|
||||
// correctly shaped still runs here on every commit.
|
||||
|
||||
async function secondaryWithDestinations(page: Page): Promise<{
|
||||
urn: string; destinations: any;
|
||||
@@ -2431,17 +2440,28 @@ async function secondaryWithDestinations(page: Page): Promise<{
|
||||
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||
s.phase === 'Secondary' && s.attainment_8_score != null)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
expect(urns.length).toBeGreaterThan(0);
|
||||
|
||||
let served = false;
|
||||
for (const urn of urns.slice(0, 25)) {
|
||||
const detail = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!detail.ok()) continue;
|
||||
const data = await detail.json();
|
||||
// The key must exist, even as null. Its absence means the backend in front
|
||||
// of us does not know about destinations at all.
|
||||
if ('destinations' in data) served = true;
|
||||
if (data.destinations?.ks4) return { urn, destinations: data.destinations };
|
||||
}
|
||||
throw new Error(
|
||||
'No secondary school returned a destinations block. Either the annual EES '
|
||||
+ 'DAG has not run on this environment, or the destinations marts are empty.',
|
||||
);
|
||||
|
||||
expect(served,
|
||||
'GET /api/schools/{urn} served no `destinations` key at all — the backend '
|
||||
+ 'is missing this feature, not merely missing its data').toBeTruthy();
|
||||
|
||||
test.skip(true,
|
||||
'No school has destination data yet: the annual EES DAG has not run on '
|
||||
+ 'this environment. The API shape is correct, so this is a data-load '
|
||||
+ 'precondition rather than a regression.');
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
|
||||
test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ const ALL_PUBLISHED = [
|
||||
|
||||
const fullPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: { all: { cohort: 180, categories: ALL_PUBLISHED, aggregates: {} } },
|
||||
groups: { all: { cohort: 180, categories: ALL_PUBLISHED } },
|
||||
};
|
||||
|
||||
const suppressedPhase: DestinationPhase = {
|
||||
@@ -36,7 +36,6 @@ const suppressedPhase: DestinationPhase = {
|
||||
cell('apprenticeship', 8), cell('employment', 6),
|
||||
cell('not_sustained', 5), cell('not_captured', 4),
|
||||
],
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ const phase: DestinationPhase = {
|
||||
{ category: 'employment', pupils: 13, percentage: 13.5, status: 'published' },
|
||||
{ category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' },
|
||||
],
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
canAggregate, aggregateCells, canRenderPublishedAggregate,
|
||||
canAggregate, aggregateCells,
|
||||
canRenderBar, toBarSegments, CARD_GROUPS,
|
||||
type DestinationCell, type DestinationGroup, type DestinationCategory,
|
||||
} from '@/lib/destinations';
|
||||
@@ -19,7 +19,6 @@ const fullGroup = (): DestinationGroup => ({
|
||||
pub('apprenticeship', 8, 180), pub('employment', 6, 180),
|
||||
pub('not_sustained', 5, 180), pub('not_captured', 4, 180),
|
||||
],
|
||||
aggregates: {},
|
||||
});
|
||||
|
||||
describe('canAggregate — R2, computing from components', () => {
|
||||
@@ -47,26 +46,6 @@ describe('aggregateCells', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => {
|
||||
it('allows it when no component is suppressed', () => {
|
||||
expect(canRenderPublishedAggregate([
|
||||
pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180),
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => {
|
||||
expect(canRenderPublishedAggregate([
|
||||
pub('school_sixth_form', 75, 180), sup('sixth_form_college'),
|
||||
])).toBe(false);
|
||||
});
|
||||
|
||||
it('allows it when two or more components are suppressed', () => {
|
||||
expect(canRenderPublishedAggregate([
|
||||
sup('school_sixth_form'), sup('sixth_form_college'),
|
||||
])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRenderBar — R1', () => {
|
||||
it('allows a bar when the whole group is published', () => {
|
||||
expect(canRenderBar(fullGroup())).toBe(true);
|
||||
|
||||
@@ -17,7 +17,6 @@ const phase = (categories = 1) => ({
|
||||
category: 'school_sixth_form' as const,
|
||||
pupils: 75, percentage: 41.7, status: 'published' as const,
|
||||
})),
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@ function toGroup(payload: DestinationGroupPayload): DestinationGroup {
|
||||
return {
|
||||
cohort: payload.cohort ?? 0,
|
||||
cells: payload.categories,
|
||||
aggregates: payload.aggregates,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
* DfE suppresses individual cells with `c`, and the destination categories sum
|
||||
* to the cohort. So subtracting the published cells from the cohort total
|
||||
* recovers a lone suppressed cell exactly — which is the case on 22% of
|
||||
* mainstream secondaries. The guards below are what stop this module's
|
||||
* consumers doing that by accident, and they are why a percentage is never
|
||||
* reconstructed from a partial sum.
|
||||
* mainstream secondaries.
|
||||
*
|
||||
* The guards here are the SECOND line of defence, not the first. Not drawing a
|
||||
* number does nothing to stop it being computed, so the real fix lives in
|
||||
* backend/data_loader.py::_mask_for_disclosure, which withholds a companion
|
||||
* cell before the figures ever leave the server. These functions keep the UI
|
||||
* honest about what it draws from an already-safe payload.
|
||||
*
|
||||
* See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
*/
|
||||
@@ -40,8 +44,6 @@ export interface DestinationCell {
|
||||
export interface DestinationGroup {
|
||||
cohort: number;
|
||||
cells: DestinationCell[];
|
||||
/** Aggregates DfE published itself, keyed by slug. */
|
||||
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
|
||||
}
|
||||
|
||||
/** Display order, which is also bar order: education, then work, then absence. */
|
||||
@@ -80,15 +82,6 @@ export function aggregateCells(
|
||||
return { pupils, percentage: (pupils / cohort) * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* R2, the other direction: DfE published this total itself. Showing it beside
|
||||
* the components is safe only when it spans no suppressed component, or two or
|
||||
* more. Exactly one, and the total names the withheld figure.
|
||||
*/
|
||||
export function canRenderPublishedAggregate(components: DestinationCell[]): boolean {
|
||||
return suppressedCount(components) !== 1;
|
||||
}
|
||||
|
||||
/** R1: a bar is drawable only when nothing in the group is withheld. */
|
||||
export function canRenderBar(group: DestinationGroup): boolean {
|
||||
return group.cohort > 0 && group.cells.every(c => c.status === 'published');
|
||||
|
||||
@@ -616,8 +616,6 @@ import type { DestinationCell, PupilGroup } from './destinations';
|
||||
export interface DestinationGroupPayload {
|
||||
cohort: number | null;
|
||||
categories: DestinationCell[];
|
||||
/** Totals DfE published itself. Never computed here — see lib/destinations.ts. */
|
||||
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
|
||||
}
|
||||
|
||||
export interface DestinationPhase {
|
||||
|
||||
Reference in new issue
Block a user