diff --git a/docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md b/docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md new file mode 100644 index 0000000..49832d9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-exam-phase-taxonomy-design.md @@ -0,0 +1,212 @@ +# Exam Results Taxonomy — Phase Grouping and Sixth-Form Separation + +**Date:** 2026-07-07 +**Status:** Approved design (taxonomy/analysis only — no implementation in this doc's scope) + +## Purpose + +Classify every exam-result metric SchoolCompare displays today into four phase +groups — **Primary**, **Secondary**, **Sixth form**, **Other** — and define an +authoritative rule for separating schools that have a sixth form from those +that don't. This document is the reference for: + +1. How the UI should group results sections and rankings by phase. +2. The future KS5 (A-level) ingestion work — the Sixth form group lists the + concrete DfE metrics as placeholders with source columns. +3. Replacing the fragile `age_range contains "18"` heuristic with the GIAS + `OfficialSixthForm` flag. + +## 1. Grouping principle + +Metrics are grouped by **the key stage of the assessment**, not by the phase +of the school displaying them. An all-through school (4–18) shows metrics in +all three exam groups; a pure primary shows only the Primary group. + +| Group | Assessments | Key stage | Taken at age | Data status | +|---|---|---|---|---| +| **Primary** | KS2 SATs (reading, writing TA, maths, GPS, science TA) | KS2 | 10–11 (Year 6) | ✅ Live — `marts.fact_ks2_performance` | +| **Secondary** | GCSEs, Attainment 8 / Progress 8, EBacc | KS4 | 15–16 (Year 11) | ✅ Live — `marts.fact_ks4_performance` | +| **Sixth form** | A levels, applied general, tech levels | KS5 (16–18) | 17–18 (Year 12–13) | ⏳ Not ingested — placeholders in §4 | +| **Other** | Non-exam context displayed alongside results | n/a | n/a | ✅ Live — various marts | + +Not covered (not displayed today, candidates for future "Other"/Primary): +EYFS Good Level of Development, Year 1 Phonics check, Year 4 Multiplication +Tables Check, KS1 assessments (no longer published at school level by DfE). + +## 2. Metric-by-metric mapping (current site) + +Every key in `backend/schemas.py` `METRIC_DEFINITIONS` — the single source of +truth for what the site displays — mapped to its phase group. `category` is +the existing schema category; source columns are the DfE names used at +ingestion (legacy performance-tables CSV for KS2, EES for KS4). + +### Primary (KS2 SATs) + +| Metric key | Category | DfE source column | +|---|---|---| +| `rwm_expected_pct` | expected | `PTRWM_EXP` | +| `reading_expected_pct` | expected | `PTREAD_EXP` | +| `writing_expected_pct` | expected | `PTWRITTA_EXP` | +| `maths_expected_pct` | expected | `PTMAT_EXP` | +| `gps_expected_pct` | expected | `PTGPS_EXP` | +| `science_expected_pct` | expected | `PTSCITA_EXP` | +| `rwm_high_pct` | higher | `PTRWM_HIGH` | +| `reading_high_pct` | higher | `PTREAD_HIGH` | +| `writing_high_pct` | higher | `PTWRITTA_HIGH` | +| `maths_high_pct` | higher | `PTMAT_HIGH` | +| `gps_high_pct` | higher | `PTGPS_HIGH` | +| `reading_progress` | progress | `READPROG` | +| `writing_progress` | progress | `WRITPROG` | +| `maths_progress` | progress | `MATPROG` | +| `reading_avg_score` | average | `READ_AVERAGE` | +| `maths_avg_score` | average | `MAT_AVERAGE` | +| `gps_avg_score` | average | `GPS_AVERAGE` | +| `rwm_expected_boys_pct` | gender | `PTRWM_EXP_B` | +| `rwm_expected_girls_pct` | gender | `PTRWM_EXP_G` | +| `rwm_high_boys_pct` | gender | `PTRWM_HIGH_B` | +| `rwm_high_girls_pct` | gender | `PTRWM_HIGH_G` | +| `rwm_expected_disadvantaged_pct` | equity | `PTRWM_EXP_FSM6CLA1A` | +| `rwm_expected_non_disadvantaged_pct` | equity | `PTRWM_EXP_NotFSM6CLA1A` | +| `disadvantaged_gap` | equity | `DIFFN_RWM_EXP` | +| `reading_absence_pct` | absence | `PTREAD_AT` | +| `gps_absence_pct` | absence | `PTGPS_AT` | +| `maths_absence_pct` | absence | `PTMAT_AT` | +| `writing_absence_pct` | absence | `PTWRITTA_AD` | +| `science_absence_pct` | absence | `PTSCITA_AD` | +| `rwm_expected_3yr_pct` | trends | `PTRWM_EXP_3YR` | +| `reading_avg_3yr` | trends | `READ_AVERAGE_3YR` | +| `maths_avg_3yr` | trends | `MAT_AVERAGE_3YR` | + +The absence metrics measure absence *from KS2 tests*, so they belong to +Primary even though they are not attainment scores. National comparators for +this group come from `marts.fact_ks2_national_averages`. + +### Secondary (KS4 / GCSE) + +| Metric key | Category | EES source column | +|---|---|---| +| `attainment_8_score` | gcse | `attainment8_average` | +| `progress_8_score` | gcse | `progress8_average` | +| `english_maths_standard_pass_pct` | gcse | `engmath_94_percent` | +| `english_maths_strong_pass_pct` | gcse | `engmath_95_percent` | +| `ebacc_entry_pct` | gcse | `ebacc_entering_percent` | +| `ebacc_standard_pass_pct` | gcse | `ebacc_94_percent` | +| `ebacc_strong_pass_pct` | gcse | `ebacc_95_percent` | +| `ebacc_avg_score` | gcse | `ebacc_aps_average` | +| `gcse_grade_91_pct` | gcse | `gcse_91_percent` | + +Also stored in `marts.fact_ks4_performance` (and `fact_performance`) but not +yet in `METRIC_DEFINITIONS` — Secondary group members when surfaced: +`progress_8_lower_ci`, `progress_8_upper_ci`, `progress_8_english`, +`progress_8_maths`, `progress_8_ebacc`, `progress_8_open`, +`prior_attainment_avg` (KS2 baseline of the GCSE cohort), `sen_pct`. + +### Sixth form (KS5) + +No metrics today. The secondary school detail view renders a static note +("Post-16 destination data coming soon") when the school has a sixth form. +Placeholders for ingestion are specified in §4. + +### Other (non-exam context) + +Displayed alongside results but not tied to any assessment: + +| Metric key / surface | Category | Source | +|---|---|---| +| `disadvantaged_pct` | context | KS2 CSV `PTFSM6CLA1A` | +| `eal_pct` | context | KS2 CSV `PTEALGRP2` | +| `sen_support_pct` | context | KS2 CSV `PSENELK` (KS4 fallback `sen_no_ehcp_pupil_percent`) | +| `stability_pct` | context | KS2 CSV `PTMOBN` | +| Ofsted grades incl. `sixth_form_provision` / `rc_sixth_form` | — | `marts.fact_ofsted_inspection` | +| Admissions (offers, oversubscription) | — | `marts.fact_admissions` | +| Finance (per-pupil spend, cost shares) | — | `marts.fact_finance` | +| Deprivation (IDACI) | — | `marts.fact_deprivation` | +| Pupil characteristics (census) | — | `marts.fact_pupil_characteristics` | + +Note: the context metrics are cohort characteristics of the KS2 cohort at +source, but they are presented (and should stay presented) as school-level +context, so they group as Other, not Primary. + +## 3. Sixth-form separation + +### Definition (authoritative) + +> A school **has a sixth form** iff GIAS `OfficialSixthForm (name)` = +> `"Has a sixth form"` for its URN. + +GIAS values are `Has a sixth form`, `Does not have a sixth form`, and +`Not applicable` / blank. `Not applicable` (nurseries, primaries, PRUs) maps +to **false**. This field is the DfE's registry flag, updated continuously, +and is the only source that correctly classifies: + +- 16–19 sixth-form colleges and UTCs (age ranges like `14-19`, `16-19` that + the current substring heuristic misclassifies as *no* sixth form); +- schools whose statutory age range extends to 18 on paper but which have no + registered post-16 provision. + +### Pipeline change (future work) + +1. `stg_gias_establishments.sql`: add + `"OfficialSixthForm (name)" as official_sixth_form`. +2. `dim_school.sql` (+ `models.py` `DimSchool`, `_marts_schema.yml`): add + `has_sixth_form boolean` = `official_sixth_form = 'Has a sixth form'`. +3. Expose `has_sixth_form` on the school API payloads. + +### Current heuristic — audit of `age_range` ~ "18" sites + +All must migrate to the `has_sixth_form` flag once exposed: + +| Site | Current behaviour | +|---|---| +| `backend/app.py:419-422` | `/api/schools?has_sixth_form=yes\|no` filters on `age_range.str.contains("18")` | +| `nextjs-app/components/SecondarySchoolDetailView.tsx:101` | "Sixth form" badge + coming-soon note from `age_range?.includes('18')` | +| `nextjs-app/components/FilterBar.tsx:370-372` | Filter labels hard-code "(11-18)" / "(11-16)" — labels should drop the age-range parenthetical since sixth form ≠ age range | + +Fallback rule: if GIAS is blank for a URN (rare; new establishments), fall +back to the age-range heuristic and log the URN. + +### UI separation rules + +- **School page**: schools with `has_sixth_form = true` show a Sixth form + results section (placeholder until KS5 data lands); schools without never + show it. Badge on the header as today, but driven by the flag. +- **Search/rankings filter**: "With sixth form" / "Without sixth form" uses + the flag; applies to secondary and all-through phases. +- **Comparison**: when comparing a with-sixth-form school against one + without, the Sixth form group renders "No sixth form" for the latter + rather than blank cells, making the structural difference explicit. + +## 4. Sixth form placeholders — future KS5 ingestion spec + +Source: DfE "A level and other 16 to 18 results" (EES, preferred — matches +the KS4 EES tap) or legacy performance-tables `england_ks5final.csv`. +Column names below are from the legacy KS5 CSV; verify against the EES +release chosen at ingestion time. + +| Proposed metric key | Name | Legacy source column | Type | +|---|---|---|---| +| `alevel_aps_per_entry` | A level average points per entry | `TALLPPE_ALEV_1618` | score | +| `alevel_avg_grade` | A level average grade (e.g. B-) | `TALLPPEGRD_ALEV_1618` | grade | +| `academic_aps_per_entry` | Academic qualifications APS per entry | `TALLPPE_ACAD_1618` | score | +| `applied_general_aps_per_entry` | Applied general APS per entry | `TALLPPE_AGEN_1618` | score | +| `tech_level_aps_per_entry` | Tech level APS per entry | `TALLPPE_TLEV_1618` | score | +| `english_progress_1618` | English progress (16–18, unfinished GCSE 4+) | `PROGENG_1618` | score | +| `maths_progress_1618` | Maths progress (16–18) | `PROGMAT_1618` | score | +| `ks5_cohort_size` | Students at end of 16–18 study | `TALLPUP_1618` | count | +| `alevel_3plus_aab_pct` | % achieving AAB+ in ≥2 facilitating subjects | `TAAB2FAC_1618` | percentage | +| `ks5_retention_pct` | Retention (completed main programme) | study-programme retention measure | percentage | +| `ks5_destinations_pct` | Sustained education/employment destination | 16–18 destination measures dataset | percentage | + +Proposed landing shape mirrors KS4: `stg_ees_ks5.sql` → +`int_ks5_with_lineage.sql` → `marts.fact_ks5_performance` (one row per URN +per year), joined into `fact_performance`, with a `category: "sixth_form"` +(or `"alevel"`) block added to `METRIC_DEFINITIONS`. + +## 5. Out of scope + +- Any implementation (pipeline, API, or UI changes) — this is the taxonomy + reference; implementation work items are §3 "Pipeline change", the + heuristic migration audit, and §4 ingestion, each to be planned separately. +- Middle schools (deemed secondary/primary): they follow the assessment-based + grouping automatically — no special casing. +- Independent schools: no DfE performance data published; unaffected.