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