feat(destinations): the disclosure rules, as executable guards
The destination categories sum to the cohort and DfE publishes the cohort total, so a lone suppressed cell is recoverable by subtraction. canAggregate, canRenderPublishedAggregate and canRenderBar are what stop a consumer doing that; toBarSegments throws rather than leaving a readable gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
This commit is contained in:
1 parent
9423f11567
commit
1980d79eee
2 files changed
+263
No files matched your search
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
canAggregate, aggregateCells, canRenderPublishedAggregate,
|
||||||
|
canRenderBar, toBarSegments, CARD_GROUPS,
|
||||||
|
type DestinationCell, type DestinationGroup, type DestinationCategory,
|
||||||
|
} from '@/lib/destinations';
|
||||||
|
|
||||||
|
const pub = (category: DestinationCategory, pupils: number, cohort: number): DestinationCell => ({
|
||||||
|
category, pupils, percentage: (pupils / cohort) * 100, status: 'published',
|
||||||
|
});
|
||||||
|
const sup = (category: DestinationCategory): DestinationCell => ({
|
||||||
|
category, pupils: null, percentage: null, status: 'suppressed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullGroup = (): DestinationGroup => ({
|
||||||
|
cohort: 180,
|
||||||
|
cells: [
|
||||||
|
pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180),
|
||||||
|
pub('further_education', 55, 180), pub('other_education', 6, 180),
|
||||||
|
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', () => {
|
||||||
|
it('allows a sum when every component is published', () => {
|
||||||
|
expect(canAggregate([pub('apprenticeship', 8, 180), pub('employment', 6, 180)])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a sum when any component is suppressed', () => {
|
||||||
|
expect(canAggregate([pub('apprenticeship', 8, 180), sup('employment')])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a sum when every component is suppressed', () => {
|
||||||
|
expect(canAggregate([sup('apprenticeship'), sup('employment')])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('aggregateCells', () => {
|
||||||
|
it('sums published cells and derives a percentage from the cohort', () => {
|
||||||
|
expect(aggregateCells([pub('apprenticeship', 8, 180), pub('employment', 6, 180)], 180))
|
||||||
|
.toEqual({ pupils: 14, percentage: (14 / 180) * 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null rather than a partial sum when a component is suppressed', () => {
|
||||||
|
expect(aggregateCells([pub('apprenticeship', 8, 180), sup('employment')], 180)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a bar when a single category is suppressed', () => {
|
||||||
|
const g = fullGroup();
|
||||||
|
g.cells[1] = sup('sixth_form_college');
|
||||||
|
expect(canRenderBar(g)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toBarSegments', () => {
|
||||||
|
it('derives widths from counts, not from rounded percentages', () => {
|
||||||
|
const segs = toBarSegments(fullGroup());
|
||||||
|
expect(segs).toHaveLength(8);
|
||||||
|
expect(segs[0].widthPct).toBeCloseTo((75 / 180) * 100, 10);
|
||||||
|
expect(segs.reduce((a, s) => a + s.widthPct, 0)).toBeCloseTo(100, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws rather than silently leaving a gap when the group is suppressed', () => {
|
||||||
|
const g = fullGroup();
|
||||||
|
g.cells[1] = sup('sixth_form_college');
|
||||||
|
expect(() => toBarSegments(g)).toThrow(/suppressed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CARD_GROUPS', () => {
|
||||||
|
it('partitions every destination category exactly once, plus the absence', () => {
|
||||||
|
const grouped = Object.values(CARD_GROUPS).flat();
|
||||||
|
expect(new Set(grouped).size).toBe(grouped.length);
|
||||||
|
expect(grouped).toEqual(expect.arrayContaining([
|
||||||
|
'school_sixth_form', 'sixth_form_college', 'further_education',
|
||||||
|
'other_education', 'apprenticeship', 'employment',
|
||||||
|
]));
|
||||||
|
expect(grouped).not.toContain('not_sustained');
|
||||||
|
expect(grouped).not.toContain('not_captured');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 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 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.
|
||||||
|
*
|
||||||
|
* 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[];
|
||||||
|
/** 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. */
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user