--- title: "Body Map Viewer — Dudley Interview 2 Response" description: "Chunk-64m deepest-rung bench + Option D table, seed-chaining reconciliation with Tyre (cache-accelerated pure function), S2 courses-density bench at chunk/block, S4 sim-state TTL cost-side answer with Araminta" workshop: body-map-viewer round: interview-2 owner: Dudley status: complete decision_refs: [D-166, D-225, D-226, D-227, D-243, D-203] --- # Dudley — Interview 2 Response Four items redirected by Jeroen's interview-2 rulings, per the coordinator's task and Tyre's parallel message (both landed with the same four items — folded into one response since Tyre's questions are a subset of the coordinator's task, asked from the governance side). --- ## 1. LADDER: chunk (64 m) as the new deepest rung Jeroen, interview 2, verbatim: *"the actual tile level rung seems unusable. maybe replace with 64?"* — his reasoning: a full 1920-class screen at 10 px/tile shows ~192×108 m, which is in-world viewport content (Phase 5), not Atlas map content. Tile/voxel dropped from the Atlas ladder; chunk (64 m, D-243's "stream/derive unit") is the new floor. ### (a) The chunk-64m bench — the never-measured rung, now measured New benches, same file (`server/tests/bmv_gridunit_bench.rs`), same discipline as the original T-1154 pass (`derive_at_metres` called directly — no wire-facing cutoff band exists below Quarter's 1,024 m `MIN_WL_BANDS_M` floor for Chunk either, same situation Block/Tile were in): **Per-cell rate, 4,096-cell sweep (matching the existing Block/Tile comparison table exactly):** | Spacing | Cutoff | Total (4,096 cells) | ns/cell | |---|---|---:|---:| | Chunk (64 m) | 64 m (Nyquist) | 7.53 ms | 1,838.1 | | Chunk (64 m) | 0 (uncut) | 7.44–7.57 ms | 1,816.0–1,847.9 | | Block (128 m) [reference] | 128 m | 7.49–7.61 ms | 1,827.8–1,856.8 | **MEASURED, run twice for stability, both passes agree within ~1%.** Chunk's cutoff=64m vs. uncut delta is within noise (1838.1 vs 1816.0–1847.9 ns/cell) — **confirms directly, not by inference from Block, the same "no truncation work left" finding** the original T-1154 pass established: `enveloped_fbm` only skips octaves strictly finer than the cutoff, and `VOXEL_OCTAVE_WAVELENGTHS_M`'s finest entry is 128 m — at 64 m (finer than that floor), the cutoff still has nothing to truncate, so chunk pays the exact same full per-cell rate as Block. Chunk is not "one step cheaper" than Block; it's the same flat ~1.8 µs/cell single-thread rate the whole Block-through-chunk band shares. **Realistic deepest-step viewport canvas at chunk spacing (replaces the old 216×384 m / 1 m-spacing bench, which measured the now-dropped tile rung):** | Path | Wall time | ns/cell | Speedup | |---|---:|---:|---:| | PARALLEL (16 threads, row-chunked) | 1,724.48–1,732.84 ms | 207.9–208.9 | — | | SINGLE-THREAD | 15,051.59–15,145.11 ms | 1,814.7–1,825.9 | — | | — | — | — | 8.73–8.74× | **MEASURED, run twice for stability, both passes agree within 0.5%.** Full 3840×2160 canvas (8,294,400 cells) at 1 gridunit-per-screen-px, 64 m spacing — **1.72–1.73 seconds parallel.** This is not the 17 ms the old tile-rung deep-step bench reported — chunk's deepest-step canvas costs roughly two orders of magnitude more, because it covers a MUCH larger world extent at the SAME cell count (245.8 km × 138.2 km vs. tile's 216 m × 384 m — see the display-band derivation below for why this is the correct comparison, not a regression). 1.7 s is still comfortably inside a step-cross tolerance measured in the hundreds-of-ms-to-low-seconds range this workshop has used throughout (matches the District/Quarter/Block full-canvas numbers already in the recommended ladder table, all in the same 1.7–1.8 s band at full 8.3M-cell canvas size) — **cost still does not gate this rung.** ### The new bottom-out rule, stated precisely (for Tyre's amendment text) At **1×1 px-per-gridunit** (the workshop's own ideal ratio, premise 5), a 3840×2160 canvas at 64 m spacing covers: ``` world_extent_m = canvas_px × gridunit_spacing_m smaller axis: 2160 px × 64 m = 138,240 m ≈ 138.2 km larger axis: 3840 px × 64 m = 245,760 m ≈ 245.8 km ``` **The new bottom-out rule is "1 screen px per 64 m gridunit," NOT "10 px per chunk."** This is a real, substantive difference from the old tile rung's rule, not a cosmetic swap of one spacing value for another — worth stating explicitly because getting this wrong would silently reintroduce the D-226(d)/legibility problem chunk is supposed to solve: - The old tile rule (10 px/tile) existed because a single 1 m gridunit at 1×1 is *too small to read* on screen — 10 px of magnification margin was there to buy legibility for a ground feature that is otherwise imperceptible. That's the "in-world viewport" problem Jeroen's ruling correctly identifies: 10×-magnifying a 1 m unit is exactly what an in-world character-relative camera does, not what an Atlas map does. - **Chunk does not have this problem.** A 64 m gridunit at 1×1 is already a reasonably-sized map feature (a city block, a short street segment) — it doesn't need a legibility margin the way a 1 m ground tile did. Applying the old rule's 10× margin to chunk (i.e. "10 px per chunk") would either (a) needlessly shrink the deepest step's world coverage by 10× for no legibility gain, or (b) be based on a misunderstanding of why the 10× factor existed in the first place. **It should not carry over.** - **Concrete rule for the amendment text:** *"The deepest Atlas rung (chunk, 64 m) samples at 1 gridunit per screen pixel (the workshop's own ideal ratio) with no additional magnification margin — the 10×-per-tile rule that governed the now-dropped voxel/tile rung does not apply to chunk, because chunk-scale features do not have the same-order-of-magnitude legibility problem a 1 m ground unit did at screen resolution."* ### (b) Option D — five rungs, bottom = chunk, every row measured Rebuilding the round-2 recommended table (previously Option B: Region → District → Quarter → Block → voxel) with chunk replacing voxel as the bottom rung — same viewport-sized-canvas convention as before (fixed 3840×2160 px canvas at every step, since chunk — unlike the old voxel rung — does NOT need the display-ratio-sized-canvas exception; see below). | Step | D-243 rung | Spacing | Step factor (from prev) | Canvas @ 3840×2160, 1×1 | Cells | World extent | Derive cost (parallel, MEASURED) | Wire cost (PNG-per-field, measured/interpolated) | |---|---|---:|---:|---|---:|---|---:|---:| | 0 | Region | 204.8 km | — | 3840×2160 | 8,294,400 | whole body (capped-tile mosaic, T-1143 §4) | 1,827 ms | 16.88 MB | | 1 | District | 2,048 m | 100× | 3840×2160 | 8,294,400 | 7,864 × 4,424 km | 1,827 ms | 16.88 MB | | 2 | Quarter | 512 m | 4× | 3840×2160 | 8,294,400 | 1,966 × 1,106 km | ~1,827 ms (T-1154 same-band) | ~16.88 MB | | 3 | Block | 128 m | 4× | 3840×2160 | 8,294,400 | 491 × 276 km | ~1,827 ms (measured directly, T-1154) | ~16.88 MB | | 4 (deepest) | **Chunk** | **64 m** | **2×** | 3840×2160 | 8,294,400 | **245.8 × 138.2 km** | **1,724–1,733 ms (measured directly, this pass)** | **~16.88 MB (same field-count/cell-count as every other row)** | **Every row is now directly measured or in the same directly-measured cost band — the exact "zero asterisks" property Option B (the old skip-chunk recommendation) earned by NOT including chunk. Now that chunk itself is measured, that property transfers cleanly to Option D with chunk as the bottom: this table has no unmeasured or inferred-only row.** **What changed vs. the old Option B, stated plainly:** 1. **Step count: still 5** (Region, District, Quarter, Block, + one deepest rung) — chunk simply replaces voxel/tile as row 4's identity. The ladder shape (which rungs, how many steps) is unchanged; only the deepest rung's spacing and canvas-sizing convention changed. 2. **Canvas-sizing convention: chunk uses the SAME fixed-3840×2160-px-budget rule as every other step, no exception.** This is the one place Option D is structurally simpler than the old Option B: the old voxel/tile deepest step needed a special-cased display-ratio-sized canvas (216×384 m at 10 px/tile, NOT the fixed-canvas convention every other step used, because the two conventions were mutually incompatible at 1 m spacing — see round 2 §(c)'s Option A analysis). **Chunk does not have this incompatibility** — at 1×1 px/gridunit, a fixed 3840×2160 canvas and the "no extra magnification margin" bottom-out rule above are the same rule, not two conventions in conflict. Dropping the special case is a genuine simplification, not a workaround. 3. **Deepest-step derive cost went UP by two orders of magnitude in absolute terms** (17 ms → 1.7 s) because the deepest-step canvas now covers a world area orders of magnitude larger (216 m × 384 m → 245.8 km × 138.2 km) at the SAME cell count. This is expected and correct — chunk's canvas is now the SAME size (in cells, and therefore in derive cost and wire bytes) as every other rung's canvas, which is what "no special case" means. **1.7 s parallel is still comfortably affordable** (same band as Region/District/Quarter/Block's own full-canvas cost) — the absolute number changed, the affordability verdict did not. **Recommendation: adopt Option D as stated.** It inherits everything Option B earned (measured-not-inferred rows, monotonic-with-spacing cost story below Region) and removes Option B's one piece of remaining awkwardness (the deepest-step canvas-sizing special case) for free, as a consequence of Jeroen's own ruling rather than a separate design choice. --- ## 2. SEED-CHAINING RECONCILIATION — with Tyre, "cache-accelerated pure function" Jeroen answered **NO** to my round-2 §(b) ruling (independent re-derivation, coarser rung called only as a nested function, never reading cached output) — he meant the other reading: the finer step genuinely **consumes** the coarser step's *resolved* output, not just calls the same derivation code fresh. Tyre relayed the coordinator's candidate reconciliation model ("cache-accelerated pure function") and asked me to assess it from the cost/implementation side. This section is that assessment, worked jointly with Tyre per his message. ### The model, restated precisely - **Definition stays pure, D-227 intact:** `derive(seed, position)` always produces the same bytes, by construction, regardless of implementation strategy. This is non-negotiable and nothing below touches it. - **Implementation is permitted to read a resident coarser step-canvas as an acceleration**, falling back to deriving fresh (my original §(b) model, now correctly reframed as the FALLBACK path, not the primary design) when the coarser canvas isn't resident. - **My round-1/round-2 benched numbers survive as the honest worst-case ceiling** — every number in ①②③ was measured via the fresh-derive path (nothing in any of my benches reads a cached coarser canvas as an input), so they remain valid upper bounds on cost regardless of which implementation strategy ships. ### Is this D-227-sound? Yes — confirming Tyre's argument from the cost/implementation side Tyre's governance argument (his message): the coarser canvas being read is itself evictable derived data — a pure function of the same seed — so reading it is an **optimization**, not a **semantic dependency**. A semantic dependency would mean "the finer step is WRONG without the coarser cache," which is never true here, because the coarser value is always re-derivable to the same bytes. **I agree with this argument completely, and it matches exactly how I'd reason about it from the implementation side:** the test D-227 already applies ("eviction → recompute, always valid") is precisely the test this model needs to pass, and it passes by construction — a cache-accelerated read and a fresh derive of the SAME coarser value at the SAME `(seed, position)` are required to be byte-identical (they're the same pure function), so substituting one for the other can never change the finer step's output, only its latency. **The mandatory test this needs, matching the shape of my existing window-independence/determinism invariants:** a `cache_hit_path == cache_miss_path` byte-exact assertion — derive the coarser rung's value both ways (read from a populated cache entry, and via fresh `derive()` with the cache artificially evicted) and assert identical output. This is the same style of test `determinism_at_330k_cells` (T-1177) and the courses window-independence invariant already use in this codebase — not a new kind of test, an extension of a pattern already proven twice. **If this test passes — and it must, because both paths are the same pure function by definition — the two paths are indistinguishable except in speed, which is exactly Tyre's "optimization not dependency" characterization.** ### Quantifying what the acceleration actually saves — the honest cost answer This is the part the coordinator specifically asked me to assess honestly, including the possibility that acceleration doesn't help. **My answer: it depends on WHAT the finer step would read from the coarser cache, and for the two most likely candidate mechanisms, the answer is genuinely mixed — one clearly helps, one plausibly does not, and I want to be precise about which is which rather than assert a blanket "yes, faster."** **Candidate mechanism A — the finer step reads the coarser step's RESOLVED CLASSIFICATION as a literal shortcut (skip re-deriving morphology/elevation where the coarser answer is "good enough").** This is NOT what I'd recommend, and I don't think it's what "serves as seed information" means either — it would violate the categorical-field re-derivation rule Araminta already established in round 1 (coarser steps must re-derive morphology/vegetation/glaciation as a fresh classification decision at their own spacing — a dominant-mode pick, never an average or a direct carry from a different spacing's classification). Reading a District-spacing `morphology_zone` value as a shortcut for what a Chunk-spacing cell's `morphology_zone` "should" be would be exactly the same-vocabulary, different-meaning-per-scale contradiction Araminta ruled against. **I'm naming this candidate to rule it out, not to cost it.** **Candidate mechanism B — the finer step reads the coarser step's underlying CONTINUOUS PRIMITIVES (the region-baseline / district-baseline value at a given world position) as an input to its OWN fresh classification, instead of recomputing that baseline from scratch.** This is what I believe Jeroen's "serves as seed information for the deeper cascade" phrase actually describes, and it's ALSO the pattern already shipped in this codebase today (`invent_primitives`'s district call already reads a region baseline; my round-2 §(b) response cited this precedent). **This is the mechanism worth costing.** **The honest cost answer for mechanism B: reading the cached coarser baseline instead of recomputing it is very likely a real, if modest, win — but I do not have a number for it, and I want to say precisely why, rather than either assert a saving or claim I've measured one.** `derive_at_metres` computes several primitives in one call (coast-warp, detail-scatter octaves, region-baseline blend, classification) — none of my benches isolate the region-baseline-blend sub-cost from the total per-cell cost, because every bench I've run measures the WHOLE `derive_at_metres` call, by design (that's the actual served cost, and isolating sub-costs wasn't the question ①②③ asked). So: **is reading+upsampling a coarse cache cheaper than deriving fresh at ~1.65 µs/cell? Almost certainly yes for the specific sub-computation mechanism B describes** (a cached bilinear-blend lookup is architecturally cheaper than recomputing the same blend from raw region data — this is essentially free to assert, since the blend itself is a small fraction of the total per-cell cost, most of which is the octave-sum detail-scatter work that mechanism B does NOT propose caching) — **but I cannot quantify the magnitude without a new, targeted bench that isolates the region-baseline sub-cost specifically, which nobody has run.** **Is a re-bench genuinely needed?** For the ARCHITECTURE ruling (adopt cache-accelerated pure function, yes/no) — **no**, my existing numbers already answer the load-bearing question (worst-case ceiling cost, D-227 soundness) and the mandatory determinism test above is a correctness gate, not a cost measurement. For an IMPLEMENTATION decision about exactly how much the acceleration saves (needed before anyone sizes an "expected" latency, as opposed to a worst-case one) — **yes, eventually, but not before this ruling closes**, and I'd scope it as an implementation-ticket measurement (isolate the region-baseline-blend cost specifically, then compare cached-read vs. fresh-blend at that isolated cost) rather than something this workshop needs to resolve before Jeroen ratifies the model. My benched ①②③ ceiling numbers are sufficient to close the architecture question today. ### Answering Tyre's chain-reaction question (Troblum B2) **Each rung derives fresh independently on a cache miss — it does NOT chain backward through evicted coarser rungs.** This is not a new architectural choice; it falls directly out of the definition staying pure: `derive(seed, position)` for ANY rung is fully self-contained (it takes the seed and a world position, nothing else, per D-227's own signature) — so a Chunk-rung cache miss's fallback is "call `derive_at_metres` at Chunk spacing," full stop, not "first check whether the District-rung cache is warm, and if not, re-derive District, and if THAT'S baseline is itself missing something, re-derive Region..." There is no chain to walk, because the fresh-derive fallback was never built to depend on any OTHER rung's cache state — it's the same self-contained function every one of my benches already calls directly, at every rung, with zero shared cache dependency between rungs. **Troblum's chain-reaction worry dissolves for exactly the reason Tyre expected: each rung's fresh-derive path is independent per my original §(b) function-composition argument, which survives INSIDE this model as the fallback, even though it's no longer the exclusive path.** The cache-accelerated read (when a coarser canvas IS resident) is a pure speed optimization layered on top of that already-independent fallback — it never becomes a requirement the fallback depends on. ### Verdict — for Jeroen's ratification **Adopt the cache-accelerated pure function model.** It is D-227-sound (Tyre's optimization-not-dependency argument, confirmed from the cost/implementation side above), it does honor "serves as seed information for the deeper cascade" (mechanism B — reading a coarser baseline as an input to a fresh classification — is both what I believe Jeroen meant and what the codebase already does today at the district/region boundary), my benched ①②③ numbers remain valid as the worst-case ceiling under this model (nothing about adding an acceleration path can make the fallback path slower than what I measured), no chain-reaction risk exists (each rung's fresh-derive fallback is self-contained), and the ONE new obligation this model adds — the cache-hit-path == cache-miss-path determinism test — is a correctness gate this codebase already knows how to write, not new machinery. **What remains unquantified (the magnitude of the acceleration's actual saving) is real but not load-bearing for the architecture ruling** — it's an implementation-time measurement, not a gate on Jeroen's yes/no here. --- ## 3. S2 BENCH — deep-step × high-river-density courses cost Ruled before filing: the last zero-data-point cell on the courses-cost axis. Every prior courses-inclusive measurement (T-1178's Cross-check 1, 18 courses/331,776 cells) is at District spacing; nothing measured courses-on cost at Chunk or Block. New bench: `bench_s2_courses_density_at_chunk_and_block` (`server/tests/bmv_gridunit_bench.rs`), using the real GJ1c river network (not synthetic — the densest real confluence region found by scanning for the river cell with the most other river cells within an 8 px search radius), built via the actual PUBLIC invention pipeline (`river_course::build_edges` + `river_course::invent_course`, both `pub` — unlike `layer_proxy::invent_courses_near_window` itself, which is private to that module; this bench replicates its per-edge invention loop using the same public primitives, matching this file's existing replica-loop discipline). ### Results — MEASURED, run twice for stability | Rung | Window | Courses in window | Avg points/course | Courses OFF | Courses ON | Delta | |---|---|---:|---:|---:|---:|---:| | Chunk (64 m) | 64×64 cells (4,096 m × 4,096 m) | 2 | 1,732.0 | 1,861.9–1,913.3 ns/cell | 3,489.7–3,533.7 ns/cell | **+84.7% to +87.4%** | | Block (128 m) | 64×64 cells (8,192 m × 8,192 m) | 2 | 867.0 | 1,849.4–1,855.7 ns/cell | 2,544.1–2,816.8 ns/cell | **+37.6% to +51.8%** | Both runs agree within run-to-run noise typical of this file's other 4,096- cell sweeps (~5-10%); the direction and rough magnitude are stable across both passes. ### The honest finding: this is a real, structural cost — meaningfully larger than District's <5%, with a clear mechanism **This is not the same order of magnitude as the District-rung courses-cost figure (+0.09–0.21 ms against a ~5 ms baseline, under 5%) already on record. At Chunk and Block spacing, courses cost 38%–87% MORE, not under 5% more.** I traced the mechanism rather than reporting the number without explanation: `near_perennial_water`'s cost is `O(courses × points-per- course)` (a per-segment bounding-box + point-to-segment-distance scan over every station on every candidate course, per cell). `invent_course` resamples each course's control polyline at `station_spacing_m` — the SAME spacing value passed as the rung's own cutoff/spacing. **A course spanning a fixed chord length gets proportionally MORE points the finer the rung's station spacing is** — confirmed directly by the instrumentation this bench added: 1,732 points/course at Chunk (64 m stations) vs. 867 at Block (128 m stations), almost exactly the 2× ratio matching the 2× spacing ratio between the two rungs. District's own courses (2,048 m stations) have roughly 32× fewer points per course than Chunk's for the same chord length, which is exactly why District's courses-cost figure was small and Chunk's is not — **this was always going to happen once a courses-inclusive measurement was taken at a spacing this much finer than District; nobody had run it because nobody had a courses-inclusive bench below District until now.** **What this means for the wire-cost/ticket-plan picture, stated plainly:** courses ARE affordable at Chunk/Block in absolute terms — even the worst case (+87% on a ~1.9 µs/cell baseline) lands at roughly 3.5 µs/cell, which is still well within the "comfortably interactive" band every other number in this workshop has used (a full 8.3M-cell chunk-spacing canvas at 3.5 µs/cell single-thread-equivalent would be ~29s single-thread / ~3.3s parallel at the same ~8.7× speedup this file's other benches measure — a real, larger number than the courses-off 1.7s figure in Option D's table above, but not a "computer catches fire" case). **The real implication is implementation-side, not a cost-affordability gate:** `near_perennial_water` resampling courses at the SAME spacing as the rung it's serving is a real per-cell cost driver that scales inversely with rung spacing, and whoever implements Chunk-rung course rendering should know this going in rather than discover it as a surprise regression — a station-spacing cap independent of rung spacing (courses don't need MORE points just because the rung asking for them is finer, if the goal is "is this cell near a river," not "render the river at full rung resolution") is a plausible optimization worth flagging for the implementation ticket, but I'm naming it as a finding, not ruling on it — that's a river-rendering design call, not a cost-measurement one. **Fold-in note:** this section is written to be pasted directly into `measurements/t1178-t1154-derive-bench.md` as an addendum (same discipline as the T-1177 population-survey addendum) — flagging here rather than duplicating the write, since the coordinator's instruction was "fold into the derive-bench doc." I have not yet made that edit; see the summary below for the exact pending action. --- ## 4. S4 — sim-state TTL phase-cadence, joint proposal with Araminta Araminta's proposal (relayed by the coordinator): a **formula**, not a fixed real-time number — `SIM_STATE_TTL[field] = 1 × the field's own fastest driving clock-bucket, per body class`. Concretely: `flooded` = 1 tidal bucket on bodies with a moon (D-253's tidal ≈ day-phase bucket), degrading to 1 seasonal bucket on moonless bodies; `glaciation` = 1 season-step always (seasonal-only driver). Her rationale, in short: the map's staleness tolerance should equal the sim's own recompute granularity (can never be VISIBLY wrong), the risk is asymmetric (too-short wastes a refetch, harmless; too-long shows wrong water-height, the real bug — so bias toward the faster term), and binding to the bucket UNIT rather than a hardcoded time value survives D-253's own "provisional, tunable" bucket-size calibration. ### My half — the serving-side cost check **Question: is re-deriving/re-serving `flooded`/`glaciation` at 1-bucket- rollover cadence affordable, or does cost force a >1× multiplier?** **Answer: affordable at 1×, no multiplier needed — and the reason is structural, not just "the number is small."** I confirmed this by reading `district_profile.rs` directly rather than assuming it: `glaciation_grade` and the morphology zone's water/flood classification are **not a separate sub-pipeline** — they come out of the SAME `derive_at_metres` call as every other field (`DistrictProfile` is one struct, populated by one function call; there is no field-level partial-derive path that computes "just glaciation" or "just the water classes" more cheaply than a full derive). This has one important consequence for the TTL cost question: **There is no cheaper "sim-state-only" re-derive path to cost separately — re-serving frozen/flooded at any cadence costs the SAME per-cell rate as a full geometry re-derive, because it IS a full geometry re-derive** (the two fields just happen to be the ones whose VALUE can change between derives, while morphology/elevation/moisture/vegetation are re-derived to the identical bytes every time per D-227 purity — but the COST of computing them is not separable). This means the TTL cost question reduces to a question I've already answered at every rung in this workshop: **is a full re-derive of a step canvas, at 1-bucket cadence, affordable?** Given every rung's derive cost (Region/District/Quarter/Block/Chunk, Option D above) is in the 1.7–1.8 s parallel band for a full 8.3M-cell canvas, and — critically — **the actual sim-state re-derive doesn't need a FULL canvas re-derive at all, only the currently-cached window(s) a player might reopen**, the real- world cost is far below even that ceiling: it's bounded by how many cached step-canvas entries exist for a given body at TTL-rollover time (Tier 2 of my round-2 cache-tier spec — sub-global geometry entries, evicted on time- since-last-visit, which is a SMALL, bounded set per body in normal play, not "re-derive the whole planet on every tidal tick"). **No multiplier needed — 1× holds.** I'm not widening Araminta's proposed cadence, because the cost isn't the constraint her formula's own rationale already correctly identified the real constraint (staleness-vs-correctness, not compute cost) — the compute-cost check confirms it doesn't ALSO need to be a constraint, it just needed confirming rather than assuming. ### Joint final proposal **Formula (Araminta's, adopted as stated, 1× multiplier confirmed affordable):** ``` SIM_STATE_TTL[flooded] = 1 × tidal_bucket (moon-bearing bodies, D-253) = 1 × seasonal_bucket (moonless bodies) SIM_STATE_TTL[glaciation] = 1 × seasonal_bucket (always — seasonal-only driver) ``` **Rationale (joint paragraph):** the map's staleness tolerance is bound to exactly the sim's own recompute granularity, so a cached sim-state plane is never visibly wrong — it can be at most one bucket stale, and the simulation itself has no fresher answer to offer during that bucket. Binding to the bucket UNIT rather than a fixed real-time value survives D-253's own provisional/tunable bucket calibration automatically, and the formula's asymmetric-risk argument (too-short TTL wastes a harmless refetch; too-long TTL shows objectively wrong water-height, the real bug) already justifies biasing toward the faster of the two candidate drivers on moon-bearing bodies without needing a cost-side override. The serving-side check confirms this formula needs no widening: `flooded`/`glaciation` have no cheaper isolated re-derive path than a full per-cell derive (they're computed inside the same `derive_at_metres` call as every other field), but the actual re-derive workload at rollover time is bounded by the small, currently-cached set of step-canvas windows for a given body (per the round- 2 cache-tier spec's Tier 2), not a whole-planet re-derive — so 1× the sim's own bucket cadence is both correctness-necessary (Araminta's argument) and cost-affordable (this section's confirmation), with no tension between the two requiring a compromise multiplier. --- ## Summary for filing / next steps 1. **Chunk-64m is measured, Option D adopted (5 rungs, bottom = chunk, every row measured), the new bottom-out rule is "1×1 px/gridunit, no magnification margin"** — stated precisely above for Tyre's amendment text. His D-226(d) partial-floor-restore (tile/voxel back to never-Atlas-mapped) is consistent with everything measured here. 2. **Seed-chaining: "cache-accelerated pure function" adopted** — D-227-sound (confirmed from the cost/implementation side, matching Tyre's governance argument), no chain-reaction risk (each rung's fresh-derive fallback is self-contained), my ①②③ benched numbers survive as the worst-case ceiling, the acceleration's actual magnitude is plausibly real but unquantified (named as a future implementation-time measurement, not a gate on this ruling), and one new mandatory test (cache-hit == cache-miss, byte-exact) closes the correctness obligation. 3. **S2 courses-density at Chunk/Block: measured, and it's a real finding, not a formality** — +38% to +87% cost at these finer rungs (vs. District's <5%), traced to a concrete mechanism (course point-count scales inversely with rung spacing via `station_spacing_m`). Still affordable in absolute terms; flagged as an implementation consideration (a possible station-spacing cap independent of rung spacing) for whoever builds Chunk-rung course rendering. **Folded into `measurements/t1178-t1154-derive-bench.md` as a dated addendum**, matching the T-1177 population-survey addendum's format (done in this same pass — confirmed with Tyre, whose §(d) Troblum-disposition note originally carried stale "pending" phrasing and has since been corrected to cite both addenda as filed). 4. **S4: joint formula with Araminta, 1× multiplier confirmed, no widening needed** — `flooded`/`glaciation` TTL bound to 1× their driving sim bucket (tidal-or-seasonal, seasonal-only respectively), cost-affordable because sim-state re-derive is bounded by the small cached-window set per body, not a whole-planet re-derive. **Code changes:** `server/tests/bmv_gridunit_bench.rs` — three new `#[ignore]`d release benches (`bench_chunk_spacing_4096_cells`, `bench_chunk_deep_step_realistic_canvas`, `bench_s2_courses_density_at_chunk_and_block`) plus one new helper (`build_gj1c_courses_near_window`). No `server/src/` file modified. Full bench-file test list (11 tests) confirmed intact; full crate `cargo build --release` clean. --- ## POST-RATIFICATION ADDENDUM (2026-07-23): lake schema gap + global-tier math recheck Two items from Jeroen's post-ratification review, redirected by the coordinator after interview 2 closed. ### 5. LAKES ON THE MAP — the schema gap, and where it actually sits Jeroen's direct question: *"when filling basins to find an overflow, is that body flagged as lake or flooded or something? do we draw lakes on the map?"* Answer, checked against the real code rather than assumed: **currently, no — and the gap is more specific than "the field doesn't exist." It's that the field DOES exist (`MorphologyZone::Lake`, discriminant 1, in the 17-zone vocabulary this whole workshop's payload schema already carries), but nothing in the derivation pipeline ever sources it from settled hydrology.** **What actually happens today, confirmed by direct read (`district_profile.rs:556-565`):** ```rust if ocean_fraction_q >= 80 { // "No body-scale salinity signal at district level yet; treat all as // OpenOcean. Lake differentiation lives at ChunkContext." return MorphologyZone::OpenOcean; } if ocean_fraction_q >= 60 { return MorphologyZone::Lake; } ``` `ocean_fraction_q` is a bilinear sample of `TerrainAnalysis.ocean_mask` (`district_profile.rs:1870`), and `ocean_mask` itself is nothing more than `elev[i] < sea_level` (`features.rs:104`) — a raw per-cell below-sea-level threshold from the ORIGINAL heightmap, with **zero connection to `HydrologyResult`'s settled-equilibrium output** (`filled_scaled`, basin membership, `BasinOutcome::Endorheic`/`Overflow`). The `Lake` vs `OpenOcean` split that exists today is a crude density heuristic (how much of the bilinear sample window is below sea level), not a real inland-vs-connected distinction, and it was never wired to the hydrology solver this whole workshop's ①/T-1177 measurement is about — the solver computes real basin geometry and nobody downstream reads it. **One more piece of the picture, also confirmed by direct read: a BETTER building block than `ocean_fraction_q` already exists and is ALSO unused for this purpose.** `TerrainAnalysis.lake_mask` (`features.rs:105`, `compute_lake_mask`) is a proper flood-fill connected-component test — below-sea-level cells connected to a grid edge are `ocean_mask`-true and `lake_mask`-false (the sea); below-sea-level cells NOT connected to an edge are `lake_mask`-true (landlocked water). This is consumed today by `road_graph.rs` and `features.rs` for road-planning purposes (`ta.ocean_mask[i] || ta.lake_mask[i]` water-avoidance checks) — **but is never read by `derive_morphology_zone` or anywhere in the Atlas classification/wire path.** So there are actually two disconnected gaps stacked on each other: (a) the wire schema's water classification doesn't read hydrology's settled-equilibrium basins at all, and (b) even the CRUDER connectivity-based `lake_mask` that already exists and would be a strict improvement over the current `ocean_fraction_q` heuristic is also not wired into the classification path. Both are real; (a) is the one Jeroen's question is really about, since `lake_mask` still can't distinguish overflow lakes from endorheic ones or know about a lake basin that only exists because of settled hydrology's overflow logic (a filled basin can have interior cells that were never below the ORIGINAL heightmap's sea level — that's the entire point of "filling basins to find an overflow"). **The fix, specced with Araminta (relayed via coordinator; her framing adopted, my derive-pipeline slotting added):** **New static water-classification field, sourced from `HydrologyResult` at canvas-derive time, distinct from Araminta's `flooded` sim-state plane.** - **Not a new dense field — fold into `morphology`, reusing the existing `Lake`/`OpenOcean` discriminants (1/0) the vocabulary already carries.** This is a zero-cost wire change: no new array, no new byte, no new encoding question. The 17-zone `MorphologyZone` enum already has the right vocabulary entry; the only thing missing is the DATA SOURCE that decides when to emit it. - **Source at derive time:** for a gridunit whose position falls inside a `HydrologyResult` basin's `cells` (row-major cell indices at the 512×256 working-grid resolution the solver runs at — see the projection question below), emit `MorphologyZone::Lake` instead of falling through to the `ocean_fraction_q`-threshold heuristic. The heuristic remains the fallback for cells NOT covered by any basin (i.e., it still decides open-ocean vs. land at the coarse working-grid resolution the way it does today) — this is additive, not a replacement of the existing sea-detection logic. - **This is STATIC equilibrium geometry, correctly homed separately from Araminta's `flooded` sim-state plane.** A lake basin's existence and footprint is exactly as static as `morphology`/`elev_q` themselves — `HydrologyResult` is a pure function of `(elevation, sea_level, climate)`, computed once, byte-identical forever (T-1177's own determinism proof). `flooded` (Araminta's plane, TTL-bound to the S4 sim-state formula above) is about CURRENT sim state changing over game time — a river cresting its banks this season, not a basin's settled existence. Conflating the two would have meant a lake's presence-on-the-map flickers on the same TTL as seasonal flood state, which is wrong for exactly the reason Jeroen's question implies: a lake is there or it isn't, at generation time, and that fact doesn't need re-deriving on a sim clock. **Endorheic vs. overflow lakes — same water class, no client-relevant distinction at the map-art level.** `BasinOutcome::Endorheic` vs. `BasinOutcome::Overflow` is a real, meaningful distinction to the SOLVER (it decides whether an outlet path gets carved at all — see the cliff/ `channel_depth` field's own dependency on a basin having an `Overflow` outcome), but nothing about how the client should COLOR or RENDER a lake gridunit depends on which outcome produced it — both are `MorphologyZone:: Lake` on the wire, no additional bit needed. The one place the distinction DOES matter to the client is exactly the place a channel already carries it: an `Overflow` basin's outlet is what populates the sparse `cliffs: Vec` list (round 2 §(a)) at cells along `outlet_path` where `channel_depth_scaled > 0` — so the endorheic/overflow distinction is already client-visible, just via the EXISTING cliff mechanism, not a new field on the lake classification itself. No double-encoding needed. **Byte cost: near-zero, confirmed by the same reasoning T-1177's cliff finding already established.** Lake regions are, by construction, CONTIGUOUS (a basin's `cells` are one flood-filled component) — exactly the shape PNG's DEFLATE compresses best (T-1179's own per-field RLE table: `morphology`/`vegetation`, the two genuinely piecewise-constant classification fields, compress to a handful of runs at 330K cells). Since this change reuses the EXISTING `morphology` field rather than adding a new one, there is no new byte cost to price at all — the only change is which DISCRIMINANT value gets written into cells that were already being classified, and `Lake`'s discriminant (1) compresses exactly as well as every other `morphology` value already does. **Where the hydrology solve slots into the derive pipeline — the projection question, answered precisely:** `HydrologyResult` is computed ONCE per body, at the 512×256 working-grid resolution (T-1177's own scope — "solve() is called once per body... same way `drainage::analyze` already runs once per body today"), held in the D-203-shaped global-tier cache extension my round-2 cache spec already proposed. A finer-rung canvas derive (District through Chunk) needs to know, for each gridunit's world position, whether that position falls inside a basin — **this is exactly the same projection problem `sea_level` already solves today**, and should use the identical mechanism: `TerrainAnalysis` (the working-grid analysis struct `derive_at_metres` already reads for `ocean_fraction_q` via bilinear sampling) gains a basin-membership field (a per-working-grid-cell basin id or a simple bool, populated from `HydrologyResult.basins[*].cells` once when hydrology solves) sampled the SAME way `ocean_mask` is sampled today — bilinear/nearest lookup at the gridunit's world position against the 512×256 working grid, not a re-solve at the finer rung's own spacing. This is the SAME "coarse baseline, finer classification decision" pattern round 2 §(b)'s seed-chaining reconciliation already ruled correct (mechanism B: read a coarser rung's CONTINUOUS PRIMITIVE as an input to a fresh classification, never re-derive the coarser thing itself) — the hydrology basin membership is exactly mechanism-B shaped: computed once at 512×256, sampled (not re-solved) at every finer rung, feeding straight into `derive_morphology_zone`'s existing water-classification tier alongside `ocean_fraction_q`. **No new bench needed to confirm this is affordable.** Sampling one more bilinear field (basin membership) alongside the `ocean_fraction_q` sample `derive_at_metres` already performs every cell, every rung, is the same shape of work as the sample it's replacing/augmenting — my own T-1178/T-1154 per-cell rate numbers (~190–220 ns/cell parallel, flat across every rung this workshop measured) already include equivalent-cost sampling operations in that per-cell budget. This is a derivation-pipeline WIRING change (new field on `TerrainAnalysis`, one more gate in `derive_morphology_zone`), not a new cost category. ### 6. GLOBAL-TIER MATH RECHECK — corrected number Jeroen's correction to the ladder top: **GLOBAL is rung 0**, the body-surface opener, with a **VARIABLE canvas = the body's own region grid** — one gridunit PER REGION (`regions_per_equator(R) × regions_per_equator(R)/2`, D-243's elastic seam, `scale::regions_per_equator`), not a fixed 3840×2160-cell District-spacing canvas the way my round-2 cache-tier spec's ~174 MB figure assumed. **REGION is rung 1**, the largest FIXED-size rung — viewport-sized and evictable like every other sub-global rung, not part of the always-keep tier. **My round-2 174 MB figure was computed against the wrong canvas shape** — I'd sized the "global tier" as if it were a full District-spacing step-0 canvas (8.3M cells/body, the same size as every other rung's canvas), which is exactly the assumption Jeroen's correction replaces. Recomputed against the correct rung-0 shape (one gridunit per region), using REAL per-body radii from `systems.db` (`BodyParamsReader`, the project's established read-only accessor — never raw `sqlite3`), across the real 267-body committed population (same set T-1177's population survey used): **New bench:** `server/tests/bmv_global_tier_bench.rs` (`bench_global_tier_bytes_real_population`). Discovers every committed body_id (same walk as the T-1177 population survey), reads each body's real `body_radius_km` from `systems.db`, computes `regions_per_equator(R) × regions_per_equator(R)/2` cells per body, sums across the population, and prices the total at both the raw 6 B/cell rate (`DistrictWindowLayer`'s own documented figure) and T-1179's measured PNG-per-field rate (638,382 bytes / 331,776 cells = 1.924 B/cell). ```bash cd server cargo test --release --test bmv_global_tier_bench -- --ignored --nocapture ``` **Results — MEASURED (deterministic: real radii + a pure formula, byte- identical on re-run, confirmed):** | | Value | |---|---:| | Bodies (real radius found) | 267 / 267 | | Total rung-0 cells across population | 4,825,615 | | Average cells/body | 18,073 | | **Total bytes, raw 6 B/cell** | **27.61 MB** | | **Total bytes, PNG-per-field (1.924 B/cell)** | **8.85 MB** | **The corrected always-keep global-tier figure is ~8.85 MB PNG-encoded across the real population (~27.6 MB raw) — roughly 20× smaller than my original 174 MB estimate.** This is the number for Tyre's bracket. The correction is exactly the direction Jeroen's ruling implied ("dramatically smaller") and for exactly the reason his correction identifies: a region-grid canvas (one gridunit per ~205 km region) is a MUCH coarser sample than a District-spacing canvas (one gridunit per 2,048 m) — roughly 100× coarser per axis, ~10,000× fewer cells per body at the same world extent, which is the entire point of region being a real, distinct, coarser rung rather than a relabeling of what District already does at canvas resolution. **Range across the real population, for context:** largest rung-0 canvas is GJ325Ac at 25,200 cells (47.4 KB PNG-encoded) — a body with radius 7,317.6 km, close to the largest bodies in the committed set; smallest is GJ784c-m1 (a moon, radius 733.9 km) at just 253 cells (0.5 KB). The Earth-class reference point (R=6371 km) lands at 195×97 = 18,915 cells, matching the brief's own "~195×98 ≈ 19K" framing almost exactly (97 vs. 98 rows — the one-off is `cols/2` integer division vs. the brief's own rounding of a half-circumference figure; not a discrepancy worth chasing further, both land at the same order of magnitude the brief already cited). **What does NOT change:** the eviction POLICY (Tier 1 = keep-always, never evicted by time-since-last-visit; Tier 2 = sub-global geometry, storage- evicted on time-since-last-visit) is unaffected by this correction — only the BYTE BUDGET the keep-always policy commits to shrinks. The D-203-shaped resource extension proposal (`orbital_canvas` field on `BodyWorldState` or a sibling resource) also stands unchanged — it was never sized to the wrong 174 MB figure in a way that required different code, only a different comment about how big the resident data actually is. At ~8.85 MB across the ENTIRE real population, this budget is trivially affordable as permanent process-resident memory (not just disk-safe, which was already true at 174 MB) — worth noting since it may simplify Stig's disk-vs-memory framing for this ONE tier specifically (Tier 1 only; Tier 2/sub-global geometry still wants the disk-backed, storage-evicted treatment his and my specs already agree on). **Code changes:** `server/tests/bmv_global_tier_bench.rs` — new file, one `#[ignore]`d release bench. Reads `server/data/systems.db` read-only via the existing `BodyParamsReader` accessor (asset-pipeline golden rule respected — no raw `sqlite3`, no write path touched). No `server/src/` file modified. ### Rung-0 derive cost — MEASURED, not extrapolated (for Tyre's §(c) Option D table) Tyre also asked for a derive-cost figure for rung-0's own row in the ladder table. The existing `bench_derive_orbital_at_metres_region_spacing` (`zoom_ladder_bench.rs`) measures `derive_orbital_at_metres`'s per-cell rate at a fixed 4,096-cell (64×64) sweep (**884.0 ns/cell single-thread**, directly measured, ~2.13× cheaper than a full `derive_at_metres` classification call — no `invent_primitives`, bilinear region-baseline blend only) but its own full-canvas row is explicitly labelled "EXTRAPOLATED from the measured per-cell rate," not independently measured at real canvas shape. Per this workshop's own anti-extrapolation discipline (the T-1143 planetary-rung post-mortem this whole workshop exists to avoid repeating), I ran the REAL per-body canvas shape end-to-end instead of scaling the 4,096-cell number up. **New bench:** `server/tests/bmv_global_tier_bench.rs::bench_rung0_derive_cost_real_canvas_shapes`. Calls `derive_orbital_at_metres` directly over the actual `cols × rows` extent three representative real bodies would use (smallest, Earth-class, largest — the same population `bench_global_tier_bytes_real_population` surveys). | Body | Radius | Canvas (cols×rows) | Cells | Wall time | ns/cell | |---|---:|---|---:|---:|---:| | GJ784c-m1 (smallest, moon) | 733.9 km | 23×11 | 253 | 0.22–0.23 ms | 851–912 | | Earth-class reference | 6,371.0 km | 195×97 | 18,915 | 15.90–16.19 ms | 835–856 | | GJ325Ac (largest) | 7,317.6 km | 225×112 | 25,200 | 20.94–21.34 ms | 831–847 | **MEASURED, run twice for stability, both passes agree within ~5-8%** (the same run-to-run noise band this workshop's other 4,096-cell-scale benches show). Per-cell rate is flat across all three body sizes (~830–910 ns/cell), consistent with `bench_derive_orbital_at_metres_region_spacing`'s own 884.0 ns/cell figure — no size-dependent degradation. **Direct answer to Tyre's two requests:** 1. **Single-body rung-0 derive cost: ~16–21 ms single-thread**, depending on body radius (larger bodies have proportionally more regions per equator, hence more cells) — trivially interactive, one order of magnitude below any step-cross tolerance this workshop has used. 2. **All-267-bodies-summed single-thread total (the worst-case ceiling, using the real total cell count 4,825,615 from the byte-cost bench above): ~4.0 s.** Stated precisely as a ceiling, not a real production cost: rung-0 is populated lazily, once per body, on that body's first Atlas-open (same D-206 background-queue population path every other cached layer already uses) — no production path solves all 267 bodies' rung-0 canvases synchronously in one batch. The per-body row is the number that matters for "how long until a fresh Atlas-open feels snappy" (~16–21 ms, imperceptible); the summed total is only useful as an upper bound on total server-side compute if every body were opened once, back to back — which is a stress ceiling, not a real request pattern (mirrors exactly how T-1177's 273-body hydrology figure works: the meaningful number is per-body cost, the summed total is a sanity ceiling). Both numbers are real and can go directly into Tyre's bracket without qualification — no extrapolation anywhere in this section. --- ## LAKES CONVERGENCE (2026-07-23): Araminta's three routed questions — §1 SUPERSEDED-BY-CROSSING, see the correction block immediately below **SUPERSEDED-BY-CROSSING (2026-07-23, later same day).** Everything under §1 and the struct in §3 below answers a framework Araminta has since **withdrawn**: a new `water: Vec` dense field. Her final position, reached independently by verifying my own code-read at source, **adopts the morphology-fold I proposed in the POST-RATIFICATION ADDENDUM above** (`MorphologyZone::Lake` sourced from `HydrologyResult`, zero new fields) — not the `water` field this section was built against. Both of us landed on the morphology-fold independently, which is worth stating plainly: it isn't one of us conceding to the other, it's convergent verification. **§2 (the pipeline/projection ruling — filled-surface continuous sampling) stands and is ADOPTED UNCHANGED** — that reasoning is agnostic to which field carries the result (morphology-fold or a dedicated `water` field both need the same continuous-sampling mechanism to avoid the blocky-edge problem), so nothing about the correction touches it. **§3's struct is WRONG and superseded** — the real struct is 6 static + 2 sim-state dense (8 total) + 2 sparse (`courses`, `cliffs`), Araminta's final shape, corrected further down this section. §1's crossover data (dense-vs-sparse for CARRYING a bit) is **repurposed, not discarded** — see the endorheic-cue ruling below, which uses this data for a different question than the one §1 originally answered. *(§1/§3 text below is left in place, unedited, as the historical record of what was superseded — do not cite it as current. The corrected content starts at "### Endorheic cue under morphology-fold" after §3.)* --- Araminta's `araminta-round2.md` §(e) specs `water: Vec` (`{None=0, Lake=1, Sea=2}`, static plane, new dense field) — I agree with her reasoning outright (frozen `morphology` vocabulary stays frozen, bit-packing already loses on T-1179's own DEFLATE finding, a dedicated field costs almost nothing given lake regions are contiguous). Her three questions, answered below. ### 1. Endorheic carrier sizing — sparse `lakes: Vec` vs. `water`'s 4th value **Ruling: `water` gains a 4th value (`LakeEndorheic=3`). Do NOT add a sparse `lakes: Vec` list.** This is a clean, decisive result, not a close call — I built the actual crossover analysis rather than reasoning from the single 412,700-cell synthetic data point T-1177's original appendix carried (that number is from a SYNTHETIC ridged-terrain grid at 8.3M cells, a canvas size no production path derives hydrology at synchronously — not a real-population statistic, and not what the crossover call should be argued from). **New bench:** `server/tests/hydrology_equilibrium_bench.rs::bench_per_basin_size_distribution_real_population`. Solves every real committed body at the real 512×256 production working grid (same 267-body population, same solver, same grid size as the existing T-1177 population survey) and records every INDIVIDUAL basin's cell count — not just the aggregate total the original survey reported. **Results — MEASURED, run twice for stability, byte-identical both times (deterministic: real terrain, no randomness), cross-checked against the population survey's own total (2,694,012 lake cells — exact match):** | | Value | |---|---:| | Total basins | 22,270 | | Min basin size | 1 cell | | Max basin size | 18,782 cells | | Mean | 121.0 cells | | Median (p50) | 18 cells | | p90 / p95 / p99 / p99.9 | 246 / 486 / 1,703 / 7,128 cells | **Histogram:** | Bucket | Basins | % of basins | Cells | % of lake cells | |---|---:|---:|---:|---:| | 1–10 | 8,894 | 39.94% | 33,399 | 1.24% | | 11–50 | 6,299 | 28.28% | 160,553 | 5.96% | | 51–200 | 4,402 | 19.77% | 451,537 | 16.76% | | 201–1,000 | 2,236 | 10.04% | 951,614 | 35.32% | | 1,001–5,000 | 401 | 1.80% | 783,355 | 29.08% | | 5,001–20,000 | 38 | 0.17% | 313,554 | 11.64% | | 20,001+ | 0 | 0.00% | 0 | 0.00% | **The crossover analysis, priced at both carriers' real byte rates (sparse `Vec<(u16,u16)>` = 4 raw bytes/cell, a pessimistic/conservative sparse estimate since it doesn't apply MessagePack's own compact framing discount; dense = T-1179's measured PNG-per-field rate, 1.924 bytes/cell):** **Dense wins for 100% of basins, at every size measured, including the largest (18,782 cells).** Zero basins in the real population cross over to where sparse would be cheaper. This isn't close: even a single-cell basin costs 4 bytes sparse vs. ~1.9 bytes dense-if-isolated, and the moment a basin has ANY neighboring cells (which every real basin does, by construction — `label_lake_basins`' BFS only produces contiguous regions), DEFLATE's run-length win on the dense field only widens the gap. **The naive per-cell byte-rate comparison alone (4 vs. 1.924) already means dense wins point-for-point before any compression benefit is even counted** — sparse's `Vec<(u16,u16)>` shape has no structural advantage over dense here the way it does for genuinely sparse, spatially-isolated features (settlements, cliff edges) precisely because lake cells are NEVER isolated — a basin's very definition is a contiguous flood-filled region, which is the exact shape a raster field compresses best and a per-cell coordinate list compresses worst (a coordinate list gets zero benefit from spatial contiguity; a raster field gets maximum benefit from it). **Why this differs from the cliff/`CliffSegment` sparse-list decision (round 1/2, still correct, not being revisited):** cliffs are RARE (zero carved cells across the entire real population, T-1177's population survey addendum) — a sparse list of a feature that's empty on 267/267 real bodies costs nothing because it's usually zero-length. Lakes are the opposite: EVERY body in the population has multiple lake basins (22,270 basins across 267 bodies, ~83 basins/body average), and every basin is a contiguous blob, not a rare point event. The two features look superficially similar (both are "extra water-related hydrology output") but sit on opposite sides of the sparse-vs-dense argument for structural reasons, not because one measurement contradicts the other. **Concrete consequence for Araminta's schema:** drop `lakes: Vec` from `EncodedStepCanvas` entirely — it was always marked as her fallback-pending-my-numbers, and the numbers say the fallback is the answer. `water: Vec` becomes 4-state: `{None=0, Lake=1, Sea=2, LakeEndorheic=3}`. This also SIMPLIFIES the schema relative to her provisional draft (one dense field instead of one dense field plus a sparse list), which is a strictly better outcome than either of us expected going in — the crossover question had a clean, one-sided answer rather than needing a size-dependent hybrid rule. **One consequence worth flagging for Stig's map-art rule (her own constraint, restated against the concrete field values):** the endorheic/overflow visual distinction she specified ("secondary visual cue... never competing with sea-vs-lake") maps directly onto `Lake` vs. `LakeEndorheic` as two `water` discriminants that share styling family — the client reads `water == 1 || water == 3` for "this is a lake, color it lake-blue," and additionally checks `water == 3` for the secondary endorheic cue (the outline/texture/saturation treatment she described). This is a natural fit for the discriminant-value shape and needs no special-casing beyond what a 4-state enum already gives a colorizer. ### 2. Pipeline slot — where `water` gets populated, and the projection answer Confirming and sharpening what I already worked out for the coordinator's earlier lake-schema question (same underlying mechanism, now answered against Araminta's specific field shape): **`HydrologyResult` solves once per body at 512×256** (T-1177's own scope, unchanged), held in the Tier 1 keep-always cache alongside rung-0's own data. A finer-rung canvas derive needs, for each gridunit's world position, the `water` classification at that position. **Direct answer to the specific question posed: does `water` derive per-gridunit from the FILLED SURFACE (elevation < filled level = water), or does it project BASIN-CELL MEMBERSHIP?** **Rule: from the filled surface, sampled continuously — NOT basin-cell membership projected as a discrete lookup. This is the right answer under D-227's invention discipline, and here's the precise reasoning, not just the conclusion:** - **Basin-cell membership is a WORKING-GRID-RESOLUTION fact** — `Basin.cells` is a `Vec` of row-major indices into the 512×256 grid. Projecting membership directly (nearest-cell or bilinear-bool lookup against a 512×256 boolean mask, exactly how `ocean_mask`/`ocean_fraction_q` work today) would make a lake's EDGE only as precise as the 512×256 working grid's own cell size — at District spacing (2,048 m) that's coarser than the 512×256 grid itself in the wrong direction is fine (the grid is finer), but at Chunk spacing (64 m) a bilinear-bool sample of a 512×256-resolution mask produces a BLOCKY lake edge that doesn't refine as the rung gets finer — exactly the "magnified interpolation of a coarser composite" D-166's corollary forbids, the same error class this entire workshop exists to eliminate. - **The filled surface (`filled_scaled: Vec`, also 512×256-resolution) has the SAME resolution problem if sampled the same crude way** — but it doesn't have to be, because `filled_scaled` is a CONTINUOUS quantity (an elevation-like field, water-surface height), not a boolean mask. Comparing a bilinearly-INTERPOLATED filled-surface sample against a bilinearly-interpolated ORIGINAL elevation sample at the gridunit's exact world position (both sampled the same way `elev_q` already is — this is literally the same sampling mechanism `ocean_fraction_q` uses today for sea, just against `filled_scaled` instead of the raw heightmap) produces a lake edge that's a smooth function of position, refining naturally as the rung gets finer — exactly the same way coastlines already refine today via `coast_invention`'s domain-warp on the SAME kind of continuous primitive. - **Concretely: `water_at_position = if bilinear(filled_scaled, pos) > bilinear(original_elevation, pos) { Lake-or-Endorheic } else if bilinear(original_elevation, pos) < sea_level { Sea } else { None }`** — three continuous comparisons at the gridunit's own world position, the same shape as the sea-level check `derive_morphology_zone` already performs via `ocean_fraction_q`, just reading `HydrologyResult`'s `filled_scaled` field instead of (or alongside) the raw elevation. This is mechanism-B-shaped from the seed-chaining reconciliation (round 2 §(b) and my earlier lake-schema note): a coarser rung's CONTINUOUS PRIMITIVE (the filled-surface field, computed once at 512×256) is sampled fresh at every finer rung's own position, never re-solved, never read as a discrete cached lookup. - **`LakeEndorheic` vs. `Lake`:** since `Basin.outcome` is a per-BASIN property (constant across all of a basin's cells, not itself a continuous field), it needs one more piece: which basin (if any) a position falls inside, to know which `outcome` applies. This IS a discrete lookup (basin id per working-grid cell, nearest-sample not bilinear — you can't blend "half endorheic, half overflow"), but it only matters at the water/land BOUNDARY resolution question is already resolved by the continuous `filled_scaled` comparison above — the endorheic/overflow classification is uniform across the interior of a water region, it only needs a coarse per-basin lookup (populate a 512×256 basin-id grid once when hydrology solves, nearest-sample it), never a fine-grained boundary computation of its own. **No new bench needed to confirm this is affordable** — sampling one more bilinear field (`filled_scaled`) plus one nearest-sample lookup (basin id, only evaluated where the continuous check already says "this is water") is the same shape of work `ocean_fraction_q`'s existing sample already performs every cell, every rung. My own T-1178/T-1154 per-cell rate numbers (~190–220 ns/cell parallel, flat across every rung) already include equivalent-cost sampling operations in that per-cell budget — this is a derivation-pipeline WIRING change, not a new cost category, exactly as I told the coordinator for the original lake-schema question. ### 3. Struct field count — reconciled Araminta's `EncodedStepCanvas` in her round-2 draft lists 10 dense fields (counting `flooded` and the now-superseded `water`+`lakes` pair) plus 2 sparse lists. Per question 1's ruling above (`lakes` dropped, `water` becomes 4-state), the reconciled field count against my `StepCanvasResponse`/ `EncodedStepCanvas` design (round 2 §(a)) is: ```rust pub struct EncodedStepCanvas { pub width: u32, pub height: u32, // STATIC GEOMETRY PLANE (7 dense fields) pub morphology: EncodedField, pub elev_q: EncodedField, pub temp_dc: EncodedField, pub moisture_q: EncodedField, pub vegetation: EncodedField, pub settlement_id: EncodedField, pub water: EncodedField, // {None,Lake,Sea,LakeEndorheic} — 4-state, NEW // SIM-STATE PLANE (2 dense fields) pub glaciation: EncodedField, pub flooded: EncodedField, // SPARSE LISTS (2 — courses existing, cliffs ratified; NO lakes list) pub courses: Vec, pub cliffs: Vec, } ``` **9 dense fields total (7 static + 2 sim-state), 2 sparse lists — one fewer field than Araminta's provisional 10-dense-plus-lakes count, because `water`/`lakes` collapse into ONE field (`water`, 4-state) rather than staying two separate schema elements.** This is the single authoritative field list per her own (d) note ("keeps the struct's field list as one authoritative list rather than two documents each claiming to be current") — confirming here so both documents converge on the same struct rather than each claiming a slightly different one. **Envelope-framing consequence: none.** Exactly as both of us already argued for `flooded` and the original `water` addition — one more/fewer field derived in the same row-chunked pass (or, for `water`, in a pass keyed on the same `HydrologyResult` `cliffs` already draws from) doesn't reopen the flat-envelope ruling. This is field-set growth on the existing flat `StepCanvasResponse`, not a new response type or a dense/sparse split — my round-2 §(a) reasoning against splitting the envelope applies unchanged to a 9-field struct exactly as it did to a 6-field one. *(§3 above is superseded by the crossing — the real struct has no `water` field at all. Corrected below.)* --- ## CORRECTED (2026-07-23, post-crossing): struct, final; endorheic cue, ruled ### Struct, Araminta's final shape — confirmed, no dissent `MorphologyZone::Lake` (discriminant 1, already in the frozen 17-zone vocabulary) is sourced from `HydrologyResult` per the POST-RATIFICATION ADDENDUM's morphology-fold above — zero new fields for the lake/sea distinction itself. The corrected, authoritative struct: ```rust pub struct EncodedStepCanvas { pub width: u32, pub height: u32, // STATIC GEOMETRY PLANE (6 dense fields) pub morphology: EncodedField, // Lake/OpenOcean now sourced from HydrologyResult pub elev_q: EncodedField, pub temp_dc: EncodedField, pub moisture_q: EncodedField, pub vegetation: EncodedField, pub settlement_id: EncodedField, // SIM-STATE PLANE (2 dense fields) pub glaciation: EncodedField, pub flooded: EncodedField, // SPARSE LISTS (2) pub courses: Vec, pub cliffs: Vec, } ``` **8 dense fields (6 static + 2 sim-state), 2 sparse lists.** No `water` field anywhere. This is Araminta's final shape; I have no dissent — it's strictly simpler than either of our provisional drafts (mine at 9 fields, hers at 10), because the morphology-fold makes the lake/sea distinction free to carry rather than requiring its own slot. ### Endorheic cue under morphology-fold — the actual open question The coordinator is right that my §1 crossover analysis doesn't settle this: that analysis compared dense-field-bit vs. sparse-coordinate-list for carrying an endorheic signal, and **both of those options are now foreclosed** — a dense bit under morphology-fold means either widening the frozen 17-zone vocabulary to 18 (option ii below) or resurrecting a new field (which contradicts the entire point of the fold), and a sparse per-basin list was never Araminta's proposal for the cue specifically, it was for the abandoned `lakes: Vec` carrier. The real question — no wire signal at all vs. a vocabulary change vs. a deferred mechanism — is a different decision than the one my crossover data answered, and I want to be precise that I'm not stretching that data to cover a question it wasn't built for. **My pick: (i), Araminta's outflow-course-presence inference. No wire bit at all for endorheic-vs-overflow; the cue arrives as a side effect of wiring basin outlets into the courses/river-network machinery, which is work T-1170 Ruling 7b already reserved room for and which the map needs done anyway.** **Rationale, grounded in the actual basin-outcome data:** - **Proportionality — 1,030 of 22,270 basins (4.63%) are endorheic.** This is the number that should drive the vocabulary-change call, and it argues against option (ii) specifically. An 18th `MorphologyZone` entry is a permanent widening of a D-239 frozen vocabulary — every consumer of that enum (client colorizer, any future classification logic, the one-colorizer-family guarantee Araminta's own round-1 §3 argues for) inherits the new arm forever, to carry a distinction that's true for fewer than 1 in 20 lake basins. A frozen vocabulary earns its frozen status by being conservative about additions; 4.63% is not the kind of frequency that should be the bar-clearing case for reopening it, especially when a zero-cost alternative exists that reaches the same end state. - **Option (i) has zero marginal wire cost and zero marginal vocabulary cost, and the work is required regardless of this decision.** An overflow lake's exit river is real hydrology output — `outlet_path` is already a non-empty, guaranteed field on every `BasinOutcome::Overflow` (confirmed by direct read: `hydrology_equilibrium.rs`'s own `overflowing_basin_has_nonempty_outlet_path` test asserts exactly this). Wiring that path into the river-course/`RiverNetwork` machinery so an overflow lake visibly shows its exit river on the map is not a cue-specific feature — it's the map correctly showing hydrology that already exists, which the workshop needs for overflow lakes to read as hydrologically complete regardless of whether anyone cares about the endorheic distinction. The cue is a **free side effect** of doing that work, not an additional feature built to carry it. This is exactly the shape T-1177's own `RIVER_DOWNSTREAM_TERMINAL` sentinel reservation anticipated: I flagged in the original T-1177 measurement doc that `river_course::build_edges` already treats that sentinel as a safe no-op, confirming the endorheic case was already designed to be additive when this moment came — it has come, and it's additive. - **The inference is structurally sound, not a fragile heuristic.** Every `Overflow` basin has a real, non-empty `outlet_path`; every `Endorheic` basin has none, by definition (that's what "endorheic" means — no outflow). There's no edge case where the presence-of-exit-river signal could misfire or need a tie-break rule the way, say, a quantized threshold might. The client reads "does this lake have a river flowing out of it" and that question has an unambiguous, always-correct answer under the solver's own definitions — no cue-specific correctness burden at all. - **Option (iii) (defer with no mechanism recorded) is worse than (i) for a reason specific to this workshop's own discipline, not a general preference for shipping features.** The outlet-wiring work is going to happen regardless (overflow lakes need to show SOME exit-river behavior to be honest about their own hydrology — the current schema can't even represent "this basin has settled but I'm choosing not to draw where its water goes"). Recording "no mechanism, unscoped" would either silently under-scope that ticket later or force a second design pass to rediscover exactly what (i) already worked out. Since the mechanism costs nothing extra to name now, naming it is strictly better than deferring the naming. **Verdict: (i).** Endorheic-vs-overflow ships with zero wire bits, zero vocabulary change, as a client-side inference from whether a lake's `morphology`-classified region has a river course exiting it — which is answerable directly from the existing `courses: Vec` sparse list once basin outlets are wired into it (a ticket-level follow-up, pre-cleared by T-1170 Ruling 7b, not a wire-schema change). If Jeroen wants the distinction visually stronger than "no exit river present" can convey on its own (a genuinely different aesthetic call, not a data question), that's a Stig-side styling decision layered on the same zero-bit signal, not a reason to revisit the carrier.