AD-MAPV10-M11: World Genesis Source of Truth
Status
Accepted, 2026-05-16. Authority: this ADR is the canonical mapv10 contract for M11a (Wave S). M11c, M11d, M11i, M11j, M11k, and M11l implementers must read it before extending the generator, viewer, validator, or capture surfaces.
Context
The mapv10 generator emits its strategic geography in three product families today: a continent polygon stack written by Stage 1, a ridge graph plus a small fixed family of basins written by Stage 2, and a river graph plus river centerlines plus ellipse-shaped lake polygons written by Stage 4. Each of those three product families originates from a hand-authored placeholder rather than a real geomorphology contract.
The Wave 0 explorer audit recorded the specific patches the placeholder has
accumulated. The ridge graph is a fixed six-node chain whose nodes are
authored once in generator/src/stages/02-ridge-basin/ridge_graph.rs and
patched per-preset. The basin set is a fixed two-ellipse pair authored in the
same file. The lake polygons in Stage 4 are bounding-box ellipses (closed_ellipse topology) generated
in generator/src/stages/04-rivers-lakes/lake_synthesis.rs. The river graph
is hand-authored in generator/src/stages/04-rivers-lakes/river_graph.rs with
hardcoded source/confluence/mouth nodes whose coordinates are tuned to the
ridge chain rather than derived from a flow product. Each of those three sources
has been patched five or more times across the M0-M10 milestones to keep the
viewer rendering plausible, and each patch has been parameter-tuning rather
than architectural redesign.
CLAUDE.md establishes the governing tenet: "When you patch the same subsystem 3+ times, the original design is wrong - stop patching, redesign." M11 closes the patch loop by replacing the placeholder triad with a declarative source-of-truth pipeline. The recipe describes what continent the author wants; the generator produces it deterministically; satisfaction reporting tells the author whether the request was met. The renderer never owns geomorphology truth.
The user issued four binding decisions on this milestone before Wave S started:
- Archetype composition is open, not a closed enum. New archetypes are composed from vocabulary tokens rather than added to a fixed list.
- The authoring contract is JSON validated by JSON Schema. Recipes do not run
through the SECS engine and have no
.secssurface. - Both a workspace-touching ADR and a package-internal ADR were considered; the user directed that mapv10 source-of-truth ADRs live in the package rather than at the workspace root. This ADR is the package ADR; there is no paired workspace ADR for M11a.
- The Valenar runtime default stays on the
world-42dummy fixture. M11 is a generator-side milestone; the viewer and Valenar runtime do not switch over in this wave.
Wave S is the first wave of M11. It commits the source-of-truth schemas, the bundled vocabulary, four starter recipe presets, the Rust DTO mirrors, validator stubs, narrative documentation, and a validator script. It does not modify any generator stage or viewer surface. The redesigned Stage 2 and Stage 4 land in M11c and M11d. The renderer-side product loaders land in M11i. The Valenar enrichment changes land in M11j. The World Lab UX lands in M11k.
Decision
Wave S commits five new JSON Schemas, one bundled vocabulary instance, four
starter recipe pairs (a recipe and an intents file each), three narrative
documents, a single package ADR (this file), Rust DTO mirrors in
generator/src/model.rs, validator stubs in generator/src/validation.rs,
and a viewer validation script in viewer/scripts/. Each surface is the
forward-looking source of truth for M11 and all later milestones that depend on
the recipe surface.
1. World Recipe schema
schema/world-recipe.schema.json defines the recipe contract:
schemaVersionismapv10-world-recipe-v1. Future revisions bump the version and ship beside the v1 schema; recipes never silently migrate.idanddisplayNameidentify the recipe in tooling and UX.archetypecarries the geomorphology declaration: orogen list, craton list, rainfall declaration, basin policy, coast policy, custom-anchor policy.countscarries top-level count constraints (primary mountain belts, secondary ranges, residual highlands, foreland/interior/endorheic basins, major rivers). Each count is a range with an optionalexactfield and a priority.intentDirectivesRef,vocabularyRefare optional relative paths to companion documents.satisfactionReportingconfigures how the generator reports satisfied, soft-satisfied, and unsatisfied requests. Hard-fail reporting cannot be disabled (emitUnsatisfiedHardFails: const true).canonicalizationrecords the determinism contract: sorted keys, shortest round-trip f64 representation, LF line endings, UTF-8 NFC without a BOM. These areconst-valued fields; recipes cannot opt out.additionalProperties: falseat every nested object level forbids silent drift.
A recipe is seed-agnostic. A given recipe replayed against different seeds
yields different concrete continents that share the same morphology family
(same orogen kinds, same craton placement bands, same rainfall pattern). A
recipe is also scale-agnostic; the province-slice / regional-slice /
realm-slice / continent scale preset is owned by GeneratorConfig, not by
the recipe.
2. World Intent Directives schema
schema/world-intent-directives.schema.json is the declarative custom-anchor
surface. Authors request specific features by name and placement; the
generator resolves each directive during the terrain-genesis or hydrology
stages and writes a resolutionStatus payload back into the directive
document. The schema commits thirteen directive kinds for M11:
custom-basin,custom-valley,custom-pass,custom-ridge,custom-plateau,custom-lake,custom-river-mouth,custom-dry-sink,custom-isthmus,custom-coastal-plain,settlement-candidate,starting-zone,named-feature.
Five placement modes are committed: near-point, between-anchors,
along-axis, inside-selected-area, archetype-derived. Each directive can
carry optional terrainConstraints and hydrologyConstraints (require basin
floor, require saddle, must touch river, must be navigable, etc.). The
resolutionStatus payload is generator-owned: every run overwrites it with
fresh state, satisfactionScore, reasons, resolvedCoordinatesKm, and
resolvedFeatureRefs fields.
Deferred kinds (not in M11): custom-volcano, custom-archipelago,
custom-strait, custom-canyon, custom-glacier. The vocabulary registry
shape allows them to be added later without bumping the directives schema.
3. World Vocabulary registry
schema/world-vocabulary.schema.json validates a single registry instance.
Tokens live in nine categories: orogenKinds, cratonKinds, ageBands,
rainfallPatterns, residualHighlandKinds, interiorBasinKinds,
islandPolicies, isthmusPolicies, nameStyles. Every token has an id,
displayName, definition, and an optional aaaReference pointing at the
real-world morphology that inspired it.
The bundled vocabulary/default-world-vocabulary.json instance committed in
Wave S carries the M11 token universe. The four starter recipes reference
only tokens that exist in that bundled instance.
A new token lands by editing the bundled instance and bumping no schemas. The
recipe schema references vocabulary by string id, not by $ref. This decouples
recipe v1 stability from vocabulary growth.
4. Terrain Genesis schema
schema/terrain-genesis.schema.json defines the vector product written by the
redesigned Stage 2 in M11c. Owns continent-scale macro geography: tectonic
provinces, an orogen graph (nodes, edges, segments), mountain belts, orogen
axes, fault lines, basin candidates, drainage-divide candidates, pass
candidates. The schema registers seven raster channels emitted alongside the
vector product:
uplift(f32, anisotropic uplift field consumed by Stage 3),crustAge(f32, erosion-maturity scalar per cell),bedrockHardness(f32, erodibility scalar for Stage 3 erosion),reliefPotential(f32, pre-erosion macro relief envelope),basinCandidateId(u32, per-cell basin membership index),drainageDivide(u8, provisional pre-hydrology divide mask),orographicRainfall(f32, rainfall field consumed by Stage 4 and the biome stage).
Each raster channel is registered as a separate manifest product via
raster-product.schema.json; the terrain-genesis vector product only carries
metadata pointers. This keeps the existing manifest-products convention.
The schema requires a validation block: recipeId, directivesRef, a
satisfactionSummary with hard-fail/soft-unsatisfied/suggest-unsatisfied/
satisfied counts, and an optional unsatisfied[] list with per-request
reasons. The schema also requires sourceDependencies matching the existing
influence-* schema convention.
5. Hydrology schema
schema/hydrology.schema.json defines the vector product written by the
redesigned Stage 4 in M11d. Replaces the synthetic ellipse-lake plus
hardcoded-river-graph stack with a priority-flood-conditioned DEM, D8 flow
routing, Strahler-ordered river network, marching-squares lake polygons, and
per-location hydrology associations. The schema registers ten raster channels:
flowDirection,downstreamIndex,catchments,streamOrder,riverSegmentId,riverWidth,waterBodyId,waterMask,floodplainMask,wetlandMask.
The existing waterMask.u8.bin keeps its key and path; M11d marks it as
truth-derived. The other channels are new.
The vector product carries a riverNetwork block (nodes, segments with
Strahler and optional Shreve orders, mouth ids, confluence ids), a
lakePolygons[] array (marching-squares polygon with minimum four vertices,
optional holes, a typed kind), a sinks[] array, a catchmentSummaries[]
array, a crossingCandidates[] array (for route planning in later milestones),
and a locationAssociations[] array (per-location freshwater access, major-
river access, navigable-river access, floodplain percentage, wetland
percentage, basin id, valley id, river-segment ids).
The schema requires sourceDependencies and validation (the same shape as
terrain-genesis).
6. Soft / hard / suggest semantics
Three priorities are committed across recipes and directives. Each maps to a specific validator behavior:
hard- the request must be satisfied or the generator emits a satisfaction record withstate: unsatisfied, incrementsvalidation.satisfactionSummary. hardFailCount, setsmanifest.validationStatustofail, and (ifsatisfactionReporting.haltOnHardFailis true) halts the run. A hard unsatisfied request is a generator-emitted failure, never a silent default.soft- the generator best-efforts the request and emits asatisfactionScorein[0, 1]. State issatisfied(score = 1.0),soft-satisfied(0 < score < 1), orunsatisfied(score = 0). The run continues; the manifest validationStatus is unaffected.suggest- the generator records the request and tries it best-effort but emits no satisfaction score. State issatisfiedorunsatisfied. The run continues; the manifest validationStatus is unaffected.
The customAnchorPolicy.conflictResolution field on each recipe controls how
a directive's hard request is reconciled with an archetype declaration that
contradicts it. The four committed modes are
hard-directive-overrides-archetype, archetype-overrides-soft-directive,
directives-suggest-only, and directives-forbidden. The default policy in
the four starter presets is hard-directive-overrides-archetype; recipes
that want a stricter archetype can change it.
No priority mode permits silent type-defaults. Missing or unsatisfiable required fields produce explicit failure records, not zeroed values.
7. Determinism canonicalization
Every recipe, directives document, and vocabulary instance carries a
canonicalization block with four const fields:
sortedKeys: true- keys are written in sorted order.floatRepresentation: "shortest-roundtrip-f64"- floats use the shortest representation that round-trips throughf64.lineEndings: "lf"- line endings are\n, never\r\n.encoding: "utf-8-nfc-no-bom"- text is UTF-8 normalized to NFC, no BOM.
The determinism contract is: a (seed, recipe, intent-directives, vocabulary)
four-tuple produces a byte-identical content_hash across runs. The existing
pipeline.rs::same_config_produces_same_core_product_bytes test extends in
M11c to assert the same property over recipe/directives/vocabulary inputs.
Serialization implementation guidance: every new DTO that derives Serialize
in model.rs uses BTreeMap<String, Value> (not HashMap) for any open or
extensible map. BTreeMap iterates in sorted key order, which makes
serde_json::to_string-emitted JSON canonical without a post-pass sort. This
is the industry-standard approach to deterministic serialization in Rust.
8. Archetype composition grammar
A recipe declares its archetype as a composition of tokens, not as a single enum value. The grammar:
archetype.orogensis a list of orogen declarations. Each declaration names anorogenKindstoken (e.g.active-margin,collisional), a placement mode (coast-parallel,interior-axis, orarchetype-derived), and per- belt parameters (length, height, age band, asymmetry).archetype.cratonsis a list of craton declarations. Each names acratonKindstoken (e.g.shield,platform), a placement mode, and per- craton parameters (age band, area fraction, residual highland policy, interior basin policy).archetype.rainfallnames arainfallPatternstoken plus orographic strength and rain-shadow azimuth.archetype.basinPolicyandarchetype.coastPolicycarry policy-level declarations referencinginteriorBasinKinds,residualHighlandKinds,islandPolicies,isthmusPoliciestokens.
New archetype families are recipe-author-side compositions of existing tokens, not new schema fields. Wholly new morphology families that require a new vocabulary token land by editing the bundled vocabulary instance.
9. Workspace-vs-package ADR location policy
The user has directed that mapv10 source-of-truth ADRs live inside the mapv10 package rather than at the workspace root. The applicable rule for this and future milestones:
- mapv10 source-of-truth ADRs live in
examples/map/mapv10/docs/ad-mapv10-<milestone>-<topic>.md. The workspace-rootdocs/adr/is not used for mapv10-package decisions. - The filename uses the
ad-mapv10-prefix even though it lives under the package convention rather than the workspacead-namespace. The visual twinning with workspace ADRs is intentional; the location is the discriminator. - Cross-cutting workspace decisions that happen to touch mapv10 remain at the workspace root under whatever prefix the workspace docs-conventions rule requires. This policy covers only mapv10 source-of-truth decisions about generator products, schemas, and pipeline contracts.
This section is the workspace-vs-package location policy for mapv10 ADRs in its entirety; there is no separate workspace-root ADR.
10. Hand-offs to later M11 waves
Wave S explicitly defers the following items to later waves and milestones:
- M11c: bumping
manifest.schema.jsonwithrecipeRef,directivesRef, andvocabularyReffields; adding--recipe,--intents, and--vocabularyCLI flags togenerator/src/main.rs; extending the determinism test to cover the(seed, recipe, directives, vocabulary)four-tuple; landing real bodies for thevalidate_world_recipe,validate_world_intent_directives,validate_world_vocabulary,validate_terrain_genesis, andvalidate_hydrologyfunctions stubbed in Wave S. - M11c: redesigning Stage 2 to emit
terrainGenesis.jsonplus the seven registered raster channels. Removing the fixed ridge-chain and ellipse- basin sources. - M11d: redesigning Stage 4 to emit
hydrology.jsonplus the ten registered raster channels. Removing the synthetic ellipse-lake and hardcoded-river sources. Deciding whether the existingriverGraph.json,riverCenterlines.json, andlakePolygons.jsonfiles remain as derived outputs or are removed. - M11i: viewer-side typed loaders for
terrainGenesis.jsonandhydrology.jsoninviewer/src/data/types.tsandmanifestLoader.ts. Fail-hard missing-product behavior matching the existing loader contract. - M11j: Stage 15 Valenar enrichment with
navigable_river_access,basin_id, andvalley_idfields projected fromhydrology.locationAssociations[]. Valenar runtime stays on theworld-42dummy fixture. - M11k: World Lab UX shell that edits a recipe in-browser. Plain DOM, not React. Reads the bundled vocabulary to populate dropdowns.
- M11l: capture refresh for the four starter presets at continent zoom.
The manifest.validationStatus enum stays ["pass", "fail"]. The new failure
pathway (hard-fail satisfaction) is a fail with
satisfactionSummary.hardFailCount > 0; no new enum value is needed.
Consequences
- The renderer cannot acquire recipe-truth surfaces until M11i. The viewer continues to consume the existing manifest products until that wave.
- The Valenar runtime continues to load the
world-42dummy fixture until M11j (and beyond, at the user's discretion). - The existing
riverGraph.json,riverCenterlines.json, andlakePolygons.jsonfiles remain valid generator outputs through M11c. M11d decides their fate. - The validator stubs land in Wave S so the validator table is structurally
complete and
cargo testkeeps passing. Real bodies land in M11c and M11d. - The
manifest.schema.jsonis not modified in Wave S. M11c bumps it. - Recipe-author tooling can begin work against the schemas as soon as Wave S lands. The schemas are stable for the duration of M11. Breaking changes require a schema version bump.
Alternatives considered
- Closed-enum archetype. Rejected by user decision. A closed enum would freeze the archetype universe at M11 and force a schema version bump every time a new continent kind is needed. Open composition lets the vocabulary grow without breaking recipe v1.
- Embedded vocabulary inside the recipe schema. Rejected because new vocabulary tokens would force a recipe schema version bump every time. The external registry decouples vocabulary growth from recipe stability.
- Single mega-schema for recipe + directives + vocabulary + terrain + hydrology. Rejected for dependency-clarity reasons. A single schema would couple every product surface to a single version stamp; partial revisions would force the whole stack to bump. Five separate schemas let each surface version independently.
- Recipe-inside-
GeneratorConfig. Rejected because recipes are seed-agnostic and scale-agnostic;GeneratorConfigowns execution shape (raster sizing, scale preset, world km box, target counts). Mixing the two would force recipe authors to re-author their recipe every time the scale preset changes. - In-renderer recipe surface. Rejected by the mapv10 charter: generated truth products are owned by the generator, never the renderer. A renderer-side recipe would couple geomorphology authoring to a Three.js scene graph and forbid headless replay.
- Workspace-root ADR for this milestone. Considered, then explicitly rejected by user direction. This ADR lives in the mapv10 package because the user has decided package ADRs are the right home for mapv10 source-of-truth decisions.
M11c — Terrain genesis algorithms
This section pins the specific algorithms Stage 1 (continent outline) and
Stage 2 (terrain genesis) use to lower a WorldRecipe, WorldVocabulary,
and optional WorldIntentDirectives into deterministic byte-identical
products. Wave G1 (M11c) implements them; Wave G2 (M11d) consumes them.
Stage 1 — continent outline
The mainland coast polygon is built by recursive midpoint subdivision
(Fournier, Fussell, Carpenter — Computer Rendering of Stochastic Models,
CACM 1982 — the "fractal mountain" paper, adapted to 2D polygons). The
base is a 16-vertex regular polygon at the continent template centroid;
each round inserts midpoints with a normal-perpendicular Gaussian
displacement whose σ halves per round. coastPolicy.jaggedness.max drives
σ₀; coastPolicy.fjordLikelihood.max triggers per-edge Z-fold fjord
subdivisions.
Islands are placed by Mitchell best-candidate sampling (Mitchell — Spectrally Optimal Sampling for Distribution Ray Tracing, SIGGRAPH 1991) in an offshore band, with per-island shapes generated by the same midpoint-subdivision algorithm.
Isthmuses are extruded as rectangular tongues attached to the mainland ring at a deterministic anchor point.
Stage 2 — tectonic provinces
Provinces are seeded from recipe orogen axis vertices, craton anchors,
foreland/interior basin requests, and Mitchell best-candidate fillers,
then relaxed by Lloyd 1982 k-means-style Voronoi smoothing on a
sampled grid (4 iterations). For each seed, the Voronoi cell is built by
iteratively half-plane clipping the world bounding rectangle through the
perpendicular bisector of every other-seed pair — the per-bisector
half-plane clip is Sutherland-Hodgman 1974 (Sutherland & Hodgman,
Reentrant Polygon Clipping, CACM 17(1), 1974) applied to a single
bisector edge (clip_polygon_half_plane_through_bisector in
generator/src/stages/geography_graph.rs). The resulting convex cell is
then clipped against the (concave) continent polygon by
clip_subject_against_concave, which ear-clips the continent into
triangles via earcutr and runs full Sutherland-Hodgman per triangle,
unioning the surviving pieces and retaining the largest-area sub-polygon
per seed. The truth product therefore contains real Voronoi cells
clipped against the actual continent shape — no angular-bin convex-hull
approximation, no deferred follow-up.
Stage 2 — anisotropic uplift kernel
For every cell, every orogen segment contributes
segment_height × along_taper(along_km, total_km, σ) × perp_taper(perp_km, w).
The width w is the per-orogen-kind reference width (active-margin =
80 km, collisional = 250 km, rift-flank = 60 km, transform-margin =
40 km, residual-eroded = 180 km, passive-margin = 25 km). Asymmetry is
applied by widening the leeward flank by asymmetryFactor and narrowing
the windward flank by the same factor — the
Cortial-Peytavie-Galin-Guérin Procedural Tectonic Planets (Eurographics
2019) § 5 anisotropic kernel.
Stage 2 — crust age, hardness, relief potential
Crust age is per-province piecewise-constant
vocabulary.ageBands[band].erosionMaturity softened by a separable 3-tap
Gaussian boundary blur (σ ≈ 1 cell) so province boundaries do not
produce shock-fronts in Stage 3. Bedrock hardness applies the
Sklar-Dietrich 2001 empirical mapping
0.20 + 0.55·age + 0.15·is_craton − 0.10·is_active_uplift clamped to
[0.05, 0.98]. Relief potential combines the three:
uplift × (1 − age × AGING_FACTOR) × (0.5 + 0.5 × hardness) with
AGING_FACTOR = 0.65 (Tucker-Slingerland 1994 § 2).
Stage 2 — orographic rainfall
The orographic rainfall raster implements the spatial-domain form of the
Smith-Barstad 2004 linear theory of orographic precipitation
(J. Atmos. Sci. 61). Per cell:
rainfall(x) = baseline + strength × (upslope_along_wind − 0.6 × downslope_along_wind)
where the wind azimuth comes from
archetype.rainfall.rainShadowAzimuth and baseline is keyed by the
rainfall pattern token (zonal-temperate = 0.55, zonal-tropical = 0.65,
orographic-dominant = 0.30, arid-continental = 0.18, rift-driven = 0.45,
monsoonal = 0.50).
Stage 2 — basin candidates
Per basin class (foreland, interior, endorheic) we compute an
eligibility scalar field and extract connected components above
threshold τ = 0.55. Component polygons are emitted by Lorensen-Cline
1987 marching squares (the 2D variant of the original marching cubes
algorithm, SIGGRAPH 1987) on the per-component boolean mask, then
simplified with Douglas-Peucker 1973 at tolerance
1.5 × cell_size_x_km. Saddle ambiguities in marching squares cases 5
and 10 are resolved by emitting the two non-bridging arcs (the
asymptotic-decider rule for boolean masks). When the marching-squares
pass produces no valid ring for a component (extremely small
components lost to simplification), a tight ring around the component
centroid keeps the closed-ring contract. The basinCandidateId.u32.bin
raster records the basin id of each cell (0 = none).
Stage 2 — drainage divides
Drainage divide candidates are extracted by Sousbie 2011 DisPerSE- style persistence-filtered ridge tracing on the relief-potential raster (Sousbie, "The persistent cosmic web and its filamentary structure", MNRAS 414:350, 2011). The algorithm:
- Locate local maxima of
reliefPotentialon the 8-neighbour stencil (ties broken by linear index, matching the Edelsbrunner-Letscher- Zomorodian 2002 simulation-of-simplicity convention). - Locate saddles via the discrete-Morse-theory test (a cell is a saddle when its 8-neighbour higher-set splits into ≥ 2 non-adjacent arcs).
- From each saddle, trace integral curves of
+∇reliefPotentialby steepest-uphill walk in the two non-adjacent steepest directions; each trace terminates at a local maximum. - Pair each saddle with the two terminal maxima; assign persistence =
saddle_value − min(max_a_value, max_b_value)(the Edelsbrunner- Letscher-Zomorodian 2002 elder rule). - Filter by persistence: keep only saddles with persistence ≥
PERSISTENCE_THRESHOLD_FRACTION × field_range(default 0.05,TODO(M11l): promote toBasinPolicy.drainageDividePersistenceMeters). - Stamp surviving ridge cells into
drainageDivide.u8.binwith rank ∝ persistence.
This is a simplified DisPerSE implementation focused on the saddle- tracing + persistence-filter portion; the full discrete-Morse complex construction (M-cells, gradient pairs) is not required here because we only consume 1-cells (ridge segments) downstream.
Stage 2 → 3 — pass / saddle candidates (post-erosion)
Pass / saddle candidates are extracted AFTER Stage 3 has produced the
eroded height.f32.bin raster, not from the pre-erosion uplift + 0.4 × inv_age field. Saddles are properties of the actual terrain; the
pre-erosion combination field can exceed elevation_range_meters.max
and is not the terrain the renderer or downstream stages see. The
pipeline orchestrates Stage 2 → Stage 3 → post-erosion saddle pass
before writing the Stage 2 terrainGenesis.json artifact, so the
passCandidates[*].elevationMeters values fall naturally inside the
configured elevation range with no parameter scaling.
The extraction algorithm itself is Edelsbrunner-Harer-Zomorodian
2002 discrete-Morse-theory saddle detection: a cell is a saddle if
its 8-neighbour higher-set splits into ≥ 2 non-adjacent arcs. Passes
are ranked by saddle elevation (descending) and capped by recipe
counts.primaryMountainBelts.max × 4 + secondaryRanges.max × 2,
minimum 2.
Stage 2 — derived compatibility outputs
ridgeGraph.json and basins.json survive the M11c rewrite as
derived compatibility outputs:
RidgeGraphis sampled from theOrogenAxispolylines at ~80 km intervals (AXIS_VERTEX_SPACING_KM), with oneRidgeEdgeper pair of adjacent samples.width_kmdefaults to 30 km. When the recipe declares no orogens, the graph is empty — Wave G1 does NOT synthesise placeholder nodes (CLAUDE.md §57).Basinsis the marching-squares polygons of eachBasinCandidate, one Basin per candidate. When the marching-squares pass produces fewer than two basins (small worlds, soft-priority recipes), the derivedBasinscollection has fewer than two entries. Wave G1 does NOT synthesise placeholder basins to satisfy the legacystages/water.rslakes[1]index access (CLAUDE.md §57); Wave G2 rewrites water.rs around the actual basin-candidate set.
Both products carry derivedFrom: "terrain-genesis-projection" in their
dimensions metadata block, and the manifest schema gains a
derivedFrom discriminator field. Wave G2 deletes them when the water
stage is redesigned.
Stage 3 — heightfield input swap
stages/heightfield/mod.rs::generate now takes &TerrainGenesisProducts
instead of &RidgeGraph, &Basins. Initial elevation per cell is
uplift_m × age_factor × hardness_boost + relief_m × 0.4 + basin_depression_m + divide_boost_m + detail_noise × 90 m where
uplift_m, age_factor, hardness_boost, and relief_m come from the
Stage 2 truth products and detail_noise is the existing
ridged-multifractal texture capped at ±90 m on land (±18 m offshore).
The Mei-2007 virtual-pipes + Šťava-2008 sediment-transport erosion solver
stays untouched; only the per-cell rainfall input (now
genesis.orographic_rainfall) and per-cell hardness modulation on the
dissolving rate (local_ks = dissolving_ks · (2.0 − hardness).max(0.05))
are new. Hard rock erodes ~40× slower than soft rock per Mei-2007
time-step, matching the Sklar-Dietrich 2001 stream-power exponent and
the geologic age-vs-relief record: shield cratons retain higher relief
than equally-aged active orogens because they dissolve slower per
step. The Mei iteration budget converges the relief field on a fixed
time horizon; hard cells retaining more relief at horizon end is the
correct geologic outcome, NOT a deadlock to be parameter-tuned away.
Seeded RNG hierarchy
Every stochastic draw in Stage 1 and Stage 2 is keyed by SeedKey::rng_for,
which mixes (seed, blake3(recipe), blake3(intents), blake3(vocabulary), fnv1a(label), ordinal) through SplitMix64 (Steele-Lea-Flood 2014).
BLAKE3 (O'Connor et al. 2020, blake3 crate) is the canonical content
hash; the first 8 bytes of its 32-byte digest are interpreted
little-endian as a u64 and folded into the SplitMix64 key. The Valenar
export envelope (stages/valenar_worlddata.rs) keeps a separate
content_hash: "sha256:..." field for downstream consumers; the two
hash spaces are intentionally distinct.
M11c follow-ups deferred to later waves
The original Wave G1 dispatch deferred Sutherland-Hodgman, marching
squares, Douglas-Peucker, and Sousbie DisPerSE behind TODO(M11c- phase-2) / (M11c-phase-3) placeholders. The Wave G1 correction pass
(CLAUDE.md §49-62 "Rewrite commitment rule") promotes those four
algorithms to the live implementation, so the deferral notes are
retired. The remaining deferred items are:
- Wave G2 (water.rs rewrite): the legacy
stages/water.rspanics withindex out of bounds: the len is N but the index is 1when the marching-squares basin pass yields fewer than 2 basin candidates. Wave G1 deliberately does NOT compensate (CLAUDE.md §57 forbids compatibility shims to keep old call sites alive); Wave G2 redesigns water.rs around the actual basin-candidate set. TODO(M11c-phase-9): semantic algorithm tests (foreland_basin_candidate_is_leeward_of_active_margin,pass_candidates_lie_on_orogen_axes_within_60km, etc.).TODO(M11l): promote to vocabulary / recipe-driven values:AGING_FACTOR(currently 0.65, Tucker-Slingerland 1994 §2),- per-policy island count ranges,
- per-orogen-kind width tables (currently constant 30 km in
derive_ridge_graph), - the rainfall baseline LUT,
BasinPolicy.simplifyToleranceKm(currently 1.5 × cell_size_x_km inbasin_polygon_via_marching_squares),BasinPolicy.drainageDividePersistenceMeters(currently 5% of the relief-potential field range inbuild_drainage_divides).
M11d — Hydrology truth algorithms
Stage 4 (stages::water::generate) consumes the post-erosion heightfield
plus Stage 2's orographic_rainfall and emits the canonical hydrology
truth surface for the map. The Wave G2 (M11d) rewrite replaces the prior
fixed-six-node ridge / two-ellipse-basin / seven-hardcoded-river-node /
bent_centerline decoration with the standard textbook hydrology
algorithms below. The legacy RiverGraph / RiverCenterlines /
LakePolygons DTOs are retained as derived projections from the new
truth so downstream Stages 5-12 keep their existing field reads; Wave T
(M11h) retires the projections.
Stage 4 — DEM conditioning
Barnes, Lehman & Rajewski 2014 priority-flood with epsilon-tilt
(Computers & Geosciences 62:117-127) fills every closed depression and
gives every cell a strictly downhill path to the open boundary. The
algorithm pushes border cells and below-sea-level coastal cells into a
min-heap keyed on (elevation, insertion_sequence, cell_index) for
deterministic tie-break, then pops and propagates an
elevation + PRIORITY_FLOOD_EPSILON_M raise per neighbour. Cells
inside a closed basin inherit a depression_label from the cell that
spilled into them.
Output product: conditionedDem.f32.bin. Constant
PRIORITY_FLOOD_EPSILON_M = 1.0e-4 m (TODO(M11l): promote to
archetype.hydrology.priorityFloodEpsilonM).
Stage 4 — D8 flow routing
O'Callaghan & Mark 1984 D8 steepest-descent routing on the conditioned
DEM (CVGIP 28(3):323-344). For each non-sea cell, evaluate the eight
neighbours in lexicographic order (E, NE, N, NW, W, SW, S, SE) and
pick the steepest downhill drop; lowest-index direction wins ties.
Sea-mouth cells encode 0xFE, interior sinks 0xFF.
Output products: flowDirection.u8.bin, downstreamIndex.u32.bin.
Stage 4 — topological flow accumulation
Topological accumulation along the D8 graph: per-cell source term is
cell_area_km² × orographic_rainfall, then sort cells by elevation
descending (ties broken by row-major index) and sum upstream
contributions into the downstream cell. The result is the
rainfall-weighted catchment area at every cell, in km²·rain-unit.
Output product: flowAccumulationD8.f32.bin — distinct from Stage 3's
flowAccumulation.f32.bin which carries the Mei 2007 pipe-model water
column at simulation end.
Stage 4 — catchments and sinks
Vincent & Soille 1991 watershed labelling: enumerate sea-mouth and
interior-sink terminal cells in row-major order, then walk each
non-sink cell downstream via downstreamIndex until it reaches a
known terminal. Sinks are classified coast-mouth (boundary cells),
lake (depression with rainfall-fed accumulation),
endorheic (interior sink whose recipe vocabulary permits closed
drainage), or dry-sink (interior sink with negligible accumulation).
Output products: catchments.u32.bin, sinks.json (embedded in
hydrology.json).
Stage 4 — stream network
Stream cells: cells whose flowAccumulationD8 >= RIVER_FLOW_THRESHOLD_FRACTION × max_flow_d8 and which are not at
sea-mouth. The threshold scales with the maximum per-cell flow (the
largest catchment in the run) rather than the sum of all flow, so it
behaves consistently across scale presets where cell count differs
by orders of magnitude. Source / confluence / mouth node cells are detected by
inflow-count + downstream-is-sink. Segments are traced from each
non-mouth node downstream until another node. Strahler 1957 ordering
(Trans. AGU 38(6):913-920): leaf segments = 1, confluence with two
equal-order upstreams promotes by one. Shreve 1966 magnitude
(J. Geology 74(1):17-37): sum of upstream magnitudes (leaf = 1).
Output products: streamOrder.u8.bin, streamMagnitude.u16.bin,
riverSegmentId.u32.bin. Network nodes and segments embed into
hydrology.json.riverNetwork. Constants RIVER_FLOW_THRESHOLD_FRACTION = 0.04 (TODO(M11l): promote to archetype.hydrology.riverThreshold).
Stage 4 — lake polygons
Lakes derive from priority-flood depression labels — every closed
depression that the algorithm raised above its spill elevation is a
candidate. For each depression, build the membership mask
depression_label[idx] == this_label && raw_height[idx] <= spill + tolerance, run Lorensen & Cline 1987 marching squares (the 2D variant
shared with Wave G1's basin extraction), simplify with Douglas &
Peucker 1973 at LAKE_SIMPLIFY_FACTOR × cell_size_x_km, classify by
spill elevation (alpine over 1800 m, freshwater-outlet otherwise),
sort by area descending.
Output: hydrology.json.lakePolygons[]. Lakes below
MIN_LAKE_AREA_FRACTION × world_area_km² (currently 0.08% of world
area) are demoted to dry-sinks rather than emitted.
Stage 4 — river centerlines
Trace the D8 chain from each segment's from_cell to its to_cell,
collecting cell-centre points, then apply Chaikin 1974 corner-cutting
(Comp. Graphics Image Processing 3(4):346-349) CHAIKIN_ITERATIONS
times (currently 2 iterations) to remove cell-aliasing without
over-rounding bends.
Output: hydrology.json.riverNetwork.segments[].points.
Stage 4 — river width
Leopold & Maddock 1953 hydraulic geometry (USGS Prof. Paper 252):
width_km = BASE_RIVER_WIDTH_KM × (1 + strahler_order × RIVER_WIDTH_ORDER_FACTOR) × max(0.5, sqrt(discharge / REFERENCE_DISCHARGE)). Navigability gates on strahler_order >= NAVIGABLE_MIN_ORDER && width_km >= NAVIGABLE_MIN_WIDTH_KM.
Output products: per-cell raster riverWidth.f32.bin and per-segment
average_width_km in hydrology.json.
Stage 4 — water masks, body ids, floodplain, wetland
Extended waterMask.u8.bin encoding (replaces the prior 0..3 legend):
0dry land1sea (open ocean or below sea-level coastal cell)2lake (cell whose catchment terminates at a lake sink and whose raw elevation is at or below the spill)3river (stream cell)4wetland (high accumulation × low slope, not lake/river)5floodplain (within band radius of a stream ofstrahler_order >= FLOODPLAIN_MIN_ORDER)
waterBodyId.u32.bin assigns id 1 to the sea, 2..N to lakes, and
N+1.. to river segments so downstream consumers can disambiguate
adjacent water bodies. floodplainMask.u8.bin ranks cells within a
FLOODPLAIN_BAND_RADIUS_CELLS × (order / FLOODPLAIN_MIN_ORDER) band of
each high-order stream. wetlandMask.u8.bin ranks cells whose
flowAccumulationD8 >= WETLAND_MIN_ACCUM_FRACTION × total_flow and
slope <= WETLAND_MAX_SLOPE. Stage 5 (biomes_materials) reads the
canonical wetlandMask from Stage 4 rather than recomputing.
Stage 4 — crossing candidates and Location associations
Crossing candidates and per-Location hydrology associations require
information that does not exist at Stage 4 generation time (route
candidates land in Wave T M11h; Location polygons land at Stage 6).
Wave G2 emits both as empty arrays inside hydrology.json and lands a
distinct stages/04-b-hydrology-locations sub-stage scaffold that runs
AFTER Stage 6 (06-political) and writes a separate
locationHydrologyAssociations.json product (Wave G2 ships it as an
empty array, marked derivedFrom: "stage-6-political"). Wave T (M11h)
is scheduled to populate the real spatial-join body
(freshwaterAccess, majorRiverAccess, navigableRiverAccess,
floodplainPct, wetlandPct, basinId, valleyId,
riverSegmentIds); Wave T also fills the crossing candidates inside
hydrology.json from route candidates.
Stage 12 — water meshes (cascade)
Stage 12 (stages::meshes) keeps owning water-mesh triangulation:
miter-joint quad-strip rivers + earcut lake / sea surfaces, conforming
to the underlying triangulated terrain mesh (no billboards, no bbox
quads). The mesh stage continues to read the legacy
WaterProducts.lake_polygons and WaterProducts.river_centerlines
fields, which Wave G2 derives from the hydrology truth product.
Wave D (M11i / M11j) is the planned wave for Stage 12 to switch onto
the canonical hydrology.json consumer surface directly.
M11d follow-ups deferred to later waves
- Wave T (M11h): retire the derived legacy
riverGraph.json/riverCenterlines.json/lakePolygons.jsonprojections; migratemap_features.rsnamed anchors (Westwatch Harbor, Mirrorfen, Westmere Shore, Crown Pass) off the area-sorted-lake selector to World Intent Directive declarations per mapv10.md invariant 5; retireBiomeMaterialProducts.wetland_maskin favour of a directwater.wetland_maskread at the influence site; populate thestages/04-b-hydrology-locationssub-stage with the real spatial-join body (Wave G2 lands the sub-stage scaffolding and the emptylocationHydrologyAssociations.jsonplaceholder). - Wave U (M11i): consume
valleyintent directives intolocationAssociations.valleyIdand into stream-network valley enforcement. - Wave M (M11m): mountain-pass-aware feature spawning; add
mountainPassAccess+crossingCostMaxfields to Location associations via ahydrology-locations-v2schema bump. - Wave L (M11l): vocabulary promotion of every
TODO(M11l)constant introduced by M11d:PRIORITY_FLOOD_EPSILON_M,RIVER_FLOW_THRESHOLD_FRACTION,CHAIKIN_ITERATIONS,BASE_RIVER_WIDTH_KM,RIVER_WIDTH_ORDER_FACTOR,RIVER_WIDTH_REFERENCE_DISCHARGE,NAVIGABLE_MIN_ORDER,NAVIGABLE_MIN_WIDTH_KM,FLOODPLAIN_BAND_RADIUS_CELLS,FLOODPLAIN_MIN_ORDER,WETLAND_MIN_ACCUM_FRACTION,WETLAND_MAX_SLOPE,LAKE_SIMPLIFY_FACTOR,MIN_LAKE_AREA_FRACTION. - Tarboton 1997 D-infinity routing: Wave G2 ships D8 only. The smoothed Chaikin centerlines hide D8 cell-aliasing aesthetically; a future wave may swap in Tarboton D-infinity if sub-cell flow vectors become needed for downstream consumers (e.g. fine-scale erosion feedback loops or render-side surface flow particles).
M11e — Biome archetype climatology (vocabulary-driven)
Wave T1 retires the closed 0..=6 classify_biome enum and the
CORRUPTED_VISUAL_BIOME_ID: u8 = 7 magic constant. Both surfaces
were compatibility-shim residue from the pre-vocabulary M11a era;
they fell under the no-fallback rule alongside the closed
archetype enums M11a already retired.
Why the redesign
The 7-id biome enum had four concrete problems that fed back into nine downstream stages:
- No designer transparency. A worldbuilder reading
default-world-vocabulary.jsonsaw orogen and craton tokens but no way to declare whattropical-rainforesteven meant; the biome semantics were buried inside a Rustmatcharm inbiomes_materials.rs::classify_biome. - Forced parameter tuning. Adjusting fertility / mineral /
biome-driven gameplay values meant editing
valenar_worlddata.rs:715-728directly — a Rust source change, not a content change. - Closed list trap. Adding
mediterranean-shrublandortropical-monsoonormangroverequired a coordinated bump acrossclassify_biome,validate_biome_material_products,biome_label,biome_tag_for_id,effectiveVisualBiomeId,tile_pyramid::LodLevel.biomeenum doc-comment, viewerbiomeName, viewer palette texture, and every downstream test fixture. The 22 starter tokens are now declarative. - Dual-meaning override raster. The M11d
effectiveVisualBiomeraster mutated the base biome id to7for corrupted cells; the base biome was no longer the truth product, and thevalidate_world_recipe-style halt-on-hard-fail discipline did not extend to render-time semantics. Wave T1 splits this into a byte-identical base echo (effectiveBiome.u8.bin) plus a sparse sidecar (effectiveOverride.u8.bin) indexingvocabulary.effectiveVisualBiomeKinds.
Vocabulary additions
schema/world-vocabulary.schema.json adds two open-list categories:
biomeKinds: 22 starter tokens covering the canonical Whittaker archetypes plus province-conditional variants. Fields per token:id,displayName,definition,nameGroupingTag(one ofcoastal/plain/forest/highland/wetland/snow/default),displayColor(24-bit RGB integer),isWater(bool),rainfallRegime(one ofany/wet/moderate/arid/hyperarid),fertilityBaseline(0..100),mineralAffinity(0..100). Soft ordering convention:seaat index 0,freshwaterat index 1.effectiveVisualBiomeKinds: 1 starter token (corrupted-substrate). Same token schema; the raster encodesindex + 1so value 0 reserves "no override".
The 22 starter biome tokens are listed in the M11e implementation
report and in vocabulary/default-world-vocabulary.json.
Stage 5 algorithm
biomes_materials::classify_biome_token is a vocabulary-driven
Whittaker decision tree. Per cell:
- Water short-circuit on the Stage 4 water mask (sea ↔ freshwater).
- Wetland mask short-circuit (Stage 4 owned) → mangrove / floodplain / wetland depending on latitude + rainfall.
- High-elevation cap (> 2350 m) → polar-ice (polar latitudes) or mountain-alpine.
- Highland band (> 1100 m or slope > 0.19) → shield-exposed-rock on shield/platform provinces, tundra at sub-polar latitudes, mountain-meadow otherwise.
- Lowland Whittaker switch on
MoistureBand × latitude_band:- Tropical (lat-unit 0.42..0.58): rainforest / monsoon / savanna
/ subtropical-desert. Monsoon arm fires when
rainfall_pattern == "monsoonal". - Subtropical (0.35..0.42, 0.58..0.65): mediterranean-shrubland
gate fires on
(moderate moisture) ∧ (zonal-temperate pattern) ∧ (lat in [0.16, 0.33] ∪ [0.67, 0.84]); rift-valley-savanna fires on rift-derived provinces; craton-arid-interior fires on shield/platform provinces with low moisture; otherwise temperate-grassland / temperate- deciduous-forest by moisture. - Temperate (0.20..0.35, 0.65..0.80): temperate-rainforest / deciduous / grassland / craton-arid-interior by moisture.
- Subpolar (0.07..0.20, 0.80..0.93): boreal-forest / tundra.
- Polar (outside 0.07..0.93): tundra / polar-ice.
- Tropical (lat-unit 0.42..0.58): rainforest / monsoon / savanna
/ subtropical-desert. Monsoon arm fires when
Wave M11f has landed: the per-cell cell.y / world_height_km
proxy is replaced with a recipe-declared latitudeBandDegrees
projection that emits a signed absolute latitude in degrees (north-
positive: y=0 → max, y=world_height_km → min). The Wave T1 unit-
band cuts above are kept here for historical context only; the live
classifier reads the Köppen-Geiger zone constants
LAT_DEG_TROPIC_OF_CANCER_ABS = 23.5,
LAT_DEG_SUBTROPICAL_POLEWARD_LIMIT = 35.0,
LAT_DEG_TEMPERATE_POLEWARD_LIMIT = 60.0, and
LAT_DEG_ARCTIC_CIRCLE_ABS = 66.5 from
stages/biomes_materials.rs (see the ## M11f section below).
The province-kind hint is sampled by point-in-polygon against
genesis.tectonicProvinces for Wave T1; M11l lifts it to a
pre-computed Wave G1 raster product. The Mediterranean
seasonality model (proper wet-winter / dry-summer split) lands in
M11k.
Stage 9 effective biome split
derive_effective_visuals now emits two parallel rasters:
effective_biome: Vec<u8>— byte-identical clone of the base biome. Corruption never mutates this channel.override_token: Vec<u8>— sparse override. Value 0 = no override; valuev > 0decodes tovocabulary.effectiveVisualBiomeKinds[v - 1]. The single starter tokencorrupted-substratewrites raster value 1 on active-corruption cells whose hard threshold is crossed.
The override category is open: future visual-override tokens (e.g.
plague-blight, volcanic-ash-fall) land as new entries in the
vocabulary without a schema bump or a Rust change. The water-cell
skip-gate now consults the resolved vocabulary's isWater flag
instead of hardcoding biome ids 0 and 5.
Downstream cascade
naming::biome_tag_for_id(u8) -> Option<&'static str>is replaced bynaming::biome_tag_for_token(&BiomeKindToken) -> &str, reading the token'snameGroupingTagfield.political_naming::runthreads the resolved vocabulary slice into every realm / province / location naming call.valenar_worlddata::LocationStats.biome_countschanges from[u32; 7]toBTreeMap<String, u32>keyed by vocabulary id.dominant_biomeresolves viamax_byon the map with lexicographic tiebreak.biome_label(usize)is retired.primary_province_biomeswitches its silentunwrap_or_else(|| "plains".to_string())fallback to a hardexpect()per the no-fallback rule.facts_for_locationreadsfertility_baselineandmineral_affinityfrom the dominant token rather than the hardcodedmatch biomearm.tile_pyramid::LodLevel.effective_visual_biome→ `.effective_biome- .override_token
. Both ride the samecascade_mode_u8downsample for now;TODO(M11-T1b)` flags a mode-vs-mean re-evaluation once the override token list grows.
- .override_token
previews::biome_preview_png(width, height, values, &[BiomeKindToken])readsdisplayColorfrom the vocabulary; a loud sentinel red surfaces out-of-range indices rather than silently substituting a default.validate_world_vocabularygains thebiomeKinds+effectiveVisualBiomeKindschecks (non-empty, id-unique, field-range, isWater-present).validate_biome_material_productsderivesmax=vocabulary.biomeKinds.len() - 1rather than the hardcoded6.validate_influence_productssplits the legacyeffectiveVisualBiomeraster check into a byte-equal-with-base check foreffectiveBiomeand an(index + 1)range check foreffectiveOverride.
Open follow-ups (deferred from Wave T1)
- Wave T1b (M11-T1b): viewer cascade.
Mapv10ThreeRenderer.ts'sbiomeName: { 0: 'sea', 1: 'plains', … 7: 'corrupted' }map is the symmetric closed-enum surface on the viewer side; it must be replaced by a vocabulary-fetched palette identical to the generator's. The biome shader must also sample the new sparseeffectiveOverride.u8.binraster and look up the palette viavocabulary.effectiveVisualBiomeKinds[value - 1]. Until the viewer dispatch lands, the renderer surfacesunknown-Nlabels for token ids ≥ 7. - Wave M11f: LANDED. Recipe
archetype.latitudeBandDegreesfield now drivesbiomes_materials.rs::generate; see the## M11fsection below for the algorithm, sign convention, and validation. - Wave M11k: seasonality model; replace the Mediterranean latitude-band gate with a wet-winter / dry-summer derivation.
- Wave M11l: lift the per-cell province-kind point-in-polygon scan into a Wave G1 raster product so Stage 5 reads an O(1) lookup instead of paying the O(P) cost per cell. Promote the fertility / mineral baselines to designer-rebalance pass when Valenar gameplay data lands.
M11h — Location-hydrology spatial-join
This wave promotes the Stage 4b
stages/04-b-hydrology-locations sub-stage from the Wave G2 empty-array
placeholder to a populated source-of-truth product. The implementation
lives in examples/map/mapv10/generator/src/stages/hydrology_locations.rs.
Source-of-truth surface
locationHydrologyAssociations.json is the only consumer-facing
product for per-Location hydrology join data. The committed empty
hydrology.json:locationAssociations[] array stays empty per M11 ADR
§744-757 + §773-782 — there is no dual write, no alias, no fallback.
Stage 7 (routes), Stage 15 (Valenar), and every downstream consumer
read locationHydrologyAssociations.json directly. The LocationPolygon
struct does NOT gain echoed hydrology fields.
Algorithm — majority-vote over enclosed cells
For each LocationPolygon in political_products_data.location_polygons.locations:
- Iterate cells inside the Location's polygon bounding box (the same
bbox
stages::political::rasterize_location_iduses). - Filter cells by
political.location_id[idx] == location.numeric_id. This re-uses the cell-ownership oracle Stage 6 already produced; no fresh point-in-polygon test is required. - Accumulate
total_cells,floodplain_cells,wetland_cells,basin_histogram[basin_raster_value], the sorted-unique set of non-zeroriver_segment_idcells, and anany_lake_cellflag. - Hard-panic if
total_cells == 0. A zero-cell Location is a contract violation from the political rasterizer; silent defaulting would mask the upstream bug.
Pre-built O(1) raster-value → string-id maps are built once before the per-Location loop:
segment_numeric_to_id: raster valuev > 0→segments[v - 1].id(thewater.rs::extract_streamsproducer writesseg_idx_one_based = segments.len() + 1before pushing the segment).basin_numeric_to_id: raster valuev→basin_candidates[v - 1000].id(thegeography_graph.rs::extract_basin_classnext_idcounter starts at 1000 and is shared across the foreland → interior → endorheic calls, so basin indexicorresponds to raster value1000 + i).
Field-by-field derivation
| Field | Derivation |
|---|---|
location_id | LocationPolygon.id |
freshwater_access | river_segment_one_based non-empty OR any_lake_cell |
major_river_access | any touching segment has strahler_order >= MAJOR_RIVER_MIN_ORDER (default 3) |
navigable_river_access | any touching segment has .navigable == Some(true) |
floodplain_pct | floodplain_cells / total_cells, range [0, 1] |
wetland_pct | wetland_cells / total_cells, range [0, 1] |
basin_id | dominant histogram entry; tiebreak by lower numeric raster value (deterministic). None if no enclosed cell has a basin. |
valley_id | None, always. Custom-valley directive resolution does not exist yet — see deferral below. |
river_segment_ids | sorted-unique segment ids touched by enclosed cells |
MAJOR_RIVER_MIN_ORDER = 3 matches FLOODPLAIN_MIN_ORDER in
water.rs so the "major river" semantic and the "rivers large enough
to grow floodplains" semantic stay in sync. TODO(M11l) promotes the
constant to WorldVocabulary.
Validator
validation::validate_location_hydrology_associations hard-fails on:
associations.len() != locations.len()(one association per Location)- any
location_idnot resolving to a Location - any
river_segment_ids[]entry not resolving to a segment - any
Some(basin_id)not resolving to a basin candidate - any
Some(valley_id)(this wave keeps it None) - any
floodplain_pctorwetland_pctoutside[0, 1]or non-finite
Deferrals (not landed in M11h)
- Wave I/M11i: custom-valley directive resolution. The
WorldIntentDirectives.directives[].kind = "valley"resolver does not exist yet. Once it lands,valley_idbecomes the resolved directive id when a Location overlaps a custom-valley footprint. Silently populatingvalley_idbefore the resolver lands would fabricate geography truth and is forbidden by the M11 no-fallback doctrine. - Wave M/M11m:
hydrology-locations-v2schema bump. Addingmountain_pass_dependencies[],orogen_dependencies[],catchment_id, orwater_body_idtoLocationHydrologyAssociationrequires bumpinghydrology.schema.jsonpast v1, since the schema closes the object withadditionalProperties: false. Adding new fields in M11h would silently expand the schema contract; forbidden. - Wave L/M11l: vocabulary promotion of
MAJOR_RIVER_MIN_ORDER(currently apub constmatching thewater.rsfloodplain min order). Joins it to the existing M11l promotion batch. - Wave F/M11f: LANDED. The recipe
latitudeBandDegreesrewiring ofbiomes_materials.rsis the disjoint scope from this wave; see the## M11fsection below for what landed. M11f and M11h were the two wave letters the original orchestrator dispatch confused, and they are now both committed independently.
M11f — Recipe latitude-band climate zone
This wave promotes Stage 5's per-cell climate-zone proxy from the
Wave T1 world-y unit-band heuristic to a recipe-declared
archetype.latitudeBandDegrees projection. The change is generator-
side only; the renderer is unaffected.
Source-of-truth surface
archetype.latitudeBandDegrees = { min, max } (both numbers, both
in [-90, 90], min < max, max - min >= 5°) is now the only
generator input that decides which Köppen-Geiger climate zone a
given per-cell world-y resolves to. The four shipped presets
declare:
| Preset | min | max |
|---|---|---|
| preset-south-america-like | -55 | +12 |
| preset-australia-like | -43 | -10 |
| preset-europe-like | +35 | +70 |
| preset-africa-like | -35 | +38 |
The schema (schema/world-recipe.schema.json) lists the field as
required on archetype. The recipe loader hard-fails when the
field is missing, swapped, out of [-90, 90], or spans less than
5°. There is no priority taxonomy on this field — the band is
recipe geometry, not a soft request that the generator can choose
to under-satisfy. Anything that would have appeared as a soft or
suggest priority would silently produce a different climate, and
the no-fallback doctrine forbids that path.
Projection formula and sign convention
Stage 5 reads the band once at stage entry and projects every per- cell world-y onto absolute latitude as
normalized_y = clamp(point.y / world_height_km, 0.0, 1.0) as f32
absolute_latitude_degrees =
latitudeBandDegrees.max
- normalized_y * (latitudeBandDegrees.max - latitudeBandDegrees.min)
Sign convention is north-positive and matches the mapv10 axis
convention used by Stage 1 (stages/geography_graph.rs coast-edge
mapping) and the viewer scene graph: y = 0 is the world
rectangle's NORTH edge and projects to latitudeBandDegrees.max;
y = world_height_km is the south edge and projects to
latitudeBandDegrees.min. Inverting this convention would silently
rotate every preset's climate by 180° — a south-america-like
continent would produce subpolar climate at its tropical equator-
end and rainforest at its sub-Antarctic tip. The canary test
south_america_like_y_zero_produces_equatorial_climate (in
stages/biomes_materials.rs::tests) locks the convention down.
Köppen-Geiger zone breakpoints
stages/biomes_materials.rs defines the climate-zone breakpoints as
Rust const f32 values, sourced from Köppen-Geiger 1936 and Peel,
Finlayson & McMahon 2007 (Hydrol. Earth Syst. Sci. 11: 1633-1644,
"Updated world map of the Köppen-Geiger climate classification"):
| Constant | Value | Meaning |
|---|---|---|
LAT_DEG_TROPIC_OF_CANCER_ABS | 23.5 | tropical / subtropical boundary |
LAT_DEG_SUBTROPICAL_POLEWARD_LIMIT | 35.0 | subtropical / temperate boundary |
LAT_DEG_TEMPERATE_POLEWARD_LIMIT | 60.0 | temperate / boreal boundary |
LAT_DEG_ARCTIC_CIRCLE_ABS | 66.5 | boreal / polar boundary |
LAT_DEG_MEDITERRANEAN_EQUATORWARD | 30.0 | Mediterranean Csa/Csb equator side |
LAT_DEG_MEDITERRANEAN_POLEWARD | 45.0 | Mediterranean Csa/Csb pole side |
These are physical-science truth, not authoring knobs; they
intentionally do NOT carry TODO(M11l) promotion markers because
they are not designer-rebalance constants. The Mediterranean
seasonality model in M11k replaces the latitude-only gate with a
true wet-winter / dry-summer derivation; it does not replace the
constants.
Predicate functions
is_tropical, is_subtropical, is_temperate, is_subpolar, and
is_polar now take absolute_latitude_degrees: f32, take the
absolute value (zones are hemisphere-symmetric for now — the
southern-hemisphere wet-summer Mediterranean fringe waits for M11k
seasonality), and compare against the constants above. The
classifier-input field is renamed
ClassifierInputs.latitude_unit -> absolute_latitude_degrees and
keeps the signed value so the future seasonality model can read it
without another DTO change.
Validation
validation::validate_world_recipe emits three new checks per
recipe load:
recipe-latitude-band-range—lat_band.min < lat_band.max.recipe-latitude-band-bounds— both numbers in[-90, 90].recipe-latitude-band-span-minimum—lat_band.max - lat_band.min >= 5°.
All three are hard-fail per the M11 no-fallback doctrine; the recipe will not load on failure and the generator does not default to a fallback band.
Determinism
pipeline.rs::same_config_recipe_intents_vocabulary_produces_same_core_product_bytes
extends the product-key list with biome, materialWeights,
materialWeightsB, forestMask, effectiveBiome, and
effectiveOverride. The four-tuple (seed, recipe, intent- directives, vocabulary) still produces byte-identical core
product bytes across re-runs after the latitude-band rewrite. The
actual byte values change relative to the pre-M11f baseline because
the classifier inputs are different — that is the intent of the
wave — but the new bytes are stable.
Out-of-scope (deferred to future waves)
- M11k seasonality model. Wet-winter / dry-summer derivation for the Mediterranean Csa/Csb gate, replacing the latitude-only band-membership check.
- M11l vocabulary / config promotion. None of the Köppen-Geiger
constants are designer-rebalance knobs; M11l does not promote
them. The pre-existing
TODO(M11l)markers on the province-kind raster lift and other vocabulary-bound parameters are unaffected. - Roadmap-prompt M11f label cleanup. The
prompts/mapv10-roadmap-m11-world-genesis.mddocument contains a label conflict where "M11f" was reused for a different wave by the original roadmap author. Resolving that prompt-side label conflict is deferred to a separate documentation-cleanup wave.
M11g — Routes through passes + crossing candidates
This wave teaches Stage 7 (routes) to read the declared PassCandidate
list from Stage 2 (terrain-genesis) and to apply a multiplicative
pass-discount on routes that travel within pass_proximity_km(config)
of a saddle. It also introduces a new Stage 7b sub-stage
stages/route_crossings.rs that emits routeCrossingCandidates.json —
a richer per-route crossing payload than the legacy
crossingAnchors.json (still emitted by Stage 7).
Source-of-truth surface
routeCrossingCandidates.json (schema:
mapv10-route-crossing-candidates-v1) is the only consumer-facing
product for the richer (route_edge_id, river_segment_id,
strahler_order, crossing_kind, cost_km) crossing payload. The
hydrology.json::crossingCandidates[] array stays committed-empty per
the M11 ADR §744-757 + orchestrator-mode.md §49 — there is no dual
write, no alias, no fallback. The legacy crossingAnchors.json from
Stage 7 stays alive in this wave; Wave M/M11m retires it after
downstream consumers migrate.
Pass-discount cost model
Citation: Hill 1985 ("Tactical movement on mountain passes", Military
Review) and Llobera-Sluckin 2007 ("Zooming in on movement: a multi-
scalar approach to the study of human movement", JAS 34) archaeological
least-cost-path GIS literature. Empirical passable-pass-vs-surrounding-
ridge traversal-cost ratios span 0.1-0.4; the M11g constant
PASS_DISCOUNT_FACTOR = 0.25 is the conservative midpoint.
Pass-proximity is pass_proximity_km(config) = max(15 km, 10 * cell_size_x_km).
The lower bound at 15 km handles the default mapv10 raster
(~12 km cells, ~120 km cell-aliasing horizon); the resolution-aware
factor 10 * cell_size_x_km keeps the threshold inside the cell-
aliasing floor for high-resolution rasters.
The discount fires in BOTH the inner terrain_aware_centerline search
objective AND the final build_route_candidate cost. Applying it only
to the final cost would land M11g inert — routes would still go around
mountains via the bbox-quad detour set, never specifically steering
through declared saddles. Seed controls at each PassCandidate inside
the [a, b] bounding box (padded by pass_proximity_km) ensure the
inner search has a polyline-control option that actually crosses the
saddle.
Crossing cost model
Citation: Leopold-Maddock 1953 ("The hydraulic geometry of stream channels and some physiographic implications", USGS Professional Paper 252) for the drainage-area scaling exponent, and Strahler 1957 ("Quantitative analysis of watershed geomorphology", Trans. AGU 38(6)) for the per-order classification.
cost_km = base * max(1, drainage_area_km2 / REFERENCE_DRAINAGE_AREA_KM2)^0.9
| Constant | Value | Rationale |
|---|---|---|
REFERENCE_DRAINAGE_AREA_KM2 | 1_000 | Mid-size Strahler-3-to-4 river anchor for the per-kind base costs. |
DRAINAGE_AREA_COST_EXPONENT | 0.9 | Leopold-Maddock 1953: width-to-discharge exponent ~0.5 plus depth-to-discharge ~0.4 sum to engineering toll exponent ~0.9. |
FORD_BASE_COST_KM | 0.3 | Shallow unimproved crossing — dominated by time loss, not construction. |
BRIDGE_BASE_COST_KM | 2.0 | Medieval-to-industrial bridge engineering toll mid-point from comparative GIS least-cost-path studies. |
FERRY_BASE_COST_KM | 5.0 | 2.5× bridge for Strahler-6+ rivers requiring a ferry or major engineered span. |
STRAHLER_FORD_MAX | 2 | Orders 1-2 are shallow, low-discharge, waded year-round. |
STRAHLER_BRIDGE_MAX | 5 | Orders 3-5 are perennial rivers spanable by masonry / wood bridges. |
PASS_DISCOUNT_FACTOR | 0.25 | Hill 1985 + Llobera-Sluckin 2007 ratio midpoint (0.1-0.4 band). |
All eight constants carry TODO(M11l) markers and ship to the M11l
vocabulary-promotion batch.
The toll is additive in km-equivalent, not a per-km multiplier:
cost = (length + total_crossing_cost_km) * terrain_penalty * pass_discount * importance_factor
Crossings are one-time engineering tolls, not per-km penalties.
Stage 7b algorithm
For each RouteCenterline polyline:
- Sample the polyline cell-by-cell at a half-cell-diagonal step so no river cell is missed.
- Look up
water.river_segment_id[cell_idx](1-based per Wave G2extract_streams; value 0 means "no segment"). - Edge-trigger on a transition into a new non-zero segment id; emit a
single
RouteCrossingCandidateper (route_edge_id, river_segment_id) pair (deterministic order via per-route counter +BTreeSetdedup). - Classify by
STRAHLER_FORD_MAX/STRAHLER_BRIDGE_MAX; cost viacrossing_cost_km(strahler_order, drainage_area_km2).
Validator
validation::validate_route_crossing_candidates hard-fails on:
- a
routeEdgeIdnot resolving to arouteGraph.edges[].id - a
riverSegmentIdnot resolving to ahydrology.riverNetwork.segments[].id - a
crossingKindoutside{ford, bridge, ferry-or-bridge-major} - a
crossingKinddisagreeing with the Strahler classifier for the declaredstrahlerOrder - a non-finite or non-positive
costKm - a
pointoutside world bounds - a duplicate
(routeEdgeId, riverSegmentId)pair - a duplicate
id
Pass-usage rate is NOT a hard validator — it lives as an integration
test on the south-america-like preset (pipeline::tests::m11g_pass_usage_rate_meets_minimum_on_south_america_like_preset).
The test asserts ≥50% of inter-realm route edges pass within
pass_proximity_km(config) of at least one declared PassCandidate.
Determinism
The Stage 7b sub-stage extends the existing (seed, recipe, intent- directives, vocabulary) byte-determinism contract. The
pipeline::tests::same_config_recipe_intents_vocabulary_produces_same_core_product_bytes
test now covers the new routeCrossingCandidates product key.
Stage 7 dependency extension
Stage 7's declared dependencies grow from ["06-political"] to
["02-geography-graph", "04-hydrology", "06-political"]. The previous
list was implicit (Stage 7 already read heightfield and hydrology
products via the function args); M11g makes the dependency explicit so
the stage-index manifest reflects the real read set.
M11g follow-ups deferred to later waves
- Wave M/M11m: retire the legacy
crossingAnchors.jsononceviewer/src/andmap_features.rshave migrated their crossing- anchor consumers torouteCrossingCandidates.json. The legacy product stays alive this wave; downstream consumers are out of M11g's scope. - Wave L/M11l: vocabulary promotion of every
TODO(M11l)constant introduced by M11g:REFERENCE_DRAINAGE_AREA_KM2,DRAINAGE_AREA_COST_EXPONENT,FORD_BASE_COST_KM,BRIDGE_BASE_COST_KM,FERRY_BASE_COST_KM,STRAHLER_FORD_MAX,STRAHLER_BRIDGE_MAX,PASS_DISCOUNT_FACTOR,MIN_PASS_PROXIMITY_KM, andPASS_PROXIMITY_CELL_FACTOR. Joined to the existing M11l promotion batch. - Wave H/M11h: locations gain mountain-pass dependencies and
crossing-cost-max fields via a
hydrology-locations-v2schema bump (this is the same Wave M/M11m note as in the M11h section above).
M11i — Tile pyramid raster-channel extension
Scope
Stage 11 (generator/src/stages/tile_pyramid.rs::generate) slices a
fixed channel set from Stage 2 (terrain genesis), Stage 4
(hydrology), Stage 5 (biomes/materials), Stage 9 (influence), and
Stage 10 (border SDF) into per-LOD per-tile rasters. Before M11i the
pyramid carried the Stage 5 / 9 / 10 / heightfield channels but
omitted the Stage 2 and Stage 4 truth scalars that downstream
overlays and inspectors need. M11i closes that gap by adding 16
channels (7 from terrain genesis, 9 from hydrology) and cleaning up
two residue items left over from Wave T1.
Decision
The tile pyramid carries every per-cell raster truth product required by a continent-scale inspector, with one explicit exception. Sixteen new channels ride the iterative-2:1 mip cascade alongside the existing channels:
| Channel | Source product | Byte format | Cascade rule |
|---|---|---|---|
uplift | genesis.uplift | f32 | cascade_box_mean_f32 |
crustAge | genesis.crust_age | f32 | cascade_box_mean_f32 |
bedrockHardness | genesis.bedrock_hardness | f32 | cascade_box_mean_f32 |
reliefPotential | genesis.relief_potential | f32 | cascade_box_mean_f32 |
basinCandidateId | genesis.basin_candidate_id | u32 | cascade_mode_u32 |
drainageDivide | genesis.drainage_divide_candidate | u8 | cascade_max_u8 |
orographicRainfall | genesis.orographic_rainfall | f32 | cascade_box_mean_f32 |
conditionedDem | water.conditioned_dem | f32 | cascade_box_mean_f32 |
flowDirection | water.flow_direction | u8 | cascade_mode_u8 |
flowAccumulationD8 | water.flow_accumulation_d8 | f32 | cascade_box_mean_f32 |
catchments | water.catchments | u32 | cascade_mode_u32 |
streamOrder | water.stream_order | u8 | cascade_max_u8 |
streamMagnitude | water.stream_magnitude | u16 | cascade_mode_u16 |
riverSegmentId | water.river_segment_id | u32 | cascade_mode_u32 |
waterBodyId | water.water_body_id | u32 | cascade_mode_u32 |
floodplainMask | water.floodplain_mask | u8 | cascade_max_u8 |
The cascade rules follow channel-class semantics, not parameter tuning:
cascade_box_mean_f32is the canonical continuous-field downsample (same kernel asheight_values,slope,material_weights). Iterative-2:1 box-means anti-alias sub-Nyquist content; a single-step direct-from-source mean would leak periodic content.cascade_mode_u32/cascade_mode_u16are the discrete-id rule (catchment / river-segment / water-body / basin-candidate id spaces, Shreve magnitudes). Mean-averaging discrete ids would produce labels that do not exist. Ties break toward the smallest value for deterministic, run-stable output, matching the existingmode_2to1_u8precedent.cascade_max_u8is the conservative-coverage rule for rank/binary masks (wetlandMask,floodplainMask,drainageDivide,streamOrder). A coarse cell that covers any positive-rank source cell retains that rank — mean-downsampling would erode the mask edge across coarse LODs, whereas the mask semantics are "is this region inside the masked class?" Matches NASA EOSDIS / ESA Sen2 QA-band / USGS landfire downsamplers.
downstreamIndex is deliberately omitted. It is a per-cell pointer
into the source-raster index space: a downsampled pointer would
dereference into the wrong cell range because the 2x2 source window
covers four distinct downstream targets with no meaningful
aggregation rule. The channel stays a Stage 4 base-resolution
raster product (pipeline.rs::downstreamIndex registration);
hydrology overlays that need downstream traversal consume the root
product directly.
Two cleanup items ship with M11i:
wetlandMaskre-source. The basewetlandMasktile asset now reads fromwater.wetland_mask(the canonical post-G2 source) instead ofbiomes.wetland_mask, and the cascade rule switches fromcascade_box_mean_u8(continuous-field mean) tocascade_max_u8(rank-mask max). The base biome stage still emitsbiomes.wetland_maskfor the influence-derivedeffectiveWetlandMaskchain; the change is scoped to the basewetlandMaskchannel only.effectiveVisualBiomeresidue cleanup. The Wave T1effectiveVisualBiomechannel was retired in favour of the two split productseffectiveBiome(base echo) andeffectiveOverride(sparse sidecar). The tile pyramid'sraster_source_dependenciesarray andrasterTileChannelscontract description still listed the retired name; both now reference the two split products instead.
Cascade tiebreak comment trail
flowDirection uses cascade_mode_u8 with a deliberate TODO note:
the mode rule picks the most common direction inside each 2x2
source window, which preserves the dominant direction but does not
enforce D8 routing self-consistency at coarse LODs.
TODO(M11-D1b): a follow-up wave should swap to a D8-aware
aggregator (Tarboton 1997 reference) that picks the dominant
downstream direction across the 2x2 window with explicit
self-consistency rules. The current rule is acceptable as a
visualisation cascade because hydrology overlays do not run flow
simulation against the coarse LODs.
flowAccumulationD8 uses cascade_box_mean_f32 with a deliberate
TODO note: mean-downsampling gives visual heatmap parity with the
other scalar overlays (height, slope, orographic rainfall), but it
does not conserve per-LOD totals (sum-downsample would). The mean
is correct for a "what is the typical accumulation around here?"
inspector; consumers that need per-LOD-conserved totals should
revisit the cascade rule. TODO(M11-D1b).
Validation
validation::validate_tile_pyramid_products bumps its per-tile
raster-asset-count floor from >= 19 to >= 35 and extends the
required-asset list to the 16 new channels (plus the existing
effectiveBiome/effectiveOverride pair). The effectiveVisualBiome
key is no longer accepted.
Forbidden patterns this wave avoided
- Inventing a downstream-aware
cascade_mode_u8heuristic forflowDirectionbeyond the existing mode rule. The Tarboton 1997 aggregator is the correct algorithm and is explicitly deferred rather than substituted with a tuned approximation. - Adding
downstreamIndexto the tile pyramid with a "least-bad" default aggregation rule. The no-fallback doctrine treats the missing meaningful rule as a stop, not as permission to invent one. - Keeping
effectiveVisualBiomealive as an alias in the manifest metadata. Wave T1 retired the name; M11i removes the last references rather than introducing a compatibility synonym.
M11j — Stage 15 Valenar enrichment
Scope
Stage 15 (generator/src/stages/valenar_worlddata.rs::generate)
projects mapv10 truth into Valenar's import JSON surface. Before
M11j the projection used the political polygons, neighbor graph,
heightfield, water, biomes, and routes — but did not surface the
Stage 4b LocationHydrologyAssociation truth product (added in
M11h). M11j closes that gap by enriching every Valenar Location
record with a per-Location hydrology projection.
Decision
The world document gains a required hydrology field per Location,
typed as ValenarWorldLocationHydrology:
| Field | Type | Source |
|---|---|---|
freshwater_access | bool | LocationHydrologyAssociation::freshwater_access |
major_river_access | bool | LocationHydrologyAssociation::major_river_access |
navigable_river_access | bool | LocationHydrologyAssociation::navigable_river_access |
floodplain_pct | f64 (0..=100) | LocationHydrologyAssociation::floodplain_pct |
wetland_pct | f64 (0..=100) | LocationHydrologyAssociation::wetland_pct |
basin_id | Option<String> | LocationHydrologyAssociation::basin_id |
river_segment_ids | Vec<String> | LocationHydrologyAssociation::river_segment_ids |
The mapv10-internal valley_id field is dropped from the
projection because Valenar consumes only the basin id. All other
fields round-trip byte-identically — the projection does not
re-compute, re-thresh, or default any value.
Schema bump
The world product schema id bumps from valenar-world-v1 to
valenar-world-v2. The format_version constant on the
ValenarWorldDocument Rust DTO bumps from 1 to 2. The mesh
manifest shape is unchanged so its schema id stays
valenar-world-mesh-v1 and its format_version stays 1. A
separate Rust constant MESH_FORMAT_VERSION = 1 was introduced so
the two documents version independently.
Hard-fail on missing association
The projection helper (project_location_hydrology) panics when
its Option<&LocationHydrologyAssociation> input is None. The
validator's new hydrology-presence check guards the field's
numeric components: a Location whose floodplain_pct or
wetland_pct is non-finite fails the run. The Rust type system
guarantees structural presence (hydrology is non-optional on
ValenarWorldLocation), and the JSON Schema marks the field
required on the location definition.
These three layers — type system, runtime panic, validator check — implement the M11 no-fallback doctrine end-to-end: a missing Stage 4b association is a hard fail, not a silent default.
Pipeline cascade
Stage 15 gains an explicit dependency on Stage 4b. The Rust
invocation site (pipeline.rs) passes
&location_hydrology_associations into valenar_worlddata::generate;
the stage's dependencies list gains "04-b-hydrology-locations";
the stage's contract()::sourceTruth gains
"locationHydrologyAssociations". The product registration string
in the run manifest moves from "valenar-world-v1" to
"valenar-world-v2". The viewer/scripts/validate-valenar-export.mjs
node validator picks up the new schema id and format_version
automatically because the JSON Schema is compiled at load time.
Fixture regeneration
The bundled viewer/public/continent-lod6/valenar/world-*.json
fixture is regenerated against the v2 generator; the v1 fixture is
incompatible with the v2 reader and was rebuilt rather than
preserved.
Forbidden patterns this wave avoided
- Silently defaulting a missing
LocationHydrologyAssociationto a "no freshwater" record. The no-fallback doctrine forbids it; the projection panics instead. - Aliasing
valenar-world-v1tovalenar-world-v2. The retired schema id is gone from the live product registration; the only remaining reference is the historical hand-off inartifacts.rs::json_product_type, documented in-place. - Adding a per-Location
valley_idfield. Valenar's consumers are defined to operate on basin-grained hydrology truth; surfacing the mapv10-internal valley graph would extend Valenar's import contract beyond what the upstream supports.
M11i / M11j follow-ups deferred to later waves
- Wave M11i-Mesh: Switch Stage 12 (
generator/src/stages/meshes.rs) from consuming the derivedWaterProductslegacy projections (river_centerlines,lake_polygons,water_mask) to directhydrology.jsonconsumption. Stage 12 currently mounts the derived projections kept alive onWaterProductsfor Wave T compatibility; the canonical hydrology surface now carries the full river network, lake polygons, sinks, catchment summaries, and the per-LOD raster channel set. The switch is deferred to scope this wave around Stage 11 + Stage 15. - Wave M11-D1b: Reconsider the
flowDirectionandflowAccumulationD8cascade rules. The current mode-downsample onflowDirectionis acceptable as a visualisation cascade but is not D8-routing-consistent at coarse LODs; the canonical follow-up is a Tarboton 1997 D8-aware aggregator that selects the dominant downstream direction inside each 2x2 source window. The mean-downsample onflowAccumulationD8is correct for inspector heatmaps but does not conserve per-LOD totals; the canonical follow-up is a sum-downsample.
M11k — World Lab UX
M11k introduces the browser-side authoring + run-launch surface that turns the existing recipe / intent / vocabulary schemas into a usable workflow. It is split into two waves because the overlay-inspector half of the original spec requires upstream typed loaders that have not yet landed.
M11k-edit (Wave P, shipped)
- Recipe editor under
viewer/src/ui/world-lab/recipeEditor.tsrenders every field declared inschema/world-recipe.schema.jsonwith the per-field widget kind recommended indocs/world-lab-ux.md. Token pickers consumeWorldVocabularyResolveraccessors for the nine vocabulary categories the recipe surface references (orogenKinds,cratonKinds,ageBands,rainfallPatterns,residualHighlandKinds,interiorBasinKinds,islandPolicies,isthmusPolicies,nameStyles). - Intent editor under
viewer/src/ui/world-lab/intentEditor.tsrenders every field declared inschema/world-intent-directives.schema.json. The 13 directivekindvalues come from the schema's closed enum; thenamingPreferences.nameStylepicker comes from vocabulary. - Run launcher under
viewer/src/ui/world-lab/runLauncher.tsPOSTs the in-memory draft to a new dev-server endpointPOST /mapv10/runs/launchand consumes the SSE stream the server exposes for stdout / stderr / exit events. The endpoint spawns the generator binary (env-pinned viaMAPV10_GENERATOR_BIN, otherwisecargo run --release --insideexamples/map/mapv10/generator/). Recipe / intent / vocabulary JSON spill toexamples/map/mapv10/recipes/.tmp/launch-<launchId>/and are deleted on exit. - Run library under
viewer/src/ui/world-lab/runLibrary.tsreads the existingGET /mapv10/runs/endpoint and renders a card per run. The endpoint'sRunStatusshape is extended with three new fields (recipeRef,generatedAtUnixMillis,thumbnailPath). Clicking a card invokesrenderer.loadRundirectly with the manifest URL — no page reload. - Compare runs under
viewer/src/ui/world-lab/compareRuns.tsrenders a two-pane diff with synced scroll, top-level metrics (product count, raster channel count, mesh count, content hash), and a thumbnail strip per pane. Post-M11i metrics (orogen count, basin count, river network size) are placeholders pending M11k-inspect. - Shell integration: a "World Lab" button in the viewer top bar
toggles a
world-lab-draweraside peer ofdebug-drawer. The renderer keeps running in the background while the drawer is open. The lab mounts lazily on first open so the Ajv2020 pipeline does not compile schemas until the user actually enters the lab. - Validation: a shared
ajvPipeline.tscompiles every committed schema under Ajv2020 strict mode +ajv-formats. The compile order matchesscripts/validate-recipe-schemas.mjsso the in-browser validation tracks the CI gate.
M11k-inspect (deferred follow-up)
The 16-channel overlay inspector planned as D.7 in the original spec
requires typed loaders for the M11c / M11d / M11f / M11g raster
channels (upliftField, crustAge, flowDirection,
flowAccumulationD8, and the other 12 channels). Those loaders land
with Wave M11i. M11k-inspect ships:
- per-channel typed loaders bound to
manifestLoader.ts; - a renderer overlay API the inspector can drive without invading scene-graph code (the renderer stays a pure consumer of generator products per invariant 9);
- an "Inspect" tab populated by every loaded raster channel with histogram + colour-ramp + lookup-by-cell readout.
Until that wave lands, Wave P ships the "Inspect" tab as a
TODO(M11k-inspect) placeholder explicitly listing the deferred
channels so the gap is visible rather than absorbed silently.
Invariants reaffirmed
- The World Lab UX shell writes recipe + intent JSON and triggers generation; it never mutates renderer scene geometry (invariant 9).
- The four committed starter recipes
(
preset-{south-america,australia,europe,africa}-like.json) stay reference fixtures, not enum members (invariant 10). The preset picker reads from disk; new presets land by dropping new JSON next to the existing four. - Hard-priority recipe / directive requests still surface unsatisfied
validation in the run manifest (invariant 7). The editor's live
ajv validation only catches structural issues; satisfaction
reporting remains a generator concern and continues to halt the
run when
satisfactionReporting.haltOnHardFail === true. - Browser drafts live in
localStorageonly; no server-side draft store exists. On Launch the in-memory draft is posted inline.
M11l — Wave Final-A: scenarios + guards + doc closure
Wave Final-A is the closing wave of Milestone 11. It introduces the
machine-evaluable proof surface for every M11 invariant: the new
generator-product scenario gates, the CLI forbidden-pattern grep guard,
the Vitest unit test enforcing the World Lab boundary, and the audit
closures that retire the visual-foundation findings from
verification/visual-foundation-audit/02-viewer-audit.md +
03-generator-audit.md. Wave Final-B (baseline + capture refresh +
real-run scenario population) is deferred until Wave T-M11f political.rs
- Wave T-M11h map-features rewrites land.
M11l-Final-A (shipped)
- 15 new scenarios under
viewer/src/scenarios/mapv10_scenarios.json, scattered to their peer clusters per Wave Final-A Q2 (no new section divider; each scenario lives next to the visual cluster it stresses):- Archetype-preset proofs (continent topdown):
mapv10_archetype_south_america_like_continent_topdown,mapv10_archetype_australia_like_continent_topdown,mapv10_archetype_europe_like_continent_topdown,mapv10_archetype_africa_like_continent_topdown. - Recipe / intent / unsatisfiable-hard:
mapv10_recipe_three_ranges_two_basins_continent_topdown,mapv10_intent_custom_basin_reaches_target_floor_z5,mapv10_intent_unsatisfiable_request_reports_hard_fail. - Visual-foundation hydrology proofs:
mapv10_visual_foundation_river_branching_strahler_oblique_z6,mapv10_visual_foundation_lake_marching_squares_z6. - Hardcoded-anchor regression guard:
mapv10_no_hardcoded_anchors_in_continent_run. - Passthrough invariant declarations (enforced by the CLI grep +
Vitest unit test):
mapv10_no_rectangular_fallback_water_z5,mapv10_renderer_config_does_not_carry_recipe_tokens,mapv10_world_lab_boundary_no_renderer_imports. - Recipe-edit hash-change pair (cross-scenario state via
scenario-session-state.json):mapv10_world_lab_recipe_record_prior_hash,mapv10_world_lab_recipe_edit_changes_hash.
- Archetype-preset proofs (continent topdown):
- 13 new
Mapv10GateSpecvariants inviewer/src/scenarios/scenarioTypes.ts:recipeArchetypeTokenPresent,biomeRasterContainsToken,terrainGenesisBasinCountMatchesRecipe,recipeSatisfactionScoreAtLeast,recipeUnsatisfiedHardCountAtLeast,hydrologyMaxStrahlerOrderAtLeast,lakePolygonsAreMarchingSquaresContour,riverMouthsTerminateInSea,routesUsePassCandidates,intentDirectiveSatisfied,intentDirectiveHardFail,codeBoundaryNoImports,manifestContentHashChangedFromPrior. Each variant has a matchingcasearm inviewer/scripts/scenario-gates.mjsand PASS+FAIL unit-test coverage inviewer/scripts/__tests__/scenario-gates.test.mjs. - CLI grep guard at
viewer/scripts/forbidden-pattern-check.mjs. Scansgenerator/src/stages/**/*.rsforclosed_ellipse(,bent_centerline,ridge_graph.nodes[\d+], the six hardcoded feature-name literals; scansviewer/src/renderer/**/*.tsfornew THREE.PlaneGeometry((with an inline-comment allow-marker for the analytical sea-level UI-raycast plane); scansviewer/src/config/TerrainVisualConfig.tsfor recipe-surface token names; scansviewer/src/ui/world-lab/**/*.tsfor renderer / three imports. Exposed asnpm run check:forbidden-patterns+ thenpm run lint:m11aggregator. Exit code 0 on clean, 1 on any forbidden token, 2 on script error. Current expected-FAIL state on master: 13 forbidden tokens instages/map_features.rs(the six hardcoded anchors + threeclosed_ellipse(calls) — resolved by Wave T-M11h pernext-work.md. - Vitest unit test
viewer/src/ui/world-lab/__tests__/world-lab-boundary.test.tsthat static-scans every.tssource underviewer/src/ui/world-lab/(excluding__tests__/) for forbidden imports ofMapv10ThreeRendererand thethreepackage. Enforces ADR §M11 invariant 9 insidenpm test. Themapv10_world_lab_boundary_no_renderer_importsscenario references this test in itsnotes[]. - Cross-scenario state plumbed through
viewer/scripts/run-scenarios.mjs: a per-sessionscenario-session-state.jsonis written next toscenario-results.jsonand threaded asextra.sessionStateintoevaluateFailConditions. The recipe-edit pair uses this to record + compare manifestrecipeRef.contentHashacross runs. - Generator-product fetching in
viewer/scripts/run-scenarios.mjs. After scenario settle, the runner fetchesmanifest.json,terrainGenesis.json,hydrology.json,routeGraph.json, the resolved recipe + intent directives viapage.evaluate(fetch). Each fetch hard-fails intoprobeErrors; gates evaluate against the fetched products throughextra.loadedProductsper the no-fallback doctrine. The runner recordsloadedProductsAvailable: { manifest, terrainGenesis, hydrology, recipe, intentDirectives, routeGraph }(boolean summary) on each scenario result entry for triage. - Audit closure annotations in
verification/visual-foundation-audit/02-viewer-audit.mdandverification/visual-foundation-audit/03-generator-audit.md. Each finding from Wave 1's two audits is keyed by its section/heading and cited against the resolving wave plus the post-M11 source surface. Findings that remain open (M11h hardcoded feature names, Final-B baseline refresh) are explicitly named as deferred follow-ups. - Roadmap entry in
roadmap.md§ "Milestone 11 - World Genesis Source of Truth" listing every sub-wave (S / C / G1 / G2 / T-M11e / T-M11f / T-M11g / T-M11h / D / P / Final-A / Final-B), status, and deliverables. next-work.mdrows for the deferred work: Tier-0M11f-political(political.rs blocker); Tier-1M11h,M11i,M11k-inspect,M11m,M11l-Final-B; Tier-3T3-7(CI integration ofcheck:forbidden-patternsonce M11h closes the expected-FAIL state).- Retired wave-letter cleanup in
prompts/mapv10-roadmap-m11-world-genesis.mdso the prompt's scenario-gate row matches the ADR letters. docs/generator.mdStage 1/2/4/4b sections confirmed fresh against this wave; no edits were required.
M11l-Final-B (deferred)
- Baseline refresh under
viewer/baselines/browser/continent-lod6/against a regenerated continent fixture that exercises the M11 recipe surface end to end. - Capture refresh via
sbundle capture mapv10 <scenario>for each new Wave Final-A scenario. - Real-run scenario population — Wave Final-A ships the new
scenarios in pre-implement FAIL state per the wave-protocol
fail-first pattern. Greening them requires the generator to emit
real
recipeRef.contentHash/recipeSatisfaction.directives[]/terrainGenesis.json/hydrology.jsonartifacts under a recipe load. This blocks on Wave T-M11f political.rs ingest plus Wave T-M11h map-features rewrite.
Wave Final-A verification
npm run typecheckfromviewer/— clean.npm testfromviewer/— every new scenario-gate case + world-lab boundary test passes. The pre-existingvalidate-valenar-export.test.mjsfailure is unrelated to Wave Final-A scope (the run root is missing in the test environment; documented as a wave deviation, not a Final-A regression).npm run validate:recipesfromviewer/— clean (8 schemas compiled, 4 recipes + 4 intent files validated, 8 cross-reference checks performed).npm run check:forbidden-patternsfromviewer/— exits 1 with 13 forbidden tokens instages/map_features.rs(expected-FAIL state pernext-work.mdM11h row). After M11h ships, this exits 0 and CI integration (T3-7) is unblocked.npm run lint:m11fromviewer/— fails because of thecheck:forbidden-patternsstep above; expected.