<!-- nornir:class: design -->
# Building height from OSM tags — the parser, `METRES_PER_LEVEL`, and the corpus
**Landed 2026-08-31, lane SPIKE, branch `lane/building-height-parse`.**
Code: `facett-map/src/height.rs` (new), `facett-map3d/src/extrude.rs`,
`facett-map/tests/building_height_corpus.rs` + `tests/data/building_height_corpus.jsonl`,
`facett-map3d/tests/no_building_spikes.rs`.
## The defect: it was shipped, it rendered, and it was silent
`facett_map::parse_building_height_m` took the **leading run of digits and dots** off
`height` (else off `building:levels`) and believed whatever fell out. It is a live path —
`korp::map_paint::classify_ways` → `Way::height_m` → `extrude::building_design_height` —
so it is exactly what korp's 3D map view draws. Measured against the real Swedish tag
zoo (lane HEIGHT, korp's live Sweden cut, 2026-08-31):
| tag as written | it drew | it means |
|---|---|---|
| `height=99999` | a **99 999 m spike** | junk data |
| `height=1;12` | a **1 m** building | a semicolon list of two statements |
| `height=25'` | **25 m** | 25 feet = **7.62 m** |
| `height=102,5` | **102 m** | 102.5 m (Swedish decimal comma) |
| `building:levels=3-4` | 3 levels | a range |
All five now parse correctly or are **refused**. A refusal returns `None`, which puts the
building on the extruder's untagged default box — the same box 95.68 % of Swedish
buildings already get. That choice is deliberate: in a renderer a wrong number is worse
than no number, because the fallback looks like a building and the spike is a hole
through the sky.
## The context that shaped the fix: 95.68 % have no height at all
MEASURED by `osm-katana buildings` over korp's Sweden cut, 2026-08-31, 3 841 483 rows:
| outcome | rows | share |
|---|---:|---:|
| no height tag at all | 3 675 499 | **95.6792 %** |
| derived from `building:levels` | 156 167 | 4.0653 % |
| tagged metres | 9 551 | 0.2486 % |
| **rejected** (junk that used to render) | 265 | 0.0069 % |
| tagged feet | **1** | 0.000026 % |
A height column does not fill Sweden with skyscrapers; it fills Stockholm, Göteborg and
Malmö. **The 95.68 % path was therefore not touched** — an untagged building still gets
6.0 m with the same ±25 % deterministic jitter and the same 0.9 × short-side footprint
cap, pinned to the metre by a test (below). This change is about junk tags, not about the
look.
The **feet** branch fires exactly once in 3.8 M Swedish buildings. It is in the parser
because korp cuts more than Sweden, and it is held up by unit tests rather than by field
data. Said, not implied.
## `METRES_PER_LEVEL`: the discrepancy is settled at 3.2, and the 6 m box did not move
Before this branch the same building extruded to a **different height depending on where
it came from**: a tile way went through facett at 3.0 m/level, a `buildings.parquet` row
through `osm_katana::shared::height` at 3.2.
**3.2 is measured, 3.0 was inherited.** 3 205 Swedish buildings carry BOTH a parseable
`height` and a parseable `building:levels`, which makes the constant an empirical
question. Sweeping it (levels counted as `building:levels + roof:levels`):
| 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 |
No constant wins on all three. 3.4 minimises the median, 3.0 the mean, **3.2 the error on
buildings of three levels or more — which is where an extrusion is actually visible** —
and 3.2 is within 0.005 of the best on the mean. For a 3D view that is the column that
matters. `roof:levels` is added to the level count because `height` includes the roof and
`building:levels` does not: worth 0.273 → **0.251** of mean relative error, over the
67 427 Swedish buildings that carry it.
### Why the default is now its own constant
The 6 m untagged default used to be spelled `DEFAULT_BUILDING_LEVELS (2.0) ×
METRES_PER_LEVEL (3.0)`. Correcting the converter to 3.2 would have dragged the default to
6.4 m **as a side effect** — moving 95.68 % of every town in the view in order to fix
0.0069 % of it.
They are different questions. One converts a MAPPED level count (an empirical constant,
±20 % median error, owned by the data). The other is a LOOK decision about a building
nobody mapped (owned by `wall_roof_ratio`'s footprint measurement). So:
* `DEFAULT_BUILDING_LEVELS` is **gone**;
* `facett_map::DEFAULT_BUILDING_HEIGHT_M = 6.0` is the untagged default, a literal;
* `facett_map::METRES_PER_LEVEL = 3.2` is the converter only.
Proved, not asserted: setting `METRES_PER_LEVEL` back to 3.0 leaves
`no_building_spikes::the_untagged_default_box_did_not_move` **green** (5.6119 m unchanged)
while the corpus check goes red on all 15 level-derived rows. That is the decoupling.
## Two texts of one decision — and the corpus that catches them drifting
`osm_katana::shared::height` (znippy-zoomies `04248be`) is the first text and the one
writer of `buildings.parquet`'s `height_m` / `height_source` columns. `facett_map::height`
is the second. **facett must not depend on osm-katana** — facett is a UI component
library, and an edge from a widget crate to a planet-scale OSM cutter would drag
arrow/parquet into every consumer of a map widget.
So LAW 5 is **not** closed by construction here, and this document says so rather than
implying otherwise. It is the same residual lane MINZOOM declared for the tag→rung map.
What is done instead is to make the duplication **detectable**:
* `facett-map/tests/data/building_height_corpus.jsonl` — 95 tag objects, 48 accepted /
47 refused, covering all five `HeightSource` branches. **Its expected column was
produced by running `shared::height::of_tags` verbatim**, not transcribed by hand.
* `facett-map/tests/building_height_corpus.rs` replays it through facett's parser and
fails **naming every disagreeing tag value and both numbers**.
* The corpus's data lines are digested (FNV-1a 64 — a drift detector, not a cryptographic
claim) against `CORPUS_DIGEST`, so a one-sided edit is loud. Comment and blank lines are
excluded so each repo may word its own header.
### The regeneration command, and the arm that is NOT landed
```
facett cargo test -p facett-map --test building_height_corpus
```
To change the decision: change it in `shared::height` first, re-run it over the corpus's
`tags` column, put the same data lines in **both** repos, and paste the new
`CORPUS_DIGEST` the failure message prints.
**RESIDUAL, stated plainly:** only the facett arm is enforced by a landed test. The
osm-katana-side arm — a test in `osm-katana` that reads the same data lines and asserts
`of_tags` reproduces them — is a **hand-off to lane HEIGHT** and was not landed by lane
SPIKE (`shared::height` lives on `lane/building-height`, not on znippy-zoomies `main`).
Until that arm lands, a change made only in osm-katana is caught by facett's corpus test
going red the next time the corpus is copied across — not at the moment it is made.
### `roof:levels` does not reach the tile today — a hand-off, not a claim
`osm-katana`'s `optimize::slim_tags` allowlist (`optimize.rs:88-103`) keeps `height` and
`building:levels` and **not** `roof:levels`. So on korp's live tile path the
`roof:levels` term in facett's parser is currently always 0 and the code is dead there.
It is in the parser anyway because it is part of the decision `shared::height` makes on
`buildings.parquet` (where the tag IS read, and where it is worth 0.273 → 0.251 of mean
relative error), and because agreement is checked by corpus. Adding `"roof:levels"` to
that allowlist is a one-line osm-katana change lane SPIKE did **not** make — it is
another repo and it changes every tile's payload. VERIFIED by reading the allowlist,
2026-08-31.
`min_height` / `building:min_level` are in `shared::height` and deliberately **not** in
facett: `Way` has no bottom-of-prism field and the extruder lifts from ground. If a
`building:part` renderer lands, that is when they come across.
facett also does not model `HeightSource`. Its one consumer — the extruder default —
does the same thing for `Absent` and `Rejected`, and inventing a distinction with no
reader is the all-null-column defect. The corpus carries the `source` column anyway, so a
future facett that wants the stamp has it.
## Gates — every one seen RED
| gate | red arm, and what it printed |
|---|---|
| the five defects, parsed | restore the old leading-digit body → `` `height=99999` parsed to Some(99999.0), expected None `` and 4 more, all five named in one message |
| the five defects, **on the mesh** | same revert → `no_building_spikes` prints all five rooftop Ys in design units against their correct readings |
| no spike survives | same revert → ``extruded a rooftop at 99999.0 m (100.5445 du), above the 1000 m ceiling`` |
| the 95.68 % box is unchanged | `DEFAULT_BUILDING_HEIGHT_M` 6.0 → 6.4 → the untagged rooftop test names the metres |
| agreement with `shared::height` | old parser → **38 of 95** rows named; `METRES_PER_LEVEL` 3.2 → 3.0 → **15 of 95** named |
| corpus not edited on one side | one `height_m` field changed → digest test prints the new constant to paste |
Traps avoided: the in-lib test's `4.0 * METRES_PER_LEVEL` expectation **tracked the
constant** and so could not tell 3.0 from 3.2 from 1.0 — the expectations are literals
now. The untagged-box gate was a ±25 % band at first, which tolerated 6.4 m; it is a
5.6119 m literal now. A `DEFAULT_BUILDING_HEIGHT_M != 2 × METRES_PER_LEVEL` guard was
written and then **removed** — it can only spot the coincidence, never the coupling, and
it went red for the wrong reason during the 3.0 decoupling run.
## Verification, and what it does NOT cover
`facett-map` + `facett-map3d`, both feature configs, serial (`--test-threads=1`):
* **default features** — 124 suites, **0 failures**.
* **`--features wgpu`** — 122 suites ok, **2 failures, both reproduced on `origin/main`
(9a349ad3) with identical numbers** and neither on the height path:
`gfx_v2_item6_parity::the_gpu_2d_writer_is_on_the_unified_path` (`cull span
0.27018228 vs 0.26041666`, a `MapTransform::viewport_bbox` inverse) and
`postfx_bypass::a_2d_ortho_view_records_zero_post_passes…` (a luma bound the test's own
message calls "a shading/clipping defect … GFX_V2 §V2.11").
* Dependents `cargo check --all-targets`: facett-demo, facett-geomap, facett-osm, facett,
facett-app, facett-map3d-flight, facett-graph3d, facett-helix, facett-about — clean.
**korp was not compiled against this branch** (its `path` dep points at the main
checkout); `DEFAULT_BUILDING_LEVELS` and `METRES_PER_LEVEL` appear nowhere in korp's
sources, so the removal cannot break it, but that is a grep, not a build.
**The adapter, said plainly:** the GPU suites on this box selected **llvmpipe (LLVM
21.1.8, Vulkan, Cpu)** — the software rasteriser, printed by `postfx_bypass` itself — and
`facett-map3d`'s `gpu::wgpu_paint_draws_the_scene_on_device` reports "needs a real GPU
adapter". **No gate in this branch asserts on pixels**; every gate here is on parsed
metres or on `Mesh3` vertex positions, both CPU and adapter-independent. Nothing about
the 4090 was proved or needed.
`cargo test --workspace` still cannot compile on facett `main`
(`xtask/tests/silenced_tests_guard.rs` → `nornir_testmatrix::`, E0433). Lane GREEN owns
that; nothing here touches `xtask`. Both new test files are ungated, so neither needs an
arm in `.nornir/testmatrix-arms.json`.