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/height.rs

//! **Building height — the ONE place OSM's height tagging is turned into a number
//! (LAW 5).**
//!
//! # There IS a consumer already, and this corrects the brief that said there was not
//!
//! The lane brief for this work said a grep across facett's 3D map sources for a
//! height/levels consumer "returns nothing". **That is wrong, and the correction matters
//! more than the module.** SURVEYED 2026-08-31:
//!
//! * `facett_map::parse_building_height_m` (`facett-map/src/lib.rs:398`) reads `height`
//!   and `building:levels` out of the tag list;
//! * it is called from `Way::from_lonlat_styled` (`:633`) for every building way korp
//!   builds, via `korp::map_paint::classify_ways` → `facett_osm::parse_tags_into`;
//! * `facett_map3d::extrude::building_design_height` (`facett-map3d/src/extrude.rs:2366`)
//!   uses the result, and falls back to `DEFAULT_BUILDING_LEVELS × METRES_PER_LEVEL`
//!   = 2.0 × 3.0 = **6 m** with ±25 % deterministic jitter, capped at 0.9 × the
//!   footprint's short side.
//!
//! So buildings are not all one box today; they are 6 m boxes with jitter, plus a real
//! height wherever a tag survived. What is wrong is WHERE the work happens and HOW
//! forgiving it is:
//!
//! 1. **It runs per frame, off the JSON.** `optimize::slim_tags` keeps `height` and
//!    `building:levels` in the allowlist, so the strings do reach the tile — as TEXT
//!    inside the `tags` blob, which is the one place a renderer cannot afford to look.
//!    korp's `parse_tags` (`serde_json` over that blob) is **55.9 % of all allocations
//!    on a tile read**, measured 2026-08-03. Every building's height is re-derived from
//!    that string on every rebuild.
//! 2. **It guesses where it should refuse.** `parse_building_height_m` takes the leading
//!    run of digits and dots, so `height=1;12` becomes a **1 m** building, `height=25'`
//!    becomes a **25 m** building (it is 7.62 m), `height=102,5` becomes 102 m, and
//!    `building:levels=3-4` becomes 3 levels — silently, every one of them. There is no
//!    plausibility ceiling either, so `height=99999` is a 99 999 m spike.
//! 3. **A wrong number is indistinguishable from a right one.** `Option<f32>` cannot say
//!    "this was surveyed" versus "this was assumed" versus "somebody tagged nonsense".
//!
//! So the decision is made HERE instead: once, at build time, into a typed column, with
//! a stamp saying which of those three it is. This module is the only writer of it.
//!
//! # What OSM actually says, measured — not assumed
//!
//! Every rule below was written against the value zoo of korp's live Sweden cut
//! (`/home/korp/.korp/work/sweden/`, cut 2026-08-31), over the **3 841 511** rows in
//! `ways.parquet` + `relations.resolved.parquet` that carry `building` or
//! `building:part`. The distinct non-plain-numeric `height` values in that population
//! number **86**, over **566** rows; the whole zoo is listed here so a later reader can
//! see what the parser was built to survive rather than guess:
//!
//! | form | example | rows (Sweden) | verdict |
//! |------|---------|---------------|---------|
//! | bare number | `height=12`, `height=12.5` | 9 019 | metres |
//! | number + `m` | `height=20 m`, `height=10m` | 556 | metres |
//! | number + spelled unit | `height=1 meter` | 1 | metres |
//! | comma decimal | `height=102,5`, `21,5`, `8,5` | 3 | metres (see below) |
//! | feet, feet+inches | `height=25'`, `39'6"` | 1 | feet → metres |
//! | semicolon list | `height=1;12`, `45 m;37 m`, `32;81` | 3 | **rejected** |
//! | free text | `height=B`, `height=Österbybruks hembygdsgård` | 2 | **rejected** |
//!
//! Cut end to end by `osm-katana buildings` on that cut (2026-08-31), the parser's own
//! verdict over all 3 841 483 building rows was: **3 675 499 absent (95.6792 %)**,
//! 156 167 derived from levels (4.0653 %), 9 551 tagged metres (0.2486 %), 265 rejected
//! (0.0069 %) and **1** tagged in feet. Those are the figures every claim in this file
//! rests on.
//!
//! `building:levels` is a smaller zoo and a dirtier one: **24** rows out of 159 589 carry
//! something that is not a number, and they are ranges (`3-4`), lists (`2;1`, `4;4.5`),
//! roof shapes filed under the wrong key (`gabled`), and building classifications
//! (`K1, 0`, `F6`). All rejected. `roof:levels` is worse still — nine of its distinct
//! non-numeric values in Sweden are **HTML colours** (`#ffb5b5`), which is a mapper's
//! editor writing `roof:colour` into the wrong key.
//!
//! ## Feet are real, and Sweden is the wrong place to prove it
//!
//! `height=39'6"` is a documented and common tagging in the US and UK. In Sweden it
//! fires **once** in 3.8 M buildings (`25'`). The branch is here because this crate cuts
//! the PLANET, not because Swedish data needs it — and that is stated rather than
//! implied, because a branch measured at 1 row is a branch that unit tests, not field
//! data, must hold up. See [`parse_length`]'s tests.
//!
//! ## The comma
//!
//! `102,5` is 102.5 m in Swedish and 1025 nowhere, but `1,500` is 1500 in `en_US` and
//! 1.5 in `sv_SE` and no amount of staring resolves it. The rule is therefore
//! syntactic and total: a single comma followed by **one or two** digits at end of
//! string is a decimal separator; a comma followed by exactly three is a thousands
//! group and the value is **rejected**, not guessed. Three Swedish rows take the first
//! branch; none take the second. The alternative — dropping all comma forms — was
//! rejected because it would silently lose a 102.5 m building, and silent loss is the
//! defect this whole module exists to avoid.
//!
//! # Metres per level: 3.2, and the measurement that chose it
//!
//! `building:levels` is a count, and turning it into metres needs an assumption. That
//! assumption is [`METRES_PER_LEVEL`] and it is **3.2 m**, chosen by measuring, not by
//! copying the 3.0 m that OSM Buildings and the Simple 3D Buildings wiki use.
//!
//! MEASURED on the same Sweden cut: **3 205** buildings carry BOTH a parseable `height`
//! and a parseable `building:levels`, which makes the constant an empirical question
//! with an answer. Sweeping the constant and scoring it by relative error against the
//! tagged height:
//!
//! | c (m/level) | median rel. err | mean rel. err | median, buildings ≥ 3 levels |
//! |------|------|------|------|
//! | 2.8 | 0.300 | 0.277 | 0.160 |
//! | 3.0 | 0.250 | **0.246** | 0.115 |
//! | **3.2** | 0.200 | 0.251 | **0.097** |
//! | 3.4 | **0.150** | 0.262 | 0.133 |
//! | 3.6 | 0.200 | 0.281 | 0.152 |
//! | 4.0 | 0.308 | 0.331 | — |
//!
//! (levels counted as `building:levels + roof:levels`, see below.)
//!
//! No constant wins on all three. 3.4 minimises the median, 3.0 minimises the mean, 3.2
//! minimises the error on buildings of three levels or more — which is where an
//! extrusion is actually visible on a map, and where the raw per-level medians converge
//! anyway (levels=7 → 3.14 m, levels=8 → 3.12, levels=9 → 3.11, levels=10 → 3.00; the
//! big per-level numbers all come from single-storey barns and industrial halls, median
//! 4.00 m at levels=1). 3.2 is the compromise that is best where it matters and within
//! 0.005 of the best elsewhere. **It is an assumption with a ±20 % median error and the
//! column says so** — a row derived this way is stamped
//! [`HeightSource::DerivedFromLevels`], and a consumer that wants only surveyed heights
//! can filter on the stamp instead of trusting all f32s equally.
//!
//! ## `roof:levels` is counted; `roof:shape` and `roof:direction` are not
//!
//! Per the OSM wiki, `height` includes the roof while `building:levels` excludes roof
//! levels, so the level total is `building:levels + roof:levels`. That is not a style
//! preference, it is measurable: at c = 3.2 including `roof:levels` moves the mean
//! relative error from 0.273 to **0.251**. 67 427 Swedish buildings carry `roof:levels`,
//! so it is not a rounding error either.
//!
//! `roof:shape` (107 484 rows — gabled 78 022, flat 10 641, hipped 7 337, …),
//! `roof:height` (11 824 rows) and `roof:direction` are **out of scope**, deliberately:
//!
//! * They describe a MESH, not a prism. This module's whole output contract is
//!   "min_height_m → height_m", a vertical extrusion; a gabled roof cannot be expressed
//!   in it, and half-expressing it would make the number mean two things.
//! * `roof:height` in particular would be a column nothing could consume: without a
//!   roof-shape generator in the renderer there is no geometry for it to modify. That is
//!   exactly the `changeset` defect `shared::schema::nodes_schema` documents — a column
//!   declared because the fact exists rather than because a writer and a reader both
//!   want it.
//!
//! If a roof generator ever lands, `roof:shape`/`roof:height`/`roof:direction` come back
//! as their own columns beside these, and `height_m` keeps meaning what it means today:
//! the top of the whole thing.

use std::borrow::Cow;

use crate::network::tag_value;

/// **Metres per building level** — the one assumption in this module, named so it can be
/// argued with. See the module docs for the measurement that chose 3.2 over 3.0.
///
/// # There is a second constant with this name, and it is not a duplicate
///
/// `facett_map::METRES_PER_LEVEL` is **3.0** (`facett-map/src/lib.rs:387`). Two constants
/// spelled the same is normally the LAW 5 defect, and here it is not — yet — because they
/// answer different questions:
///
/// * THIS one converts a MAPPED `building:levels` into metres, and 3.2 is the value that
///   minimises the error against 3 205 Swedish buildings that carry both tags.
/// * facett's is half of `DEFAULT_BUILDING_LEVELS × METRES_PER_LEVEL` = 6 m, the height of
///   a building nobody mapped at all. That is a LOOK decision about a fallback, and
///   facett's own `wall_roof_ratio` test is the measurement behind it.
///
/// They DO become one decision the moment facett's `parse_building_height_m` stops
/// deriving heights and reads `height_m` off this table instead — which is the hand-off
/// in `.nornir/osm-katana-v2-design.md`. After that change facett's constant is a
/// fallback-only number and this one is the only converter, which is the by-construction
/// close LAW 5 asks for. Until then, they overlap on every building carrying
/// `building:levels`, and a way built from a tile gets 3.0 while a row of
/// `buildings.parquet` gets 3.2. **That is a real, live discrepancy and it is stated
/// here rather than hidden.**
pub const METRES_PER_LEVEL: f32 = 3.2;

/// One international foot, exactly. `height=39'` is feet, and feet are 0.3048 m by
/// definition since 1959.
pub const METRES_PER_FOOT: f32 = 0.3048;

/// Metres per inch — `39'6"` is thirty-nine feet and six INCHES, not six of anything
/// else.
pub const METRES_PER_INCH: f32 = METRES_PER_FOOT / 12.0;

/// The tallest building this module will believe. The Burj Khalifa is 828 m; anything
/// over a kilometre in an OSM `height` tag is a units error or a typo, and letting one
/// through puts a 12 000 m spike through a city.
pub const MAX_PLAUSIBLE_HEIGHT_M: f32 = 1_000.0;

/// The most levels this module will believe, before the level → metre conversion. 163
/// is the Burj Khalifa; the tallest thing in the Sweden cut has 18.
pub const MAX_PLAUSIBLE_LEVELS: f32 = 200.0;

// ── where a number came from ────────────────────────────────────────────────────

/// **Where a building's height came from** — the column that lets a consumer and an
/// operator tell a MEASURED building from an ASSUMED one.
///
/// The wire form is a `u8` (see [`Self::code`]) because it lands in a parquet column
/// that is read per row, and because a `Utf8` here would be five distinct strings
/// dictionary-encoded to no purpose.
///
/// # Why [`Self::Rejected`] exists, and why it is not folded into [`Self::Absent`]
///
/// "Nobody tagged this building" and "somebody tagged it and the parser threw the value
/// away" are different facts with different owners. Folded together, the 566 Swedish
/// `height=20 m` rows and the 2 `height=Österbybruks hembygdsgård` rows would be
/// indistinguishable from the 3.67 M rows with no height tag at all — and a parser
/// regression that started rejecting every `N m` form would move a counter nobody
/// watches by 0.015 % and be invisible. Split, the same regression moves
/// `Rejected` from 8 rows to 566 and shows up in the manifest histogram
/// `BuildingStats::height_sources` writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum HeightSource {
    /// No `height`, no `building:levels`. The building has no height in OSM and this
    /// crate did not invent one: `height_m` is NULL, not a default.
    Absent,
    /// `height` parsed as metres (bare, `12 m`, `12m`, `1 meter`, `102,5`).
    TaggedMetres,
    /// `height` parsed as feet / feet + inches (`25'`, `39'6"`).
    TaggedFeet,
    /// No usable `height`; derived from `building:levels` (+ `roof:levels`) ×
    /// [`METRES_PER_LEVEL`]. **An assumption, ±20 % median** — see the module docs.
    DerivedFromLevels,
    /// A `height` or `building:levels` tag WAS present and could not be used: a
    /// semicolon list, free text, a non-positive or implausible number. Counted, never
    /// silently merged into [`Self::Absent`].
    Rejected,
}

impl HeightSource {
    /// Every variant, for exhaustive tests and histograms. A `match` in [`Self::code`]
    /// plus this array is what makes a forgotten variant a compile error in one place
    /// and a test failure in the other.
    pub const ALL: [HeightSource; 5] = [
        HeightSource::Absent,
        HeightSource::TaggedMetres,
        HeightSource::TaggedFeet,
        HeightSource::DerivedFromLevels,
        HeightSource::Rejected,
    ];

    /// The on-disk code. **Spelled as literals, not as `self as u8`**, so the wire
    /// numbering is a thing the test file can pin against a hand-written table rather
    /// than a thing that silently follows declaration order. Reorder the enum and
    /// `height_source_codes_are_pinned_to_the_wire` goes red instead of every parquet
    /// on disk quietly changing meaning.
    pub fn code(self) -> u8 {
        match self {
            HeightSource::Absent => 0,
            HeightSource::TaggedMetres => 1,
            HeightSource::TaggedFeet => 2,
            HeightSource::DerivedFromLevels => 3,
            HeightSource::Rejected => 4,
        }
    }

    /// The inverse of [`Self::code`] — what a READER does with the column.
    pub fn from_code(c: u8) -> Option<Self> {
        match c {
            0 => Some(HeightSource::Absent),
            1 => Some(HeightSource::TaggedMetres),
            2 => Some(HeightSource::TaggedFeet),
            3 => Some(HeightSource::DerivedFromLevels),
            4 => Some(HeightSource::Rejected),
            _ => None,
        }
    }

    /// The operator-facing spelling, for the manifest histogram.
    pub fn name(self) -> &'static str {
        match self {
            HeightSource::Absent => "absent",
            HeightSource::TaggedMetres => "tagged_metres",
            HeightSource::TaggedFeet => "tagged_feet",
            HeightSource::DerivedFromLevels => "derived_from_levels",
            HeightSource::Rejected => "rejected",
        }
    }

    /// Did a human actually state this height? True for the two tagged forms only —
    /// the question a consumer asks when it wants to shade surveyed buildings
    /// differently from assumed ones.
    pub fn is_surveyed(self) -> bool {
        matches!(self, HeightSource::TaggedMetres | HeightSource::TaggedFeet)
    }
}

/// The unit a length value was written in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LengthUnit {
    Metres,
    Feet,
}

// ── the parser ──────────────────────────────────────────────────────────────────

/// **Parse one OSM length value into metres.** Returns the metres AND the unit it was
/// written in, because the caller needs the unit for [`HeightSource`] and the metres for
/// the column — deriving one from the other after the fact would be a second decision.
///
/// `None` means "this string is not a length": empty, a semicolon list, free text, or a
/// number outside `0 < v <= `[`MAX_PLAUSIBLE_HEIGHT_M`]. `None` is never a default —
/// the caller turns it into [`HeightSource::Rejected`] and it gets counted.
///
/// Accepted, in the order tried:
///
/// 1. **feet / inches** — `39'`, `39'6"`, `39' 6"`, `39 ft`, `39ft`, `39 feet`
/// 2. **metres** — `12`, `12.5`, `12 m`, `12m`, `12 metres`, `1 meter`, `102,5`
///
/// Feet are tried first because `'` and `"` are unambiguous; nothing in the metre
/// grammar contains them.
pub fn parse_length(raw: &str) -> Option<(f32, LengthUnit)> {
    let s = raw.trim();
    if s.is_empty() {
        return None;
    }
    // A semicolon list is TWO statements about one building. Picking the first is the
    // silent fallback this module refuses to make; `45 m;37 m` on a multipolygon means
    // its parts differ, and the parts carry their own tags.
    if s.contains(';') {
        return None;
    }
    let (metres, unit) = parse_feet(s)
        .map(|f| (f, LengthUnit::Feet))
        .or_else(|| parse_metres(s).map(|m| (m, LengthUnit::Metres)))?;
    if !metres.is_finite() || metres <= 0.0 || metres > MAX_PLAUSIBLE_HEIGHT_M {
        return None;
    }
    Some((metres, unit))
}

/// `39'`, `39'6"`, `39' 6"`, `39ft`, `39 feet` → metres. `None` if there is no foot
/// marker at all (so a bare `39` falls through to the metre branch, which is correct:
/// OSM's default unit is metres).
fn parse_feet(s: &str) -> Option<f32> {
    // The `'` / `"` form.
    if let Some(q) = s.find('\'') {
        let feet = parse_decimal(&s[..q])?;
        let rest = s[q + 1..].trim();
        let inches = if rest.is_empty() {
            0.0
        } else {
            // `6"` — the inch marker is required. `39'6` is not a documented form and
            // guessing it would be inventing data.
            let r = rest.strip_suffix('"').or_else(|| rest.strip_suffix('\u{201d}'))?;
            parse_decimal(r)?
        };
        if !(0.0..12.0).contains(&inches) {
            return None;
        }
        return Some(feet * METRES_PER_FOOT + inches * METRES_PER_INCH);
    }
    // The spelled form.
    for suffix in ["feet", "foot", "ft"] {
        if let Some(head) = strip_suffix_ci(s, suffix) {
            return Some(parse_decimal(head)? * METRES_PER_FOOT);
        }
    }
    None
}

/// A bare number, or a number with a metre unit spelled any of the ways Sweden spells
/// it. The suffixes are tried longest-first so `metres` is not eaten as `m` + `etres`.
fn parse_metres(s: &str) -> Option<f32> {
    for suffix in ["metres", "meters", "metre", "meter", "m"] {
        if let Some(head) = strip_suffix_ci(s, suffix) {
            // `strip_suffix_ci` on `"m"` would also strip the `m` off a bare word, so
            // the head still has to parse as a number; `height=B` fails here.
            return parse_decimal(head);
        }
    }
    parse_decimal(s)
}

/// Case-insensitive suffix strip that also eats the space before the unit. Returns the
/// head, or `None` when the suffix is not there.
///
/// Compares BYTES before it cuts. `str::split_at` panics on a non-char-boundary index,
/// and `height=Österbybruks hembygdsgård` is a real Swedish tag value — a value ending
/// in a multi-byte character would have taken the converter down on a tag nobody can
/// control. Every suffix here is ASCII, so a matching tail proves the cut point is a
/// char boundary and the slice below cannot panic.
fn strip_suffix_ci<'a>(s: &'a str, suffix: &str) -> Option<&'a str> {
    debug_assert!(suffix.is_ascii(), "the byte compare below assumes ASCII units");
    let (b, sb) = (s.as_bytes(), suffix.as_bytes());
    if b.len() < sb.len() {
        return None;
    }
    let cut = b.len() - sb.len();
    if !b[cut..].eq_ignore_ascii_case(sb) {
        return None;
    }
    Some(s[..cut].trim_end())
}

/// A decimal number, with the comma rule from the module docs: `,` followed by one or
/// two digits at end of string is a decimal separator; `,` followed by three is a
/// thousands group and the value is rejected rather than guessed.
fn parse_decimal(s: &str) -> Option<f32> {
    let s = s.trim();
    if s.is_empty() {
        return None;
    }
    let normalised: Cow<'_, str> = if let Some(c) = s.find(',') {
        // Exactly one comma, or it is not a decimal separator in any locale.
        if s[c + 1..].contains(',') {
            return None;
        }
        let frac = &s[c + 1..];
        if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
            return None;
        }
        if frac.len() >= 3 {
            // `1,500` — 1500 in en_US, 1.5 in sv_SE. Unresolvable, so refused.
            return None;
        }
        Cow::Owned(s.replacen(',', ".", 1))
    } else {
        Cow::Borrowed(s)
    };
    // `str::parse::<f32>` accepts `inf`, `NaN`, `1e9` and a leading `+`. Only the last
    // of those is wanted here, so the shape is checked before the parse.
    let b = normalised.as_bytes();
    let mut i = 0usize;
    if b[i] == b'+' || b[i] == b'-' {
        i += 1;
    }
    let mut digits = 0usize;
    let mut dots = 0usize;
    while i < b.len() {
        match b[i] {
            b'0'..=b'9' => digits += 1,
            b'.' => dots += 1,
            _ => return None,
        }
        i += 1;
    }
    if digits == 0 || dots > 1 {
        return None;
    }
    normalised.parse::<f32>().ok().filter(|v| v.is_finite())
}

/// A level COUNT: a plain non-negative decimal, no units. `building:levels=3.5` is a
/// real and common tagging (294 Swedish rows) so this is an `f32`, not an integer —
/// rounding to a `u8` happens once, at the column, and never inside the arithmetic.
pub fn parse_levels(raw: &str) -> Option<f32> {
    let s = raw.trim();
    if s.is_empty() || s.contains(';') {
        return None;
    }
    let v = parse_decimal(s)?;
    if !(0.0..=MAX_PLAUSIBLE_LEVELS).contains(&v) {
        return None;
    }
    Some(v)
}

// ── the decision ────────────────────────────────────────────────────────────────

/// Everything this module decides about one building, and the stamp saying how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BuildingHeight {
    /// Height of the top above ground, in metres. `None` when OSM does not say and this
    /// module refuses to invent one — read [`Self::source`] to learn which kind of
    /// silence it is.
    pub height_m: Option<f32>,
    /// Height of the BOTTOM above ground, for a `building:part` that starts partway up.
    /// Without it such a part floats or sinks. `None` means "on the ground", which for
    /// the 99.98 % of Swedish buildings with no `min_height` is also the truth.
    pub min_height_m: Option<f32>,
    /// `building:levels`, rounded to a whole floor for display. **Reported, not the
    /// derivation**: the metres in [`Self::height_m`] come from the unrounded value, so
    /// `building:levels=3.5` gives `levels = 4` and `height_m = 11.2`, and the two are
    /// consistent with the tag rather than with each other.
    pub levels: Option<u8>,
    /// Where [`Self::height_m`] came from.
    pub source: HeightSource,
}

impl BuildingHeight {
    /// The nothing-known answer.
    pub const ABSENT: BuildingHeight = BuildingHeight {
        height_m: None,
        min_height_m: None,
        levels: None,
        source: HeightSource::Absent,
    };
}

/// **The height decision for one feature, from its `tags` JSON.** The ONE caller-facing
/// entry point of this module.
///
/// Reads the tags with [`crate::network::tag_value`] — the flat-object scanner — and not
/// with `serde_json`, for the reason in the module docs. Seven scans of a ~200-byte
/// object per building is ~6 GB of byte-walking over a Swedish cut and costs nothing
/// measurable at build time; a `serde_json::Value` per building would allocate a map and
/// a String per pair for 3.8 M rows.
///
/// Precedence, and it is deliberate: a TAGGED height always beats a derived one, even
/// when both are present. `height` is what a human stated about this building;
/// `building:levels × 3.2` is what this module assumed about it.
pub fn of_tags(tags: &str) -> BuildingHeight {
    if tags.is_empty() || tags == "{}" {
        return BuildingHeight::ABSENT;
    }

    let raw_height = tag_value(tags, "height");
    let raw_levels = tag_value(tags, "building:levels");

    // The reported level count, independent of whether it fed the height: a building
    // with `height=12` and `building:levels=4` reports both, and a UI that wants to say
    // "4 floors" does not have to re-derive it.
    let levels_f = raw_levels.as_deref().and_then(parse_levels);
    let levels = levels_f.map(|v| v.round().clamp(0.0, 255.0) as u8);

    // 1. a tagged height.
    if let Some(raw) = raw_height.as_deref() {
        if let Some((m, unit)) = parse_length(raw) {
            return BuildingHeight {
                height_m: Some(m),
                min_height_m: min_height_of(tags, Some(m)),
                levels,
                source: match unit {
                    LengthUnit::Metres => HeightSource::TaggedMetres,
                    LengthUnit::Feet => HeightSource::TaggedFeet,
                },
            };
        }
    }

    // 2. derived from levels. `roof:levels` is added because `height` includes the roof
    //    and `building:levels` does not (module docs; worth 0.022 of mean relative
    //    error).
    if let Some(l) = levels_f {
        let roof = tag_value(tags, "roof:levels")
            .as_deref()
            .and_then(parse_levels)
            .unwrap_or(0.0);
        let m = (l + roof) * METRES_PER_LEVEL;
        if m > 0.0 && m <= MAX_PLAUSIBLE_HEIGHT_M {
            return BuildingHeight {
                height_m: Some(m),
                min_height_m: min_height_of(tags, Some(m)),
                levels,
                source: HeightSource::DerivedFromLevels,
            };
        }
        // `building:levels=0` — 196 Swedish rows. A zero-height prism is not a
        // building, and the tag IS present, so this is Rejected and not Absent.
        return BuildingHeight {
            height_m: None,
            min_height_m: min_height_of(tags, None),
            levels,
            source: HeightSource::Rejected,
        };
    }

    // 3. nothing usable. Which silence was it?
    let source = if raw_height.is_some() || raw_levels.is_some() {
        HeightSource::Rejected
    } else {
        HeightSource::Absent
    };
    BuildingHeight {
        height_m: None,
        min_height_m: min_height_of(tags, None),
        levels,
        source,
    }
}

/// The bottom of the prism: `min_height` in metres, else `building:min_level` ×
/// [`METRES_PER_LEVEL`].
///
/// `top` is the height already decided, and it is a CONSTRAINT: a `min_height` at or
/// above the top describes an inverted prism, which is a tagging error rather than a
/// building, and is dropped. The drop is silent in the sense that no separate counter
/// exists for it — 355 Swedish rows carry `min_height` at all, so a counter would be
/// noise; the row still carries its full `tags` and an operator can go and look.
fn min_height_of(tags: &str, top: Option<f32>) -> Option<f32> {
    let m = match tag_value(tags, "min_height").as_deref().and_then(|s| {
        parse_length(s).map(|(v, _)| v)
    }) {
        Some(v) => v,
        None => {
            tag_value(tags, "building:min_level")
                .as_deref()
                .and_then(parse_levels)
                .map(|l| l * METRES_PER_LEVEL)?
        }
    };
    if !m.is_finite() || m < 0.0 {
        return None;
    }
    if m == 0.0 {
        // `min_height=0` says "on the ground", which is what NULL already says. Two
        // spellings of one fact is the ambiguity `min_zoom_field`'s doc argues against.
        return None;
    }
    match top {
        Some(t) if m >= t => None,
        _ => Some(m),
    }
}

// ── tests ───────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ **RED-when-broken: the whole tag zoo, one row per form.**
    ///
    /// Table-driven over every documented and every MEASURED form (see the module docs
    /// for where each row's example came from), with the expected metres written by
    /// hand beside it. Break the feet branch — return `None` from [`parse_feet`], or
    /// swap [`METRES_PER_FOOT`] for 1.0 — and the failure names the input string, not
    /// "assertion failed".
    ///
    /// A parser that returns `None` for everything is the fastest parser there is, so
    /// the accepted and rejected arms are counted separately and both are pinned: a
    /// table that had drifted to all-`None` would fail the accepted count before it
    /// failed any single row.
    #[test]
    fn parse_length_survives_the_whole_osm_height_zoo() {
        // (input, expected metres, expected unit)
        let accepted: &[(&str, f32, LengthUnit)] = &[
            // plain metres — 9 019 Swedish rows
            ("12", 12.0, LengthUnit::Metres),
            ("12.5", 12.5, LengthUnit::Metres),
            ("0.7", 0.7, LengthUnit::Metres),
            ("118.7", 118.7, LengthUnit::Metres),
            // metres with a unit — 556 Swedish rows
            ("12 m", 12.0, LengthUnit::Metres),
            ("12m", 12.0, LengthUnit::Metres),
            ("20 M", 20.0, LengthUnit::Metres),
            ("14.5 m", 14.5, LengthUnit::Metres),
            ("1 meter", 1.0, LengthUnit::Metres),
            ("3 metres", 3.0, LengthUnit::Metres),
            ("3 meters", 3.0, LengthUnit::Metres),
            ("3 metre", 3.0, LengthUnit::Metres),
            ("  8.0 m  ", 8.0, LengthUnit::Metres),
            // comma decimal — 3 Swedish rows
            ("102,5", 102.5, LengthUnit::Metres),
            ("8,5", 8.5, LengthUnit::Metres),
            ("21,5", 21.5, LengthUnit::Metres),
            // FEET. One Swedish row (`25'`); the rest is what the planet cut needs.
            ("25'", 25.0 * 0.3048, LengthUnit::Feet),
            ("39'6\"", 39.0 * 0.3048 + 6.0 * 0.0254, LengthUnit::Feet),
            ("39' 6\"", 39.0 * 0.3048 + 6.0 * 0.0254, LengthUnit::Feet),
            ("6'11\"", 6.0 * 0.3048 + 11.0 * 0.0254, LengthUnit::Feet),
            ("39 ft", 39.0 * 0.3048, LengthUnit::Feet),
            ("39ft", 39.0 * 0.3048, LengthUnit::Feet),
            ("39 feet", 39.0 * 0.3048, LengthUnit::Feet),
            ("100 FT", 100.0 * 0.3048, LengthUnit::Feet),
        ];
        for (input, want, unit) in accepted {
            let got = parse_length(input).unwrap_or_else(|| {
                panic!("`height={input}` must parse — it is a form that occurs in OSM")
            });
            assert!(
                (got.0 - want).abs() < 1e-3,
                "`height={input}` parsed to {} m, expected {want} m",
                got.0
            );
            assert_eq!(
                got.1, *unit,
                "`height={input}` was read as {:?}, expected {unit:?} — the unit is what \
                 stamps HeightSource, so getting it wrong mislabels a surveyed building",
                got.1
            );
        }

        let rejected: &[&str] = &[
            "",
            "   ",
            // free text — 2 Swedish rows
            "B",
            "Österbybruks hembygdsgård",
            // a value ENDING in a multi-byte character: `split_at` on a byte index
            // would panic here, and a converter that panics on a tag value is a
            // converter a single mapper can stop.
            "5 må",
            "höjdä",
            "ö",
            // semicolon lists — 3 Swedish rows
            "1;12",
            "45 m;37 m",
            "32;81",
            // non-positive and implausible
            "0",
            "-4",
            "0 m",
            "12000",
            // the ambiguous comma
            "1,500",
            "1,2,3",
            "12,",
            "12,x",
            // shapes `f32::from_str` would happily take and OSM never means
            "1e9",
            "inf",
            "NaN",
            "12.5.5",
            // a foot form with no number, and inches out of range
            "'6\"",
            "39'13\"",
            "39'6", // the inch marker is not optional
        ];
        for input in rejected {
            assert_eq!(
                parse_length(input),
                None,
                "`height={input}` must be REJECTED — accepting it puts a wrong number in \
                 a column whose whole point is that its numbers are trustworthy"
            );
        }

        // The all-`None` guard: this table has to keep proving it accepts things.
        assert!(
            accepted.len() >= 20 && rejected.len() >= 15,
            "the zoo table shrank to {} accepted / {} rejected — a table this small \
             cannot hold a parser to anything",
            accepted.len(),
            rejected.len()
        );
    }

    /// ★ **RED-when-broken: feet, on its own, against metres.**
    ///
    /// The zoo test above would still pass if [`parse_feet`] were deleted AND the
    /// expectations were regenerated from the code. This one cannot: `25'` and `25` are
    /// the same digits and must give numbers that differ by a factor of 3.28, and the
    /// constant is written out here rather than referenced.
    #[test]
    fn feet_are_not_metres() {
        let (feet, u1) = parse_length("25'").expect("25' is feet");
        let (metres, u2) = parse_length("25").expect("25 is metres");
        assert_eq!(u1, LengthUnit::Feet);
        assert_eq!(u2, LengthUnit::Metres);
        assert!(
            (feet - 7.62).abs() < 1e-3,
            "25 feet is 7.62 m, got {feet} — the foot conversion is wrong"
        );
        assert!(
            (metres - 25.0).abs() < 1e-6,
            "a bare `25` is 25 metres (OSM's default unit), got {metres}"
        );
        let ratio = metres / feet;
        assert!(
            (ratio - 3.2808).abs() < 1e-2,
            "`25` and `25'` came out {ratio:.4}x apart; feet and metres are 3.2808x \
             apart, so the feet branch is not converting"
        );
        // inches are inches: 39'6" must sit exactly half a foot above 39'.
        let (a, _) = parse_length("39'").unwrap();
        let (b, _) = parse_length("39'6\"").unwrap();
        assert!(
            (b - a - 0.1524).abs() < 1e-3,
            "39'6\" - 39' should be six inches (0.1524 m), got {:.4} m",
            b - a
        );
    }

    /// ★ **RED-when-broken: the wire codes are pinned to hand-written literals.**
    ///
    /// This is the guard the brief warns about — `x as u8 == x as u8` can never fail,
    /// and neither can `code() == code()`. The table below is written by hand and is
    /// the SPECIFICATION of the column: reorder the enum, insert a variant in the
    /// middle, or renumber `code()`, and this goes red rather than every
    /// `buildings.parquet` on disk silently changing meaning.
    #[test]
    fn height_source_codes_are_pinned_to_the_wire() {
        let wire: [(HeightSource, u8, &str); 5] = [
            (HeightSource::Absent, 0, "absent"),
            (HeightSource::TaggedMetres, 1, "tagged_metres"),
            (HeightSource::TaggedFeet, 2, "tagged_feet"),
            (HeightSource::DerivedFromLevels, 3, "derived_from_levels"),
            (HeightSource::Rejected, 4, "rejected"),
        ];
        assert_eq!(
            wire.len(),
            HeightSource::ALL.len(),
            "a variant was added to HeightSource without a wire code — the parquet \
             column would gain a value no reader has a name for"
        );
        for (v, code, name) in wire {
            assert_eq!(v.code(), code, "{name}: wire code moved");
            assert_eq!(v.name(), name, "{v:?}: operator-facing name moved");
            assert_eq!(
                HeightSource::from_code(code),
                Some(v),
                "code {code} does not read back as {v:?} — from_code is not the inverse \
                 of code()"
            );
        }
        assert_eq!(HeightSource::from_code(5), None, "5 is not a source");
        assert_eq!(HeightSource::from_code(255), None, "255 is not a source");
        // and the codes are distinct, which `as u8` would give for free but a
        // hand-written match would not.
        let mut seen: Vec<u8> = HeightSource::ALL.iter().map(|s| s.code()).collect();
        seen.sort_unstable();
        seen.dedup();
        assert_eq!(seen.len(), 5, "two variants share a wire code");
        // only the two TAGGED forms are surveyed — this is what a consumer filters on.
        assert_eq!(
            HeightSource::ALL
                .iter()
                .filter(|s| s.is_surveyed())
                .count(),
            2,
            "is_surveyed must name exactly the two tagged forms; a derived height is an \
             assumption and must not pass for a measurement"
        );
        assert!(!HeightSource::DerivedFromLevels.is_surveyed());
    }

    /// ★ **RED-when-broken: the decision, end to end, on real tag objects.**
    ///
    /// Each row is a `tags` JSON of the shape `crate::tags::serialize_tags` writes, with
    /// the expected height, level count and SOURCE beside it. The source column is what
    /// makes this test capable of catching a silent fallback: a change that made an
    /// untagged building default to 10 m would keep every height assertion happy on the
    /// tagged rows and fail on `Absent` immediately.
    #[test]
    fn of_tags_decides_and_says_how() {
        // (tags, height_m, levels, source)
        let cases: &[(&str, Option<f32>, Option<u8>, HeightSource)] = &[
            // nothing — 95.7 % of Swedish buildings
            (r#"{"building":"yes"}"#, None, None, HeightSource::Absent),
            (r#"{}"#, None, None, HeightSource::Absent),
            // tagged metres
            (
                r#"{"building":"yes","height":"12"}"#,
                Some(12.0),
                None,
                HeightSource::TaggedMetres,
            ),
            (
                r#"{"building":"yes","height":"20 m"}"#,
                Some(20.0),
                None,
                HeightSource::TaggedMetres,
            ),
            // tagged feet
            (
                r#"{"building":"yes","height":"25'"}"#,
                Some(7.62),
                None,
                HeightSource::TaggedFeet,
            ),
            // derived from levels
            (
                r#"{"building":"apartments","building:levels":"4"}"#,
                Some(12.8),
                Some(4),
                HeightSource::DerivedFromLevels,
            ),
            // roof levels are counted on top
            (
                r#"{"building":"house","building:levels":"2","roof:levels":"1"}"#,
                Some(9.6),
                Some(2),
                HeightSource::DerivedFromLevels,
            ),
            // fractional levels: the metres are exact, the reported count is rounded
            (
                r#"{"building":"house","building:levels":"3.5"}"#,
                Some(11.2),
                Some(4),
                HeightSource::DerivedFromLevels,
            ),
            // a tagged height WINS over levels, and levels are still reported
            (
                r#"{"building":"yes","building:levels":"4","height":"9"}"#,
                Some(9.0),
                Some(4),
                HeightSource::TaggedMetres,
            ),
            // present but unusable — NOT the same as absent
            (
                r#"{"building":"yes","height":"1;12"}"#,
                None,
                None,
                HeightSource::Rejected,
            ),
            (
                r#"{"building":"yes","height":"B"}"#,
                None,
                None,
                HeightSource::Rejected,
            ),
            (
                r#"{"building":"yes","building:levels":"0"}"#,
                None,
                Some(0),
                HeightSource::Rejected,
            ),
            (
                r#"{"building":"yes","building:levels":"3-4"}"#,
                None,
                None,
                HeightSource::Rejected,
            ),
            // a height tag that fails but levels that work: levels carry it
            (
                r#"{"building":"yes","building:levels":"5","height":"gabled"}"#,
                Some(16.0),
                Some(5),
                HeightSource::DerivedFromLevels,
            ),
        ];
        for (tags, want_h, want_l, want_s) in cases {
            let got = of_tags(tags);
            assert_eq!(
                got.source, *want_s,
                "{tags}: source was {:?}, expected {want_s:?}",
                got.source
            );
            match (got.height_m, want_h) {
                (None, None) => {}
                (Some(a), Some(b)) => assert!(
                    (a - b).abs() < 1e-3,
                    "{tags}: height {a} m, expected {b} m"
                ),
                (a, b) => panic!("{tags}: height {a:?}, expected {b:?}"),
            }
            assert_eq!(got.levels, *want_l, "{tags}: levels");
        }
        // The distribution of the table is itself the guard against a degenerate
        // implementation: `of_tags` returning ABSENT for everything would satisfy three
        // rows here and this count.
        let sources: Vec<HeightSource> = cases.iter().map(|c| of_tags(c.0).source).collect();
        for s in HeightSource::ALL {
            assert!(
                sources.contains(&s),
                "no case in this table produces {s:?}; a variant nothing exercises is a \
                 variant nothing tests"
            );
        }
    }

    /// ★ **RED-when-broken: `min_height` puts the bottom of a part off the ground.**
    ///
    /// Without it a `building:part` that starts on the fourth floor is drawn from
    /// ground level and swallows the four floors below it. The inverted case
    /// (`min_height >= height`) must be refused, not clamped: clamping produces a
    /// zero-height prism that looks like a rendering bug rather than a data one.
    #[test]
    fn min_height_lifts_a_part_and_refuses_to_invert() {
        let p = of_tags(r#"{"building:part":"yes","height":"30","min_height":"12"}"#);
        assert_eq!(p.height_m, Some(30.0));
        assert_eq!(p.min_height_m, Some(12.0), "min_height must survive in metres");

        // `building:min_level` is the level spelling of the same fact.
        let q = of_tags(r#"{"building:part":"yes","height":"30","building:min_level":"4"}"#);
        assert!(
            (q.min_height_m.unwrap() - 4.0 * METRES_PER_LEVEL).abs() < 1e-3,
            "building:min_level=4 must lift the part by 4 x METRES_PER_LEVEL, got {:?}",
            q.min_height_m
        );

        // `min_height` beats `building:min_level` — metres beat an assumption, the same
        // precedence `of_tags` uses for the top.
        let r = of_tags(
            r#"{"building:part":"yes","height":"30","min_height":"12","building:min_level":"9"}"#,
        );
        assert_eq!(r.min_height_m, Some(12.0));

        // Inverted and degenerate cases are refused.
        for tags in [
            r#"{"building:part":"yes","height":"10","min_height":"10"}"#,
            r#"{"building:part":"yes","height":"10","min_height":"14"}"#,
            r#"{"building:part":"yes","height":"10","min_height":"0"}"#,
            r#"{"building:part":"yes","height":"10","min_height":"junk"}"#,
        ] {
            assert_eq!(
                of_tags(tags).min_height_m,
                None,
                "{tags}: a min_height at or above the top is an inverted prism and must \
                 be dropped, not clamped"
            );
        }
        // A building with no min_height at all sits on the ground, and that is NULL and
        // not 0.0 — one spelling per fact.
        assert_eq!(
            of_tags(r#"{"building":"yes","height":"10"}"#).min_height_m,
            None
        );
    }

    /// ★ **RED-when-broken: the level constant is used, and it is the named one.**
    ///
    /// The trap here is the identity value: with `METRES_PER_LEVEL = 1.0` a test that
    /// asserted `4 levels -> 4.0` would pass and mean nothing. So the assertion is that
    /// the derived height is a MULTIPLE of the constant AND that the constant is in the
    /// band the measurement supports — change it to 1.0 or 10.0 and this goes red with
    /// the number in the message.
    #[test]
    fn metres_per_level_is_the_measured_constant_and_is_actually_applied() {
        assert!(
            (2.5..=4.0).contains(&METRES_PER_LEVEL),
            "METRES_PER_LEVEL is {METRES_PER_LEVEL} m. The Sweden cut's 3 205 buildings \
             carrying both height and levels put the honest band at 2.8-3.6 m/level \
             (median 3.60, mean-optimal 3.0, best-for-tall 3.2). A value outside 2.5-4.0 \
             is not a floor height."
        );
        for n in 1u8..=12 {
            let tags = format!(r#"{{"building":"yes","building:levels":"{n}"}}"#);
            let h = of_tags(&tags).height_m.unwrap();
            assert!(
                (h - f32::from(n) * METRES_PER_LEVEL).abs() < 1e-3,
                "{n} levels gave {h} m, not {n} x {METRES_PER_LEVEL}"
            );
        }
        // and a 10-storey block is taller than a 2-storey house by the right amount —
        // the check an identity constant cannot pass.
        let tall = of_tags(r#"{"building":"yes","building:levels":"10"}"#)
            .height_m
            .unwrap();
        let short = of_tags(r#"{"building":"yes","building:levels":"2"}"#)
            .height_m
            .unwrap();
        assert!(
            (tall - short - 8.0 * METRES_PER_LEVEL).abs() < 1e-3,
            "the 8-storey difference came out {} m",
            tall - short
        );
        assert!(
            tall > 25.0 && tall < 40.0,
            "a 10-storey building came out {tall} m, which is not a 10-storey building"
        );
    }

    /// A `building:levels` value beyond belief must not become a kilometre of prism.
    #[test]
    fn implausible_values_are_refused_rather_than_scaled() {
        assert_eq!(parse_levels("9999"), None);
        assert_eq!(
            of_tags(r#"{"building":"yes","building:levels":"9999"}"#).source,
            HeightSource::Rejected
        );
        assert_eq!(
            of_tags(r#"{"building":"yes","height":"99999"}"#).source,
            HeightSource::Rejected
        );
        // 163 levels is the Burj Khalifa and must still work.
        let burj = of_tags(r#"{"building":"yes","building:levels":"163"}"#);
        assert_eq!(burj.source, HeightSource::DerivedFromLevels);
        assert_eq!(burj.levels, Some(163));
    }
}