Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy-zoomies

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"
        );
    }
}