Files
settled-reach/docs/workshops/body-map-viewer/dudley-interview2-response.md
T
jpmschweitzerandClaude Fable 5 29c22cb728 docs(meta): body-map-viewer workshop — rounds, measurements, outcomes, as-built briefing
The complete workshop record: four round-1 positions, five round-2 syntheses
(incl. Troblum's adversarial pass with addendum + final scorecard — all seven
findings resolved), both lead interviews, Qatux's round notes and the 8-section
workshop-outcomes.md (the lakes message-crossing documented as process
history), measurement ⑥ (set_pixel/c1) + the population-survey and chunk/S2
addenda in the measurement docs, the brief's appendix updated through ⑥, and
architecture-briefing-final.md — Jeroen's outline written back as-built
(six-level ladder, lakes, ~9MB resident global tier). README row: Complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:57:45 +02:00

70 KiB
Raw Blame History

title, description, workshop, round, owner, status, decision_refs
title description workshop round owner status decision_refs
Body Map Viewer — Dudley Interview 2 Response 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 body-map-viewer interview-2 Dudley complete
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.447.57 ms 1,816.01,847.9
Block (128 m) [reference] 128 m 7.497.61 ms 1,827.81,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.01847.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.481,732.84 ms 207.9208.9
SINGLE-THREAD 15,051.5915,145.11 ms 1,814.71,825.9
8.738.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.721.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.71.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,7241,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.91,913.3 ns/cell 3,489.73,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.41,855.7 ns/cell 2,544.12,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.090.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.71.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 neededflooded/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):

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<CliffSegment> 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 (~190220 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).

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.220.23 ms 851912
Earth-class reference 6,371.0 km 195×97 18,915 15.9016.19 ms 835856
GJ325Ac (largest) 7,317.6 km 225×112 25,200 20.9421.34 ms 831847

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 (~830910 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: ~1621 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" (~1621 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<u8> 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<u8> ({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<LakeBasin> vs. water's 4th value

Ruling: water gains a 4th value (LakeEndorheic=3). Do NOT add a sparse lakes: Vec<LakeBasin> 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
110 8,894 39.94% 33,399 1.24%
1150 6,299 28.28% 160,553 5.96%
51200 4,402 19.77% 451,537 16.76%
2011,000 2,236 10.04% 951,614 35.32%
1,0015,000 401 1.80% 783,355 29.08%
5,00120,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<LakeBasin> from EncodedStepCanvas entirely — it was always marked as her fallback-pending-my-numbers, and the numbers say the fallback is the answer. water: Vec<u8> 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 factBasin.cells is a Vec<usize> 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<i64>, 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 (~190220 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:

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<RiverCourse>,
    pub cliffs:  Vec<CliffSegment>,
}

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:

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<RiverCourse>,
    pub cliffs:  Vec<CliffSegment>,
}

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<LakeBasin> 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<RiverCourse> 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.