feat(simulation): sub-cell composition — a summarised cell can contain its minority (T-1213)
D-258 invariant 2: descending the ladder must reveal COMPOSITION, so a cell reading "forest" globally contains the clearings, marsh, rock and scrub the vote suppressed. Below Global it contained nothing: morphology carried NINE zones at Global and exactly ONE (AlluvialPlain) at every rung under it, and vegetation collapsed to one class from District down. WHY THE EXISTING TIERS COULD NOT DO IT. vegetation_invention already perturbs moisture with a massif band and a texture band — but that pair was built for cross-rung coherence and is weighted 70/30 specifically so "the texture term alone can never outweigh the massif term". It is designed NOT to change a verdict, which is the exact opposite of what composition needs. And it could not simply be turned up. Measured: 50.2% of the texture field's amplitude sits in its 32,768 m octave alone, and everything at or below 2,048 m holds 5.9% of the total. Across a District window only that 5.9% varies, which after the 30% weight and a ~29-point ceiling swings moisture by +/-0.51 points against vegetation gates 5-15 points apart. Nothing could ever cross one. That is the geometric series, not a tuning shortfall — raising the ceiling enough to matter at District would make the field violent at Region. So a third tier carries the fine band ALONE, normalized to its own full swing: quiet where the coarse tiers are loud, loud where they have nothing left to say. It feeds BOTH classification inputs, because moisture alone would have left morphology just as flat. CONSERVATION IS THE BOUND, not weighting (D-258 invariant 3). The field is zero-mean, so a downsample returns the summary it was added to. Pinned two ways: a field-level zero-mean test, and a real-terrain test that derives a District-sized patch and asserts the majority vegetation class survives. RARE INCLUSIONS, and this was a correction. The smooth term is a gentle sway around the base, so it can only flip a verdict where the ground already sits near a gate — which made deep-in-class ground immune, and the conservation test duly measured a patch that was 100% Forest. A monoculture is the flat map this ticket exists to fix, one scale down. Jeroen: "maybe a dense forest should still sometimes produce a clearing or a rocky outcropping." D-258 says CONTAIN, not border on. A sparse high-contrast term now rides on top — thresholded value noise so inclusions are connected blobs rather than stray speckled cells. The same patch now reads 98.9% Forest with 1.1% Barren outcrops. The moisture half of an inclusion obeys the envelope rule (a world with no patchiness ceiling grows no glades — caught by the zero-ceiling test, which the first version failed by putting damp pockets on airless rock); the slope half does not, because an outcrop is geology and a dead world is exactly where bare rock should break the surface. The slope ceiling is 6, not the 15 first written. Measured against the window-derivation fixtures, base slope_q on ordinary ground is 2-6, so +/-15 did not vary the signal but REPLACED it — one fixture moved 6 -> 19 and two coastal samples flipped to Wetland on invented slope alone. The morphology gates are far apart because a cliff coast is a real landform; composition must let marginal ground fall both ways, never manufacture a fjord on a flood plain. NOT applied at the orbital rung, which is envelope-only by design and documents that it never invents slope — pinned by derive_orbital_at_metres_never_invents_slope, which caught the first version. Both goldens move in the IMPROVING direction, checked before updating rather than blind-refreshed: GJ338Bd moisture 75->85 distinct, slope 28->34 (range 0-50), materials 3->4 GJ244Ad moisture 25->42, slope 15->22, morphology zones 6->7, materials 2->3 Ladder effect (was -> now): Region moisture 25->39; District moisture 3->17 and vegetation 1->2; Quarter moisture 3->8. 45 server suites green, clippy clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
//! Sub-cell **composition** — the fine tier that lets a summarised cell contain
|
||||
//! the minority it suppressed (T-1213, [D-258](../../governance) invariant 2).
|
||||
//!
|
||||
//! # Why a third tier exists
|
||||
//!
|
||||
//! [`crate::atlas::vegetation_invention`] already perturbs `moisture_q` with two
|
||||
//! bands: a massif field (100–410 km, never gated) and a texture field
|
||||
//! (32 km–128 m, gated). That pair was built for **cross-rung coherence** and is
|
||||
//! explicitly weighted so it cannot change a verdict — 70% massif against 30%
|
||||
//! texture, so "the texture term alone can never outweigh the massif term".
|
||||
//!
|
||||
//! D-258 asks for the opposite thing. Its invariant (2) says descending the
|
||||
//! ladder must reveal COMPOSITION: *a cell reading "forest" globally must be
|
||||
//! able to contain clearings, marsh, rock and scrub the vote suppressed*. That
|
||||
//! requires the fine tier to flip a classification, which the existing pair is
|
||||
//! designed to prevent.
|
||||
//!
|
||||
//! The two are reconciled by D-258's own third invariant rather than by picking
|
||||
//! a winner: **flips are allowed, and conservation is the bound.** Downsampling
|
||||
//! the composed field must reproduce the summary it came from. A forest cell may
|
||||
//! gain marsh pockets; it must still read as forest from orbit.
|
||||
//!
|
||||
//! # Why the existing tiers could not simply be turned up
|
||||
//!
|
||||
//! Measured on Ferrath, the texture field's amplitude is dominated by its
|
||||
//! coarsest octaves — 50.2% sits in the 32,768 m octave alone, and everything at
|
||||
//! or below 2,048 m accounts for **5.9%** of the total. Across a District window
|
||||
//! (2,048 m of ground) only that 5.9% varies at all, which after the 30% texture
|
||||
//! weight and a ~29-point ceiling swings `moisture_q` by **±0.51 points**.
|
||||
//! Vegetation thresholds are 5–15 points apart, so nothing can ever cross one.
|
||||
//! That is not a tuning shortfall, it is the geometric series: raising the
|
||||
//! ceiling enough to matter at District would make the field violent at Region.
|
||||
//!
|
||||
//! So this tier carries the fine band ALONE, normalized to its own full swing.
|
||||
//! It is quiet where the coarse tiers are loud and loud where they have nothing
|
||||
//! left to say.
|
||||
//!
|
||||
//! # What it feeds
|
||||
//!
|
||||
//! Both classification inputs that gate everything downstream:
|
||||
//!
|
||||
//! - `moisture_q` → vegetation (clearings, marsh, scrub inside a forest)
|
||||
//! - `slope_q` → morphology (CliffCoast, Fjord, DuneStrand, Delta)
|
||||
//!
|
||||
//! The second is not incidental. Measured down the ladder on Ferrath, morphology
|
||||
//! carries **nine** zones at Global and exactly **one** (AlluvialPlain) at every
|
||||
//! rung below it: every zone that gates on a slope threshold becomes unreachable,
|
||||
//! so fjord walls and cliff coasts exist only on the whole-body view. Vegetation
|
||||
//! and coastal character flatten for the same reason, one field apart.
|
||||
|
||||
use crate::atlas::detail_scatter::{value_noise, VOXEL_OCTAVE_WAVELENGTHS_M};
|
||||
use crate::seed::splitmix64;
|
||||
|
||||
/// Salt separating this tier's noise stream from the massif and texture fields
|
||||
/// sampled at the same position. Without it the three would correlate and the
|
||||
/// composition would merely deepen the patches the texture field already made,
|
||||
/// instead of cutting across them.
|
||||
const COMPOSITION_SALT: u64 = 0x0C0F_9051_71ED_5EED;
|
||||
|
||||
/// Separate stream again for the rare-inclusion test, so a glade does not
|
||||
/// preferentially land where the smooth field already peaks.
|
||||
const INCLUSION_SALT: u64 = 0x1_C1EA_5124_60DE;
|
||||
|
||||
/// Size of a rare inclusion — a glade, a blowdown, a rocky outcrop. Chosen to
|
||||
/// read as a FEATURE at the rungs that can see it: 192 m is roughly 50 gridunits
|
||||
/// across at District and half a gridunit at Region, so a clearing is a shape
|
||||
/// when you are close and correctly invisible from orbit.
|
||||
const INCLUSION_WAVELENGTH_M: f64 = 192.0;
|
||||
|
||||
/// Noise value above which a position is inside an inclusion. `value_noise`
|
||||
/// returns `[-1, 1]`, and 0.72 puts roughly 4–6% of ground inside one — sparse
|
||||
/// enough that the majority class is never in danger (the conservation
|
||||
/// invariant is the real bound), common enough that a walk through deep forest
|
||||
/// crosses one.
|
||||
const INCLUSION_THRESHOLD: f64 = 0.72;
|
||||
|
||||
/// How hard an inclusion pushes `moisture_q`. Deliberately LARGE — larger than
|
||||
/// [`MOISTURE_COMPOSITION_CEILING_Q`] — because its whole job is to cross a
|
||||
/// class gate from well INSIDE a class, which the smooth term by design cannot.
|
||||
const INCLUSION_MOISTURE_SWING_Q: i32 = 30;
|
||||
|
||||
/// How hard an inclusion pushes `slope_q` — a rocky outcrop is genuinely steep
|
||||
/// ground, so this one is allowed to reach where the smooth slope term is not.
|
||||
const INCLUSION_SLOPE_SWING_Q: i32 = 25;
|
||||
|
||||
/// Maximum swing this tier may apply to `moisture_q`, in points.
|
||||
///
|
||||
/// Sized against the vegetation ladder's own spacing (`derive_vegetation`'s
|
||||
/// gates sit 5–15 points apart): large enough that ground sitting NEAR a
|
||||
/// threshold crosses it in places, small enough that ground sitting well inside
|
||||
/// a class never leaves it. That asymmetry is what makes the invention
|
||||
/// conservative — a marginal forest grows clearings, a deep one does not.
|
||||
pub const MOISTURE_COMPOSITION_CEILING_Q: i32 = 12;
|
||||
|
||||
/// Maximum swing this tier may apply to `slope_q`, in points.
|
||||
///
|
||||
/// SMALL, and deliberately smaller than the moisture ceiling — the opposite of
|
||||
/// the first attempt, which set it to 15 on the reasoning that the morphology
|
||||
/// gates it feeds are far apart (Fjord at 40, CliffCoast at 55). That reasoning
|
||||
/// was backwards. Measured against the window-derivation golden's fixtures,
|
||||
/// base `slope_q` on ordinary ground is 2–6, so a ±15 swing does not VARY the
|
||||
/// signal, it REPLACES it: one fixture moved 6 → 19, and two coastal samples
|
||||
/// flipped to Wetland on the strength of invented slope alone.
|
||||
///
|
||||
/// The gates are far apart for a reason: a cliff coast is a real landform, not
|
||||
/// a dice roll. Composition must let ground that genuinely sits near a
|
||||
/// threshold fall on both sides of it — never manufacture a fjord on a flood
|
||||
/// plain. Where the terrain is flat, flat is the honest answer, and the
|
||||
/// morphology variety visible at Global comes from places that actually have
|
||||
/// slope.
|
||||
pub const SLOPE_COMPOSITION_CEILING_Q: i32 = 6;
|
||||
|
||||
/// The fine-band composition field at a world position, in `[-1, 1]`.
|
||||
///
|
||||
/// Only [`VOXEL_OCTAVE_WAVELENGTHS_M`] (1,024–128 m) — the band that actually
|
||||
/// varies inside a District window — normalized across its own octaves so the
|
||||
/// tier uses its full swing rather than the 5.9% tail it holds inside the
|
||||
/// texture field's series.
|
||||
///
|
||||
/// `min_wavelength_m` gates octaves finer than the caller's sample density, the
|
||||
/// same discipline every other invented field follows; `0.0` = no cutoff.
|
||||
fn composition_field(seed: u64, wx: f64, wy: f64, min_wavelength_m: f64) -> f64 {
|
||||
let seed = splitmix64(seed ^ COMPOSITION_SALT);
|
||||
let mut sum = 0.0;
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
for (i, &wl) in VOXEL_OCTAVE_WAVELENGTHS_M.iter().enumerate() {
|
||||
if wl < min_wavelength_m {
|
||||
amp *= 0.5;
|
||||
continue;
|
||||
}
|
||||
sum += value_noise(
|
||||
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||||
wx,
|
||||
wy,
|
||||
wl,
|
||||
) * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
}
|
||||
if norm <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(sum / norm).clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Composition offsets for one position: `(moisture_q, slope_q)`, in points.
|
||||
///
|
||||
/// **Zero-mean by construction**, which is what satisfies D-258's conservation
|
||||
/// invariant: `value_noise` is symmetric about zero, so averaging the offsets
|
||||
/// over any area large enough to contain whole features returns to the
|
||||
/// unperturbed summary. Composition adds variety within a cell without moving
|
||||
/// what the cell reads as from orbit — the property
|
||||
/// `composition_conserves_the_summary` pins on real terrain.
|
||||
///
|
||||
/// The two offsets are drawn from the SAME field rather than two independent
|
||||
/// ones, and deliberately: on real ground steep places drain, so slope and
|
||||
/// moisture are correlated, and a rocky outcrop should tend to be the drier
|
||||
/// patch rather than an unrelated one. The moisture offset is negated for that
|
||||
/// reason — where this tier lifts slope it drops moisture.
|
||||
pub fn composition_offsets(
|
||||
seed: u64,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
min_wavelength_m: f64,
|
||||
moisture_ceiling_q: i32,
|
||||
) -> (i32, i32) {
|
||||
let f = composition_field(seed, wx, wy, min_wavelength_m);
|
||||
// Ceiling is caller-supplied for moisture so a bone-dry or airless world,
|
||||
// whose vegetation envelope is already zero, invents no damp pockets — the
|
||||
// same envelope rule `vegetation_invention` applies one tier up.
|
||||
let mut moisture = ((-f * moisture_ceiling_q as f64).round() as i32)
|
||||
.clamp(-moisture_ceiling_q, moisture_ceiling_q);
|
||||
let mut slope = ((f * SLOPE_COMPOSITION_CEILING_Q as f64).round() as i32)
|
||||
.clamp(-SLOPE_COMPOSITION_CEILING_Q, SLOPE_COMPOSITION_CEILING_Q);
|
||||
|
||||
// RARE INCLUSIONS — the clearing in the deep wood, the rocky outcrop on the
|
||||
// plain (Jeroen, 2026-08-08: "maybe a dense forest should still sometimes
|
||||
// produce a clearing or a rocky outcropping").
|
||||
//
|
||||
// The smooth offsets above are a gentle sway around the base value, so they
|
||||
// can only change a verdict where the ground already sits near a gate. That
|
||||
// made deep-in-class ground immune, and the conservation test duly measured
|
||||
// a District patch that was 100% Forest — a monoculture, which is the flat
|
||||
// map this ticket exists to fix, one scale down.
|
||||
//
|
||||
// D-258 says a cell reading forest must be able to CONTAIN clearings, marsh,
|
||||
// rock and scrub. Contain, not "border on". So a sparse, high-contrast term
|
||||
// rides on top: rare enough that the majority is never threatened (the
|
||||
// conservation invariant is what bounds it), strong enough to cross a gate
|
||||
// from well inside a class.
|
||||
if is_inclusion(seed, wx, wy) {
|
||||
let sign = if f >= 0.0 { 1 } else { -1 };
|
||||
// The moisture half obeys the envelope rule: a world whose patchiness
|
||||
// ceiling is zero (airless, bone-dry) grows no glades, because it has no
|
||||
// vegetation to clear. Caught by `zero_moisture_ceiling_invents_no_moisture`,
|
||||
// which the first version of this failed — the inclusion was applied
|
||||
// unconditionally and put damp pockets on airless rock.
|
||||
if moisture_ceiling_q > 0 {
|
||||
moisture = (moisture - sign * INCLUSION_MOISTURE_SWING_Q).clamp(-100, 100);
|
||||
}
|
||||
// The slope half is NOT gated. An outcrop is geology, not biology — a
|
||||
// dead world is exactly where bare rock should break the surface.
|
||||
slope = (slope + sign * INCLUSION_SLOPE_SWING_Q).clamp(-100, 100);
|
||||
}
|
||||
(moisture, slope)
|
||||
}
|
||||
|
||||
/// Does this position fall inside a rare inclusion — a clearing, a blowdown, a
|
||||
/// rocky outcrop?
|
||||
///
|
||||
/// Thresholded value noise rather than a per-cell dice roll, so inclusions come
|
||||
/// out as connected BLOBS a few hundred metres across. A per-cell test would
|
||||
/// scatter single stray cells through the forest, which reads as speckle
|
||||
/// (exactly the failure the client-side stipple's first attempt had) rather
|
||||
/// than as a glade.
|
||||
fn is_inclusion(seed: u64, wx: f64, wy: f64) -> bool {
|
||||
let s = splitmix64(seed ^ INCLUSION_SALT);
|
||||
value_noise(s, wx, wy, INCLUSION_WAVELENGTH_M) > INCLUSION_THRESHOLD
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_is_deterministic() {
|
||||
let a = composition_field(42, 1_000.0, 2_000.0, 0.0);
|
||||
let b = composition_field(42, 1_000.0, 2_000.0, 0.0);
|
||||
assert_eq!(a, b, "same seed and position must give the same field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_stays_in_unit_range() {
|
||||
for i in 0..500 {
|
||||
let v = composition_field(7, i as f64 * 37.0, i as f64 * 91.0, 0.0);
|
||||
assert!((-1.0..=1.0).contains(&v), "field out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole reason this tier exists: it must VARY across a District window
|
||||
/// (2,048 m), where the texture field it supplements is effectively
|
||||
/// constant.
|
||||
#[test]
|
||||
fn field_varies_across_a_district_window() {
|
||||
let mut lo = f64::MAX;
|
||||
let mut hi = f64::MIN;
|
||||
for i in 0..64 {
|
||||
let wx = i as f64 * 32.0; // 64 samples across 2,048 m
|
||||
let v = composition_field(11, wx, 0.0, 0.0);
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
assert!(
|
||||
hi - lo > 0.5,
|
||||
"swing across a District window was only {:.3} — this tier exists \
|
||||
precisely because the coarse bands are flat at this scale",
|
||||
hi - lo
|
||||
);
|
||||
}
|
||||
|
||||
/// D-258 invariant 3, at the field level: the offsets average back to
|
||||
/// nothing, so a downsample reproduces the summary they were added to.
|
||||
#[test]
|
||||
fn offsets_are_zero_mean_over_area() {
|
||||
let mut m_sum = 0i64;
|
||||
let mut s_sum = 0i64;
|
||||
let n = 4_000;
|
||||
for i in 0..n {
|
||||
let wx = (i % 64) as f64 * 128.0;
|
||||
let wy = (i / 64) as f64 * 128.0;
|
||||
let (m, s) = composition_offsets(99, wx, wy, 0.0, MOISTURE_COMPOSITION_CEILING_Q);
|
||||
m_sum += m as i64;
|
||||
s_sum += s as i64;
|
||||
}
|
||||
let m_mean = m_sum as f64 / n as f64;
|
||||
let s_mean = s_sum as f64 / n as f64;
|
||||
assert!(
|
||||
m_mean.abs() < 1.0,
|
||||
"moisture offset mean {m_mean:.3} — a biased tier would shift the \
|
||||
summary it is supposed to preserve"
|
||||
);
|
||||
assert!(
|
||||
s_mean.abs() < 1.5,
|
||||
"slope offset mean {s_mean:.3} is biased"
|
||||
);
|
||||
}
|
||||
|
||||
/// Slope and moisture must move OPPOSITE ways: steep drains.
|
||||
#[test]
|
||||
fn steep_places_are_the_drier_places() {
|
||||
let mut checked = 0;
|
||||
for i in 0..200 {
|
||||
let wx = i as f64 * 71.0;
|
||||
let (m, s) = composition_offsets(5, wx, 0.0, 0.0, MOISTURE_COMPOSITION_CEILING_Q);
|
||||
if s.abs() >= 3 && m.abs() >= 3 {
|
||||
assert!(
|
||||
(s > 0) != (m > 0),
|
||||
"slope {s} and moisture {m} moved the same way — a rocky \
|
||||
outcrop should be the drier patch, not a wetter one"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 10, "only {checked} samples exercised the check");
|
||||
}
|
||||
|
||||
/// A zero ceiling invents nothing — the envelope rule, so a bone-dry world
|
||||
/// grows no damp pockets.
|
||||
#[test]
|
||||
fn zero_moisture_ceiling_invents_no_moisture() {
|
||||
for i in 0..100 {
|
||||
let (m, _) = composition_offsets(3, i as f64 * 55.0, 0.0, 0.0, 0);
|
||||
assert_eq!(m, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gating above the whole band silences the tier rather than aliasing it.
|
||||
#[test]
|
||||
fn cutoff_above_the_band_yields_no_offset() {
|
||||
let v = composition_field(42, 500.0, 500.0, 4_096.0);
|
||||
assert_eq!(v, 0.0, "every octave truncated must give exactly 0.0");
|
||||
}
|
||||
|
||||
/// **D-258 invariant 3 — the binding acceptance gate, on real terrain.**
|
||||
///
|
||||
/// "Downsampling rung 0.5 must reproduce the rung-0 summary it came from: a
|
||||
/// forest cell may gain marsh pockets but must still read as forest from
|
||||
/// orbit." The zero-mean test above proves the FIELD averages out; this
|
||||
/// proves the consequence that actually matters — that the CLASSIFICATION a
|
||||
/// patch of ground reads as does not move when composition is applied and
|
||||
/// then averaged back.
|
||||
///
|
||||
/// Runs the real derive over a District-sized patch twice, with and without
|
||||
/// composition, and compares the MAJORITY vegetation verdict. Composition
|
||||
/// may recolour a minority of cells — that is its entire purpose — but the
|
||||
/// plurality must survive, or the map would disagree with the world it
|
||||
/// zoomed out from.
|
||||
#[test]
|
||||
#[ignore = "loads a real body's cascade; run explicitly"]
|
||||
fn composition_conserves_the_summary_on_real_terrain() {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let body = "GJ820Bc";
|
||||
let Ok((snapshot, params)) =
|
||||
crate::atlas::believability::cascade_snapshot_for_body(42, body)
|
||||
else {
|
||||
eprintln!("skip: {body} not loadable");
|
||||
return;
|
||||
};
|
||||
let (_l1, ta) = crate::atlas::layer1::run_layer1_with_moisture(
|
||||
&snapshot.heightmap,
|
||||
crate::atlas::district_profile::derive_moisture_ceiling_q(¶ms),
|
||||
);
|
||||
let climate = crate::atlas::district_profile::ClimateConstants::default();
|
||||
let seed = crate::seed::SeedChain::root(42).derive(crate::seed::SeedDomain::Body, 1);
|
||||
|
||||
// A District-sized patch (2,048 m) at the descent ladder's own anchor,
|
||||
// sampled at ~16 m so the fine band is fully resolved.
|
||||
let (cx, cy) = (29_422_009.0f64, -5_675_959.0f64);
|
||||
let mut tally: BTreeMap<u8, u32> = BTreeMap::new();
|
||||
for iy in 0..128 {
|
||||
for ix in 0..128 {
|
||||
let wx = cx + (ix as f64 - 64.0) * 16.0;
|
||||
let wy = cy + (iy as f64 - 64.0) * 16.0;
|
||||
let prof = crate::atlas::district_profile::derive_at_metres(
|
||||
seed,
|
||||
body,
|
||||
¶ms,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
0.0,
|
||||
&[],
|
||||
);
|
||||
*tally.entry(prof.vegetation_class as u8).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let total: u32 = tally.values().sum();
|
||||
let (majority, count) = tally
|
||||
.iter()
|
||||
.max_by_key(|(_, c)| **c)
|
||||
.map(|(k, c)| (*k, *c))
|
||||
.expect("non-empty tally");
|
||||
|
||||
// The plurality must still BE the plurality after composition — a
|
||||
// downsample of this patch returns the class it started as.
|
||||
let share = count as f64 / total as f64;
|
||||
assert!(
|
||||
share > 0.5,
|
||||
"composition broke the summary: majority class {majority} holds only \
|
||||
{:.1}% of a District patch across {} classes {:?}. D-258 allows \
|
||||
minority pockets, not a new majority — this would make the map \
|
||||
disagree with the view it was zoomed in from.",
|
||||
100.0 * share,
|
||||
tally.len(),
|
||||
tally
|
||||
);
|
||||
// ...and it must not be a monoculture either, or nothing was composed.
|
||||
assert!(
|
||||
tally.len() > 1 || share == 1.0,
|
||||
"tally {tally:?} — expected either composition or an honestly uniform patch"
|
||||
);
|
||||
eprintln!(
|
||||
"conservation: majority class {majority} at {:.1}% across {} classes {:?}",
|
||||
100.0 * share,
|
||||
tally.len(),
|
||||
tally
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1702,6 +1702,13 @@ fn build_district_profile(
|
||||
near_perennial_water: bool,
|
||||
lake_from_hydrology: bool,
|
||||
lake_margin_q: i32,
|
||||
// T-1213: apply the fine composition tier. FALSE on the orbital path, which
|
||||
// is envelope-only by design and documents that it never invents slope
|
||||
// (`derive_orbital_at_metres_never_invents_slope` pins it). Global does not
|
||||
// need it either — it already carries nine morphology zones and 75 moisture
|
||||
// values; composition exists for the rungs below, where those collapse to
|
||||
// one.
|
||||
invent_composition: bool,
|
||||
) -> DistrictProfile {
|
||||
let tectonic_class = derive_tectonic_class(body_params);
|
||||
|
||||
@@ -1746,7 +1753,32 @@ fn build_district_profile(
|
||||
world_y_m,
|
||||
min_wavelength_m,
|
||||
);
|
||||
let moisture_q = (base_moisture_q + moisture_perturb).clamp(0, 100);
|
||||
// T-1213 — the fine composition tier (D-258 invariant 2). The massif/texture
|
||||
// pair above is weighted so it can never change a verdict (70/30, by
|
||||
// design); this one is allowed to, because "descending the ladder reveals
|
||||
// COMPOSITION" means precisely that a summarised cell must be able to
|
||||
// contain the minority it suppressed. Conservation is the bound rather than
|
||||
// weighting: the field is zero-mean, so the summary survives a downsample.
|
||||
//
|
||||
// It feeds BOTH classification inputs. Moisture alone would leave morphology
|
||||
// just as flat — measured on Ferrath, nine zones at Global and exactly one
|
||||
// below it, because every coastal family (CliffCoast at slope_q >= 55, Fjord
|
||||
// at >= 40) gates on a threshold that sub-Global slope never reaches.
|
||||
let (composition_moisture, composition_slope) = if invent_composition {
|
||||
crate::atlas::composition::composition_offsets(
|
||||
seed.seed(),
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
min_wavelength_m,
|
||||
veg_envelope
|
||||
.ceiling_q
|
||||
.min(crate::atlas::composition::MOISTURE_COMPOSITION_CEILING_Q),
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let moisture_q = (base_moisture_q + moisture_perturb + composition_moisture).clamp(0, 100);
|
||||
let slope_q = (slope_q + composition_slope).clamp(0, 100);
|
||||
|
||||
// Climate-derived fields: computed from temperature + moisture primitives
|
||||
// (D-239 §2). This is the correct call order — temperature must be resolved
|
||||
@@ -2054,6 +2086,7 @@ fn derive_at_metres_with_riparian(
|
||||
near_perennial_water,
|
||||
lake_from_hydrology,
|
||||
lake_margin_q,
|
||||
true, // metres-addressable path: composition applies (T-1213)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2273,6 +2306,13 @@ pub fn derive_orbital_at_metres(
|
||||
false,
|
||||
lake_from_hydrology,
|
||||
lake_margin_q,
|
||||
// T-1213: NO composition at the orbital rung. This path is
|
||||
// envelope-only by design and documents that it never invents slope
|
||||
// (pinned by `derive_orbital_at_metres_never_invents_slope`, which
|
||||
// caught the first version of this change). Global does not need it
|
||||
// regardless — it already carries nine morphology zones and 75 moisture
|
||||
// values; composition exists for the rungs where those collapse to one.
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod cascade;
|
||||
pub mod chunk_context;
|
||||
pub mod city_context_reader;
|
||||
pub mod coast_invention;
|
||||
pub mod composition;
|
||||
pub mod detail_scatter;
|
||||
pub mod district_mix;
|
||||
pub mod district_profile;
|
||||
|
||||
@@ -2662,6 +2662,21 @@ mod tests {
|
||||
.len();
|
||||
grad += &format!(" RELIEF[uniq {ruq:3} d4mean {:.2}]", rsum / rn.max(1.0));
|
||||
|
||||
// Cliffs travel by TWO independent routes and both are worth
|
||||
// watching down the ladder: the sparse `cliffs` list (explicit
|
||||
// segment geometry, not derived from the elevation field) and the
|
||||
// morphology gates, which key on slope_q — CliffCoast at >= 55,
|
||||
// Fjord at >= 40 (D-239 §6). If slope_q flattens the way elev_q
|
||||
// does, those zones become unreachable and coastal character is
|
||||
// lost even though the segment list survives.
|
||||
let morph_zones: std::collections::BTreeSet<u8> =
|
||||
raw.morphology.iter().copied().collect();
|
||||
grad += &format!(
|
||||
" cliffs {} zones {:?}",
|
||||
raw.cliffs.len(),
|
||||
morph_zones.iter().copied().collect::<Vec<u8>>()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{:8?} {:>9.1} m/gu {}x{} elev[uniq {:3} span {:3}] moist[uniq {:3} span {:3}] \
|
||||
morph[uniq {:2}] veg[uniq {:2}] glac[uniq {:2}] courses {}{grad}",
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"voxel_sampled_districts": 64,
|
||||
"contrast": {
|
||||
"moisture_q": {
|
||||
"min": 17,
|
||||
"max": 97,
|
||||
"distinct": 75
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"distinct": 85
|
||||
},
|
||||
"elev_q": {
|
||||
"min": 0,
|
||||
@@ -17,8 +17,8 @@
|
||||
},
|
||||
"slope_q": {
|
||||
"min": 0,
|
||||
"max": 27,
|
||||
"distinct": 28
|
||||
"max": 50,
|
||||
"distinct": 34
|
||||
},
|
||||
"ocean_fraction_q": {
|
||||
"min": 0,
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"morphology_zones": 9,
|
||||
"vegetation_classes": 4,
|
||||
"terrain_materials": 3,
|
||||
"terrain_materials": 4,
|
||||
"voxel_relief_m": 28,
|
||||
"micro_habitat_distinct": 3,
|
||||
"land_districts": 698,
|
||||
@@ -50,8 +50,8 @@
|
||||
"contrast": {
|
||||
"moisture_q": {
|
||||
"min": 0,
|
||||
"max": 24,
|
||||
"distinct": 25
|
||||
"max": 49,
|
||||
"distinct": 42
|
||||
},
|
||||
"elev_q": {
|
||||
"min": 0,
|
||||
@@ -60,17 +60,17 @@
|
||||
},
|
||||
"slope_q": {
|
||||
"min": 0,
|
||||
"max": 15,
|
||||
"distinct": 15
|
||||
"max": 34,
|
||||
"distinct": 22
|
||||
},
|
||||
"ocean_fraction_q": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"distinct": 36
|
||||
},
|
||||
"morphology_zones": 6,
|
||||
"morphology_zones": 7,
|
||||
"vegetation_classes": 3,
|
||||
"terrain_materials": 2,
|
||||
"terrain_materials": 3,
|
||||
"voxel_relief_m": 22,
|
||||
"micro_habitat_distinct": 0,
|
||||
"land_districts": 1602,
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
"wx_m": 2000000,
|
||||
"wy_m": 1500000,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 8,
|
||||
"morphology": 16,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 3,
|
||||
"slope_q": 0,
|
||||
"elev_q": 31,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 97,
|
||||
"moisture_q": 59,
|
||||
"moisture_q": 65,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -73,15 +73,15 @@
|
||||
"wx_m": 2050000,
|
||||
"wy_m": 1500000,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 8,
|
||||
"morphology": 16,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 3,
|
||||
"slope_q": 0,
|
||||
"elev_q": 25,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 132,
|
||||
"moisture_q": 56,
|
||||
"moisture_q": 65,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -128,11 +128,11 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 3,
|
||||
"slope_q": 4,
|
||||
"elev_q": 27,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 115,
|
||||
"moisture_q": 58,
|
||||
"moisture_q": 57,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -179,11 +179,11 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 4,
|
||||
"slope_q": 3,
|
||||
"elev_q": 20,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 116,
|
||||
"moisture_q": 52,
|
||||
"moisture_q": 54,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -230,11 +230,11 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 2,
|
||||
"precipitation": 1,
|
||||
"slope_q": 4,
|
||||
"slope_q": 1,
|
||||
"elev_q": 61,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -120,
|
||||
"moisture_q": 43,
|
||||
"moisture_q": 49,
|
||||
"vegetation": 1
|
||||
},
|
||||
{
|
||||
@@ -281,11 +281,11 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 2,
|
||||
"slope_q": 1,
|
||||
"elev_q": 5,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 106,
|
||||
"moisture_q": 50,
|
||||
"moisture_q": 51,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -332,7 +332,7 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 2,
|
||||
"slope_q": 0,
|
||||
"elev_q": 48,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
@@ -383,7 +383,7 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 2,
|
||||
"slope_q": 1,
|
||||
"elev_q": 39,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
@@ -434,7 +434,7 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 2,
|
||||
"slope_q": 0,
|
||||
"elev_q": 45,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
@@ -485,7 +485,7 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 4,
|
||||
"slope_q": 5,
|
||||
"elev_q": 39,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
@@ -515,11 +515,11 @@
|
||||
"wx_m": 1200000,
|
||||
"wy_m": 8500000,
|
||||
"min_wl_m": 4096,
|
||||
"morphology": 8,
|
||||
"morphology": 12,
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 2,
|
||||
"slope_q": 27,
|
||||
"elev_q": 76,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
@@ -536,7 +536,7 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 0,
|
||||
"slope_q": 3,
|
||||
"slope_q": 0,
|
||||
"elev_q": 79,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -2147483648,
|
||||
@@ -638,11 +638,11 @@
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 6,
|
||||
"slope_q": 2,
|
||||
"elev_q": 13,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 811,
|
||||
"moisture_q": 62,
|
||||
"moisture_q": 71,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -688,12 +688,12 @@
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 6,
|
||||
"precipitation": 2,
|
||||
"slope_q": 11,
|
||||
"elev_q": 24,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 749,
|
||||
"moisture_q": 60,
|
||||
"moisture_q": 50,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -739,12 +739,12 @@
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 4,
|
||||
"precipitation": 2,
|
||||
"slope_q": 7,
|
||||
"elev_q": 33,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 703,
|
||||
"moisture_q": 59,
|
||||
"moisture_q": 53,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -773,12 +773,12 @@
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 3,
|
||||
"precipitation": 1,
|
||||
"slope_q": 28,
|
||||
"elev_q": 24,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 691,
|
||||
"moisture_q": 49,
|
||||
"moisture_q": 19,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -790,12 +790,12 @@
|
||||
"morphology": 15,
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 4,
|
||||
"precipitation": 4,
|
||||
"slope_q": 0,
|
||||
"elev_q": 21,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 707,
|
||||
"moisture_q": 49,
|
||||
"moisture_q": 85,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -842,11 +842,11 @@
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 4,
|
||||
"slope_q": 0,
|
||||
"elev_q": 65,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 300,
|
||||
"moisture_q": 30,
|
||||
"moisture_q": 38,
|
||||
"vegetation": 2
|
||||
},
|
||||
{
|
||||
@@ -893,11 +893,11 @@
|
||||
"tectonic": 2,
|
||||
"glaciation": 0,
|
||||
"precipitation": 2,
|
||||
"slope_q": 6,
|
||||
"slope_q": 7,
|
||||
"elev_q": 5,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 644,
|
||||
"moisture_q": 49,
|
||||
"moisture_q": 48,
|
||||
"vegetation": 3
|
||||
},
|
||||
{
|
||||
@@ -944,11 +944,11 @@
|
||||
"tectonic": 0,
|
||||
"glaciation": 0,
|
||||
"precipitation": 3,
|
||||
"slope_q": 5,
|
||||
"slope_q": 1,
|
||||
"elev_q": 0,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": 280,
|
||||
"moisture_q": 56,
|
||||
"moisture_q": 64,
|
||||
"vegetation": 6
|
||||
},
|
||||
{
|
||||
@@ -991,15 +991,15 @@
|
||||
"wx_m": 1250942,
|
||||
"wy_m": 158849,
|
||||
"min_wl_m": 1024,
|
||||
"morphology": 8,
|
||||
"morphology": 16,
|
||||
"tectonic": 0,
|
||||
"glaciation": 2,
|
||||
"precipitation": 1,
|
||||
"slope_q": 2,
|
||||
"precipitation": 2,
|
||||
"slope_q": 0,
|
||||
"elev_q": 75,
|
||||
"ocean_fraction_q": 0,
|
||||
"temperature_dc": -110,
|
||||
"moisture_q": 54,
|
||||
"moisture_q": 60,
|
||||
"vegetation": 1
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user