feat(compare): comprehension logic (report cards, admissions, verdicts, strips)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Comprehension rules for the compare screen, kept pure and unit-tested.
|
||||
*
|
||||
* These encode the expert-review requirements (spec §8 of the compare
|
||||
* redesign): report-card summaries count graded areas only (safeguarding is
|
||||
* a separate binary judgement), problem areas are always NAMED rather than
|
||||
* folded into counts, grade labels pass through from the API (live-sampled
|
||||
* Ofsted vocabulary — never invented here), admissions chips use one
|
||||
* consistent metric, and progress bands follow DfE's confidence-interval
|
||||
* methodology instead of thresholding point estimates.
|
||||
*/
|
||||
|
||||
import type { OfstedInspection, SchoolAdmissions } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verdicts against an anchor (England average or state-school benchmark)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Verdict = 'above' | 'close' | 'below';
|
||||
|
||||
export function verdict(value: number, anchor: number, tolerance = 2): Verdict {
|
||||
if (value >= anchor + tolerance) return 'above';
|
||||
if (value <= anchor - tolerance) return 'below';
|
||||
return 'close';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ofsted — three regimes, one display model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OFSTED_LEGACY_GRADES: Record<number, string> = {
|
||||
1: 'Outstanding',
|
||||
2: 'Good',
|
||||
3: 'Requires improvement',
|
||||
4: 'Inadequate',
|
||||
};
|
||||
|
||||
/** rc_ key → the area label used across the reviewed mockups. */
|
||||
const RC_AREA_LABELS: Record<string, string> = {
|
||||
rc_inclusion: 'Inclusion',
|
||||
rc_curriculum_teaching: 'Curriculum & teaching',
|
||||
rc_achievement: 'Achievement',
|
||||
rc_attendance_behaviour: 'Attendance & behaviour',
|
||||
rc_personal_development: 'Personal development',
|
||||
rc_leadership_governance: 'Leadership & governance',
|
||||
rc_early_years: 'Early years',
|
||||
rc_sixth_form: 'Sixth form',
|
||||
};
|
||||
|
||||
export function rcAreaLabel(key: string): string {
|
||||
return RC_AREA_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
export interface ReportCardSummary {
|
||||
/** Graded areas only, grouped by label, best grade first. */
|
||||
counts: Array<{ label: string; count: number }>;
|
||||
/** Areas rated Needs attention / Urgent improvement — always named. */
|
||||
problems: Array<{ areaLabel: string; label: string }>;
|
||||
safeguarding: 'met' | 'not_met' | null;
|
||||
/** True when every graded area is Expected standard or better and
|
||||
* safeguarding is not "not met". */
|
||||
allClear: boolean;
|
||||
}
|
||||
|
||||
const PROBLEM_CODES = new Set([4, 5]);
|
||||
|
||||
export function summariseReportCard(ofsted: OfstedInspection): ReportCardSummary {
|
||||
const entries = Object.entries(ofsted.report_card ?? {});
|
||||
const byCode = new Map<number, { label: string; count: number }>();
|
||||
const problems: ReportCardSummary['problems'] = [];
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
if (PROBLEM_CODES.has(entry.code)) {
|
||||
problems.push({ areaLabel: rcAreaLabel(key), label: entry.label });
|
||||
} else {
|
||||
const existing = byCode.get(entry.code);
|
||||
if (existing) existing.count += 1;
|
||||
else byCode.set(entry.code, { label: entry.label, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const counts = [...byCode.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, v]) => v);
|
||||
|
||||
const safeguarding =
|
||||
ofsted.rc_safeguarding_met === true
|
||||
? 'met'
|
||||
: ofsted.rc_safeguarding_met === false
|
||||
? 'not_met'
|
||||
: null;
|
||||
|
||||
return {
|
||||
counts,
|
||||
problems,
|
||||
safeguarding,
|
||||
allClear: entries.length > 0 && problems.length === 0 && safeguarding !== 'not_met',
|
||||
};
|
||||
}
|
||||
|
||||
export type OfstedDisplay =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'graded'; grade: number; gradeLabel: string; carriedForward: false }
|
||||
| { kind: 'carried_forward'; grade: number; gradeLabel: string; carriedForward: true }
|
||||
| { kind: 'report_card'; summary: ReportCardSummary };
|
||||
|
||||
export function ofstedDisplay(
|
||||
ofsted: OfstedInspection | null | undefined,
|
||||
): OfstedDisplay {
|
||||
if (!ofsted) return { kind: 'none' };
|
||||
|
||||
// A report card is the newest inspection format; when present it wins —
|
||||
// never derive or prefer an overall grade alongside it.
|
||||
if (ofsted.report_card && Object.keys(ofsted.report_card).length > 0) {
|
||||
return { kind: 'report_card', summary: summariseReportCard(ofsted) };
|
||||
}
|
||||
|
||||
const grade = ofsted.overall_effectiveness;
|
||||
const gradeLabel = grade != null ? OFSTED_LEGACY_GRADES[grade] : undefined;
|
||||
if (grade == null || gradeLabel === undefined) return { kind: 'none' };
|
||||
|
||||
if (ofsted.grade_source === 'ungraded_carried_forward') {
|
||||
return { kind: 'carried_forward', grade, gradeLabel, carriedForward: true };
|
||||
}
|
||||
return { kind: 'graded', grade, gradeLabel, carriedForward: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admissions — one consistent chip metric (first-preference success)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdmissionsSummary {
|
||||
firstPrefPct: number | null;
|
||||
chip: { tone: 'good' | 'warn' | 'neutral'; text: string } | null;
|
||||
/** e.g. "Named on 457 forms · 180 places" — total preferences at any rank,
|
||||
* deliberately not phrased as head-to-head applications. */
|
||||
interest: string | null;
|
||||
}
|
||||
|
||||
export function summariseAdmissions(
|
||||
a: SchoolAdmissions | null | undefined,
|
||||
): AdmissionsSummary {
|
||||
if (!a) return { firstPrefPct: null, chip: null, interest: null };
|
||||
|
||||
const pct =
|
||||
a.first_preference_offer_pct != null
|
||||
? Math.round(a.first_preference_offer_pct)
|
||||
: null;
|
||||
|
||||
let chip: AdmissionsSummary['chip'] = null;
|
||||
if (pct != null) {
|
||||
if (pct >= 100) {
|
||||
chip = { tone: 'good', text: 'All first choices offered' };
|
||||
} else if (pct < 75) {
|
||||
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
|
||||
} else {
|
||||
chip = { tone: pct >= 90 ? 'good' : 'neutral', text: `${pct}% of first choices offered` };
|
||||
}
|
||||
}
|
||||
|
||||
const interest =
|
||||
a.total_applications != null && a.places_offered != null
|
||||
? `Named on ${a.total_applications.toLocaleString('en-GB')} forms · ${a.places_offered.toLocaleString('en-GB')} places`
|
||||
: null;
|
||||
|
||||
return { firstPrefPct: pct, chip, interest };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress bands — DfE confidence-interval methodology
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function progressBand(
|
||||
score: number | null,
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
): 'above' | 'average' | 'below' | null {
|
||||
if (score == null || lower == null || upper == null) return null;
|
||||
if (lower > 0) return 'above';
|
||||
if (upper < 0) return 'below';
|
||||
return 'average';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dot-strip geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StripPoint {
|
||||
/** 0–100 percentage position along the track. */
|
||||
pos: number;
|
||||
labelAbove: boolean;
|
||||
value: number;
|
||||
schoolIndex: number;
|
||||
}
|
||||
|
||||
/** Labels within 4% of the domain of a lower neighbour flip above the strip
|
||||
* (the reviewed mockups' collision nudge). */
|
||||
export function stripPositions(
|
||||
values: Array<number | null>,
|
||||
min = 0,
|
||||
max = 100,
|
||||
): StripPoint[] {
|
||||
const span = max - min;
|
||||
const points = values
|
||||
.map((value, schoolIndex) => ({ value, schoolIndex }))
|
||||
.filter((p): p is { value: number; schoolIndex: number } => p.value != null)
|
||||
.map((p) => ({
|
||||
value: p.value,
|
||||
schoolIndex: p.schoolIndex,
|
||||
pos: Math.min(100, Math.max(0, ((p.value - min) / span) * 100)),
|
||||
labelAbove: false,
|
||||
}));
|
||||
|
||||
const nudge = span * 0.04;
|
||||
let lastBelow = -Infinity;
|
||||
for (const p of [...points].sort((a, b) => a.value - b.value)) {
|
||||
if (p.value - lastBelow < nudge) {
|
||||
p.labelAbove = true;
|
||||
} else {
|
||||
lastBelow = p.value;
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Latest non-null yearly value of `metricKey` per school, in `urns` order. */
|
||||
export function latestValues(
|
||||
data: Record<string, { yearly_data: Array<Record<string, unknown> & { year: number }> }>,
|
||||
urns: number[],
|
||||
metricKey: string,
|
||||
): Array<number | null> {
|
||||
return urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const v = rows[i][metricKey];
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user