Files
settled-reach/server/src/atlas/composition.rs
T
jpmschweitzerandClaude Opus 5 869837f728 test(simulation): the conservation gate's monoculture check was a tautology (T-1213)
D-258 invariant 2 says descending the ladder must reveal COMPOSITION — a cell
reading forest must be able to contain the clearings and rock the vote
suppressed. One assertion stood behind that, and it read:

    assert!(tally.len() > 1 || share == 1.0, ...)

A single-class tally has a 100% share by definition, so both branches are always
satisfiable: the check could never fail, including in the exact case its own
message names, "or nothing was composed". The invariant had a test and no gate.

Split into the two bounds the invariant actually has, because it is two-sided:
conservation caps how much may be invented (majority > 50%, already asserted) and
composition sets a floor on how little (minority >= 0.1%). Verified by raising
the floor to 2% and watching it fail on the measured 1.07%, then restoring it —
the floor is a tripwire for "did anything happen", deliberately far below the
measurement rather than tuned to it.

Measured at the descent ladder's own anchor on Ferrath:
  conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}

So composition IS working in the data and conservation holds. The map is flat
anyway, and tooling/atlas-flatness (added here) says why the eye was not enough:

    rung      distinct   lum p1-p99
    Global        1581       145.69
    Region        2923        33.59
    District        53        13.72
    Quarter         46        11.01

Region carries almost TWICE Global's distinct-colour count while holding a
quarter of its structure — the dither pass adds colour noise, not information, so
a colour-count metric would have called the flattest rung the richest. Structure
falls ~92% from Global to Quarter.

The cause is a channel mismatch rather than a missing generator: composition
perturbs moisture_q/slope_q, and the base map draws morphology hue x elev_q
lightness. The ladder scenarios pass no overlays deliberately, so the composed
fields are never rendered in the very shots that judge this work. Recorded on
T-1213 with the three ways forward; the choice touches D-258 and is Jeroen's.

The gate is still #[ignore]d — noted on the ticket as worth moving into a harness
that runs, since believability and window-derivation already load real bodies in
the normal cargo test path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:35:19 +02:00

453 lines
21 KiB
Rust
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.
//! 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 (100410 km, never gated) and a texture field
//! (32 km128 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 515 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 46% 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 515 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 26, 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 floor on how much of a District patch the minority classes must hold for
/// composition to count as having happened (D-258 invariant 2's lower bound).
///
/// Deliberately far below the measured value rather than tuned to it: at the
/// descent ladder's own anchor on Ferrath the minority holds 1.07% (175 of
/// 16,384 samples), and this floor is 0.1%. It is a "did anything happen at all"
/// tripwire, not a quality bar — the bar for whether the map READS as composed
/// is visual, and lives in the ladder captures rather than in an assertion.
///
/// One-sided on purpose. The upper bound (conservation — the majority must
/// survive) is the invariant's other half and is asserted separately; a single
/// combined check is what let the previous version collapse into a tautology.
#[cfg(test)]
const MIN_COMPOSED_MINORITY_SHARE: f64 = 0.001;
/// The fine-band composition field at a world position, in `[-1, 1]`.
///
/// Only [`VOXEL_OCTAVE_WAVELENGTHS_M`] (1,024128 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(&params),
);
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,
&params,
&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.
//
// This assertion used to read `tally.len() > 1 || share == 1.0`, which is
// a TAUTOLOGY: a single-class tally has a 100% share by definition, so
// both branches were always satisfiable and the check could never fail —
// including in the exact case its own message describes, "nothing was
// composed". It was the only thing standing behind D-258's invariant 2
// (descending reveals COMPOSITION), and it was standing behind nothing.
//
// Both bounds are now asserted separately, because the invariant is
// two-sided: conservation caps how much may be invented (above), and
// composition sets a floor on how little (here). Measured at this anchor:
// 2 classes, minority 175/16384 = 1.07%.
assert!(
tally.len() > 1,
"tally {tally:?} — a District patch resolved to ONE vegetation class. \
D-258 invariant 2 says a summarised cell must be able to CONTAIN the \
minority it suppressed; a monoculture here means the fine tier did \
nothing at this scale, which is the flat map this work exists to fix."
);
let minority = total - count;
let minority_share = minority as f64 / total as f64;
assert!(
minority_share >= MIN_COMPOSED_MINORITY_SHARE,
"minority classes hold {:.2}% of the patch, below the {:.2}% floor — \
inclusions this sparse are indistinguishable from none at map scale. \
tally {tally:?}",
100.0 * minority_share,
100.0 * MIN_COMPOSED_MINORITY_SHARE,
);
eprintln!(
"conservation: majority class {majority} at {:.1}% across {} classes {:?}",
100.0 * share,
tally.len(),
tally
);
}
}