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>
70 KiB
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 |
|
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:
- 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.
- 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.
- 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
- 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.
- 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.
- 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 intomeasurements/t1178-t1154-derive-bench.mdas 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). - S4: joint formula with Araminta, 1× multiplier confirmed, no widening
needed —
flooded/glaciationTTL 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 existingLake/OpenOceandiscriminants (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-zoneMorphologyZoneenum 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
HydrologyResultbasin'scells(row-major cell indices at the 512×256 working-grid resolution the solver runs at — see the projection question below), emitMorphologyZone::Lakeinstead of falling through to theocean_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
floodedsim-state plane. A lake basin's existence and footprint is exactly as static asmorphology/elev_qthemselves —HydrologyResultis 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 (~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).
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:
- 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.
- 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<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 |
|---|---|---|---|---|
| 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<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 fact —
Basin.cellsis aVec<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 howocean_mask/ocean_fraction_qwork 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, becausefilled_scaledis 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 wayelev_qalready is — this is literally the same sampling mechanismocean_fraction_quses today for sea, just againstfilled_scaledinstead 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 viacoast_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 checkderive_morphology_zonealready performs viaocean_fraction_q, just readingHydrologyResult'sfilled_scaledfield 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. LakeEndorheicvs.Lake: sinceBasin.outcomeis 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 whichoutcomeapplies. 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 continuousfilled_scaledcomparison 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:
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
MorphologyZoneentry 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_pathis already a non-empty, guaranteed field on everyBasinOutcome::Overflow(confirmed by direct read:hydrology_equilibrium.rs's ownoverflowing_basin_has_nonempty_outlet_pathtest asserts exactly this). Wiring that path into the river-course/RiverNetworkmachinery 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 ownRIVER_DOWNSTREAM_TERMINALsentinel reservation anticipated: I flagged in the original T-1177 measurement doc thatriver_course::build_edgesalready 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
Overflowbasin has a real, non-emptyoutlet_path; everyEndorheicbasin 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.