fix: order nearby schools by distance, not by how alike they are #151
@@ -1,9 +1,16 @@
|
||||
# Similar Schools Nearby — Design
|
||||
# Other Schools Nearby — Design
|
||||
|
||||
**Date:** 2026-09-21
|
||||
**Status:** awaiting review
|
||||
**Date:** 2026-09-21, revised 2026-09-22
|
||||
**Status:** revised after staging review
|
||||
**Scope:** school detail pages, both phase templates
|
||||
|
||||
> **Revision, 2026-09-22.** The first build ranked by intake similarity and used
|
||||
> distance as a tiebreak. On staging a Catholic primary showed six Catholic
|
||||
> primaries, none of them close enough to be a real option, and omitted the
|
||||
> community school down the road. Distance now decides the order and nothing
|
||||
> else does; the tier system is gone. The reasoning is kept below rather than
|
||||
> quietly overwritten, because the mistake is the instructive part.
|
||||
|
||||
## Goal
|
||||
|
||||
Give a school detail page an answer to the question every reader arrives with
|
||||
@@ -15,7 +22,8 @@ school. This section adds that edge — up to six nearby schools of the same pha
|
||||
and a comparable intake, three at a time in a carousel, each a crawlable link and
|
||||
each addable to the comparison basket in one click.
|
||||
|
||||
Mockup, with all three tier states live in both themes:
|
||||
Mockup, in both themes (drawn against the original tiered design, so its ledes
|
||||
and chip fallbacks are one revision behind the copy specified below):
|
||||
<https://claude.ai/artifact/168KdUMcfkUeGWW2FGjuec>
|
||||
|
||||
Source of the same page in the repo: `mockups/similar-schools-nearby.html`.
|
||||
@@ -37,19 +45,23 @@ alternative, and there are three ways that claim goes wrong:
|
||||
3. A **single-sex** school of the opposite sex. Not a weak match — not an option
|
||||
at all.
|
||||
|
||||
So the design separates two kinds of rule, and never confuses them:
|
||||
So the design separates two kinds of fact, and never confuses them:
|
||||
|
||||
- **Hard filters** encode the claims above. They are never relaxed, at any
|
||||
distance, even if that means the section does not render.
|
||||
- **Soft preferences** describe how closely the intake resembles this school's.
|
||||
They relax in tiers, and the card's own text always states what survived.
|
||||
- **Hard filters** encode the claims above. They decide eligibility, and are
|
||||
never relaxed at any distance, even if that means the section does not render.
|
||||
- **Shared characteristics** — gender, religious character, selectivity —
|
||||
describe how closely an intake resembles this school's. They are *reported on
|
||||
the card and never ranked on*, so the reader weighs them rather than having
|
||||
them weighed for them.
|
||||
|
||||
Everything below follows from that split.
|
||||
Everything below follows from that split. The revision at the top of this
|
||||
document is what happens when the second kind is treated as the first.
|
||||
|
||||
## Selection algorithm
|
||||
|
||||
A backend helper, `_similar_schools_payload(urn)` in `backend/app.py`, modelled
|
||||
on the existing `_places_payload(urn)` and operating on the cached
|
||||
A backend helper, `_nearby_schools_payload(urn)` in `backend/app.py`, modelled
|
||||
on the existing `_places_payload(urn)` and delegating to
|
||||
`backend/nearby_schools.select_nearby(frame, urn)`, which operates on the cached
|
||||
`load_latest_school_data()` frame — one row per URN, already carrying
|
||||
`latitude`, `longitude`, `phase`, `gender`, `religious_denomination`,
|
||||
`admissions_policy`, `school_type` and `status`.
|
||||
@@ -77,64 +89,63 @@ school the frontend treats as special for benchmarking but the backend treats as
|
||||
mainstream for matching would be dropped from its own England comparison and
|
||||
then offered as a peer to a mainstream school on the next page along.
|
||||
|
||||
**Up to six cards, three visible.** Six is a cap, not a quota: the section shows
|
||||
every school that qualifies at the tiers it used, up to six. Three fit the row,
|
||||
and the rest are reached with the carousel arrows. Two is the minimum that
|
||||
renders at all.
|
||||
**Up to six cards, three visible.** Three fit the row; the rest are reached with
|
||||
the carousel arrows. Two is the minimum that renders at all.
|
||||
|
||||
### Soft preferences, relaxed in tiers
|
||||
### Order: distance, and nothing else
|
||||
|
||||
| Tier | Additionally requires | Radius |
|
||||
|---|---|---|
|
||||
| 1 | exact gender equality **and** same religious character | 3 miles |
|
||||
| 2 | exact gender equality | 5 miles |
|
||||
| 3 | nothing beyond the hard filters | 10 miles |
|
||||
The nearest eligible schools, closest first. Similarity does not enter the
|
||||
ranking at any point.
|
||||
|
||||
**Tiers relax to reach a usable set, never to fill the last slots.**
|
||||
**Why not, having built it the other way first.** The original design ranked by
|
||||
tiers — same gender and faith within 3 miles, then same gender within 5, then
|
||||
anything within 10 — and used distance only to order the result. Two things
|
||||
followed, and both showed up on the first Catholic primary anyone looked at:
|
||||
|
||||
Work down the tiers until the schools found so far reach three. Call the tier
|
||||
that got there T. The section then shows up to six schools drawn from tiers 1
|
||||
to T, nearest first — and does not open tier T+1 merely because six slots are
|
||||
not yet full.
|
||||
- A faith match at 2.9 miles outranked a community school at 0.3 miles. For a
|
||||
primary, whose catchment is routinely under a mile, the far school is not a
|
||||
weaker option; it is not an option.
|
||||
- Because the row filled from the best tier before widening, three Catholic
|
||||
schools within 3 miles were enough to fill all six slots with Catholic
|
||||
schools. The stopping rule that produced this had been added to prevent the
|
||||
*opposite* failure — padding a row with weak distant matches — and made this
|
||||
one certain.
|
||||
|
||||
Worked through:
|
||||
The premise was backwards. **Distance is a constraint and intake is a
|
||||
preference.** A parent cannot act on a school outside their reach however well
|
||||
it matches, and they are perfectly capable of noticing a shared denomination
|
||||
for themselves if we show it to them. So similarity moved from the ranking to
|
||||
the card: `shared` reports what a school genuinely has in common, and the reader
|
||||
applies their own weighting.
|
||||
|
||||
| Qualifying | T | Shown |
|
||||
|---|---|---|
|
||||
| 14 at tier 1 | 1 | the 6 nearest tier-1 schools |
|
||||
| 4 at tier 1 | 1 | all 4 — tier 2 is never opened |
|
||||
| 2 at tier 1, 7 more at tier 2 | 2 | the 6 nearest of those 9 |
|
||||
| 2 at tier 1, 1 at tier 2 | 2 | all 3 |
|
||||
| 2 across all three tiers | 3 | both, since 2 is the minimum |
|
||||
The hard filters above were always where the defensibility lived. They are
|
||||
untouched.
|
||||
|
||||
Without that stopping rule, a cap of six would reliably drag in tier-3 schools
|
||||
ten miles away to fill a row that three good matches had already earned. The old
|
||||
cap of three hid this; six exposes it, which is why the rule is stated rather
|
||||
than left to the loop.
|
||||
### Reach: a sanity bound, not a target
|
||||
|
||||
**Faith relaxes before gender.** A faith mismatch changes the character of a
|
||||
school; a gender mismatch can mean the school is not available to the reader's
|
||||
child at all. Ordering them the other way would fill the section with schools
|
||||
that cannot be applied to.
|
||||
| Phase | Reach |
|
||||
|---|---|
|
||||
| Primary, middle deemed primary, all-through | 2 miles |
|
||||
| Secondary, middle deemed secondary | 6 miles |
|
||||
| 16 plus | 10 miles |
|
||||
|
||||
### Two decisions that are easy to get wrong later
|
||||
Ordering by distance already handles density — a school in inner London fills
|
||||
all six slots inside a mile and never approaches the cap. The cap decides one
|
||||
thing: what happens where the area is sparse. It differs by phase because
|
||||
catchments do, and because people travel furthest for post-16.
|
||||
|
||||
**Selected by tier, displayed by distance.** Tier decides *which* three schools
|
||||
earn a slot. The rendered order is then distance ascending, because "nearby" is
|
||||
the promise in the heading and a reader scanning the row reads the first card as
|
||||
the closest. A tier-2 school at 0.4 miles therefore appears above a tier-1
|
||||
school at 2.9 miles, and the chips explain the difference in match quality.
|
||||
**A primary with nothing inside two miles renders no section**, and that is the
|
||||
intended answer rather than a gap. The alternative is a section headed "nearby"
|
||||
listing a school four miles from a five-year-old.
|
||||
|
||||
**Past the sixth school, the rest are dropped without a count.** In inner
|
||||
London dozens clear tier 1, and a parent there will notice three is not the
|
||||
neighbourhood — hence six. Beyond that the section does not try to be the list:
|
||||
`NearbyPlaces` sits directly beneath and already leads to the place pages, which
|
||||
are built for browsing a full set and which the school page exists to feed.
|
||||
**Past the sixth school, the rest are dropped without a count.** The section
|
||||
does not try to be the list: `NearbyPlaces` sits directly beneath and already
|
||||
leads to the place pages, which are built for browsing a full set and which the
|
||||
school page exists to feed.
|
||||
|
||||
**Fewer than two results renders nothing.** Not an empty state, not a single
|
||||
lonely card, not padding with schools that failed the hard filters. The section
|
||||
is absent, the nav item is absent, and the page is unchanged from today. A page
|
||||
with one weak match is better off without the section than with it.
|
||||
lonely card. The section is absent, the nav item is absent, and the page is
|
||||
unchanged from before it existed.
|
||||
|
||||
### Distance
|
||||
|
||||
@@ -157,14 +168,13 @@ reader to assume otherwise.
|
||||
| `distance_miles` | one decimal place |
|
||||
| `school_type` | GIAS type, translated, for the card's meta line |
|
||||
| `age_range` | for the meta line |
|
||||
| `shared` | the chip strings the tier actually justifies — see below |
|
||||
| `tier` | 1, 2 or 3 — drives the lede's wording and the chip styling |
|
||||
| `shared` | what this school genuinely shares with the subject; may be empty |
|
||||
| `metric_value` | the phase-appropriate headline figure, or null |
|
||||
| `metric_key` | `rwm_expected_pct` or `attainment_8_score` — see below |
|
||||
| `metric_year` | the year the figure is from |
|
||||
|
||||
The metric follows the phase side the school was *matched* on, not the
|
||||
neighbour's own phase, so a row of cards never mixes two scales. The secondary
|
||||
The metric follows the subject school's phase side, not the neighbour's own
|
||||
phase, so a row of cards never mixes two scales. The secondary
|
||||
side uses `attainment_8_score`; the primary side uses `rwm_expected_pct`. Where
|
||||
the neighbour has no value for that key, the card reads "Not published" rather
|
||||
than falling back to the other key.
|
||||
@@ -183,12 +193,6 @@ correctly — against secondaries. The section therefore takes its lede noun fro
|
||||
the school's own phase rather than from its template, or it would print "Other
|
||||
primary schools near <sixth form college>" above a row of secondaries.
|
||||
|
||||
`tier` is carried explicitly rather than inferred from the contents of
|
||||
`shared`, because the frontend needs it for two separate decisions — whether the
|
||||
lede may claim a similar intake, and whether a chip renders as a brand-tinted
|
||||
fill or a muted outline — and inferring it from chip count would couple those
|
||||
decisions to the copy.
|
||||
|
||||
Up to six rows of roughly 130 bytes each. It rides in the existing detail payload
|
||||
rather than a new endpoint because the page already makes exactly one server
|
||||
fetch for its data, and `/school/[slug]` regenerates at most weekly
|
||||
@@ -199,9 +203,11 @@ week.
|
||||
frontend treats absent and empty identically, which is what allowed
|
||||
`NearbyPlaces` to ship without a lockstep deploy of the two images.
|
||||
|
||||
`shared` is computed on the backend beside the tier that produced it, not
|
||||
re-derived on the frontend. Deriving it twice is how a card comes to claim a
|
||||
match the selection did not actually make.
|
||||
`shared` is computed on the backend, beside the data it is derived from, not
|
||||
re-derived on the frontend. Deriving it twice is how a card comes to claim
|
||||
something the selection never established. An empty list is a real answer and
|
||||
renders no chips: a bare card costs a school nothing but the likeness it does
|
||||
not have, since the order was already settled by distance.
|
||||
|
||||
## Frontend
|
||||
|
||||
@@ -308,16 +314,21 @@ shows.
|
||||
|
||||
## Copy, and what the section is allowed to claim
|
||||
|
||||
**The lede tracks the deepest tier shown.** At tiers 1–2 it reads "Other primary
|
||||
schools near X, with a similar intake." Where any card came from tier 3 it drops
|
||||
"with a similar intake", because for at least one of the cards that is not what
|
||||
was matched. Six cards make this more likely to fire than three did, which is
|
||||
correct: a wider net is exactly when the claim needs dropping.
|
||||
**The lede never claims an intake.** It reads "Other primary schools near X." —
|
||||
one sentence, no variants. The earlier version varied the wording by tier, which
|
||||
only existed to soften a claim the section should not have been making.
|
||||
|
||||
**Chips state only what is shared.** A tier-2 card carries fewer chips rather
|
||||
than a chip it has not earned; a tier-3 card falls back to the plain phase name,
|
||||
styled as a muted outline rather than a brand-tinted fill so the difference is
|
||||
visible at a glance.
|
||||
**The heading is "Other schools nearby", not "Similar schools nearby".** The
|
||||
hard filters do guarantee a comparable set — same phase, same selectivity,
|
||||
mainstream never beside special — but nothing ranks on likeness, so the heading
|
||||
does not say it does. The nav item reads "Nearby schools" and the section id is
|
||||
`nearby`.
|
||||
|
||||
**Chips state only what is shared, and may be absent entirely.** A card with
|
||||
nothing in common renders no chip row rather than falling back to a filler.
|
||||
Since chips no longer affect the order, an empty one costs that school nothing
|
||||
except a claim it cannot support — and a Catholic parent scanning the row still
|
||||
spots "Roman Catholic" on the card that carries it, and weighs it themselves.
|
||||
|
||||
**The neighbour's metric carries no valence colour.** Green and terracotta are
|
||||
reserved site-wide for comparison against the England average. Colouring a
|
||||
@@ -364,9 +375,11 @@ synthetic frame rather than live marts:
|
||||
- a special school returns only special schools; a mainstream school returns none
|
||||
- a Boys school never returns a Girls school; Mixed matches both
|
||||
- closed schools and schools without coordinates are never returned
|
||||
- tier relaxation fills in order, and a school taken at tier 1 is not repeated
|
||||
- tiers stop relaxing once three are found: four tier-1 matches never open tier 2
|
||||
- results are ordered by distance ascending, always
|
||||
- a faith match never outranks a closer school (the staging defect, pinned)
|
||||
- the nearest eligible school is always present
|
||||
- more than six qualifying schools returns the six nearest
|
||||
- reach is capped per phase, and a primary beyond two miles returns `[]`
|
||||
- an all-through school is offered on both phase sides
|
||||
- a `16 plus` school is matched against secondaries and colleges, never primaries
|
||||
- `is_secondary_phase` and `PHASE_GROUPS` agree on every GIAS phase value
|
||||
@@ -376,7 +389,8 @@ synthetic frame rather than live marts:
|
||||
**Frontend**, in `nextjs-app/__tests__`:
|
||||
|
||||
- the section renders nothing for absent, empty and single-row inputs
|
||||
- the lede drops "with a similar intake" when any card is tier 3
|
||||
- the lede never claims a similar intake
|
||||
- an empty `shared` renders no chips rather than a filler
|
||||
- a null metric renders "Not published"
|
||||
- the nav item appears only alongside the section
|
||||
- every card is in the DOM, including the ones scrolled out of view
|
||||
@@ -408,10 +422,11 @@ journeys are confirmed on the post-merge staging run.
|
||||
- Autoplay, dots, or an infinite loop on the carousel. It is a short list a
|
||||
reader scans deliberately, not a banner competing for attention, and a row
|
||||
that moves on its own is a row that moves while someone is reading it.
|
||||
- Statistical neighbours on deprivation, size or cohort profile. If the tiers
|
||||
prove too coarse, that is the trigger to move this computation into a dbt mart
|
||||
— `_similar_schools_payload` is a deliberate seam for exactly that swap.
|
||||
- Statistical neighbours on deprivation, size or cohort profile. If plain
|
||||
distance proves too blunt, that is the trigger to move this computation into a
|
||||
dbt mart — `select_nearby` is a deliberate seam for exactly that swap.
|
||||
- Precomputing neighbours in `marts.*`. Rejected for now: a new mart is inert
|
||||
until Airflow runs, so the feature would ship dark, and every tuning change to
|
||||
the tiers would become a pipeline round-trip instead of a deploy.
|
||||
the rules would become a pipeline round-trip instead of a deploy. The revision
|
||||
at the top of this document is the argument for keeping that loop short.
|
||||
- Any change to `/api/compare`, the compare page, or the comparison basket.
|
||||
Reference in new issue
Block a user