//! 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 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,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 = 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. // // 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 ); } }