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
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
/**
|
|
* Destination measures — categories, the card grouping, and the disclosure
|
|
* guards.
|
|
*
|
|
* 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 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.
|
|
*/
|
|
|
|
export type DestinationCategory =
|
|
| 'school_sixth_form'
|
|
| 'sixth_form_college'
|
|
| 'further_education'
|
|
| 'other_education'
|
|
| 'apprenticeship'
|
|
| 'employment'
|
|
| 'not_sustained'
|
|
| 'not_captured'
|
|
// 16-18 only.
|
|
| 'higher_education';
|
|
|
|
export type PupilGroup = 'all' | 'disadvantaged' | 'other';
|
|
|
|
export type DestinationStatus = 'published' | 'suppressed' | 'not_applicable';
|
|
|
|
export type CardGroup = 'academic' | 'college' | 'work';
|
|
|
|
export interface DestinationCell {
|
|
category: DestinationCategory;
|
|
pupils: number | null;
|
|
percentage: number | null;
|
|
status: DestinationStatus;
|
|
}
|
|
|
|
export interface DestinationGroup {
|
|
cohort: number;
|
|
cells: DestinationCell[];
|
|
}
|
|
|
|
/** Display order, which is also bar order: education, then work, then absence. */
|
|
export const CATEGORY_ORDER: DestinationCategory[] = [
|
|
'higher_education',
|
|
'school_sixth_form', 'sixth_form_college', 'further_education', 'other_education',
|
|
'apprenticeship', 'employment', 'not_sustained', 'not_captured',
|
|
];
|
|
|
|
/**
|
|
* Our grouping, not DfE's — the single most arguable thing on the page, which
|
|
* is why it lives in exactly one place. `not_sustained` and `not_captured` are
|
|
* deliberately absent: they are the absence of a destination, not a route, and
|
|
* "activity not captured" includes independent schools and moving abroad.
|
|
*/
|
|
export const CARD_GROUPS: Record<CardGroup, DestinationCategory[]> = {
|
|
academic: ['higher_education', 'school_sixth_form', 'sixth_form_college'],
|
|
college: ['further_education', 'other_education'],
|
|
work: ['apprenticeship', 'employment'],
|
|
};
|
|
|
|
export function suppressedCount(cells: DestinationCell[]): number {
|
|
return cells.filter(c => c.status === 'suppressed').length;
|
|
}
|
|
|
|
/** R2: a sum computed from components is safe only if every component is published. */
|
|
export function canAggregate(cells: DestinationCell[]): boolean {
|
|
return cells.length > 0 && cells.every(c => c.status === 'published');
|
|
}
|
|
|
|
export function aggregateCells(
|
|
cells: DestinationCell[], cohort: number,
|
|
): { pupils: number; percentage: number } | null {
|
|
if (!canAggregate(cells) || cohort <= 0) return null;
|
|
const pupils = cells.reduce((sum, c) => sum + (c.pupils ?? 0), 0);
|
|
return { pupils, percentage: (pupils / cohort) * 100 };
|
|
}
|
|
|
|
/** 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');
|
|
}
|
|
|
|
export interface BarSegment {
|
|
category: DestinationCategory;
|
|
pupils: number;
|
|
/** Exact width from the count — never the rounded percentage. */
|
|
widthPct: number;
|
|
/** Rounded value for the segment label. */
|
|
labelPct: number;
|
|
}
|
|
|
|
export function toBarSegments(group: DestinationGroup): BarSegment[] {
|
|
if (!canRenderBar(group)) {
|
|
throw new Error(
|
|
'toBarSegments: refusing to draw a bar for a group with suppressed categories — '
|
|
+ 'the gap left behind would disclose the withheld figure (R1).',
|
|
);
|
|
}
|
|
const byCategory = new Map(group.cells.map(c => [c.category, c]));
|
|
return CATEGORY_ORDER.flatMap<BarSegment>(category => {
|
|
const cell = byCategory.get(category);
|
|
if (!cell || cell.pupils === null) return [];
|
|
const widthPct = (cell.pupils / group.cohort) * 100;
|
|
return [{ category, pupils: cell.pupils, widthPct, labelPct: Math.round(widthPct) }];
|
|
});
|
|
}
|
|
|
|
export const CATEGORY_LABELS: Record<DestinationCategory, string> = {
|
|
higher_education: 'UK higher education',
|
|
school_sixth_form: 'State-funded school sixth form',
|
|
sixth_form_college: 'Sixth-form college',
|
|
further_education: 'FE and other colleges',
|
|
other_education: 'Other education destination',
|
|
apprenticeship: 'Apprenticeship',
|
|
employment: 'Employment',
|
|
not_sustained: 'Not recorded as a sustained destination',
|
|
not_captured: 'Activity not captured',
|
|
};
|
|
|
|
export const CARD_QUESTIONS: Record<CardGroup, { question: string; hint: string }> = {
|
|
academic: {
|
|
question: 'Do leavers stay on an academic route?',
|
|
hint: 'a school sixth form or a sixth-form college',
|
|
},
|
|
college: {
|
|
question: 'Or move to a college?',
|
|
hint: 'an FE or other college',
|
|
},
|
|
work: {
|
|
question: 'Or straight into work?',
|
|
hint: 'an apprenticeship or a job',
|
|
},
|
|
};
|
|
|
|
/** Which card a category belongs to, or null for the two absence categories. */
|
|
export function cardGroupFor(category: DestinationCategory): CardGroup | null {
|
|
for (const [group, categories] of Object.entries(CARD_GROUPS) as [CardGroup, DestinationCategory[]][]) {
|
|
if (categories.includes(category)) return group;
|
|
}
|
|
return null;
|
|
}
|