PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 54s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m58s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
183 lines
8.0 KiB
Markdown
183 lines
8.0 KiB
Markdown
# GIAS Code Dictionaries — Codes in Marts, Names in Code
|
|
|
|
**Date:** 2026-07-09
|
|
**Status:** Implemented 2026-07-09 — see docs/superpowers/plans/2026-07-09-gias-code-dictionaries.md
|
|
|
|
## Goal
|
|
|
|
Six GIAS classification fields are stored in the marts as repeated name
|
|
strings. Replace them with the official DfE integer codes and translate
|
|
code → name in application code. After this change the marts carry only
|
|
codes for:
|
|
|
|
| GIAS field | Today (marts, string) | After (marts, int) |
|
|
|---|---|---|
|
|
| `TypeOfEstablishment (name)` | `dim_school.school_type` | `school_type_code` |
|
|
| `EstablishmentStatus (name)` | `dim_school.status` | `status_code` |
|
|
| `PhaseOfEducation (name)` | `dim_school.phase` | `phase_code` |
|
|
| `OfficialSixthForm (name)` | (already reduced to `has_sixth_form` bool) | `official_sixth_form_code` in staging only; mart keeps the bool |
|
|
| `ReligiousCharacter (name)` | `dim_school.religious_character` | `religious_character_code` |
|
|
| `AdmissionsPolicy (name)` | `dim_school.admissions_policy` | `admissions_policy_code` |
|
|
|
|
Motivation: smaller marts and stable enum values for filtering. (Honest
|
|
sizing note: at ~25k open schools the raw performance win is modest; the
|
|
durable benefits are storage, DfE-governed vocabulary, and filter values
|
|
that can't drift with GIAS renames.)
|
|
|
|
## Decisions (made during brainstorming)
|
|
|
|
1. **GIAS native codes**, not custom enums. The GIAS bulk CSV publishes an
|
|
official `X (code)` column beside every `X (name)` column. We ingest the
|
|
DfE's own codes; no invented mapping to maintain.
|
|
2. **Translation lives in the backend at the API boundary.** The API keeps
|
|
serving today's name strings; the frontend, e2e journeys, and API
|
|
consumers are untouched.
|
|
|
|
## Design
|
|
|
|
### 1. Tap (Singer schema)
|
|
|
|
Add the six `(code)` columns to `GIASEstablishmentsStream.schema` in
|
|
`pipeline/plugins/extractors/tap-uk-gias/tap_uk_gias/tap.py`:
|
|
|
|
```
|
|
"TypeOfEstablishment (code)", "EstablishmentStatus (code)",
|
|
"PhaseOfEducation (code)", "OfficialSixthForm (code)",
|
|
"ReligiousCharacter (code)", "AdmissionsPolicy (code)"
|
|
```
|
|
|
|
The `(name)` columns **stay declared** — raw keeps both so we can detect
|
|
dictionary drift (§4) and regenerate dictionaries from live data.
|
|
|
|
### 2. Staging (`stg_gias_establishments.sql`)
|
|
|
|
- Add int casts: `school_type_code`, `status_code`, `phase_code`,
|
|
`official_sixth_form_code`, `religious_character_code`,
|
|
`admissions_policy_code` (all `cast(nullif(trim(...), '') as integer)`).
|
|
- Remove the corresponding name columns from the staging select
|
|
(`school_type`, `status`, `phase`, `official_sixth_form`,
|
|
`religious_character`, `admissions_policy`). Names live only in raw.
|
|
|
|
### 3. Marts
|
|
|
|
**`dim_school`** stores codes only:
|
|
|
|
- `school_type_code`, `status_code`, `phase_code`,
|
|
`religious_character_code`, `admissions_policy_code` replace their
|
|
string columns.
|
|
- Status filter becomes `where status_code in (<open>, <proposed-to-close>)`.
|
|
The numeric values are read from live raw data at implementation time
|
|
(`select distinct "EstablishmentStatus (code)", "EstablishmentStatus (name)"`),
|
|
never assumed from memory. Same filter in `dim_location`.
|
|
- `has_sixth_form` derives from `official_sixth_form_code`
|
|
(`<has-code>` → true, `<does-not>/<not-applicable>` → false, null →
|
|
`statutory_high_age >= 18` fallback). The `lower(trim(...))` string guard
|
|
becomes obsolete and is removed.
|
|
- `phase_code` derivation keeps today's cascade but emits codes:
|
|
1. GIAS `phase_code` when it is a real value (not the not-applicable code);
|
|
2. statutory-age inference emits the matching GIAS code
|
|
(Primary / Secondary / All-through — numeric values confirmed from
|
|
live data at implementation);
|
|
3. school-name heuristics (unchanged — they match `school_name`, which is
|
|
not one of the six fields) emit the same codes;
|
|
4. else null.
|
|
- dbt schema tests: `accepted_values` (severity **warn**) on every code
|
|
column, values taken from the dictionary; `not_null` warn on `phase_code`
|
|
(mirrors today's phase test); `has_sixth_form` tests unchanged.
|
|
|
|
**`dim_location`**: only the status filter changes (must stay byte-identical
|
|
to `dim_school`'s — the API inner-joins the two).
|
|
|
|
### 4. Dictionaries
|
|
|
|
**Canonical module: `backend/gias_codes.py`**
|
|
|
|
```python
|
|
ESTABLISHMENT_STATUS: dict[int, str]
|
|
SCHOOL_TYPE: dict[int, str]
|
|
PHASE_OF_EDUCATION: dict[int, str]
|
|
OFFICIAL_SIXTH_FORM: dict[int, str]
|
|
RELIGIOUS_CHARACTER: dict[int, str]
|
|
ADMISSIONS_POLICY: dict[int, str]
|
|
|
|
def translate(code: int | None, mapping: dict[int, str]) -> str | None:
|
|
"""None -> None; unknown code -> 'Unknown (<code>)' + warning log."""
|
|
```
|
|
|
|
- Contents are generated from live raw data
|
|
(`SELECT DISTINCT code, name FROM raw.gias_establishments ...` per field)
|
|
and sanity-checked against the DfE GIAS registers. Names must be
|
|
byte-identical to what the API serves today.
|
|
- Unknown codes never blank the UI: `translate` returns `"Unknown (<code>)"`
|
|
and logs, so a new DfE value degrades gracefully.
|
|
|
|
**Pipeline copy: `pipeline/scripts/gias_codes.py`**
|
|
|
|
The app and pipeline Docker images have disjoint build contexts
|
|
(`Dockerfile` copies `backend/`; `pipeline/Dockerfile` copies `pipeline/`),
|
|
so the Typesense sync cannot import the backend module. It gets a
|
|
byte-identical copy, and a backend unit test asserts
|
|
`backend/gias_codes.py` and `pipeline/scripts/gias_codes.py` have identical
|
|
content — drift fails CI. (Deliberately chosen over codegen: six dicts do
|
|
not justify build machinery.)
|
|
|
|
**Seed for drift detection: `pipeline/transform/seeds/gias_code_names.csv`**
|
|
|
|
Columns `field,code,name` mirroring the dictionary. A dbt test (severity
|
|
warn) compares live raw `(code, name)` pairs against the seed; when DfE adds
|
|
or renames a value the nightly run warns, prompting a dictionary + seed
|
|
update in one PR.
|
|
|
|
### 5. Backend translation (API contract unchanged)
|
|
|
|
- `_MAIN_QUERY` selects the code columns instead of the name columns.
|
|
- `load_school_data_as_dataframe()` translates immediately after
|
|
`pd.read_sql`, writing today's column names:
|
|
|
|
```python
|
|
df["phase"] = df["phase_code"].map(...)
|
|
df["school_type"] = df["school_type_code"].map(...) # then normalize_school_type as today
|
|
df["status"] = df["status_code"].map(...)
|
|
df["religious_denomination"] = df["religious_character_code"].map(...)
|
|
df["admissions_policy"] = df["admissions_policy_code"].map(...)
|
|
```
|
|
|
|
Everything downstream — `PHASE_GROUPS`, filters, payload builders,
|
|
`/api/filters`, frontend, e2e — sees exactly today's strings. No frontend
|
|
changes.
|
|
|
|
- `backend/models.py` `DimSchool`: string columns replaced by
|
|
`*_code = Column(Integer)`.
|
|
|
|
### 6. Typesense sync
|
|
|
|
`pipeline/scripts/sync_typesense.py` selects `phase`, `school_type`,
|
|
`religious_character` today. It switches to the code columns and translates
|
|
via `pipeline/scripts/gias_codes.py` before indexing, so facet values in
|
|
search are unchanged.
|
|
|
|
### 7. Rollout
|
|
|
|
- No DB migration: marts are full-rebuild tables.
|
|
- Deploy window: until the first post-merge pipeline run, the old marts
|
|
still carry string columns while the new backend queries code columns, so
|
|
the backend's query fails and it serves empty data (the one-column retry
|
|
built for `has_sixth_form` doesn't generalise to six columns, and a full
|
|
old-schema fallback query isn't worth it). **Decision: accept the window
|
|
and close it operationally — the runbook is merge → deploy → trigger
|
|
`school_data_daily` immediately.** The DAG's final step already calls
|
|
`/api/admin/reload`, so the backend recovers without a restart.
|
|
- Tests: backend unit tests for `translate()` (known / unknown / None),
|
|
payload tests asserting names still served, the file-parity test, dbt
|
|
schema/seed tests. Frontend: no changes; existing Jest suite is the
|
|
regression net.
|
|
|
|
## Out of scope
|
|
|
|
- Recoding other string columns (`gender`, `urban_rural`,
|
|
`nursery_provision`, `local_authority_name` …) — same pattern can follow
|
|
later if this proves out.
|
|
- Collapsing academy subtypes (today's `normalize_school_type`) — kept
|
|
as-is, applied after translation.
|
|
- Serving codes through the API — the contract deliberately keeps names.
|