osm-katana/src/shared/class.rs
//! **`min_zoom` — the first rung at which a feature is allowed to draw, and the ONE
//! place the tag → rung decision is made (LAW 5).**
//!
//! # Where the single source lives, and why it is HERE
//!
//! The constellation already had exactly one semantic OSM tag → class map, and it was
//! not in this crate: `facett_map::style::classify_with` (ways → `WayKind` + `Lod`)
//! and `facett_map::style::classify_label` (settlement/label tiers), with
//! `facett_map::LayerCategory::from_osm_tags` layered on top for the POI buckets and
//! korp consuming all three. `osm-katana`'s own [`crate::network`] classifier is a
//! different thing — it extracts the RAW tag value (`highway=motorway` ⇒ class
//! `"motorway"`) for a server-side `WHERE class IN (…)`; it says nothing about rungs.
//! `tiles.rs` says outright that there is no lod→zoom mapping in this crate and that
//! that is the point (`mapwire-9`).
//!
//! So DELTA 1 had to decide where the rung decision lives. Three candidates were
//! weighed:
//!
//! * **`facett-map`, and osm-katana depends on it.** Rejected. `facett-map` is an
//! egui component crate (`facett-core`, `egui`, `earcutr`); a headless
//! PBF → GeoParquet converter that publishes to crates.io cannot take a GUI
//! framework as a hard dependency to learn that a lake is a lake.
//! * **`edda`, the constellation's declared home for shared specs.** Correct in
//! principle and recorded here as the migration target the moment a THIRD consumer
//! appears. Rejected for now on facts: edda holds sixty crates and not one of them
//! is geo (surveyed 2026-08-31), so this would be a new crate plus a crates.io
//! release cascade that must land BEFORE osm-katana can publish again — and a
//! `path =` dependency on a sibling repo breaks in every worktree that is not a
//! sibling of `edda/` on disk.
//! * **HERE, in the writer.** Chosen. It adds no dependency edge and closes no cycle
//! that was open: korp already depends on osm-katana, osm-katana depends on neither
//! korp nor facett. And it is the only site in the pipeline that can bake the
//! decision INTO THE DATA and make a change to it re-cut that data — the rung table
//! is spelled into [`Tier::ladder_spelling`], which [`crate::network::NetworkRecipe`]
//! carries, so editing a rule invalidates the tables it wrote. `facett-map`'s own
//! ladder cannot do that, and its doc says why it should not try: `LOD_TIER_ZOOMS`
//! is *"a LOOK decision, not a data one"*.
//!
//! **What that leaves open, said plainly:** `classify_with`'s match arms and this
//! rule table are two texts describing one taxonomy. This module is a faithful,
//! arm-for-arm transcription of them (see [`RULES`], where every arm cites its
//! source), not a second opinion — but a transcription is not a construction. The
//! by-construction close is to lift `classify_with`'s kind+tier half out of
//! `facett-map` into the edda crate above and have BOTH sides call it; that is a
//! facett + edda + znippy-zoomies change and it is not this lane's. Until it lands,
//! [`Tier::ladder_spelling`] is the machine-readable statement of this side's ladder
//! that the facett side can be asserted against.
//!
//! # Two ladders, one rule — and why that is not a twin
//!
//! `facett-map` steps its CARTOGRAPHIC tier on `LOD_TIER_ZOOMS = [0, 6, 9, 12, 15]`
//! (five tiers, five cameras). The DATA ladder — korp's `DEFAULT_TILE_ZOOMS` and the
//! v2.0 parquet's rungs — is `[6, 9, 12, 15]`, four rungs. That is deliberately the
//! same shape facett's `lod_step` already documents as *"one rule, any level list"*:
//! two lists, one rule, one classification. What must not be twinned is the
//! CLASSIFICATION, and there is one of those.
//!
//! [`Tier::min_zoom`] is the projection between the two lists: the coarsest rung the
//! data actually serves at or below the tier's camera. `Country` (camera 0) and
//! `Region` (camera 6) both land on rung **6**, because 6 is the coarsest rung that
//! exists — which is exactly why this does not force a fifth rung. **Four rungs, no
//! more**: korp's ladder is a four-element array and a fifth rung is the trap the z9
//! lane hit on 2026-08-24.
use crate::graph::oneway_of;
use crate::network::tag_value;
/// **The rungs.** korp's `map_tiles::DEFAULT_TILE_ZOOMS`, and the `zooms` axis of every
/// pyramid this crate has ever cut.
///
/// FOUR, and a fifth is not a tuning knob: korp's level handling is bound to a
/// four-element ladder and `facett-map`'s `Lod` to five tiers over it. Adding one here
/// changes what every consumer's index arithmetic means.
pub const RUNGS: [u8; 4] = [6, 9, 12, 15];
/// **`facett_map::layer::LOD_TIER_ZOOMS`** — the camera at which each [`Tier`] opens.
/// Five entries for five tiers, and the first is 0 (a country label draws at every
/// camera). Transcribed; see the module note on what a transcription is and is not.
pub const TIER_CAMERAS: [u8; Tier::COUNT] = [0, 6, 9, 12, 15];
/// **The cartographic tier — `facett_map::style::Lod`, variant for variant.**
///
/// The names, the order and the discriminants are facett's. It is repeated here as a
/// TYPE and not as a second decision: nothing outside this module may name a variant
/// (`the_tag_to_rung_map_has_one_writer` refuses it in production text), because the
/// only thing a caller ever needs is the `u8` [`min_zoom_of_tags`] returns.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Tier {
/// Continental/country scale: motorways, trunk rail, rivers, coastline, a country
/// or city NAME.
Country = 0,
/// Regional: secondary roads, lakes, forest and landuse, a town name.
Region = 1,
/// Sub-regional (the z9 rung): tertiary roads, canals, a village or kommun name.
District = 2,
/// City: residential streets, buildings, streams, a hamlet name.
City = 3,
/// Street furniture: the footpath network, service roads, ditches.
Detail = 4,
}
impl Tier {
/// How many tiers there are — `facett_map::style::LOD_COUNT`.
pub const COUNT: usize = 5;
/// Every tier, coarsest first.
pub const ALL: [Tier; Tier::COUNT] = [
Tier::Country,
Tier::Region,
Tier::District,
Tier::City,
Tier::Detail,
];
/// **The first [`RUNGS`] rung at which a feature of this tier may draw.**
///
/// The projection of [`TIER_CAMERAS`] onto [`RUNGS`]: the coarsest rung at or above
/// the tier's camera. `Country`'s camera is 0 and no rung is coarser than 6, so it
/// clamps UP to 6 — the same "there is no such thing as reading a level the pyramid
/// does not hold" rule `facett_map::layer::lod_level` states for the other end.
#[must_use]
pub fn min_zoom(self) -> u8 {
project(TIER_CAMERAS[self as usize], &RUNGS)
}
/// A stable, machine-readable spelling of the whole ladder — every tier, its
/// camera and its rung — for a recipe token and for a cross-repo assertion against
/// `facett-map`'s side. Changing a rung changes this string, and therefore changes
/// [`crate::network::NetworkRecipe`]'s token, and therefore re-cuts the tables the
/// old rung wrote.
#[must_use]
pub fn ladder_spelling() -> String {
Tier::ALL
.iter()
.map(|t| format!("{t:?}@{}>{}", TIER_CAMERAS[*t as usize], t.min_zoom()))
.collect::<Vec<_>>()
.join(",")
}
}
/// **WHAT a feature is — `facett_map::WayKind`, variant for variant, for the OSM half.**
///
/// [`Tier`] answers *when* a feature opens; this answers *what it is*, which is the
/// other half of the decision [`RULES`] already makes and used to throw away. It is the
/// column korp reads instead of re-deriving the answer from `tags` per feature per
/// frame: `map_paint::classify_ways` → `facett_osm::parse_tags_into` → `classify_with`,
/// and korp's `parse_tags` is **55.9 % of all allocations on a tile read** (measured
/// 2026-08-03).
///
/// # Seven variants, not nine — and `railway` really is `Other`
///
/// `facett_map::WayKind` has nine. Two of them (`Overprint`, `Glow`) are minted by a
/// HOST for its own ink — an operator's tagless WKB overlay, a spirit trail — and no OSM
/// tag can produce either, so declaring them here would be a column value no cut could
/// ever write. The other seven are transcribed one for one, including the one that looks
/// like an omission: `classify_with`'s `railway` arm returns `WayKind::Other`, so this
/// table says `Other` for rail too. That is a hole in the shared taxonomy and it is
/// facett's to close — inventing a `Rail` variant HERE would make the cut send a kind the
/// painter cannot receive, which is the drift the transcription discipline exists to
/// prevent (see the module note on what a transcription is and is not).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Kind {
/// Every `highway` a vehicle uses, including `service` and `track`.
Road,
/// The footpath network, and every marked route (a route is a line even when the way
/// under it is a road — `classify_with`'s trail arm).
Path,
/// `natural=coastline` — the one line a whole-country pane must carry.
Coastline,
/// Water areas and every `waterway`, plus a ferry route (a sea line, not a trail).
Water,
/// Green: `natural=wood|forest|…`, `leisure=park|…`, and all of `landuse`.
Park,
/// `building=*` and a bare `amenity` POI.
Building,
/// Everything the table classifies that facett paints with no kind of its own —
/// rail, boundaries, `place` labels — and the rung a feature no rule matched takes.
Other,
}
impl Kind {
/// Every kind, in wire order.
pub const ALL: [Kind; 7] = [
Kind::Road,
Kind::Path,
Kind::Coastline,
Kind::Water,
Kind::Park,
Kind::Building,
Kind::Other,
];
/// **The `u8` the `kind` column holds.**
///
/// A literal `match`, not `self as u8`, for the reason
/// [`crate::shared::height::HeightSource::code`] is one: `self as u8` makes the wire
/// format a function of declaration ORDER, so inserting a variant silently renumbers
/// every cut on disk and no guard can see it. `the_kind_codes_are_pinned_to_the_wire`
/// holds this against a hand-written table.
#[must_use]
pub fn code(self) -> u8 {
match self {
Kind::Road => 0,
Kind::Path => 1,
Kind::Coastline => 2,
Kind::Water => 3,
Kind::Park => 4,
Kind::Building => 5,
Kind::Other => 6,
}
}
/// The inverse of [`code`](Self::code) — `None` for a byte this vocabulary does not
/// declare, so a reader of an older or newer cut can say so instead of guessing.
#[must_use]
pub fn from_code(code: u8) -> Option<Kind> {
Kind::ALL.into_iter().find(|k| k.code() == code)
}
/// **`facett_map::WayKind::as_str`, verbatim** — the string both sides spell a kind
/// with, so a divergence reads as a divergence and not as two vocabularies.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Kind::Road => "road",
Kind::Path => "path",
Kind::Coastline => "coastline",
Kind::Water => "water",
Kind::Park => "park",
Kind::Building => "building",
Kind::Other => "other",
}
}
}
/// **The coarsest rung at or above `camera`**, or the deepest rung when the camera is
/// past the whole ladder.
///
/// A free function taking the ladder rather than a method reading [`RUNGS`], so a guard
/// can hand it a DIFFERENT ladder and watch the answer move. A projection that reads one
/// global constant can only ever be compared with itself — the identity-value trap LAW 2
/// names, and the exact shape korp's `ebf46d1` was written to kill.
#[must_use]
pub fn project(camera: u8, rungs: &[u8]) -> u8 {
rungs
.iter()
.copied()
.find(|&r| r >= camera)
.or_else(|| rungs.last().copied())
.unwrap_or(camera)
}
/// **The rung a feature gets when NO rule matches it.**
///
/// This is a decision, not a default, and it is the one `osm-other` got wrong from the
/// other side: an unclassified feature admitted at the COARSEST rung is how 57 % of a z6
/// tile ended up as grey remainder painted over the road network.
///
/// The value is **12** — `Tier::City`'s rung — and it is 12 rather than 15 for a reason
/// that is about direction, not caution. `facett_map::style::classify_with`'s own
/// fallthrough is `Lod::City`, so the painter will draw an unmatched feature from camera
/// 12. Writing 15 here would mean the DATA withheld, between camera 12 and 15, something
/// the painter was ready to draw — a client wanting what the server refuses, which
/// facett's `classify_with` doc names as the dangerous direction. Writing 6 would be the
/// `osm-other` defect. 12 is the rung where the two sides already agree.
///
/// Loudness is not the value's job: [`min_zoom_of_tags`] cannot tell a caller that it
/// guessed, so [`classify_tier`] returns `Option<Tier>` and [`unclassified_share`]
/// counts the `None`s over a batch. A classification hole shows up as a NUMBER.
pub const UNCLASSIFIED_MIN_ZOOM: u8 = 12;
/// **The `class_id` a feature no rule matched carries** — `u16::MAX`, a NAMED value.
///
/// The same decision [`UNCLASSIFIED_MIN_ZOOM`] makes, for the same reason: a nullable
/// `class_id` would let "we did not classify this" and "the classifier has not run" wear
/// one cell, and a reader could not tell them apart. `u16::MAX` and not `RULES.len()`
/// because the length moves every time a rule is added — a sentinel that walks is a
/// sentinel that silently becomes a real rule id on the next cut, and every old file on
/// disk then reads as classified.
pub const UNCLASSIFIED_CLASS_ID: u16 = u16::MAX;
/// **The [`Kind`] a feature no rule matched carries.**
///
/// [`Kind::Other`], which is `classify_with`'s own generic-fallthrough kind — the same
/// "the two sides already agree here" argument [`UNCLASSIFIED_MIN_ZOOM`] makes for the
/// rung. `Other` is a real classification for rail and boundaries too, so the sentinel
/// that says "no rule matched" is [`UNCLASSIFIED_CLASS_ID`] and never this.
pub const UNCLASSIFIED_KIND: Kind = Kind::Other;
/// **The `layer` a feature with no usable `layer` tag carries** — 0, ground level.
///
/// OSM's own default. Documented range is −5..=5 (`LAYER_MIN`/`LAYER_MAX` below); a
/// value outside it, a decimal, or a multi-value list (`-1;1`) is REFUSED to 0 rather
/// than clamped or truncated, the rule [`crate::shared::height`] settled on for the same
/// class of tag zoo: a clamp invents a fact, and `layer=100` is a typo, not a bridge a
/// hundred decks up.
pub const DEFAULT_LAYER: i8 = 0;
/// The shallowest `layer` value this column will accept. OSM's documented floor.
pub const LAYER_MIN: i8 = -5;
/// The deepest `layer` value this column will accept. OSM's documented ceiling.
pub const LAYER_MAX: i8 = 5;
// ── the rule table, as DATA ─────────────────────────────────────────────────────
/// One tag test. Data rather than `match` arms for the same reason
/// [`crate::network::Rule`] is: the table renders itself into a recipe token, so
/// changing what a rung CONTAINS re-cuts the data automatically.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum When {
/// Tag `key` holds one of `values`.
ValueIn {
key: &'static str,
values: &'static [&'static str],
},
/// Tag `key` is present, whatever its value. The catch-all arm of a key
/// (`highway=<anything else>`), and therefore always LAST among that key's rules.
Present { key: &'static str },
/// Tag `key` is present AND tag `guard` does not hold one of `guard_values`.
///
/// The marked-route arms need this: a cycling route signposted along a primary road
/// carries `route=bicycle` onto the way, and letting the route win would demote an
/// arterial. 343 rows in the 2026-08-27 Sweden trails cut are such ways (measured on
/// the facett side, quoted in `classify_with`).
PresentUnless {
key: &'static str,
guard: &'static str,
guard_values: &'static [&'static str],
},
}
impl When {
fn matches(&self, tags: &str) -> bool {
match *self {
When::ValueIn { key, values } => {
tag_value(tags, key).is_some_and(|v| values.contains(&v.as_ref()))
}
When::Present { key } => tag_value(tags, key).is_some(),
When::PresentUnless {
key,
guard,
guard_values,
} => {
tag_value(tags, key).is_some()
&& !tag_value(tags, guard).is_some_and(|v| guard_values.contains(&v.as_ref()))
}
}
}
/// Canonical spelling for the recipe token.
///
/// `pub` because `class_id` is an INDEX into [`RULES`] and a guard that wants to say
/// *which* rule an id points at has to be able to name it. Spelling a rule is the
/// only stable way to do that: an index moves whenever a rule is inserted above it,
/// and a test pinned to a bare index would be pinned to the table's length.
#[must_use]
pub fn spell(&self) -> String {
match *self {
When::ValueIn { key, values } => format!("{key}∈[{}]", values.join("|")),
When::Present { key } => format!("{key}?"),
When::PresentUnless {
key,
guard,
guard_values,
} => format!("{key}?!{guard}∈[{}]", guard_values.join("|")),
}
}
}
/// One row of the ladder: a test, the tier it grants, and the kind it names.
///
/// The kind rides HERE rather than in a second table for the reason the whole module
/// exists: `classify_with` decides both in one arm, and two tables would be two chances
/// to disagree about one feature. A row's `class_id` is this rule's index, so `kind` and
/// `min_zoom` on disk are both readable off the id — they cannot drift from each other
/// because there is nothing between them to drift.
#[derive(Clone, Copy, Debug)]
pub struct Rung {
pub when: When,
pub tier: Tier,
pub kind: Kind,
}
/// The road classes a marked route may not demote — `facett_map`'s arterial guard list.
const ARTERIALS: &[&str] = &[
"motorway",
"motorway_link",
"trunk",
"trunk_link",
"primary",
"primary_link",
"secondary",
"secondary_link",
"tertiary",
"tertiary_link",
];
macro_rules! rung {
($tier:ident, $kind:ident, $key:literal in [$($v:literal),* $(,)?]) => {
Rung {
when: When::ValueIn { key: $key, values: &[$($v),*] },
tier: Tier::$tier,
kind: Kind::$kind,
}
};
($tier:ident, $kind:ident, $key:literal) => {
Rung { when: When::Present { key: $key }, tier: Tier::$tier, kind: Kind::$kind }
};
($tier:ident, $kind:ident, $key:literal unless_arterial) => {
Rung {
when: When::PresentUnless { key: $key, guard: "highway", guard_values: ARTERIALS },
tier: Tier::$tier,
kind: Kind::$kind,
}
};
}
/// **THE LADDER. First rule that matches wins.**
///
/// A faithful, arm-for-arm transcription of the two functions that already answer
/// "what class is this feature and at which rung does it open", in their own order:
///
/// | block | transcribed from |
/// |---|---|
/// | `place=*` | `facett_map::style::classify_label` — the settlement backbone |
/// | marked routes | `classify_with`'s `railtier-1` arm, ordered by its MEASURED precision |
/// | `highway` · `railway` · coastline · water · `waterway` · green · `landuse` · building | `classify_with`, arm for arm |
///
/// # The `place=*` block is here for BOTH geometries, and that is the point
///
/// `classify_label` sees `place=*`; `classify_with` does not — it has no `place` arm at
/// all, so a `place=city` closed WAY falls through it to `Lod::City` while a `place=city`
/// NODE gets `Lod::Country`. That is the same shape as `korp-map-pt-1` (2026-08-31),
/// where points and ways went through different tag → class maps and a sub-pixel lake
/// became an off-by-default grey dot. One table, read by one function, for both — so the
/// question cannot be asked twice.
///
/// The settlement rungs carry the measured 2026-08-31 national counts that set them:
/// `country` 1 · `region` 5 · `city` 6 · `town` 67 · `municipality` 132 · `village` 996 ·
/// `hamlet` 14 348 · `isolated_dwelling` 25 221. `city` sits on **Country** and `village`
/// on **District** because of those numbers — six city names on a whole-country pane is
/// the minimum a map carries, and 996 villages is regional density where 14 348 hamlets
/// is not.
pub const RULES: &[Rung] = &[
// ── coastline, FIRST — before `place` (`islet-1`, fixed 2026-08-31) ──────────
// This arm used to sit down with the water areas, below the `place` block, and the
// `place` block ends in a catch-all. So a shoreline way that also carries
// `place=islet` — which in an archipelago is most of them — matched `place` first and
// was written `min_zoom = 12`, while facett paints coastline at rung 6. MEASURED on
// the live Sweden cut 2026-08-31: of 79 084 `natural=coastline` ways, **32 049
// (40.5 %) also carry a `place` tag** (`islet` 31 761, `island` 286), so two fifths of
// Sweden's shoreline was absent from every z6–z9 view that wanted to draw it. That is
// a client wanting geometry the server withheld — the direction this module's own doc
// calls the dangerous one, because the client cannot ask for what was never written.
//
// The ORDER is the fix, and the order is the rule: a tag that says what the geometry
// IS beats a tag that says what to call it. `place=islet` is a label on an island;
// `natural=coastline` is the line being drawn. Found by lane WIRE's cross-repo ladder
// guard; landed here because this lane was already moving `rules_spelling()` and the
// two re-cuts are one re-cut.
rung!(Country, Coastline, "natural" in ["coastline"]),
// ── boundaries — a geometry, BEFORE `place` (added 2026-09-01) ───────────────
// MEASURED on the live Sweden cut 2026-08-31: 37.65 % of `relations.parquet` and
// 12.24 % of `ways.parquet` matched no rule, and after `type`/`restriction` (which
// have nothing to draw) the biggest unmatched relation keys were `boundary`
// 717 019 and `admin_level` 599 853. Neither this table nor facett's
// `classify_with` had an arm, so Sweden's border and every kommun line sat on
// `UNCLASSIFIED_MIN_ZOOM` — a rung nobody decided. The block sits above `place`
// for the coastline reason: a boundary relation that also carries a settlement
// label is still the boundary being drawn.
//
// The rung follows `admin_level`, which is what a boundary's level MEANS: 2 is a
// country (Country), 3–4 a state/län (Region), 5–7 a county/kommun (District),
// 8+ a stadsdel/suburb (City). The level is asked before the `boundary` key so a
// way segment tagged with the level alone lands in the same place as the relation
// it belongs to. Protected areas are areas a Region camera draws; anything else
// that calls itself a boundary (postal_code, maritime, political, census …) gets
// City, the same rung the unclassified fallthrough would have given it — decided
// here, and counted as classified rather than as a hole.
rung!(Country, Other, "admin_level" in ["1", "2"]),
rung!(Region, Other, "admin_level" in ["3", "4"]),
rung!(District, Other, "admin_level" in ["5", "6", "7"]),
rung!(City, Other, "admin_level"),
rung!(Region, Other, "boundary" in ["national_park", "protected_area"]),
rung!(City, Other, "boundary"),
// ── place: the settlement backbone (classify_label) ──────────────────────────
//
// `Kind::Other` throughout, and it is a transcription and not an oversight:
// `classify_with` has no `place` arm at all, so a `place=*` way reaches its generic
// fallthrough, which is `WayKind::Other`. The rung comes from `classify_label`; the
// kind can only come from the function that paints geometry, and that function says
// Other.
rung!(Country, Other, "place" in ["country", "state", "province", "region"]),
rung!(Country, Other, "place" in ["city"]),
rung!(Region, Other, "place" in ["town"]),
rung!(District, Other, "place" in ["village"]),
rung!(District, Other, "place" in ["municipality", "borough"]),
// suburb / hamlet / neighbourhood / locality / isolated_dwelling / farm / islet …
rung!(City, Other, "place"),
// ── marked routes, ordered by the precision measured on the trails cut ───────
// `Kind::Path` for all six — `classify_with`'s trail arm returns `WayKind::Path`
// whatever the way under the route is tagged as, and `is_area: false` with it: a
// marked route is a LINE even when it closes into a loop.
rung!(District, Path, "piste:type" unless_arterial),
rung!(City, Path, "snowmobile" unless_arterial),
rung!(District, Path, "route" unless_arterial),
rung!(District, Path, "sac_scale" unless_arterial),
rung!(District, Path, "trail_visibility" unless_arterial),
// `mtb:scale` is on 100 % of `mtb` AND 25 % of `hiking`, so it is asked LAST or it
// takes a quarter of the hiking network with it.
rung!(City, Path, "mtb:scale" unless_arterial),
// ── highway ──────────────────────────────────────────────────────────────────
rung!(Country, Road, "highway" in ["motorway", "trunk", "primary"]),
// The ramps are not the road (`railtier-2`): korp sends no link before camera 7.
rung!(Region, Road, "highway" in ["motorway_link", "trunk_link", "primary_link", "secondary"]),
rung!(District, Road, "highway" in ["secondary_link", "tertiary", "tertiary_link"]),
rung!(City, Road, "highway" in ["residential", "unclassified", "living_street"]),
// `service` and `track` are Detail but they are still ROADS — `classify_with` paints
// both with `WayKind::Road`. The tier is how deep they sit; the kind is what they are.
rung!(Detail, Road, "highway" in ["service", "track"]),
rung!(Detail, Path, "highway" in ["footway", "cycleway", "path", "pedestrian", "steps"]),
rung!(District, Road, "highway" in ["road", "busway", "bus_guideway"]),
// `raceway`/`rest_area`/`services` and every unlisted value share classify_with's
// `_ => City` arm; one catch-all rule expresses both.
rung!(City, Road, "highway"),
// ── railway ──────────────────────────────────────────────────────────────────
// `Kind::Other`, transcribed: `classify_with`'s railway arm builds its style with
// `kind: WayKind::Other`. See [`Kind`]'s note — a `Rail` variant invented here would
// be a kind facett has no arm to receive.
rung!(Country, Other, "railway" in ["rail", "narrow_gauge"]),
rung!(District, Other, "railway" in ["light_rail", "subway", "tram", "funicular", "preserved", "miniature"]),
rung!(City, Other, "railway"),
// ── water areas ──────────────────────────────────────────────────────────────
// (`natural=coastline` was here, in `classify_with`'s order. It is now the FIRST rule
// in the table — see the note at the top for the 32 049 shoreline ways that cost.)
rung!(Region, Water, "natural" in ["water", "lake", "bay", "wetland"]),
rung!(Region, Water, "water"),
// ── waterway ─────────────────────────────────────────────────────────────────
rung!(Country, Water, "waterway" in ["river", "riverbank", "dock", "basin"]),
rung!(District, Water, "waterway" in ["canal"]),
rung!(Detail, Water, "waterway" in ["ditch", "drain"]),
// stream, dam, weir, lock_gate, fish_pass, waterfall, rapids and any other value
// all share classify_with's `City` arms.
rung!(City, Water, "waterway"),
// ── green and landuse ────────────────────────────────────────────────────────
rung!(Region, Park, "natural" in ["wood", "forest", "scrub", "grassland", "heath"]),
rung!(Region, Park, "leisure" in ["park", "garden", "nature_reserve", "recreation_ground"]),
// classify_with lists nine landuse values and then falls back to `has("landuse")` at
// the same tier, so the catch-all is the whole rule.
rung!(Region, Park, "landuse"),
// ── building / amenity ───────────────────────────────────────────────────────
// `classify_with` paints `has("building") || amenity` in ONE arm with
// `WayKind::Building`, so a bare amenity POI is a Building here too.
rung!(City, Building, "building"),
rung!(City, Building, "amenity"),
];
/// **The tier `tags` earns, or `None` when no rule matched.**
///
/// `None` is a real state and not a shrug: [`min_zoom_of_tags`] turns it into
/// [`UNCLASSIFIED_MIN_ZOOM`], and [`unclassified_share`] counts it so a hole in the table
/// is a number a run reports rather than a rung a feature quietly occupies.
#[must_use]
pub fn classify_tier(tags: &str) -> Option<Tier> {
classify_rule(tags).map(|(_, r)| r.tier)
}
/// **The rule that matched, and WHERE IT SITS — the `class_id` writer.**
///
/// [`classify_tier`] walked this same table, knew exactly which rule it stopped on, and
/// then threw the identity away and kept only the rung. Everything downstream that
/// wanted to know *what a feature is* had to ask the tags again: korp's
/// `map_paint::classify_ways` re-parses the `tags` JSON per way per frame through
/// `facett_osm::parse_tags_into`, and korp's `parse_tags` is **55.9 % of all allocations
/// on a tile read** (measured 2026-08-03).
///
/// The index IS the answer, and that is the construction: `min_zoom` and `kind` on disk
/// are both readable off the one id, so they cannot disagree about a row — there is
/// nothing between them to drift. `None` when no rule matched, which the columns spell
/// [`UNCLASSIFIED_CLASS_ID`] / [`UNCLASSIFIED_KIND`] / [`UNCLASSIFIED_MIN_ZOOM`].
#[must_use]
pub fn classify_rule(tags: &str) -> Option<(u16, &'static Rung)> {
RULES
.iter()
.position(|r| r.when.matches(tags))
.map(|i| (i as u16, &RULES[i]))
}
/// **The `layer` a feature draws on** — the OSM `layer` tag, or [`DEFAULT_LAYER`].
///
/// A vertical ORDER, not a height: `layer=1` says "above whatever is at 0 here", which
/// is what makes it a paint-order term rather than a geometry one. korp `6e22d78`
/// (2026-09-05) is why it is a column: one `landuse=grass layer=1` area outranked the
/// WHOLE road network, and no depth fix reaches a rank the painter computed from a tag
/// it re-parsed per frame.
///
/// Refuses rather than guesses, the rule [`crate::shared::height::parse_length`] settled
/// on for the same class of tag zoo — a decimal, a list (`-1;1`), anything outside OSM's
/// documented [`LAYER_MIN`]..=[`LAYER_MAX`] comes back [`DEFAULT_LAYER`]. Clamping
/// `layer=100` to 5 would invent a bridge five decks up out of a typo.
#[must_use]
pub fn layer_of_tags(tags: &str) -> i8 {
tag_value(tags, "layer")
.and_then(|v| v.trim().parse::<i8>().ok())
.filter(|l| (LAYER_MIN..=LAYER_MAX).contains(l))
.unwrap_or(DEFAULT_LAYER)
}
/// Is this tag value one of OSM's three spellings of "no"?
fn says_no(v: &str) -> bool {
matches!(v.trim(), "no" | "false" | "0")
}
/// **Does this feature cross OVER what is under it?**
///
/// True for any `bridge` value that is not a "no" — `viaduct`, `boardwalk`, `aqueduct`,
/// `cantilever` and the forty other documented values are all bridges, so the test is
/// presence-minus-negation and not `== "yes"`. `man_made=bridge` counts too: it is how
/// the bridge DECK is tagged as an area, and an area is exactly the case korp's paint
/// order needs the column for (an area may honour `layer` only if it says why).
#[must_use]
pub fn bridge_of_tags(tags: &str) -> bool {
tag_value(tags, "bridge").is_some_and(|v| !says_no(&v))
|| tag_value(tags, "man_made").is_some_and(|v| v == "bridge")
}
/// **Does this feature run UNDER what is above it?**
///
/// Presence-minus-negation on `tunnel`, for the same reason [`bridge_of_tags`] is:
/// `building_passage`, `culvert` and `avalanche_protector` are tunnels. `covered=yes` is
/// deliberately NOT read — a covered way is roofed, not buried, and merging the two
/// would put arcades underground.
#[must_use]
pub fn tunnel_of_tags(tags: &str) -> bool {
tag_value(tags, "tunnel").is_some_and(|v| !says_no(&v))
}
/// **Every column the tag → class decision writes, taken in ONE pass over `tags`.**
///
/// The one call site per row, so a writer cannot fill five of the six columns and
/// forget the sixth, and so the `class_id` a row carries is by construction the rule its
/// `kind` and `min_zoom` were read off.
///
/// `oneway` is [`crate::network::oneway_of`] — the value the graph has resolved at cut
/// time since the adjacency landed, including the roundabout that is one-way by
/// definition and almost never says so. It is reused rather than re-derived: a second
/// `oneway` reading would be a router and a painter disagreeing about which way a street
/// runs (LAW 5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RowClass {
/// The index of the rule that matched, or [`UNCLASSIFIED_CLASS_ID`].
pub class_id: u16,
/// [`Kind::code`] of that rule's kind, or [`UNCLASSIFIED_KIND`]'s.
pub kind: u8,
/// That rule's rung, or [`UNCLASSIFIED_MIN_ZOOM`].
pub min_zoom: u8,
/// [`layer_of_tags`].
pub layer: i8,
/// [`bridge_of_tags`].
pub bridge: bool,
/// [`tunnel_of_tags`].
pub tunnel: bool,
/// [`crate::network::oneway_of`] — `1` forward, `-1` backward, `0` both ways.
pub oneway: i8,
}
/// **THE class-column writer.** One pass, one decision, six columns.
#[must_use]
pub fn class_of_tags(tags: &str) -> RowClass {
let (class_id, kind, min_zoom) = match classify_rule(tags) {
Some((i, r)) => (i, r.kind, r.tier.min_zoom()),
None => (
UNCLASSIFIED_CLASS_ID,
UNCLASSIFIED_KIND,
UNCLASSIFIED_MIN_ZOOM,
),
};
RowClass {
class_id,
kind: kind.code(),
min_zoom,
layer: layer_of_tags(tags),
bridge: bridge_of_tags(tags),
tunnel: tunnel_of_tags(tags),
oneway: oneway_of(tags),
}
}
/// **THE `min_zoom` WRITER — the first rung at which this feature may draw.**
///
/// The only entry point anything outside this module uses, for a WAY, a NODE or a
/// RELATION alike. It takes the `tags` JSON column verbatim (the flat, BTreeMap-sorted
/// object [`crate::tags::serialize_tags`] writes) and reads it with
/// [`crate::network::tag_value`] — the same scanner the network extractor uses, not a
/// second `serde_json` parse.
#[must_use]
pub fn min_zoom_of_tags(tags: &str) -> u8 {
classify_tier(tags).map_or(UNCLASSIFIED_MIN_ZOOM, Tier::min_zoom)
}
/// The fraction of `tags` values no rule in [`RULES`] matched — the classification hole,
/// as a number a run can print. `0.0` for an empty input.
#[must_use]
pub fn unclassified_share<'a>(tags: impl Iterator<Item = &'a str>) -> f64 {
let mut n = 0usize;
let mut miss = 0usize;
for t in tags {
n += 1;
if classify_tier(t).is_none() {
miss += 1;
}
}
if n == 0 { 0.0 } else { miss as f64 / n as f64 }
}
/// The canonical spelling of the whole ladder — every rule and every rung — for a recipe
/// token. Editing a rule changes this, which changes the recipe, which re-cuts the tables
/// the old rule wrote.
///
/// **This string is a CROSS-REPO CONTRACT, not just a token term.**
/// `examples/ladder_spelling` prints it into `facett-map/osm-katana-ladder.txt`, which
/// `facett_map::style::osm_katana_ladder` parses and evaluates against its own arms, and
/// `tests/facett_ladder_fixture` pins it here so a rule edit goes red in this repo with
/// facett's path in the message. So it is deliberately NOT the place the row-18 `kind`
/// went: appending a term here would have made every osm-katana build red until facett
/// regenerated and re-parsed the file, in another lane's repo. The kind rides
/// [`kinds_spelling`] instead — same staleness, no cross-repo edit. Folding it into this
/// string is the right end state and is facett's call to take.
#[must_use]
pub fn rules_spelling() -> String {
RULES
.iter()
.map(|r| format!("{}>{}", r.when.spell(), r.tier.min_zoom()))
.collect::<Vec<_>>()
.join(",")
}
/// **The kind each rule grants, in table order** — the recipe term that makes a `kind`
/// edit re-cut (row 18).
///
/// [`rules_spelling`] already moves when a rule is added, removed, reordered or
/// re-tiered, so the one thing it cannot see is a rule whose TEST and RUNG are unchanged
/// and whose kind moved — a `landuse` that stops being `park`, say. That is not a
/// cosmetic difference: `kind` is a column on disk and `class_id` is an index into this
/// table, so a cut written under the old kinds would read Current while every row of it
/// claimed the wrong thing.
///
/// Positional, so it also moves on any reorder — belt and braces with the rules string,
/// and the fail-safe direction either way (Stale → re-cut).
#[must_use]
pub fn kinds_spelling() -> String {
RULES
.iter()
.map(|r| r.kind.as_str())
.collect::<Vec<_>>()
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
// ── GATE 1: ONE WRITER, PROVABLE ────────────────────────────────────────────
/// ★ **RED-when-broken: a SECOND tag → rung map anywhere in production text.**
///
/// Modelled on korp `ebf46d1`, and for the same reason it exists there. A value
/// comparison cannot see this defect: `RULES` compared against a copy of `RULES`, or
/// `Tier::City.min_zoom() == 12` where the projection returns that constant, is a
/// value compared with itself through one function — the identity-value trap LAW 2
/// names, which is how the ladder's THIRD reader survived in korp until the guard was
/// rewritten to scan SHAPE instead.
///
/// So: walk every `.rs` under `src/`, take the text ABOVE its test module, and refuse
/// any file but this one that names a [`Tier`] variant. Callers never need the enum —
/// [`min_zoom_of_tags`] hands back a `u8` — so naming a tier outside here is either a
/// second ladder or the beginning of one.
///
/// **RED ARM** (proved before this was trusted): add `let _ = Tier::Country;` to
/// `src/network.rs` and this fails with `a second tag → rung decision lives in
/// ["network.rs"]`, naming the file. Removed: green.
///
/// Non-vacuity runs three ways, because a scan that reached nothing, or that never
/// found the writer, or that found no CALLER, reports clean for the wrong reason.
#[test]
fn the_tag_to_rung_map_has_one_writer() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files: Vec<std::path::PathBuf> = Vec::new();
walk(&src, &mut files);
files.sort();
let mut scanned = 0usize;
let mut saw_the_writer = false;
let mut callers: Vec<String> = Vec::new();
let mut offenders: Vec<String> = Vec::new();
for path in &files {
let text = std::fs::read_to_string(path).expect("source file must be readable");
// Tests legitimately name tiers (this module's own do). Judge production only.
let cut = text
.find("\nmod tests")
.or_else(|| text.find("\n#[cfg(test)]"))
.unwrap_or(text.len());
let production = &text[..cut];
scanned += 1;
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
if production.contains("pub fn min_zoom_of_tags") {
saw_the_writer = true;
continue; // this file IS the one writer
}
if production.contains("min_zoom_of_tags(") {
callers.push(name.clone());
}
if production.contains("Tier::") {
offenders.push(name);
}
}
assert!(
scanned > 20,
"the source scan found only {scanned} .rs files under {src:?} — it is not \
looking at osm-katana, so its silence proves nothing"
);
assert!(
saw_the_writer,
"the scan never saw `pub fn min_zoom_of_tags` — it is not reading the file \
that holds the one writer, so an empty offender list is meaningless"
);
assert!(
!callers.is_empty(),
"no production file calls `min_zoom_of_tags(` — the column is not wired to \
anything, so 'there is only one writer' is true of a writer nobody uses and \
this guard is about nothing"
);
assert!(
offenders.is_empty(),
"a second tag → rung decision lives in {offenders:?}. Only \
`shared/class.rs` may name a `Tier`; everything else takes the `u8` from \
`min_zoom_of_tags`. Two tag → rung maps is the `korp-map-pt-1` defect \
(points and ways on different rungs) waiting to happen again."
);
}
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.filter_map(Result::ok) {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
// ── the projection, and the anti-identity arm ───────────────────────────────
/// ★ **RED-when-broken: the tier → rung projection is a constant, or lands off the
/// ladder.**
///
/// Three arms, and the third is the one that matters. Arm 1 spells the answers out
/// rather than recomputing them (a test that recomputes the function under test
/// proves nothing). Arm 2 pins that every rung a tier can claim is a rung the data
/// actually serves — a tier added at camera 14 would land on 14, which is not in
/// [`RUNGS`], and the pyramid has no such level. Arm 3 hands [`project`] a
/// DIFFERENT ladder and demands the answer move: a projection that ignored its
/// `rungs` argument would pass arms 1 and 2 forever.
#[test]
fn the_tier_projects_onto_a_rung_the_data_serves() {
// 1. The ladder, spelled. Country and Region share rung 6 — that is what keeps
// a five-tier look ladder on a four-rung data ladder.
let got: Vec<u8> = Tier::ALL.iter().map(|t| t.min_zoom()).collect();
assert_eq!(
got,
vec![6, 6, 9, 12, 15],
"the tier → rung projection moved; korp's ladder is {RUNGS:?}"
);
// 2. Every rung a tier can claim is a rung that exists.
for t in Tier::ALL {
assert!(
RUNGS.contains(&t.min_zoom()),
"{t:?} claims rung {} which is not in {RUNGS:?} — the data has no such \
level, so nothing would ever be read at it",
t.min_zoom()
);
}
// 3. ANTI-IDENTITY (LAW 2). If `project` ignored its ladder, arms 1 and 2 above
// would still pass on a hardcoded table. Give it a ladder without 9 and the
// District answer MUST move.
assert_eq!(project(9, &RUNGS), 9);
assert_eq!(
project(9, &[6, 12]),
12,
"project() does not read its `rungs` argument, so every arm above is a \
constant compared with itself"
);
assert_eq!(project(0, &[6, 9, 12, 15]), 6, "camera 0 clamps up to the coarsest rung");
assert_eq!(project(99, &RUNGS), 15, "past the ladder clamps to the deepest rung");
}
/// **Four rungs, no more.** A fifth rung forces a fifth variant in korp's level
/// handling — the trap the z9 lane hit on 2026-08-24.
#[test]
fn the_ladder_is_four_ascending_rungs_and_five_tiers() {
assert_eq!(RUNGS.len(), 4, "korp's ladder is four rungs: {RUNGS:?}");
assert!(RUNGS.windows(2).all(|w| w[0] < w[1]), "rungs must ascend: {RUNGS:?}");
assert_eq!(TIER_CAMERAS.len(), Tier::COUNT);
assert!(
TIER_CAMERAS.windows(2).all(|w| w[0] < w[1]),
"tier cameras must ascend: {TIER_CAMERAS:?}"
);
// The tier cameras are {0} ∪ RUNGS — that is what makes the projection exact
// rather than a rounding.
for c in TIER_CAMERAS {
assert!(
c == 0 || RUNGS.contains(&c),
"tier camera {c} is neither 0 nor a rung the data serves"
);
}
}
// ── GATE 3: the unknown case is a decision, not a default ───────────────────
/// ★ **RED-when-broken: an unclassified feature admitted to a coarse rung.**
///
/// The `osm-other` defect, pinned. A feature no rule matches must not be able to
/// occupy rung 6 or 9, where it would paint over the road network on a country view.
/// It also must not be pushed DEEPER than the painter's own fallthrough, which would
/// make the data withhold what `classify_with`'s `Lod::City` arm is ready to draw.
#[test]
fn an_unmatched_feature_lands_on_the_named_rung_and_is_counted() {
// A real OSM relation with none of the table's keys: a turn restriction — the
// single biggest unmatched key on the live Sweden cut (2 260 349 relations,
// 2026-08-31) and rightly so, it has no geometry to draw. (Until 2026-09-01 this
// fixture was a län boundary; boundaries have an arm now.)
let odd = r#"{"restriction":"no_left_turn","type":"restriction"}"#;
assert_eq!(classify_tier(odd), None, "this fixture must not match a rule, or the \
assertions below are about a classified feature");
assert_eq!(min_zoom_of_tags(odd), UNCLASSIFIED_MIN_ZOOM);
assert_eq!(UNCLASSIFIED_MIN_ZOOM, 12, "the named rung is City's, matching \
classify_with's own fallthrough");
assert!(
UNCLASSIFIED_MIN_ZOOM > RUNGS[1],
"an unclassified feature reached rung {UNCLASSIFIED_MIN_ZOOM}, at or above \
the regional rung {} — that is the `osm-other` defect: 57 % of a z6 tile as \
grey remainder painted over the road network",
RUNGS[1]
);
// The hole is a NUMBER, not a silence.
let batch = [
odd,
r#"{"highway":"motorway"}"#,
r#"{"natural":"water"}"#,
r#"{"man_made":"pier"}"#,
];
let share = unclassified_share(batch.iter().copied());
assert!(
(share - 0.5).abs() < 1e-9,
"unclassified_share said {share}, expected 0.5 (2 of 4)"
);
// Non-vacuity: a counter that always says 0 would pass nothing here, and one
// that always says 1 would pass no other test.
assert_eq!(unclassified_share([r#"{"highway":"motorway"}"#].into_iter()), 0.0);
assert_eq!(unclassified_share(std::iter::empty()), 0.0);
}
// ── the transcription, arm for arm ──────────────────────────────────────────
/// The rungs the ladder actually grants, as a table. This is the readable statement
/// of what DELTA 1 decided; a facett-side change that moves a tier shows up here as a
/// failing row rather than as a map that quietly draws the wrong thing.
#[test]
fn the_ladder_grants_the_transcribed_rungs() {
let cases: &[(&str, u8, &str)] = &[
// place — the settlement backbone, with its measured national counts
(r#"{"name":"Sverige","place":"country"}"#, 6, "country ×1"),
(r#"{"name":"Stockholm","place":"city"}"#, 6, "city ×6 — moved to Country 2026-08-31"),
(r#"{"name":"Sigtuna","place":"town"}"#, 6, "town ×67 (Region → rung 6)"),
(r#"{"name":"Härnevi","place":"village"}"#, 9, "village ×996 — District"),
(r#"{"name":"Uppsala kommun","place":"municipality"}"#, 9, "municipality ×132"),
(r#"{"name":"Bro","place":"hamlet"}"#, 12, "hamlet ×14 348 — City"),
(r#"{"name":"Torp","place":"isolated_dwelling"}"#, 12, "isolated_dwelling ×25 221"),
// roads
(r#"{"highway":"motorway","ref":"E4"}"#, 6, "motorway"),
(r#"{"highway":"motorway_link"}"#, 6, "a ramp is Region → rung 6"),
(r#"{"highway":"secondary"}"#, 6, "secondary is Region"),
(r#"{"highway":"tertiary"}"#, 9, "tertiary moved to District 2026-08-25"),
(r#"{"highway":"residential","name":"Sveavägen"}"#, 12, "residential"),
(r#"{"highway":"service"}"#, 15, "service is street furniture"),
(r#"{"highway":"path"}"#, 15, "the footpath network"),
(r#"{"highway":"construction"}"#, 12, "an unlisted highway value → City"),
// rail
(r#"{"railway":"rail"}"#, 6, "trunk rail"),
(r#"{"railway":"tram"}"#, 9, "tram"),
(r#"{"railway":"level_crossing"}"#, 12, "rail furniture"),
// water
(r#"{"natural":"coastline"}"#, 6, "coastline"),
(r#"{"natural":"water","name":"Mälaren"}"#, 6, "a lake is Region → rung 6"),
(r#"{"waterway":"river","name":"Dalälven"}"#, 6, "river"),
(r#"{"waterway":"canal"}"#, 9, "canal moved to District with tertiary"),
(r#"{"waterway":"stream"}"#, 12, "stream"),
(r#"{"waterway":"ditch"}"#, 15, "ditch"),
// green, landuse, building
(r#"{"natural":"wood"}"#, 6, "forest is Region"),
(r#"{"landuse":"farmland"}"#, 6, "landuse is Region"),
(r#"{"building":"yes"}"#, 12, "buildings wait for camera 12"),
(r#"{"amenity":"cafe","name":"Kafé"}"#, 12, "a bare amenity POI"),
// marked routes, and the arterial guard
(r#"{"highway":"path","sac_scale":"hiking"}"#, 9, "a hiking route is not a footpath"),
(r#"{"highway":"track","piste:type":"nordic"}"#, 9, "a piste"),
(r#"{"highway":"path","mtb:scale":"2"}"#, 12, "mtb"),
(r#"{"highway":"path","snowmobile":"yes"}"#, 12, "snowmobile"),
(
r#"{"highway":"primary","route":"bicycle"}"#,
6,
"a route along an arterial is still an arterial — the guard",
),
];
let mut seen = std::collections::BTreeSet::new();
for (tags, want, why) in cases {
let got = min_zoom_of_tags(tags);
assert_eq!(got, *want, "{why}: {tags} got rung {got}, expected {want}");
seen.insert(got);
}
// NON-VACUITY: a table that only ever produced one rung would pass every row
// above if that rung were the expected one everywhere. All four must appear.
assert_eq!(
seen.into_iter().collect::<Vec<_>>(),
RUNGS.to_vec(),
"the fixture table does not exercise every rung, so a collapsed ladder \
could pass it"
);
assert!(cases.len() >= 30, "only {} rows — too thin to be a ladder", cases.len());
}
/// A `place=city` on a WAY and on a NODE is one question with one answer. The
/// geometry never reaches [`min_zoom_of_tags`], which is the construction that makes
/// gate 2 true rather than watched; this pins that the shared table actually carries
/// `place`, which is the tag `classify_with` alone would have missed.
#[test]
fn place_is_read_for_both_geometries() {
let city = r#"{"name":"Stockholm","place":"city"}"#;
assert_eq!(min_zoom_of_tags(city), 6);
assert_ne!(
min_zoom_of_tags(city),
UNCLASSIFIED_MIN_ZOOM,
"`place` fell through to the unclassified rung — that is exactly the hole \
`classify_with` has (it carries no `place` arm), and the reason the table \
unions it with `classify_label`"
);
}
/// ★ **RED-when-broken: a shoreline is coastline before it is a place (`islet-1`).**
///
/// `RULES` is ORDER-SENSITIVE — `tier_of_tags` takes the first arm that matches — and
/// the `place` block ends in a catch-all. While `natural=coastline` sat below it, an
/// islet's shoreline matched `place` and was written `min_zoom = 12` while facett
/// paints coastline at rung 6, so the geometry a z6 view wanted had never been
/// written. MEASURED on the live Sweden cut 2026-08-31: **32 049 of 79 084
/// `natural=coastline` ways (40.5 %) also carry a `place` tag** — `islet` 31 761,
/// `island` 286.
///
/// **Red arm, seen 2026-08-31:** move the coastline rule back below the `place` block
/// and the two combined cases below read 12 instead of 6.
///
/// The assertion is on the ORDER's consequence, not on the index of a rule in the
/// table: an order test that re-derives the order from `RULES` is `RULES` compared
/// against a copy of `RULES`, which this module's own doc names as the shape that
/// cannot go red.
#[test]
fn an_islets_shoreline_is_drawn_at_the_coastline_rung() {
// The combination, both orderings of the two keys in the JSON object.
for tags in [
r#"{"natural":"coastline","place":"islet"}"#,
r#"{"place":"islet","natural":"coastline"}"#,
r#"{"natural":"coastline","place":"island"}"#,
r#"{"name":"Kobben","natural":"coastline","place":"islet"}"#,
] {
assert_eq!(
min_zoom_of_tags(tags),
6,
"a coastline way carrying `place` was written at the `place` rung, so \
every islet shoreline in the archipelago is missing from a z6 view that \
wants to draw it: {tags}"
);
}
// The controls, so this cannot pass by everything being 6.
assert_eq!(
min_zoom_of_tags(r#"{"place":"islet"}"#),
12,
"a bare `place=islet` is a LABEL and belongs on the City rung — if this is 6 \
the fix moved the place catch-all instead of ordering the coastline arm"
);
assert_eq!(min_zoom_of_tags(r#"{"place":"hamlet"}"#), 12);
assert_eq!(min_zoom_of_tags(r#"{"natural":"coastline"}"#), 6);
}
/// ★ **RED-when-broken: a boundary is a geometry, and it has a rung.**
///
/// MEASURED on the live Sweden cut 2026-08-31: 37.65 % of `relations.parquet` and
/// 12.24 % of `ways.parquet` matched NO rule, and the top unmatched relation keys
/// after `type`/`restriction` were `boundary` 717 019 and `admin_level` 599 853.
/// Neither `RULES` nor facett's `classify_with` had an arm for them, so a national
/// border and a kommun line both landed on `UNCLASSIFIED_MIN_ZOOM` — a rung the
/// table did not DECIDE. Every case below is a literal, so an arm that lands them
/// all on one rung fails on the spread.
///
/// Seen RED 2026-09-01 with no boundary block in `RULES`: the first case reports
/// `classify_tier` = `None` for a national border.
#[test]
fn a_boundary_is_drawn_at_the_rung_its_admin_level_earns() {
let cases: &[(&str, Tier)] = &[
// Sweden's border — the one line a whole-country pane must carry.
(
r#"{"admin_level":"2","boundary":"administrative","name":"Sverige","type":"boundary"}"#,
Tier::Country,
),
// A boundary WAY segment carries the same two keys and nothing else.
(r#"{"admin_level":"2","boundary":"administrative"}"#, Tier::Country),
// län
(r#"{"admin_level":"4","boundary":"administrative","type":"boundary"}"#, Tier::Region),
// kommun
(r#"{"admin_level":"7","boundary":"administrative","type":"boundary"}"#, Tier::District),
// stadsdel / district — the finest thing a District camera wants
(r#"{"admin_level":"9","boundary":"administrative"}"#, Tier::City),
// a segment tagged with the level alone (no `boundary` key at all)
(r#"{"admin_level":"4"}"#, Tier::Region),
// protected areas are areas a Region camera draws
(r#"{"boundary":"national_park","name":"Sarek"}"#, Tier::Region),
(r#"{"boundary":"protected_area","protect_class":"2"}"#, Tier::Region),
// and everything else that calls itself a boundary (postal_code, maritime,
// political, census …) is at least a decided rung, not the fallthrough
(r#"{"boundary":"postal_code","postal_code":"11122"}"#, Tier::City),
];
for (tags, want) in cases {
let got = classify_tier(tags);
assert_eq!(
got,
Some(*want),
"{tags}: classify_tier = {got:?}, the ladder must put it on {want:?} — a \
boundary with no rule lands on UNCLASSIFIED_MIN_ZOOM, a rung nobody decided"
);
}
// The spread: an arm that parks every boundary on one rung is not this arm.
let rungs: std::collections::BTreeSet<u8> =
cases.iter().map(|(t, _)| min_zoom_of_tags(t)).collect();
assert_eq!(
rungs.into_iter().collect::<Vec<_>>(),
vec![6, 9, 12],
"the boundary block must spread over three rungs by admin_level"
);
// Controls: a turn restriction still has no rung (it has no geometry to draw),
// and a road that happens to carry `admin_level` is still a road.
assert_eq!(
classify_tier(r#"{"restriction":"no_left_turn","type":"restriction"}"#),
None,
"a turn restriction has nothing to draw and must stay unclassified"
);
assert_eq!(
min_zoom_of_tags(r#"{"admin_level":"2","boundary":"administrative","natural":"coastline"}"#),
6,
"a coastline that is also a border draws at the coastline rung"
);
}
// ── row 18: the class block ─────────────────────────────────────────────────
/// ★ **RED-when-broken: a [`Kind`] is renumbered on the wire.**
///
/// The hand-written table below IS the column specification — `code()` is a literal
/// `match`, so this is not `self as u8` compared with `self as u8`. Reorder the enum
/// or renumber an arm and the table names the variant that moved. It also pins
/// `from_code ∘ code = id`, distinctness both ways, and the strings, which are
/// `facett_map::WayKind::as_str`'s and must stay spelled the same in both repos.
#[test]
fn the_kind_codes_are_pinned_to_the_wire() {
let wire: &[(Kind, u8, &str)] = &[
(Kind::Road, 0, "road"),
(Kind::Path, 1, "path"),
(Kind::Coastline, 2, "coastline"),
(Kind::Water, 3, "water"),
(Kind::Park, 4, "park"),
(Kind::Building, 5, "building"),
(Kind::Other, 6, "other"),
];
assert_eq!(
wire.len(),
Kind::ALL.len(),
"a Kind variant was added or removed without moving this table — the wire \
format is what this table says, not what the enum happens to be"
);
for (k, code, name) in wire {
assert_eq!(k.code(), *code, "{k:?} moved on the wire");
assert_eq!(k.as_str(), *name, "{k:?}'s spelling moved away from facett's");
assert_eq!(Kind::from_code(*code), Some(*k), "code {code} does not decode to {k:?}");
}
// Distinct in both directions, so no two kinds can share a cell.
let codes: std::collections::BTreeSet<u8> = Kind::ALL.iter().map(|k| k.code()).collect();
assert_eq!(codes.len(), Kind::ALL.len(), "two kinds share a code: {codes:?}");
let names: std::collections::BTreeSet<&str> =
Kind::ALL.iter().map(|k| k.as_str()).collect();
assert_eq!(names.len(), Kind::ALL.len(), "two kinds share a name: {names:?}");
// A byte outside the vocabulary is a `None`, not a guess.
assert_eq!(Kind::from_code(7), None);
assert_eq!(Kind::from_code(255), None);
}
/// ★ **RED-when-broken: `class_id` is not the index of the rule that matched.**
///
/// The unit half of `tests/class_columns_base_tables.rs`. It asserts on the SPELLING
/// the id resolves to rather than on the number, because an index is a function of
/// the table's length and a test pinned to `17` would be pinned to how many rules sit
/// above it — brittle in the way that teaches nothing.
#[test]
fn the_class_id_names_the_rule_that_matched() {
let cases: &[(&str, &str, Kind)] = &[
(r#"{"highway":"motorway"}"#, "highway∈[motorway|trunk|primary]", Kind::Road),
(r#"{"highway":"service"}"#, "highway∈[service|track]", Kind::Road),
(r#"{"highway":"path"}"#, "highway∈[footway|cycleway|path|pedestrian|steps]", Kind::Path),
(r#"{"natural":"coastline"}"#, "natural∈[coastline]", Kind::Coastline),
(r#"{"natural":"water"}"#, "natural∈[water|lake|bay|wetland]", Kind::Water),
(r#"{"waterway":"ditch"}"#, "waterway∈[ditch|drain]", Kind::Water),
(r#"{"landuse":"farmland"}"#, "landuse?", Kind::Park),
(r#"{"building":"yes"}"#, "building?", Kind::Building),
(r#"{"railway":"rail"}"#, "railway∈[rail|narrow_gauge]", Kind::Other),
(r#"{"place":"village"}"#, "place∈[village]", Kind::Other),
];
for (tags, want_rule, want_kind) in cases {
let (id, rule) = classify_rule(tags)
.unwrap_or_else(|| panic!("{tags}: no rule matched, so there is no id to check"));
assert_eq!(
rule.when.spell(),
*want_rule,
"{tags}: class_id {id} points at `{}`",
rule.when.spell()
);
assert_eq!(rule.kind, *want_kind, "{tags}: rule {id} grants the wrong kind");
// The id is an INDEX and must resolve back to the same rule.
assert_eq!(
RULES[id as usize].when.spell(),
rule.when.spell(),
"class_id {id} does not index the rule it was taken from"
);
// And every column of the block agrees with the rule the id names — the
// construction that makes `kind` and `min_zoom` unable to disagree.
let c = class_of_tags(tags);
assert_eq!(c.class_id, id);
assert_eq!(c.kind, rule.kind.code());
assert_eq!(c.min_zoom, rule.tier.min_zoom());
}
// The unknown case takes NAMED values, not an absence and not rule 0.
let odd = r#"{"office":"government"}"#;
assert!(classify_rule(odd).is_none(), "this fixture must match no rule");
let c = class_of_tags(odd);
assert_eq!(c.class_id, UNCLASSIFIED_CLASS_ID);
assert_eq!(c.kind, UNCLASSIFIED_KIND.code());
assert_eq!(c.min_zoom, UNCLASSIFIED_MIN_ZOOM);
assert_ne!(
c.class_id, 0,
"the unmatched sentinel is rule 0's id — an unclassified feature would read \
as `{}`",
RULES[0].when.spell()
);
// NON-VACUITY: the cases must reach many distinct ids and many distinct kinds,
// or a constant classifier passes every row above.
let ids: std::collections::BTreeSet<u16> =
cases.iter().filter_map(|(t, _, _)| classify_rule(t).map(|(i, _)| i)).collect();
assert_eq!(ids.len(), cases.len(), "two cases share a class_id: {ids:?}");
let kinds: std::collections::BTreeSet<Kind> = cases.iter().map(|(_, _, k)| *k).collect();
assert!(kinds.len() >= 5, "only {} distinct kinds exercised", kinds.len());
}
/// ★ **RED-when-broken: `layer` / `bridge` / `tunnel` / `oneway` guess instead of
/// reading.**
///
/// Every value below is a real OSM spelling. The refusals matter as much as the
/// accepts: `layer=1.5` and `layer=-1;1` are two statements about one way and
/// `layer=100` is a typo, so all three come back [`DEFAULT_LAYER`] rather than a
/// truncation or a clamp — the rule [`crate::shared::height`] settled on for the
/// same class of tag zoo.
#[test]
fn the_tag_columns_read_the_tag_and_refuse_what_they_cannot_read() {
// layer — accepted
for (tags, want) in [
(r#"{"layer":"1"}"#, 1i8),
(r#"{"layer":"-1"}"#, -1),
(r#"{"layer":"0"}"#, 0),
(r#"{"layer":"5"}"#, 5),
(r#"{"layer":"-5"}"#, -5),
(r#"{"layer":"+2"}"#, 2),
] {
assert_eq!(layer_of_tags(tags), want, "{tags}");
}
// layer — refused to the ground, with the reason in the name of the case
for tags in [
r#"{}"#, // absent
r#"{"layer":"6"}"#, // outside OSM's documented range
r#"{"layer":"-6"}"#, // ditto
r#"{"layer":"100"}"#, // a typo, not a hundred decks up
r#"{"layer":"1.5"}"#, // layer is an ORDER, not a height
r#"{"layer":"-1;1"}"#, // two statements about one way
r#"{"layer":"yes"}"#, // free text
] {
assert_eq!(
layer_of_tags(tags),
DEFAULT_LAYER,
"{tags} must be refused to the ground, not clamped or truncated"
);
}
// bridge — presence minus negation, plus the AREA spelling
for tags in [
r#"{"bridge":"yes"}"#,
r#"{"bridge":"viaduct"}"#,
r#"{"bridge":"boardwalk"}"#,
r#"{"bridge":"aqueduct"}"#,
r#"{"man_made":"bridge"}"#,
] {
assert!(bridge_of_tags(tags), "{tags} is a bridge");
}
for tags in [
r#"{}"#,
r#"{"bridge":"no"}"#,
r#"{"bridge":"false"}"#,
r#"{"bridge":"0"}"#,
r#"{"man_made":"pier"}"#,
] {
assert!(!bridge_of_tags(tags), "{tags} is not a bridge");
}
// tunnel — the same rule, and `covered` is deliberately not it
for tags in [
r#"{"tunnel":"yes"}"#,
r#"{"tunnel":"building_passage"}"#,
r#"{"tunnel":"culvert"}"#,
] {
assert!(tunnel_of_tags(tags), "{tags} is a tunnel");
}
for tags in [r#"{}"#, r#"{"tunnel":"no"}"#, r#"{"covered":"yes"}"#] {
assert!(
!tunnel_of_tags(tags),
"{tags} is not a tunnel — a covered way is roofed, not buried"
);
}
// A bridge is not a tunnel and vice versa, or the two columns are one column.
let b = r#"{"bridge":"yes","highway":"motorway","layer":"1"}"#;
assert!(bridge_of_tags(b) && !tunnel_of_tags(b));
let t = r#"{"highway":"motorway","layer":"-1","tunnel":"yes"}"#;
assert!(tunnel_of_tags(t) && !bridge_of_tags(t));
// oneway rides `graph::oneway_of` — one reading, so a router and a painter
// cannot disagree about which way a street runs (LAW 5).
assert_eq!(class_of_tags(r#"{"oneway":"yes"}"#).oneway, 1);
assert_eq!(class_of_tags(r#"{"oneway":"-1"}"#).oneway, -1);
assert_eq!(class_of_tags(r#"{"oneway":"no"}"#).oneway, 0);
assert_eq!(class_of_tags(r#"{"highway":"primary"}"#).oneway, 0);
assert_eq!(
class_of_tags(r#"{"highway":"primary","junction":"roundabout"}"#).oneway,
1,
"a roundabout is one-way by definition and almost never carries oneway=yes"
);
assert_eq!(
class_of_tags(r#"{"oneway":"yes"}"#).oneway,
crate::graph::oneway_of(r#"{"oneway":"yes"}"#),
"the class block reads `oneway` a second way — there must be one reading"
);
}
/// ★ **RED-when-broken: a rule's KIND can move without the recipe token moving.**
///
/// `class_id` is an INDEX into [`RULES`] and `kind` is a column on disk, so a cut
/// written under an older rule table must not read Current. [`rules_spelling`]
/// already moves when a rule is added, removed, reordered or re-tiered — the one
/// thing it cannot see is a rule whose test and rung are unchanged and whose KIND
/// moved. That is what [`kinds_spelling`] is for, and this is its guard.
///
/// **RED ARM, seen 2026-09-05:** change `rung!(Region, Park, "landuse")` to
/// `rung!(Region, Other, "landuse")` and the position assertion below fails naming
/// the index; `rules_spelling()` is byte-identical across that edit, which is the
/// whole reason the second term exists.
///
/// The two spellings are checked to be DIFFERENT strings as well, because a
/// `kinds_spelling` that accidentally returned `rules_spelling` would pass every
/// "does it move" assertion for the wrong reason.
#[test]
fn the_kinds_spelling_carries_what_the_rules_spelling_cannot() {
let k = kinds_spelling();
let parts: Vec<&str> = k.split(',').collect();
assert_eq!(
parts.len(),
RULES.len(),
"the kinds spelling drops rules, so a kind could be edited without moving \
it: {k}"
);
// POSITIONAL, so the term moves on a reorder as well as on a re-kind. Spot rules
// by their own spelling rather than by a literal index, which the table's length
// would otherwise pin.
for (want_rule, want_kind) in [
("natural∈[coastline]", "coastline"),
("landuse?", "park"),
("building?", "building"),
("railway∈[rail|narrow_gauge]", "other"),
("highway∈[footway|cycleway|path|pedestrian|steps]", "path"),
("waterway∈[canal]", "water"),
("highway∈[service|track]", "road"),
] {
let at = RULES
.iter()
.position(|r| r.when.spell() == want_rule)
.unwrap_or_else(|| panic!("no rule spells `{want_rule}` any more"));
assert_eq!(
parts[at], want_kind,
"rule {at} (`{want_rule}`) grants `{}` but the kinds spelling says `{}`",
want_kind, parts[at]
);
}
// NON-VACUITY: every kind a rule actually grants must appear, or the term is
// decoration for that kind; and the string must DISCRIMINATE.
for kind in Kind::ALL {
if RULES.iter().any(|x| x.kind == kind) {
assert!(
parts.contains(&kind.as_str()),
"{kind:?} is granted by a rule but never appears in the kinds \
spelling: {k}"
);
}
}
assert!(
parts.iter().collect::<std::collections::BTreeSet<_>>().len() >= 5,
"the kinds spelling holds only {} distinct kinds — a table that granted one \
kind everywhere would pass every assertion above",
parts.iter().collect::<std::collections::BTreeSet<_>>().len()
);
assert_ne!(
k,
rules_spelling(),
"the kinds term IS the rules term, so it adds nothing the recipe did not \
already carry"
);
// And the cross-repo half: `rules_spelling` must stay the string facett has a
// checked-in copy of. A kind term folded in here would turn every build red
// until facett regenerated `osm-katana-ladder.txt`, in another lane's repo.
let r = rules_spelling();
assert!(
!r.contains("=coastline") && !r.contains("=building"),
"a kind leaked into the facett-facing ladder spelling: {r}"
);
assert_eq!(
RULES.len(),
r.split(',').count(),
"the rules spelling drops rules, so a rule could be edited without moving it"
);
}
/// The spellings move with the ladder — otherwise a rung edit could not re-cut the
/// tables the old rung wrote.
#[test]
fn the_spellings_carry_the_ladder() {
let s = Tier::ladder_spelling();
assert!(s.contains("Country@0>6"), "ladder spelling: {s}");
assert!(s.contains("District@9>9"), "ladder spelling: {s}");
assert!(s.contains("Detail@15>15"), "ladder spelling: {s}");
let r = rules_spelling();
assert!(r.contains("place∈[village]>9"), "rules spelling missing village: {r}");
assert!(r.contains("highway?>12"), "rules spelling missing the highway catch-all: {r}");
// NON-VACUITY: the spelling must DISCRIMINATE, or a recipe built from it could
// never notice a ladder change.
assert_ne!(
rules_spelling().len(),
0,
"an empty rules spelling cannot move a recipe token"
);
assert_eq!(
RULES.len(),
rules_spelling().split(',').count(),
"the spelling drops rules, so a rule could be edited without moving it"
);
}
}