Files
settled-reach/docs/workshops/tile-derivation-contract/tyre-round1.md
T
jpmschweitzerandClaude Opus 4.8 fa8b6ebf08 docs(governance): file D-239 tile derivation contract, resolve Q-101
Resolves Q-101 — how a coarse Layer-1 cell + seed becomes coherent ~1m
voxel geometry across the scale jumps. The tile-derivation-contract
workshop output:

- three-carrier refinement chain RegionProfile -> ChunkContext -> VoxelColumn,
  pure deterministic, no authoring at the derivation layers
- district-temperature climate primitive (2x2 km, C, nullable) + separate
  moisture; everything climatic derives from temperature(+moisture)
- scattered, transient freeze/snow model (freshwater +5..-10, sea ice own
  band, snow moisture-gated; forms cold phase / melts warm phase)
- stateless f64-to-voxel domain warp (anti-squaring)
- 8 morphology families over a frozen 17-zone vocabulary, gated decision tree
- seams prevented at source (gate ordering + build-time matrix); valid
  geomorphic seams kept sharp + warped

Includes workshop brief, round-1 positions, and workshop-outcomes.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:14:55 +02:00

343 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Tyre Round 1 — RegionHint API, anti-squaring, determinism/perf"
workshop: tile-derivation-contract
participant: Tyre
round: 1
date: 2026-06-07
---
# Tyre Round 1: The Derivation API Contract
## Framing the problem correctly
The 78 km → 1 km → 64 m → 1 m span is not a single derivation problem — it is
three distinct problems with different failure modes, and conflating them is how
you get squaring. My position up front: **each scale boundary needs a different
coherence mechanism**, and the `RegionHint` I sketched earlier needs to be split
into three typed carriers, one per boundary. The overall structure is:
```
Layer 1 cell (78 km) → RegionProfile (region-scale morphology zone + parameters)
RegionProfile → ChunkContext (64 m, resolved from heightmap position)
ChunkContext + seed → VoxelColumn (1 m, the actual derive call)
```
The `RegionHint` name survives but becomes specifically the `RegionProfile` — the
thing the region-level pass fills once and hands to all chunks within it.
---
## Scale boundary 1: 78 km cell → ~1 km region (RegionProfile)
This boundary is **classification**, not interpolation. A region does not gradually
become a fjord coast — it is or it isn't, based on contextual inputs. The squaring
risk here is not visual grid artifacts; it is abrupt zone transitions at region edges.
### What the 78 km cell hands down
The Layer 1 outputs already available per D-208 / D-209 / D-227:
- `elevation` (f32 heightmap, but read as integer-scaled u16 per D-010)
- `flow_accumulation` (D8 integer count)
- `flow_direction` (D8 integer 0–7)
- `sub_biome` (D-210 / D-228 — climate + vegetation zone)
- `river_network` membership (bool)
What the region pass adds (runs once per region, stored as `RegionProfile`):
```rust
pub struct RegionProfile {
// Classification inputs — all integer or enum
pub morphology_zone: MorphologyZone, // fjord / meander / delta / etc.
pub lithology: Lithology, // resolved from body parameters + elevation strata
pub glaciation: GlaciationGrade, // 0..4 integer grade
pub slope_class: SlopeClass, // Flat / Gentle / Steep / Cliff (computed from 3×3 elev kernel)
pub drainage_class: DrainageClass, // None / Ephemeral / Perennial / Major
pub sea_level_margin: i32, // elevation - sea_level, integer metres
// Morphology algorithm selector — derived from all above
pub morphology_family: MorphologyFamily,
// Parameters handed to ChunkContext
pub relief_amplitude: u16, // max voxel-height variation within region, integer metres
pub meander_intensity: u8, // 0–255 integer
pub erosion_grade: u8, // 0–255 integer
pub dune_orientation: u8, // compass octant (0–7), prevailing wind
}
```
`MorphologyZone` is the D-228 "region-level morphology zone" field, resolved here.
`MorphologyFamily` is the algorithm selector — that is Gestalt's domain to enumerate;
I just need to know what I am selecting between.
### Lithology: resolving the ocean/lake mask gap
Q-101 flags the D-223 ocean-polygon removal as an "unowned dependency". My resolution:
**sea-level threshold + flood-fill from heightmap edge, all integer**.
```rust
fn classify_water_body(
body_params: &BodyParams, // has sea_level, radius
elevation_grid: &Grid<u16>, // integer-scaled metres
pos: (u16, u16),
) -> WaterBodyClass {
// cells below sea_level threshold that are connected to the heightmap
// edge (flood-fill) → ocean; isolated depressions below sea_level → lake
}
```
This is a single flood-fill pass — O(n) over the Layer-1 grid (512×256 = 131k cells),
produces a bitmask. Deterministic, integer-only, no authored polygon dependency.
Lithology strata are body-class parameters (tectonic activity, age) → integer lookup
table per `(body_class, elevation_band)`. No f32 in classification.
### Region boundary blending (anti-squaring at this boundary)
The morphology zone must not flip discretely at region edges. Mechanism: **dual
classification with a blending weight**, resolved at the `ChunkContext` level.
Each region classifies to a primary and a secondary `MorphologyFamily`, with an
integer weight `primary_weight: u8` (255 = fully primary, 128 = equal mix). Chunks
near region boundaries inherit both families and blend their parameter tables.
The boundary width is one chunk (64 m) on each side — chosen to be invisible at
normal view distances and narrow enough not to corrupt chunk interiors.
---
## Scale boundary 2: ~1 km region → 64 m chunk (ChunkContext)
This is the **critical** boundary for squaring. Adjacent chunks from different
regions must tile seamlessly. The failure mode here is visible grid lines where
chunk borders coincide with region zone changes.
### ChunkContext struct
```rust
pub struct ChunkContext {
// Position identity
pub chunk_pos: ChunkPos, // integer chunk grid coordinates
pub chunk_seed: SeedChain, // SeedChain::derive(Block, chunk_id)
// Inherited from RegionProfile (primary)
pub primary: RegionProfile,
// Boundary blending — present only for boundary chunks
pub secondary: Option<RegionProfile>,
pub blend_weight: u8, // 255 = fully primary (no blend)
// Heightmap slice — integer, resolved at chunk resolution
pub elevation_min: u16,
pub elevation_max: u16,
pub elevation_grid: [u16; 64*64], // 4096 u16 values, bilinear from Layer 1 → chunk res
// Flow data at chunk resolution
pub flow_direction: [u8; 64*64], // D8 octant per chunk-scale cell
pub river_cells: u64, // bitmask of 8×8 macro-cells with river presence
}
```
`ChunkContext` is **computed once per chunk** and cached per D-227 (same eviction
tier as the derived tile data itself). Computing it requires reading the four
surrounding `RegionProfile` records plus the heightmap tile. Cost: integer arithmetic
over 64×64 arrays — well under 1 ms.
### Anti-squaring: domain warping at the chunk scale
The principal technique for seam-free transitions at chunk boundaries is
**position-domain warping using a global continuous noise field** — matching the
D-228 "global, position-keyed continuous noise field — never per-chunk" directive
for the cohesion matrix at the material layer.
The same principle applies at the morphology level: the warping offsets are derived
from a **global field** keyed on (body_id, world_seed), evaluated at any (x, y)
position. Any chunk can independently compute the warp offset for any position
within or adjacent to itself — producing identical values for the shared boundary.
This is the structural guarantee of seam-freedom: warp values are global functions,
not per-chunk state.
Concretely:
```rust
fn warp_offset(seed: u64, body_id: u64, x: f64, y: f64) -> (f64, f64) {
// Two independent value-noise fields (no coherent noise dependency)
// Using integer-grid hash + bilinear interpolation to keep it f64-confined
// Returns sub-voxel displacement in (dx, dy), magnitude capped at ~8m
}
```
The warp magnitude (~8 m maximum) is chosen to be larger than one tile (1 m) but
smaller than a chunk (64 m). This means:
- Chunk-grid lines are visibly displaced by up to 8 m in both directions
- No straight-line grid artifact can persist for more than 8 m
- Adjacent chunks compute the warp for their shared edge independently and get
identical values (pure function of world coordinates + seed)
The warp is applied in **world-space coordinates before the morphology algorithm runs** —
the algorithm sees warped positions and produces terrain that has no knowledge of
the chunk grid. The chunk boundary is invisible because neither algorithm has it.
### Relation to the D-227 chunk cache
The domain-warp field does not need to be cached separately. Each chunk computes
warp offsets only for its own tiles (the 64×64 grid) plus a 16-tile overlap margin
on each side (for the blend zone). This overlap is cheap — 96×96 evaluations vs 64×64.
The warp function itself is O(1) per position; the cost scales with chunk area, not
with neighbor count.
---
## Scale boundary 3: 64 m chunk → 1 m voxel (the derive call)
This is where D-227's `subtile(x,y,z) = derive(seed, atlas, position)` lives.
### Refined function signature
```rust
pub fn derive_voxel(
ctx: &ChunkContext, // pre-computed chunk context (cached)
pos: VoxelPos, // integer (x, y, z) — 1 m resolution
) -> TileAxes {
// Returns the D-228 composite:
// TerrainMaterial, FloorMaterial, Vegetation, Water, elevation
}
pub struct TileAxes {
pub terrain_material: TerrainMaterial, // u8 enum
pub floor_material: FloorMaterial, // u8 enum
pub vegetation: VegetationKind, // u8 enum
pub water: WaterDepth, // u8 enum
pub elevation: u16, // integer metres
}
```
The `pos` parameter carries world-space coordinates, not chunk-local coordinates.
The algorithm:
1. Apply `warp_offset(ctx.chunk_seed.seed(), body_id, pos.x, pos.y)` — warp the
position.
2. If `ctx.blend_weight < 255`: resolve `TileAxes` twice (primary + secondary
`RegionProfile`), then integer-blend the parameters before selecting materials.
The blend uses the same warp offset to prevent the blend seam from aligning with
the chunk edge.
3. Call the `MorphologyFamily`-specific derivation function with the `ChunkContext`
parameters.
4. Derive `TerrainMaterial` from `lithology + elevation + slope`.
5. Derive `Vegetation` from `sub_biome + terrain_material + elevation`.
6. Derive `Water` from regional water-height vs `elevation` (the D-228 cheap
seasonal/tidal water model).
7. Return `TileAxes`.
Step 3 is Gestalt's domain (the morphology algorithm implementations). The contract
from my side: each family receives `ChunkContext` + warped position and returns an
`ElevationDelta: i16` (delta from the base heightmap elevation, signed, integer metres).
That delta feeds into step 4 onward.
### Structural decisions (D-010 integer discipline)
All structural decisions — which material, which morphology zone, which elevation band —
are **integer-only**. f64 is confined to the warp offset computation and any
within-voxel interpolation. This is not ceremonial: D-227 makes it save-critical.
A single f32 comparison that flips on a different hardware FPU would desync every
mutator reference in a save file.
Specific constraints I am placing:
- `ElevationDelta` is `i16` (integer metres). No sub-voxel elevation in the
structural layer. Sub-voxel geometry is a render concern (shape-from-material,
D-228: "sand slumps to angle of repose, rock breaks to vertical face").
- Material selection is a series of integer comparisons / lookup table reads.
- The warp offset is f64 during computation but is immediately truncated to an
`i16` (integer metre displacement) before any structural decision uses it.
The sub-metre remainder is discarded — only the rendered visual uses sub-voxel
precision, and that is a client concern.
---
## Performance contract
Target: <5 ms per chunk, per D-227.
Budget breakdown (64×64 = 4,096 tiles per chunk):
| Work item | Cost estimate | Basis |
|-----------|--------------|-------|
| `ChunkContext` construction (once per chunk) | ~0.2 ms | 4×4 RegionProfile lookups + 64×64 integer array ops |
| Warp offset computation (4,096 + margin positions) | ~0.5 ms | ~5,000 hash + bilinear evaluations |
| Morphology algorithm (4,096 tiles, varies by family) | ~1–3 ms | family dependent; Gestalt/Troblum to validate |
| Material + vegetation + water derivation (4,096 tiles) | ~0.5 ms | integer table lookups |
| **Total** | **~2.2–4.2 ms** | headroom for complex families |
The chunk cache means this cost is paid once per chunk per play session (or per
Atlas view request). Adjacent chunks share no mutable state; they are embarrassingly
parallel under Rayon (D-208 precedent).
The `ChunkContext` is the critical cache object — it amortizes the `RegionProfile`
lookups across all 4,096 tile derivations in the chunk. Evicting a chunk from cache
means recomputing its `ChunkContext` plus all tile derivations; the `RegionProfile`
cache above it is longer-lived (one per ~1 km region vs one per 64 m chunk).
---
## What I need from teammates
**From Gestalt:**
- The ≥6 morphology family names and their context selectors, so I can populate
`MorphologyFamily` as a typed enum with explicit `#[repr(u8)]` discriminants
(stability requirement — same as `SeedDomain`).
- The `ElevationDelta` range each family can produce. I need to know if any family
wants sub-voxel precision (answer: no — see D-010 argument above) or more than
±255 m variation within a chunk (answer: physically implausible for a 64 m chunk).
- Any context inputs the family selector needs beyond what `RegionProfile` already
carries. My position: `RegionProfile` should be sufficient; if a family needs a
per-tile input not in `RegionProfile`, that is a signal the classification belongs
at the `ChunkContext` level, not the voxel level.
**From Troblum:**
- Stress-test the warp budget specifically. 5,000 hash + bilinear evaluations in
0.5 ms is ~90 ns/evaluation — plausible on modern hardware but worth verifying.
The warp can be precomputed into a lookup grid within the `ChunkContext` if that
is faster, at the cost of ~12 KB of additional cache memory per chunk.
- Validate the `ChunkContext` construction cost. The 64×64 integer bilinear
interpolation from Layer-1 resolution to chunk resolution is ~8× upsampling;
that should be fast but Troblum should confirm.
**From Miri:**
- Which morphology zones need sub-region variation within a single 64 m chunk?
For example: a meander reach with an oxbow lake needs the lake to appear within
a chunk that the region classifies as "meander reach". Does that require a
secondary zone classification at the chunk level, or does the morphology algorithm
handle it internally?
- The D-228 "named features matter for the wiki/atlas" list — which ones require
the `RegionProfile.morphology_zone` to be queryable at Atlas-generation time?
That determines whether `RegionProfile` needs to be persisted to the layer cache
or can be recomputed on Atlas queries.
---
## Open issues I am flagging for Round 2
1. **River flow direction at the voxel level.** Q-101 notes "river flow direction
derives from the D8 network at query time." The D8 network is Layer-1 resolution
(512×256). At 1 m voxel resolution, a river cell needs flow direction at meter
scale. The meander algorithm (Gestalt's domain) presumably drives local course
variation — but does the D8 direction act as a basin-scale constraint or a
cell-level input? This affects whether `flow_direction` in `ChunkContext` is
one value per chunk or one value per 8×8 macro-cell within the chunk.
2. **`RegionProfile` persistence vs recompute.** The Atlas viewer (D-225) currently
caches `Layer1Output` (rivers, basins, attractors). `RegionProfile` is a finer
grain. For Phase 4 Atlas use, we may want to compute `RegionProfile` lazily on
Atlas request rather than precomputing for all regions — the number of regions
per body is on the order of 78km/1km ≈ ~6,000 per body. That is cheap to
recompute on demand but expensive to precompute at atlas-load time for all bodies.
3. **`MorphologyFamily` transition zones and pathological adjacencies.** What happens
when a fjord coast is adjacent to a meander delta? The blend mechanism handles it
at the chunk level but the `RegionProfile` classifier needs to produce a sensible
`secondary` morphology zone for the boundary chunks. This requires the
morphology-zone vocabulary (Gestalt's Round 1 output) before Round 2 can finalize.
4. **Body-class modulation of `RIVER_THRESHOLD`.** Q-101 flags this. My position:
`RIVER_THRESHOLD` should be a body parameter computed from `(hydrosphere, tectonic_activity,
precipitation_class)` using an integer lookup table. This gives Nigel the handle
without touching the D8 algorithm itself. The lookup table lives in `body_params.rs`
alongside other per-body scalar parameters.