Skip to main content

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 .secs surface.
  • 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-42 dummy 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:

  • schemaVersion is mapv10-world-recipe-v1. Future revisions bump the version and ship beside the v1 schema; recipes never silently migrate.
  • id and displayName identify the recipe in tooling and UX.
  • archetype carries the geomorphology declaration: orogen list, craton list, rainfall declaration, basin policy, coast policy, custom-anchor policy.
  • counts carries top-level count constraints (primary mountain belts, secondary ranges, residual highlands, foreland/interior/endorheic basins, major rivers). Each count is a range with an optional exact field and a priority.
  • intentDirectivesRef, vocabularyRef are optional relative paths to companion documents.
  • satisfactionReporting configures how the generator reports satisfied, soft-satisfied, and unsatisfied requests. Hard-fail reporting cannot be disabled (emitUnsatisfiedHardFails: const true).
  • canonicalization records the determinism contract: sorted keys, shortest round-trip f64 representation, LF line endings, UTF-8 NFC without a BOM. These are const-valued fields; recipes cannot opt out.
  • additionalProperties: false at 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 with state: unsatisfied, increments validation.satisfactionSummary. hardFailCount, sets manifest.validationStatus to fail, and (if satisfactionReporting.haltOnHardFail is 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 a satisfactionScore in [0, 1]. State is satisfied (score = 1.0), soft-satisfied (0 < score < 1), or unsatisfied (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 is satisfied or unsatisfied. 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 through f64.
  • 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.orogens is a list of orogen declarations. Each declaration names an orogenKinds token (e.g. active-margin, collisional), a placement mode (coast-parallel, interior-axis, or archetype-derived), and per- belt parameters (length, height, age band, asymmetry).
  • archetype.cratons is a list of craton declarations. Each names a cratonKinds token (e.g. shield, platform), a placement mode, and per- craton parameters (age band, area fraction, residual highland policy, interior basin policy).
  • archetype.rainfall names a rainfallPatterns token plus orographic strength and rain-shadow azimuth.
  • archetype.basinPolicy and archetype.coastPolicy carry policy-level declarations referencing interiorBasinKinds, residualHighlandKinds, islandPolicies, isthmusPolicies tokens.

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-root docs/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 workspace ad- 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.json with recipeRef, directivesRef, and vocabularyRef fields; adding --recipe, --intents, and --vocabulary CLI flags to generator/src/main.rs; extending the determinism test to cover the (seed, recipe, directives, vocabulary) four-tuple; landing real bodies for the validate_world_recipe, validate_world_intent_directives, validate_world_vocabulary, validate_terrain_genesis, and validate_hydrology functions stubbed in Wave S.
  • M11c: redesigning Stage 2 to emit terrainGenesis.json plus the seven registered raster channels. Removing the fixed ridge-chain and ellipse- basin sources.
  • M11d: redesigning Stage 4 to emit hydrology.json plus the ten registered raster channels. Removing the synthetic ellipse-lake and hardcoded-river sources. Deciding whether the existing riverGraph.json, riverCenterlines.json, and lakePolygons.json files remain as derived outputs or are removed.
  • M11i: viewer-side typed loaders for terrainGenesis.json and hydrology.json in viewer/src/data/types.ts and manifestLoader.ts. Fail-hard missing-product behavior matching the existing loader contract.
  • M11j: Stage 15 Valenar enrichment with navigable_river_access, basin_id, and valley_id fields projected from hydrology.locationAssociations[]. Valenar runtime stays on the world-42 dummy 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-42 dummy fixture until M11j (and beyond, at the user's discretion).
  • The existing riverGraph.json, riverCenterlines.json, and lakePolygons.json files 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 test keeps passing. Real bodies land in M11c and M11d.
  • The manifest.schema.json is 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; GeneratorConfig owns 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:

  1. Locate local maxima of reliefPotential on the 8-neighbour stencil (ties broken by linear index, matching the Edelsbrunner-Letscher- Zomorodian 2002 simulation-of-simplicity convention).
  2. 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).
  3. From each saddle, trace integral curves of +∇reliefPotential by steepest-uphill walk in the two non-adjacent steepest directions; each trace terminates at a local maximum.
  4. 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).
  5. Filter by persistence: keep only saddles with persistence ≥ PERSISTENCE_THRESHOLD_FRACTION × field_range (default 0.05, TODO(M11l): promote to BasinPolicy.drainageDividePersistenceMeters).
  6. Stamp surviving ridge cells into drainageDivide.u8.bin with 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:

  • RidgeGraph is sampled from the OrogenAxis polylines at ~80 km intervals (AXIS_VERTEX_SPACING_KM), with one RidgeEdge per pair of adjacent samples. width_km defaults to 30 km. When the recipe declares no orogens, the graph is empty — Wave G1 does NOT synthesise placeholder nodes (CLAUDE.md §57).
  • Basins is the marching-squares polygons of each BasinCandidate, one Basin per candidate. When the marching-squares pass produces fewer than two basins (small worlds, soft-priority recipes), the derived Basins collection has fewer than two entries. Wave G1 does NOT synthesise placeholder basins to satisfy the legacy stages/water.rs lakes[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.rs panics with index out of bounds: the len is N but the index is 1 when 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 in basin_polygon_via_marching_squares),
    • BasinPolicy.drainageDividePersistenceMeters (currently 5% of the relief-potential field range in build_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):

  • 0 dry land
  • 1 sea (open ocean or below sea-level coastal cell)
  • 2 lake (cell whose catchment terminates at a lake sink and whose raw elevation is at or below the spill)
  • 3 river (stream cell)
  • 4 wetland (high accumulation × low slope, not lake/river)
  • 5 floodplain (within band radius of a stream of strahler_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.json projections; migrate map_features.rs named anchors (Westwatch Harbor, Mirrorfen, Westmere Shore, Crown Pass) off the area-sorted-lake selector to World Intent Directive declarations per mapv10.md invariant 5; retire BiomeMaterialProducts.wetland_mask in favour of a direct water.wetland_mask read at the influence site; populate the stages/04-b-hydrology-locations sub-stage with the real spatial-join body (Wave G2 lands the sub-stage scaffolding and the empty locationHydrologyAssociations.json placeholder).
  • Wave U (M11i): consume valley intent directives into locationAssociations.valleyId and into stream-network valley enforcement.
  • Wave M (M11m): mountain-pass-aware feature spawning; add mountainPassAccess + crossingCostMax fields to Location associations via a hydrology-locations-v2 schema 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:

  1. No designer transparency. A worldbuilder reading default-world-vocabulary.json saw orogen and craton tokens but no way to declare what tropical-rainforest even meant; the biome semantics were buried inside a Rust match arm in biomes_materials.rs::classify_biome.
  2. Forced parameter tuning. Adjusting fertility / mineral / biome-driven gameplay values meant editing valenar_worlddata.rs:715-728 directly — a Rust source change, not a content change.
  3. Closed list trap. Adding mediterranean-shrubland or tropical-monsoon or mangrove required a coordinated bump across classify_biome, validate_biome_material_products, biome_label, biome_tag_for_id, effectiveVisualBiomeId, tile_pyramid::LodLevel.biome enum doc-comment, viewer biomeName, viewer palette texture, and every downstream test fixture. The 22 starter tokens are now declarative.
  4. Dual-meaning override raster. The M11d effectiveVisualBiome raster mutated the base biome id to 7 for corrupted cells; the base biome was no longer the truth product, and the validate_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) indexing vocabulary.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 of coastal / plain / forest / highland / wetland / snow / default), displayColor (24-bit RGB integer), isWater (bool), rainfallRegime (one of any / wet / moderate / arid / hyperarid), fertilityBaseline (0..100), mineralAffinity (0..100). Soft ordering convention: sea at index 0, freshwater at index 1.
  • effectiveVisualBiomeKinds: 1 starter token (corrupted-substrate). Same token schema; the raster encodes index + 1 so 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:

  1. Water short-circuit on the Stage 4 water mask (sea ↔ freshwater).
  2. Wetland mask short-circuit (Stage 4 owned) → mangrove / floodplain / wetland depending on latitude + rainfall.
  3. High-elevation cap (> 2350 m) → polar-ice (polar latitudes) or mountain-alpine.
  4. Highland band (> 1100 m or slope > 0.19) → shield-exposed-rock on shield/platform provinces, tundra at sub-polar latitudes, mountain-meadow otherwise.
  5. 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.

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; value v > 0 decodes to vocabulary.effectiveVisualBiomeKinds[v - 1]. The single starter token corrupted-substrate writes 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 by naming::biome_tag_for_token(&BiomeKindToken) -> &str, reading the token's nameGroupingTag field. political_naming::run threads the resolved vocabulary slice into every realm / province / location naming call.
  • valenar_worlddata::LocationStats.biome_counts changes from [u32; 7] to BTreeMap<String, u32> keyed by vocabulary id. dominant_biome resolves via max_by on the map with lexicographic tiebreak. biome_label(usize) is retired. primary_province_biome switches its silent unwrap_or_else(|| "plains".to_string()) fallback to a hard expect() per the no-fallback rule. facts_for_location reads fertility_baseline and mineral_affinity from the dominant token rather than the hardcoded match biome arm.
  • tile_pyramid::LodLevel.effective_visual_biome → `.effective_biome
    • .override_token. Both ride the same cascade_mode_u8downsample for now;TODO(M11-T1b)` flags a mode-vs-mean re-evaluation once the override token list grows.
  • previews::biome_preview_png(width, height, values, &[BiomeKindToken]) reads displayColor from the vocabulary; a loud sentinel red surfaces out-of-range indices rather than silently substituting a default.
  • validate_world_vocabulary gains the biomeKinds + effectiveVisualBiomeKinds checks (non-empty, id-unique, field-range, isWater-present). validate_biome_material_products derives max=vocabulary.biomeKinds.len() - 1 rather than the hardcoded 6. validate_influence_products splits the legacy effectiveVisualBiome raster check into a byte-equal-with-base check for effectiveBiome and an (index + 1) range check for effectiveOverride.

Open follow-ups (deferred from Wave T1)

  • Wave T1b (M11-T1b): viewer cascade. Mapv10ThreeRenderer.ts's biomeName: { 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 sparse effectiveOverride.u8.bin raster and look up the palette via vocabulary.effectiveVisualBiomeKinds[value - 1]. Until the viewer dispatch lands, the renderer surfaces unknown-N labels for token ids ≥ 7.
  • Wave M11f: LANDED. Recipe archetype.latitudeBandDegrees field now drives biomes_materials.rs::generate; see the ## M11f section 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:

  1. Iterate cells inside the Location's polygon bounding box (the same bbox stages::political::rasterize_location_id uses).
  2. 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.
  3. Accumulate total_cells, floodplain_cells, wetland_cells, basin_histogram[basin_raster_value], the sorted-unique set of non-zero river_segment_id cells, and an any_lake_cell flag.
  4. 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 value v > 0segments[v - 1].id (the water.rs::extract_streams producer writes seg_idx_one_based = segments.len() + 1 before pushing the segment).
  • basin_numeric_to_id: raster value vbasin_candidates[v - 1000].id (the geography_graph.rs::extract_basin_class next_id counter starts at 1000 and is shared across the foreland → interior → endorheic calls, so basin index i corresponds to raster value 1000 + i).

Field-by-field derivation

FieldDerivation
location_idLocationPolygon.id
freshwater_accessriver_segment_one_based non-empty OR any_lake_cell
major_river_accessany touching segment has strahler_order >= MAJOR_RIVER_MIN_ORDER (default 3)
navigable_river_accessany touching segment has .navigable == Some(true)
floodplain_pctfloodplain_cells / total_cells, range [0, 1]
wetland_pctwetland_cells / total_cells, range [0, 1]
basin_iddominant histogram entry; tiebreak by lower numeric raster value (deterministic). None if no enclosed cell has a basin.
valley_idNone, always. Custom-valley directive resolution does not exist yet — see deferral below.
river_segment_idssorted-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_id not 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_pct or wetland_pct outside [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_id becomes the resolved directive id when a Location overlaps a custom-valley footprint. Silently populating valley_id before the resolver lands would fabricate geography truth and is forbidden by the M11 no-fallback doctrine.
  • Wave M/M11m: hydrology-locations-v2 schema bump. Adding mountain_pass_dependencies[], orogen_dependencies[], catchment_id, or water_body_id to LocationHydrologyAssociation requires bumping hydrology.schema.json past v1, since the schema closes the object with additionalProperties: false. Adding new fields in M11h would silently expand the schema contract; forbidden.
  • Wave L/M11l: vocabulary promotion of MAJOR_RIVER_MIN_ORDER (currently a pub const matching the water.rs floodplain min order). Joins it to the existing M11l promotion batch.
  • Wave F/M11f: LANDED. The recipe latitudeBandDegrees rewiring of biomes_materials.rs is the disjoint scope from this wave; see the ## M11f section 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:

Presetminmax
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"):

ConstantValueMeaning
LAT_DEG_TROPIC_OF_CANCER_ABS23.5tropical / subtropical boundary
LAT_DEG_SUBTROPICAL_POLEWARD_LIMIT35.0subtropical / temperate boundary
LAT_DEG_TEMPERATE_POLEWARD_LIMIT60.0temperate / boreal boundary
LAT_DEG_ARCTIC_CIRCLE_ABS66.5boreal / polar boundary
LAT_DEG_MEDITERRANEAN_EQUATORWARD30.0Mediterranean Csa/Csb equator side
LAT_DEG_MEDITERRANEAN_POLEWARD45.0Mediterranean 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-rangelat_band.min < lat_band.max.
  • recipe-latitude-band-bounds — both numbers in [-90, 90].
  • recipe-latitude-band-span-minimumlat_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.md document 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
ConstantValueRationale
REFERENCE_DRAINAGE_AREA_KM21_000Mid-size Strahler-3-to-4 river anchor for the per-kind base costs.
DRAINAGE_AREA_COST_EXPONENT0.9Leopold-Maddock 1953: width-to-discharge exponent ~0.5 plus depth-to-discharge ~0.4 sum to engineering toll exponent ~0.9.
FORD_BASE_COST_KM0.3Shallow unimproved crossing — dominated by time loss, not construction.
BRIDGE_BASE_COST_KM2.0Medieval-to-industrial bridge engineering toll mid-point from comparative GIS least-cost-path studies.
FERRY_BASE_COST_KM5.02.5× bridge for Strahler-6+ rivers requiring a ferry or major engineered span.
STRAHLER_FORD_MAX2Orders 1-2 are shallow, low-discharge, waded year-round.
STRAHLER_BRIDGE_MAX5Orders 3-5 are perennial rivers spanable by masonry / wood bridges.
PASS_DISCOUNT_FACTOR0.25Hill 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:

  1. Sample the polyline cell-by-cell at a half-cell-diagonal step so no river cell is missed.
  2. Look up water.river_segment_id[cell_idx] (1-based per Wave G2 extract_streams; value 0 means "no segment").
  3. Edge-trigger on a transition into a new non-zero segment id; emit a single RouteCrossingCandidate per (route_edge_id, river_segment_id) pair (deterministic order via per-route counter + BTreeSet dedup).
  4. Classify by STRAHLER_FORD_MAX / STRAHLER_BRIDGE_MAX; cost via crossing_cost_km(strahler_order, drainage_area_km2).

Validator

validation::validate_route_crossing_candidates hard-fails on:

  • a routeEdgeId not resolving to a routeGraph.edges[].id
  • a riverSegmentId not resolving to a hydrology.riverNetwork.segments[].id
  • a crossingKind outside {ford, bridge, ferry-or-bridge-major}
  • a crossingKind disagreeing with the Strahler classifier for the declared strahlerOrder
  • a non-finite or non-positive costKm
  • a point outside 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.json once viewer/src/ and map_features.rs have migrated their crossing- anchor consumers to routeCrossingCandidates.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, and PASS_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-v2 schema 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:

ChannelSource productByte formatCascade rule
upliftgenesis.upliftf32cascade_box_mean_f32
crustAgegenesis.crust_agef32cascade_box_mean_f32
bedrockHardnessgenesis.bedrock_hardnessf32cascade_box_mean_f32
reliefPotentialgenesis.relief_potentialf32cascade_box_mean_f32
basinCandidateIdgenesis.basin_candidate_idu32cascade_mode_u32
drainageDividegenesis.drainage_divide_candidateu8cascade_max_u8
orographicRainfallgenesis.orographic_rainfallf32cascade_box_mean_f32
conditionedDemwater.conditioned_demf32cascade_box_mean_f32
flowDirectionwater.flow_directionu8cascade_mode_u8
flowAccumulationD8water.flow_accumulation_d8f32cascade_box_mean_f32
catchmentswater.catchmentsu32cascade_mode_u32
streamOrderwater.stream_orderu8cascade_max_u8
streamMagnitudewater.stream_magnitudeu16cascade_mode_u16
riverSegmentIdwater.river_segment_idu32cascade_mode_u32
waterBodyIdwater.water_body_idu32cascade_mode_u32
floodplainMaskwater.floodplain_masku8cascade_max_u8

The cascade rules follow channel-class semantics, not parameter tuning:

  • cascade_box_mean_f32 is the canonical continuous-field downsample (same kernel as height_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_u16 are 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 existing mode_2to1_u8 precedent.
  • cascade_max_u8 is 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:

  1. wetlandMask re-source. The base wetlandMask tile asset now reads from water.wetland_mask (the canonical post-G2 source) instead of biomes.wetland_mask, and the cascade rule switches from cascade_box_mean_u8 (continuous-field mean) to cascade_max_u8 (rank-mask max). The base biome stage still emits biomes.wetland_mask for the influence-derived effectiveWetlandMask chain; the change is scoped to the base wetlandMask channel only.
  2. effectiveVisualBiome residue cleanup. The Wave T1 effectiveVisualBiome channel was retired in favour of the two split products effectiveBiome (base echo) and effectiveOverride (sparse sidecar). The tile pyramid's raster_source_dependencies array and rasterTileChannels contract 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_u8 heuristic for flowDirection beyond the existing mode rule. The Tarboton 1997 aggregator is the correct algorithm and is explicitly deferred rather than substituted with a tuned approximation.
  • Adding downstreamIndex to 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 effectiveVisualBiome alive 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:

FieldTypeSource
freshwater_accessboolLocationHydrologyAssociation::freshwater_access
major_river_accessboolLocationHydrologyAssociation::major_river_access
navigable_river_accessboolLocationHydrologyAssociation::navigable_river_access
floodplain_pctf64 (0..=100)LocationHydrologyAssociation::floodplain_pct
wetland_pctf64 (0..=100)LocationHydrologyAssociation::wetland_pct
basin_idOption<String>LocationHydrologyAssociation::basin_id
river_segment_idsVec<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 LocationHydrologyAssociation to a "no freshwater" record. The no-fallback doctrine forbids it; the projection panics instead.
  • Aliasing valenar-world-v1 to valenar-world-v2. The retired schema id is gone from the live product registration; the only remaining reference is the historical hand-off in artifacts.rs::json_product_type, documented in-place.
  • Adding a per-Location valley_id field. 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 derived WaterProducts legacy projections (river_centerlines, lake_polygons, water_mask) to direct hydrology.json consumption. Stage 12 currently mounts the derived projections kept alive on WaterProducts for 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 flowDirection and flowAccumulationD8 cascade rules. The current mode-downsample on flowDirection is 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 on flowAccumulationD8 is 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.ts renders every field declared in schema/world-recipe.schema.json with the per-field widget kind recommended in docs/world-lab-ux.md. Token pickers consume WorldVocabularyResolver accessors 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.ts renders every field declared in schema/world-intent-directives.schema.json. The 13 directive kind values come from the schema's closed enum; the namingPreferences.nameStyle picker comes from vocabulary.
  • Run launcher under viewer/src/ui/world-lab/runLauncher.ts POSTs the in-memory draft to a new dev-server endpoint POST /mapv10/runs/launch and consumes the SSE stream the server exposes for stdout / stderr / exit events. The endpoint spawns the generator binary (env-pinned via MAPV10_GENERATOR_BIN, otherwise cargo run --release -- inside examples/map/mapv10/generator/). Recipe / intent / vocabulary JSON spill to examples/map/mapv10/recipes/.tmp/launch-<launchId>/ and are deleted on exit.
  • Run library under viewer/src/ui/world-lab/runLibrary.ts reads the existing GET /mapv10/runs/ endpoint and renders a card per run. The endpoint's RunStatus shape is extended with three new fields (recipeRef, generatedAtUnixMillis, thumbnailPath). Clicking a card invokes renderer.loadRun directly with the manifest URL — no page reload.
  • Compare runs under viewer/src/ui/world-lab/compareRuns.ts renders 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-drawer aside peer of debug-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.ts compiles every committed schema under Ajv2020 strict mode + ajv-formats. The compile order matches scripts/validate-recipe-schemas.mjs so 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 localStorage only; 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.
  • 13 new Mapv10GateSpec variants in viewer/src/scenarios/scenarioTypes.ts: recipeArchetypeTokenPresent, biomeRasterContainsToken, terrainGenesisBasinCountMatchesRecipe, recipeSatisfactionScoreAtLeast, recipeUnsatisfiedHardCountAtLeast, hydrologyMaxStrahlerOrderAtLeast, lakePolygonsAreMarchingSquaresContour, riverMouthsTerminateInSea, routesUsePassCandidates, intentDirectiveSatisfied, intentDirectiveHardFail, codeBoundaryNoImports, manifestContentHashChangedFromPrior. Each variant has a matching case arm in viewer/scripts/scenario-gates.mjs and PASS+FAIL unit-test coverage in viewer/scripts/__tests__/scenario-gates.test.mjs.
  • CLI grep guard at viewer/scripts/forbidden-pattern-check.mjs. Scans generator/src/stages/**/*.rs for closed_ellipse(, bent_centerline, ridge_graph.nodes[\d+], the six hardcoded feature-name literals; scans viewer/src/renderer/**/*.ts for new THREE.PlaneGeometry( (with an inline-comment allow-marker for the analytical sea-level UI-raycast plane); scans viewer/src/config/TerrainVisualConfig.ts for recipe-surface token names; scans viewer/src/ui/world-lab/**/*.ts for renderer / three imports. Exposed as npm run check:forbidden-patterns + the npm run lint:m11 aggregator. Exit code 0 on clean, 1 on any forbidden token, 2 on script error. Current expected-FAIL state on master: 13 forbidden tokens in stages/map_features.rs (the six hardcoded anchors + three closed_ellipse( calls) — resolved by Wave T-M11h per next-work.md.
  • Vitest unit test viewer/src/ui/world-lab/__tests__/world-lab-boundary.test.ts that static-scans every .ts source under viewer/src/ui/world-lab/ (excluding __tests__/) for forbidden imports of Mapv10ThreeRenderer and the three package. Enforces ADR §M11 invariant 9 inside npm test. The mapv10_world_lab_boundary_no_renderer_imports scenario references this test in its notes[].
  • Cross-scenario state plumbed through viewer/scripts/run-scenarios.mjs: a per-session scenario-session-state.json is written next to scenario-results.json and threaded as extra.sessionState into evaluateFailConditions. The recipe-edit pair uses this to record + compare manifest recipeRef.contentHash across runs.
  • Generator-product fetching in viewer/scripts/run-scenarios.mjs. After scenario settle, the runner fetches manifest.json, terrainGenesis.json, hydrology.json, routeGraph.json, the resolved recipe + intent directives via page.evaluate(fetch). Each fetch hard-fails into probeErrors; gates evaluate against the fetched products through extra.loadedProducts per the no-fallback doctrine. The runner records loadedProductsAvailable: { 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.md and verification/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.md rows for the deferred work: Tier-0 M11f-political (political.rs blocker); Tier-1 M11h, M11i, M11k-inspect, M11m, M11l-Final-B; Tier-3 T3-7 (CI integration of check:forbidden-patterns once M11h closes the expected-FAIL state).
  • Retired wave-letter cleanup in prompts/mapv10-roadmap-m11-world-genesis.md so the prompt's scenario-gate row matches the ADR letters.
  • docs/generator.md Stage 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.json artifacts 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 typecheck from viewer/ — clean.
  • npm test from viewer/ — every new scenario-gate case + world-lab boundary test passes. The pre-existing validate-valenar-export.test.mjs failure 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:recipes from viewer/ — clean (8 schemas compiled, 4 recipes + 4 intent files validated, 8 cross-reference checks performed).
  • npm run check:forbidden-patterns from viewer/ — exits 1 with 13 forbidden tokens in stages/map_features.rs (expected-FAIL state per next-work.md M11h row). After M11h ships, this exits 0 and CI integration (T3-7) is unblocked.
  • npm run lint:m11 from viewer/ — fails because of the check:forbidden-patterns step above; expected.