--- title: "Body Map Viewer — Araminta Round 1" description: "Named-feature encoding, per-gridunit payload schema, and encoding continuity across steps — argued from the measured wire-size table (T-1179) and hydrology cliff proposal (T-1177)" workshop: body-map-viewer round: 1 author: Araminta status: complete --- # Araminta — Round 1 Answering my three questions from the [workshop brief](body-map-viewer-workshop-brief.md#participants). Grounded in T-1179's measured wire table, T-1177's cliff representation proposal, the current `DistrictWindowLayer` struct (`server/src/atlas/layer_proxy.rs`), and the carrier three-way rule (`docs/architecture/river-courses-t1170.md`, Ruling 1c). Keep clean, layer detail later — but the wire contract is exactly the kind of "invisible until it's wrong" decision that deserves the extra pass now, because every renderer downstream inherits whatever we lock here. --- ## 1. Named-feature encoding (THE wire decision) **Verdict: don't treat this as one decision. It's three, because rivers, settlements/POIs, and roads don't share a truth model — the carrier three-way rule already sorted them, and my job is to encode each correctly, not force a single scheme across all of them.** ### Settlements + POIs + their names → ids-with-lookup, whole-body family (rule i) This is *already built and already correct* — I want to name that explicitly so the workshop doesn't accidentally relitigate a solved problem. Names live in `atlas_city_names` (D-223), a names-only pool with no geometry or population, read once per body via `handle_city_names_request` (`atlas_data_proxy.rs`). Settlement *geometry* (footprint aggregates: density, dominant type/zoning) rides the whole-body `SettlementLayer` family (D-226 T-1112/T-1119) — rung-independent, computed once, filtered client-side by zoom. This is rule (i) by construction: settlements are discrete, finite, rung-independent, valid forever once placed. **Encoding: a small integer id per settlement, resolved against the whole-body name pool the client already holds.** Never inline the string into every gridunit that happens to be near a city — that's paying string bytes on a per-cell raster for something that changes at most a few hundred times per body. POIs (once they exist, per the outline's "later" scope) follow the identical shape: id + whole-body lookup, not per-gridunit inline text. **What the per-gridunit payload carries instead: presence, not identity.** A gridunit near a settlement doesn't need to know the settlement's *name* — it needs to know *"a settlement occupies me"* so the map-art function can render footprint fill/color, and the **id** so the client can join against the lookup it already has cached (for label placement, click-through, the sidebar). See §2 below for the exact field. ### Rivers → sparse feature list, windowed payload, ids (not inline names) Rivers are rule (iii) — rung-indexed invention (T-1170, Ruling 1c) — which already settled the carrier (`DistrictWindowLayer.courses: Vec`, outside the windowed-family ceiling, per the same echo key as the six dense arrays). I have nothing to add to the *geometry* carrier — Tyre's ruling is correct and I'm not reopening it. My scope is the **naming** half, which Ruling 1c's `RiverCourse` struct doesn't currently carry at all (`edge_id`, `class`, `points`, `terminus` — no name field). **River names: same ids-with-lookup shape as settlements, one level indirect.** `edge_id` is already the stable identity (`river_course::pack_cell_id`, stable across every window/rung shipping that edge, per the doc). Do not inline "Kelvin's Run" into every `RiverCourse` polyline on every window that happens to cross it — a trunk river crosses dozens of windows over its length, and the string would repeat dozens of times for zero new information (the same argument PNG encoding wins for the dense fields: don't pay bytes for something the client can dereference). Instead: name assignment is a whole-body, rung-independent property of the **edge-id graph** (rivers don't get renamed between gridunits), so it belongs in a names-only whole-body lookup exactly like `atlas_city_names` — `river_names: BTreeMap` fetched once per body, joined client-side against the `edge_id` already on every `RiverCourse`. This is additive to the existing whole-body atlas-names request path, not a new mechanism — same shape, second table. One nuance rivers have that settlements don't: a single named river usually spans many `edge_id`s (a trunk plus its class-2 tributaries share a name; "unnamed" streams don't get an entry at all). That's a **naming-assignment generator concern** (which edges share a name), not a wire-encoding concern — the wire only needs "this edge_id resolves to this name-table key or none," so I'm not blocking on it; flagging it so Dudley's naming generator (whenever that lands) knows the wire already expects a many-edges-to-one-name join, not a strict 1:1. ### Roads → not in this workshop's scope, but the encoding falls out for free Roads aren't measured or built yet (outline: "later, ... roads, railroads, etc will also be in scope"), but worth stating now since it's free: roads are graph-like exactly like rivers, so they inherit rule (iii) + the same ids-with-lookup naming shape without a new design pass when they land. Not claiming this as decided — just noting the pattern generalizes and nobody needs to re-litigate named-feature encoding a third time. ### Why not dense classification rasters for any of these The brief posed "dense classification rasters + sparse feature list vs inline identity vs ids-with-lookup" as the open question. A dense raster (e.g. a `settlement_id: Vec` per-gridunit array, one entry per cell) is the wrong shape for anything that is **sparse and discrete** — settlements and named rivers occupy a small fraction of gridunits in any canvas. T-1179's own per-field RLE numbers make this concrete: `morphology`/`vegetation` (genuinely dense, every cell has a value) still only manage 6 runs at 330K cells because they're spatially coherent — a settlement-id raster would be mostly a single "no settlement" sentinel with a handful of small filled regions, which is exactly what a sparse feature list (a handful of `{id, footprint}` entries) encodes in a few hundred bytes instead of 330K–8.3M raster cells that are 99%+ one repeated sentinel. **Dense rasters are for continuous fields (rule ii territory — biome, elevation, moisture); sparse feature lists + ids-with-lookup are for discrete named things (rule i/iii territory). Don't blend them — the moment "sparse feature" bleeds into "raster," we're back to paying for information nobody asked for.** --- ## 2. The per-gridunit payload schema **Baseline: the six fields `DistrictWindowLayer` ships today are exactly right and I'm not adding classification vocabulary — I'm adding the fields the outline's own example promised and the cliff proposal requires.** Jeroen's outline literally specified the payload: *"biome: grassland, settlement: null/'name here', river: null/'name here/unnamed', frozen: true/false, flooded: true/false, sea: true/false."* Reconciling that against what's measured and what's shipped: | Outline's ask | Wire field today | My call | |---|---|---| | biome | `morphology: Vec` (17-zone D-239 §6) | Carries it. "Biome" in the outline reads as morphology zone — keep the existing vocabulary, don't add a second one. | | — | `elev_q: Vec` | Keep (height, per outline's separate ask). | | frozen | `glaciation: Vec` (5-grade) | Carries it — a boolean collapse of glaciation is a client-side *derivation of the same data*, not a new field. Client reads `glaciation > 0` if it wants a bool; server shouldn't duplicate the same fact two ways on the wire. | | flooded, sea | `morphology` (has water zone classes) + hydrology's settled state | See "map time axis" note below — this is where the outline's "sea"/"flooded" distinction needs a decision the workshop, not me, should rule on (Q1 to Jeroen in the brief). I'm not duplicating a boolean the morphology vocabulary can already express; if the workshop wants a fast client-side "is this wet" check without decoding the full morphology enum, that's a **cheap additive derived bit**, not new server-computed information — flag it, don't block on it. | | settlement: null/name | **new**, see below | Presence + id, not inline name (§1). | | river: null/name | `courses: Vec` + **new** name lookup | Already ships geometry; name resolves via the new whole-body table (§1). | | — | `moisture_q`, `vegetation` | Keep — not in the outline's own example list but already shipped and load-bearing (vegetation climate law, D-239 §8). | **New field: `settlement_id: Vec` (0 = none), per-gridunit dense array, same shape as `morphology`.** This is the one genuinely new dense field my schema adds. It's dense (not sparse-list) because "which settlement, if any, covers this gridunit" is a per-cell lookup a renderer needs at paint time — exactly the same shape argument as `morphology` itself (a classification raster, not a feature list, because every cell needs an answer, most of them "none"). This is different from the settlement *name* (§1's ids-with-lookup) — the id here is cheap (u32, near-constant runs, same RLE profile as `morphology`/`vegetation` in T-1179's per-field table — settlements are rare and spatially coherent, so this field costs almost nothing extra under PNG encoding). The **name** join happens client-side against the whole-body lookup; the wire never repeats the string. **Cliff/vertical structure: adopt T-1177's proposal as the payload shape, gated on frequency, not on principle.** Dudley's measurement is unambiguous: gorge carving is *rare* (zero carved cells across all three production-scale benches; genuine carving needs a narrow two-basin-saddle geometry that's uncommon at continental working-grid resolution). That changes my answer from "add three dense fields" to "add them as sparse, not dense": - `channel_depth: Vec` and `cliff_edge: Vec` **as dense per-gridunit arrays would be wasteful** given near-zero occupancy — RLE would crush them to a handful of runs, but PNG already wins on the honestly-dense fields, so paying for two more mostly-zero dense arrays is not free even if cheap. - **My call: fold the cliff case into the same sparse-feature-list shape as settlements/rivers, not a seventh/eighth dense array.** A `cliffs: Vec` field (`{ cell: (u16,u16), channel_depth: u16 }`, `cliff_edge` implied by list membership) parallel to `courses` — present only where it's true, empty in the overwhelming majority of windows T-1177 measured. This keeps the common case (no gorge in this window) at zero bytes and matches the carrier three-way rule's own logic: this is rung-indexed invented detail (rule iii — hydrology solves once per body but the *carved representation* is a windowed-rung concern the same way course geometry is), so it belongs on the windowed payload as a sparse list, not baked into the dense sentinel arrays. - Elevation stays single-valued (`elev_q`) for every gridunit, including carved ones — it's the rim/dominant height per T-1177's own reasoning. The channel floor is a derived value (`elev_q - channel_depth`) the client computes only for gridunits that appear in the sparse `cliffs` list. No change to the existing single-height contract for the 99%+ common case. **What the client is allowed to do with these fields — restated plainly, because "map art function, not data function" needs a hard line:** - Color/style pixels from the classification fields (morphology, glaciation, vegetation, moisture) — yes, that's the entire job. - Interpolate/smooth/tween the color *presentation* between adjacent gridunits or between step-crosses (cosmetic) — yes, per the brief's premise 3 investigation item. - Invent geometry, decide where a river bends, decide whether a cell is flooded, decide whether a cliff exists — **no, never.** Every one of those is a server-derived fact already on the wire (courses' points, morphology's water classes, the new `cliffs` list). If the client ever needs to guess at content the schema doesn't carry, that's a missing field, not a license to invent — send it back to this schema, don't let it leak into client-side derivation logic. This is the same discipline D-227 already enforces server-side; it just needs restating as a *client* rule now that the client receives finished-content data instead of raw geometry it used to interpolate itself. - Resolve names via the whole-body lookup tables (§1) — yes, a join, not a derivation. --- ## 3. Encoding continuity across steps **One colorizer family, confirmed — and the wire schema I'm proposing is exactly what makes that cheap to guarantee, because every step ships the same six-plus-two field shape at a different spacing, never a different vocabulary.** T-1143 §6's rule (one colorizer spans every rung, cited already for temperature in D-226 T-1124 §2 — *"deliberately not a separate district-tier quantization... one temperature colorizer spans both zoom levels"*) already set the precedent I'm extending to the whole schema: **every step's payload uses the identical field set, identical enum vocabularies (`MorphologyZone`'s 17 zones, `VegetationClass`'s 7 including `Marine`, `GlaciationGrade`'s 5 grades), at every step from global down to the deepest gridunit spacing.** The only thing that changes between steps is *sampling density*, never *meaning*. This is what makes the between-step magnification red flag (Tyre's #1) tolerable — a magnified hold of step-N's canvas for one step interval is showing the *same* classification vocabulary at coarser spacing, not a different color language snapping in, so the discontinuity is a resolution jump, never a palette jump. **"Average-back across step boundaries" — confirm the mechanism, name the limit.** For continuous quantized fields (`elev_q`, `temp_dc`, `moisture_q`) average-back is safe and already implicit in how a coarser step's cell is itself computed (it's a spatial mean over finer terrain, same as today's Region/District relationship). For **categorical** fields (`morphology`, `vegetation`, `glaciation`), averaging is meaningless — you cannot average "grassland" and "forest" into a third zone. R3 in the delivered ladder design doc already flags this as an open risk ("average-back unverified for categorical morphology gates") and T-1179's own per-field RLE table gives the concrete reason it matters: `morphology` and `vegetation` are the two fields that compress to near-nothing (6 runs at 330K cells) *because* they're genuinely piecewise-constant zone classifications, not smoothly-varying scalars — averaging them at a step boundary would destroy exactly the property that makes them cheap and legible. **My call: coarser steps must derive morphology/vegetation/ glaciation as a fresh classification decision at that step's own spacing (the dominant-mode rule D-226's `dominant_district_type`/`dominant_zoning` precedent already uses for settlement aggregates — pick the plurality class, don't blend), never as a numeric average of the finer step's discrete values.** This is consistent with the whole "each step is a derivation sampled at gridunit resolution" framing (Tyre's D-166 corollary repoint) — a coarser step doesn't downsample a finer step's raster, it re-derives at its own spacing, and for categorical fields that re-derivation is a mode/ plurality pick, not an arithmetic mean. **"Different classes of content at lower zoom" (forest → clearings/ponds) — this is new vocabulary, not more density, and it needs to be scoped now so it doesn't sneak in unbounded.** The outline names this directly: *"at low enough zoom, a green forest biome may start showing clearings and ponds or such — to be determined and tinkered with."* My read: this is **sub-zone detail-scatter within an existing classification**, the same category as `voxel_relief`/`voxel_mosaic`'s invented detail-scatter (D-227, T-1154's octave-cutoff family) — not a new top-level vocabulary entry on `morphology`. A "forest with a clearing" is still `Forest` morphology at the district level; the clearing is deeper-step invented texture *within* that classification, resolved the same way finer steps already resolve everything else (fresh derivation at that step's `min_wavelength_m` cutoff). **Concretely: this does not need a new wire field or a vocabulary change at all** — it falls directly out of `min_wl_m` already being echoed per window (the octave cutoff field `DistrictWindowLayer` carries today) and the deeper step simply deriving at a smaller cutoff, which — per T-1154's measurement — is trivially affordable (17 ms parallel at the deepest realistic 83K-cell canvas). The one thing I'd ask the synthesis round to be disciplined about: keep this as "existing classification, finer octave detail," not "a growing zoo of sub-biome enum values" — that way it never threatens the one-colorizer-family guarantee, it just adds spatial texture underneath a class that was already decided at the coarser step. If a future pass wants clearings/ponds to carry *distinct* semantic meaning (e.g. a pond is walkable water, not decoration), that's a scope question for whoever owns block/tile classification next, not a step-continuity question — flagging the boundary, not answering past it. --- ## Summary for the wire contract / tagged-envelope synthesis (feeding Q2/Tyre) For whoever writes the synthesis-round wire contract: - **Per-step payload = the existing six dense fields (`morphology`, `elev_q`, `temp_dc`, `moisture_q`, `vegetation`, `glaciation`) + one new dense field (`settlement_id: Vec`) + two sparse feature lists (`courses` — already shipped — and new `cliffs`) + the existing `courses` name resolution now needs a whole-body `river_names` lookup alongside the existing `atlas_city_names` settlement lookup.** - **Encoding: PNG-per-field for every dense array** (T-1179's unambiguous winner — smallest and fastest at every measured size, no per-field hand-tuning needed since DEFLATE already captures the compressibility spread the per-field RLE table exposed). Sparse lists (`courses`, `cliffs`) stay MessagePack-native — they're already small (~1–2 KB typical per T-1170) and don't benefit from raster encoding. - **This payload is categorically larger than the 30 KB windowed-query cap at every measured size (21×–563×, T-1179)** — confirms the brief's own read that the tagged-envelope migration is not avoidable by a smarter encoding choice. I have nothing to add to that call beyond confirming the numbers hold for the schema I'm proposing (it's the same six fields T-1179 measured, plus one more near-free dense field and two near-free sparse lists — doesn't change the order-of-magnitude verdict). - **Vocabulary stays frozen across every step.** No per-step schema variants, no new enum arms below the district-level vocabulary already governed by D-239. Deeper detail is resolution and octave cutoff, never a new field.