diff --git a/client/project.godot b/client/project.godot index 04e1db288..efe652cc0 100644 --- a/client/project.godot +++ b/client/project.godot @@ -17,7 +17,7 @@ config/name="The Settled Reach" ; in the editor and in a shipped build, where res://../project.yaml does not ; exist at all (T-1241). Kept honest by `reach check client-version`, which the ; pre-push hook runs — do not edit this by hand without moving project.yaml too. -config/version="0.4.14" +config/version="0.4.15" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/project.yaml b/project.yaml index 83d8defa4..22ee9e8b9 100644 --- a/project.yaml +++ b/project.yaml @@ -71,7 +71,12 @@ name: The Settled Reach # paths renamed to their reach verbs, T-1289/T-1253) touched # server/src/atlas/trait_catalog_reader.rs, canvas_sources.py itself, and two # client files in the registry. Path-based gate, spent rather than dodged. -version: 0.4.14 +# 0.4.15 changes DERIVED BYTES (T-1213): the coast/terrain character reads a +# continuous glacial weight instead of the categorical grade, removing a seam +# the None->Light isotherm cut through the relief field; and the composition +# tier's rare inclusions now honour the rung's octave cutoff, so Region no +# longer carries aliased ValleyFloor speckle. Warm caches are genuinely wrong. +version: 0.4.15 repository: settled-reach diff --git a/server/src/atlas/coast_invention.rs b/server/src/atlas/coast_invention.rs index b83c46d6c..d6af7d984 100644 --- a/server/src/atlas/coast_invention.rs +++ b/server/src/atlas/coast_invention.rs @@ -17,7 +17,9 @@ //! rainier → higher erosion → smoother mature coasts; dry / thin-atmosphere → //! sharp young coasts. //! - **Tier 2 — position character** ([`coast_character_at`]): latitude, -//! driver-tier `GlaciationGrade` (high-latitude glaciated coasts go fjordy), +//! driver-tier glacial weight (high-latitude glaciated coasts go fjordy — +//! a continuous ramp over the grade bands, [`glacial_weight`], so the +//! character never steps on an isotherm either), //! local wetness, and a seeded long-wavelength heterogeneity field //! ([`character_field`], the T-1084 `voxel_mosaic` pattern at 100–400 km //! octaves) so stretches of the *same* coast differ. Longitude participates @@ -173,30 +175,88 @@ pub struct CoastCharacter { pub ridge: f64, } -/// Resolve the invention character at `(wx, wy)` world metres. -/// -/// `driver_glaciation` / `driver_moisture_q` are the **one-step-stale** climate -/// drivers computed from the *unwarped* raw-bilinear primitives (the T-1125 -/// circularity ruling — see the call site in `district_profile`). -pub fn coast_character_at( - env: &BodyCoastEnvelope, - seed: u64, - wx: f64, - wy: f64, - lat_deg: f64, - driver_glaciation: GlaciationGrade, - driver_moisture_q: i32, -) -> CoastCharacter { - // Seeded heterogeneity in [0,1]: same-coast stretches differ (Jeroen). - let hetero = character_field(seed, wx, wy); - let fjord: f64 = match driver_glaciation { +/// The fjord weight of each glaciation grade — how strongly glacial carving +/// shapes the invention character. Kept as the grade-level statement of the +/// ramp [`glacial_weight`] interpolates; [`glacial_weight`] agrees with it at +/// each band's centre. +pub fn grade_glacial_weight(grade: GlaciationGrade) -> f64 { + match grade { GlaciationGrade::None => 0.0, GlaciationGrade::Light => 0.30, GlaciationGrade::Moderate => 0.70, GlaciationGrade::Heavy => 1.0, // Under a permanent cap the fjord carving is there but partly buried. GlaciationGrade::IceCap => 0.90, + } +} + +/// `(temperature °C, weight)` knots of the continuous glacial ramp: the warm +/// edge of Light, then the centre of each grade's band in +/// `derive_glaciation_grade_from_climate` (Light −4..5, Moderate −14..−5, +/// Heavy −29..−15, IceCap ≤ −30). Linear between knots, flat beyond the ends. +const GLACIAL_KNOTS: [(f64, f64); 5] = [ + (6.0, 0.0), + (0.5, 0.30), + (-9.5, 0.70), + (-22.0, 1.0), + (-34.0, 0.90), +]; + +/// Continuous glacial weight in `[0, 1]` from the driver climate (T-1213). +/// +/// This replaced reading the CATEGORICAL grade, which made the character step: +/// the grade flips None → Light on an isotherm, `fjord` jumped 0 → 0.30, and +/// ridge, scatter floor and warp amplitude all jumped with it. On Ferrath's +/// descent-ladder Region that isotherm crossed the canvas as a straight seam in +/// the relief field, which the hillshade drew as a stair-stepped cliff — a +/// biome edge falling on a line, which D-258 (1) and the D-243 edge-fuzz rule +/// both forbid. The grade stays the classification; only the character's +/// dependence on it is made continuous. +/// +/// The moisture gate (no ice below `moisture_q` 10) is ramped over 5..15 for +/// the same reason. Airless bodies carry no glacial weight (ice is geology). +pub fn glacial_weight(temperature_c: Option, moisture_q: i32) -> f64 { + let Some(t) = temperature_c else { + return 0.0; }; + let t = t as f64; + let first = GLACIAL_KNOTS[0]; + let last = GLACIAL_KNOTS[GLACIAL_KNOTS.len() - 1]; + let temp_weight = if t >= first.0 { + first.1 + } else if t <= last.0 { + last.1 + } else { + GLACIAL_KNOTS + .windows(2) + .find(|w| t <= w[0].0 && t >= w[1].0) + .map(|w| { + let f = (w[0].0 - t) / (w[0].0 - w[1].0); + w[0].1 + f * (w[1].1 - w[0].1) + }) + .unwrap_or(0.0) + }; + let moisture_gate = ((moisture_q as f64 - 5.0) / 10.0).clamp(0.0, 1.0); + temp_weight * moisture_gate +} + +/// Resolve the invention character at `(wx, wy)` world metres. +/// +/// `fjord` (0–1, see [`glacial_weight`]) and `driver_moisture_q` are the +/// **one-step-stale** climate drivers computed from the *unwarped* raw-bilinear +/// primitives (the T-1125 circularity ruling — see the call site in +/// `district_profile`). +pub fn coast_character_at( + env: &BodyCoastEnvelope, + seed: u64, + wx: f64, + wy: f64, + lat_deg: f64, + fjord: f64, + driver_moisture_q: i32, +) -> CoastCharacter { + // Seeded heterogeneity in [0,1]: same-coast stretches differ (Jeroen). + let hetero = character_field(seed, wx, wy); let lat_frac = (lat_deg.abs() / 90.0).clamp(0.0, 1.0); // Local wetness echoes the tier-1 erosion chain at position scale: the wet // stretches of a body erode smoother than its dry stretches. @@ -362,13 +422,71 @@ mod tests { #[test] fn character_fjord_raises_roughness_amplitude_and_ridge() { let env = body_coast_envelope(¶ms("ice", "thin", "frozen"), TectonicClass::Stable); - let temperate = coast_character_at(&env, 42, 1e6, 2e6, 40.0, GlaciationGrade::None, 40); - let fjordy = coast_character_at(&env, 42, 1e6, 2e6, 70.0, GlaciationGrade::Heavy, 40); + let temperate = coast_character_at( + &env, + 42, + 1e6, + 2e6, + 40.0, + grade_glacial_weight(GlaciationGrade::None), + 40, + ); + let fjordy = coast_character_at( + &env, + 42, + 1e6, + 2e6, + 70.0, + grade_glacial_weight(GlaciationGrade::Heavy), + 40, + ); assert!(fjordy.roughness > temperate.roughness); assert!(fjordy.warp_amplitude_px > temperate.warp_amplitude_px); assert!(fjordy.ridge > temperate.ridge); } + /// The ramp says what the grades said, where the grades are unambiguous: + /// at each band's centre it returns that grade's weight. + #[test] + fn glacial_weight_agrees_with_the_grade_at_band_centres() { + use crate::atlas::district_profile::derive_glaciation_grade_from_climate; + for t in [20.0f32, 0.5, -9.5, -22.0, -40.0] { + let grade = derive_glaciation_grade_from_climate(Some(t), 60); + assert!( + (glacial_weight(Some(t), 60) - grade_glacial_weight(grade)).abs() < 1e-9, + "at {t} °C the ramp disagrees with {grade:?}" + ); + } + } + + /// The reason the ramp exists: no step anywhere. The categorical version + /// jumped 0.30 across one tenth of a degree at 6 °C. + #[test] + fn glacial_weight_never_steps() { + let mut prev = glacial_weight(Some(30.0), 60); + for i in 1..=800 { + let t = 30.0 - i as f32 * 0.1; + let w = glacial_weight(Some(t), 60); + assert!( + (w - prev).abs() < 0.01, + "glacial weight jumped {prev:.3} -> {w:.3} at {t} °C" + ); + prev = w; + } + for m in 0..100 { + let a = glacial_weight(Some(-10.0), m); + let b = glacial_weight(Some(-10.0), m + 1); + // One moisture point is a tenth of the 5..15 ramp, at most 0.1 of weight. + assert!((a - b).abs() <= 0.1 + 1e-9, "moisture gate stepped at {m}"); + } + } + + #[test] + fn glacial_weight_is_zero_when_airless_or_dry() { + assert_eq!(glacial_weight(None, 80), 0.0); + assert_eq!(glacial_weight(Some(-25.0), 3), 0.0); + } + #[test] fn character_heterogeneity_varies_along_a_coast() { // Same body, same latitude, positions ~600 km apart: the seeded field must @@ -385,7 +503,7 @@ mod tests { i as f64 * 600_000.0, 500_000.0, 30.0, - GlaciationGrade::None, + grade_glacial_weight(GlaciationGrade::None), 50, ) .warp_amplitude_px @@ -406,7 +524,15 @@ mod tests { ¶ms("ocean", "breathable", "temperate"), TectonicClass::Stable, ); - let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); + let ch = coast_character_at( + &env, + 42, + 3e6, + 1e6, + 30.0, + grade_glacial_weight(GlaciationGrade::Light), + 55, + ); let a = coast_warp_px(42, 3e6, 1e6, &ch, 0.0); let b = coast_warp_px(42, 3e6, 1e6, &ch, 0.0); assert_eq!(a, b, "warp must be deterministic"); @@ -433,7 +559,15 @@ mod tests { ¶ms("ocean", "breathable", "temperate"), TectonicClass::Stable, ); - let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); + let ch = coast_character_at( + &env, + 42, + 3e6, + 1e6, + 30.0, + grade_glacial_weight(GlaciationGrade::Light), + 55, + ); for i in 0..100 { let wx = i as f64 * 9_137.0; let wy = i as f64 * -7_211.0; @@ -454,7 +588,15 @@ mod tests { ¶ms("ocean", "breathable", "temperate"), TectonicClass::Stable, ); - let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55); + let ch = coast_character_at( + &env, + 42, + 3e6, + 1e6, + 30.0, + grade_glacial_weight(GlaciationGrade::Light), + 55, + ); let (dx, dy) = coast_warp_px(42, 1_000.0, 2_000.0, &ch, 1_000_000.0); assert_eq!((dx, dy), (0.0, 0.0)); } @@ -468,7 +610,15 @@ mod tests { ¶ms("ocean", "breathable", "temperate"), TectonicClass::Stable, ); - let ch = coast_character_at(&env, 17, 1.2e6, 9e5, 25.0, GlaciationGrade::None, 60); + let ch = coast_character_at( + &env, + 17, + 1.2e6, + 9e5, + 25.0, + grade_glacial_weight(GlaciationGrade::None), + 60, + ); let uncut = coast_warp_px(17, 1.2e6, 9e5, &ch, 0.0); let cut = coast_warp_px(17, 1.2e6, 9e5, &ch, 16_385.0); assert_ne!( @@ -491,7 +641,15 @@ mod tests { ¶ms("ocean", "breathable", "temperate"), TectonicClass::Stable, ); - let ch = coast_character_at(&env, 5, 2.5e6, 1.5e6, 15.0, GlaciationGrade::None, 45); + let ch = coast_character_at( + &env, + 5, + 2.5e6, + 1.5e6, + 15.0, + grade_glacial_weight(GlaciationGrade::None), + 45, + ); let district = coast_warp_px(5, 2.5e6, 1.5e6, &ch, 4_096.0); let quarter = coast_warp_px(5, 2.5e6, 1.5e6, &ch, 1_024.0); assert_ne!( @@ -508,7 +666,15 @@ mod tests { ¶ms("ocean", "breathable", "temperate"), TectonicClass::Stable, ); - let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60); + let ch = coast_character_at( + &env, + 42, + 1e6, + 1e6, + 20.0, + grade_glacial_weight(GlaciationGrade::None), + 60, + ); let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch, 0.0); let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0); assert_ne!(wdx, scatter); diff --git a/server/src/atlas/composition.rs b/server/src/atlas/composition.rs index 3be38f42d..8ee294230 100644 --- a/server/src/atlas/composition.rs +++ b/server/src/atlas/composition.rs @@ -204,7 +204,14 @@ pub fn composition_offsets( // 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) { + // + // Gated by the caller's cutoff like every other invented octave. It was + // not, and at Region (379 m cells) the 192 m field was sampled below + // Nyquist: it did not vanish as the constant's doc intended, it ALIASED — + // a +25 slope push scattered at random through the canvas, tipping cells + // into slope-gated ValleyFloor as grid-locked speckle that the hillshade + // then embossed into stair-steps along the zone's frontier. + if INCLUSION_WAVELENGTH_M >= min_wavelength_m && 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 @@ -337,24 +344,105 @@ mod tests { assert_eq!(v, 0.0, "every octave truncated must give exactly 0.0"); } - /// **D-258 invariant 3 — the binding acceptance gate, on real terrain.** + /// ...and the rare inclusions obey the same cutoff. They did not, and at + /// Region the 192 m inclusion field aliased into ValleyFloor speckle; the + /// test above only covered the smooth field, so it stayed green throughout. + #[test] + fn cutoff_above_the_inclusion_scale_invents_nothing() { + let mut inclusions_seen = 0; + for i in 0..4_000 { + let (wx, wy) = ((i % 64) as f64 * 97.0, (i / 64) as f64 * 97.0); + if is_inclusion(42, wx, wy) { + inclusions_seen += 1; + } + assert_eq!( + composition_offsets(42, wx, wy, 4_096.0, MOISTURE_COMPOSITION_CEILING_Q), + (0, 0), + "a rung that cannot resolve a 192 m inclusion must not be handed one" + ); + } + assert!(inclusions_seen > 20, "sample never landed in an inclusion"); + } + + /// Tally the vegetation verdicts over a square patch of real ground. + /// + /// `composed` switches the fine tier on or off through the one derive core + /// every production path uses, so the two tallies differ by composition and + /// by nothing else. + #[allow(clippy::too_many_arguments)] + fn tally_patch( + seed: crate::seed::SeedChain, + body: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + (cx, cy): (f64, f64), + cells: usize, + step_m: f64, + composed: bool, + ) -> std::collections::BTreeMap { + let mut tally = std::collections::BTreeMap::new(); + let half = cells as f64 / 2.0; + for iy in 0..cells { + for ix in 0..cells { + let prof = crate::atlas::district_profile::derive_at_metres_with_riparian( + seed, + body, + params, + ta, + cx + (ix as f64 - half) * step_m, + cy + (iy as f64 - half) * step_m, + climate, + 0.0, + false, + None, + composed, + ); + *tally.entry(prof.vegetation_class as u8).or_insert(0u32) += 1; + } + } + tally + } + + /// `(class, share)` of the plurality in a tally. + fn majority(tally: &std::collections::BTreeMap) -> (u8, f64) { + let total: u32 = tally.values().sum(); + let (class, count) = tally + .iter() + .max_by_key(|(_, c)| **c) + .map(|(k, c)| (*k, *c)) + .expect("non-empty tally"); + (class, count as f64 / total as f64) + } + + /// **D-258 invariants 2 and 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. + /// proves the consequence that matters — that the CLASSIFICATION a patch of + /// ground reads as does not move when composition is applied. /// - /// 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. + /// Every patch is derived TWICE, with the fine tier off and on, through the + /// same core. The uncomposed plurality is the summary; the composed one must + /// equal it. An earlier version of this test derived once and only checked + /// that some class held a majority, which cannot tell a conserved summary + /// from a replaced one — a forest patch turned wholly to scrub passes it. + /// + /// Patches whose own summary is marginal (below `DECISIVE_SHARE`) are a + /// real biome boundary, where composition may legitimately tip the vote, so + /// they are reported but not judged. The number that ARE judged is asserted + /// too, or a body change that made every patch marginal would pass by + /// judging nothing. + /// + /// Runs in the ordinary `cargo test` path (it was `#[ignore]`d, so the + /// binding gate for this work ran only when someone typed it). ~3 s debug. #[test] - #[ignore = "loads a real body's cascade; run explicitly"] fn composition_conserves_the_summary_on_real_terrain() { - use std::collections::BTreeMap; + /// A summary this lopsided is a verdict, not a boundary. + const DECISIVE_SHARE: f64 = 0.7; + /// Fewer judged patches than this and the test is proving nothing. + const MIN_JUDGED: usize = 4; let body = "GJ820Bc"; let Ok((snapshot, params)) = @@ -369,84 +457,98 @@ mod tests { ); let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = crate::seed::SeedChain::root(42).derive(crate::seed::SeedDomain::Body, 1); + let radius_m = params.body_radius_km.expect("Ferrath has a radius") * 1000.0; + let circumference_m = std::f64::consts::TAU * radius_m; + let meridian_m = std::f64::consts::PI * radius_m; - // 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; + // The descent ladder's own anchor, then a spread across the body: four + // longitudes by three latitudes (wy runs north-negative, equator at 0). + let mut anchors = vec![(29_422_009.0f64, -5_675_959.0f64)]; + for lat_deg in [-40.0f64, 0.0, 40.0] { + for k in 0..4 { + anchors.push(( + circumference_m * (k as f64 + 0.125) / 4.0, + -lat_deg / 180.0 * meridian_m, + )); } } - 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; + let mut judged = 0usize; + let mut changed_cells = 0u32; + for (i, &anchor) in anchors.iter().enumerate() { + // 64 x 64 at 32 m = one District (2,048 m); the fine band bottoms + // out at 128 m and inclusions are 192 m, so 32 m resolves both. + let base = tally_patch(seed, body, ¶ms, &ta, &climate, anchor, 64, 32.0, false); + let comp = tally_patch(seed, body, ¶ms, &ta, &climate, anchor, 64, 32.0, true); + let (base_class, base_share) = majority(&base); + let (comp_class, comp_share) = majority(&comp); + changed_cells += base + .iter() + .map(|(k, n)| n.abs_diff(*comp.get(k).unwrap_or(&0))) + .sum::() + / 2; + eprintln!( + "anchor {i}: summary {base_class} at {:.1}% {base:?} -> composed {comp_class} at {:.1}% {comp:?}", + 100.0 * base_share, + 100.0 * comp_share, + ); + if base_share < DECISIVE_SHARE { + continue; + } + judged += 1; + assert_eq!( + comp_class, + base_class, + "anchor {i} {anchor:?}: composition REPLACED the summary — class \ + {base_class} at {:.1}% became class {comp_class} at {:.1}%. D-258 \ + allows minority pockets, not a new majority; the map would \ + disagree with the view it was zoomed in from.", + 100.0 * base_share, + 100.0 * comp_share, + ); + assert!( + comp_share > 0.5, + "anchor {i}: the summary class survived only as a plurality \ + ({:.1}%) — composition invented more than it conserved", + 100.0 * comp_share + ); + } 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 + judged >= MIN_JUDGED, + "only {judged} of {} patches had a decisive summary to conserve — the \ + gate judged too little to mean anything", + anchors.len() ); - // ...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." + changed_cells > 0, + "composition changed no verdict in any patch — the fine tier is off" ); - let minority = total - count; - let minority_share = minority as f64 / total as f64; + + // Invariant 2's floor, at the ladder anchor where it was measured: a + // summarised cell must CONTAIN the minority it suppressed. Sampled at + // 16 m so the value stays comparable with the 1.07% on record. + // + // This assertion once read `tally.len() > 1 || share == 1.0`, a + // tautology — a single-class tally has a 100% share by definition, so it + // could never fail, including in the case its own message named. + let ladder = tally_patch( + seed, body, ¶ms, &ta, &climate, anchors[0], 128, 16.0, true, + ); + let (_, share) = majority(&ladder); + let minority_share = 1.0 - share; 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:?}", + ladder.len() > 1 && minority_share >= MIN_COMPOSED_MINORITY_SHARE, + "ladder anchor tally {ladder:?}: minority holds {:.2}%, below the \ + {:.2}% floor — descending reveals no composition at the rung this \ + work exists to fix", 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 + "conservation: {judged}/{} patches judged, {changed_cells} cells recomposed; \ + ladder anchor minority {:.2}% {ladder:?}", + anchors.len(), + 100.0 * minority_share, ); } } diff --git a/server/src/atlas/district_profile.rs b/server/src/atlas/district_profile.rs index afd3ae4c4..144e5cde8 100644 --- a/server/src/atlas/district_profile.rs +++ b/server/src/atlas/district_profile.rs @@ -1198,7 +1198,8 @@ struct InventedPrimitives { /// Steps: /// 1. **Driver tier (one-step-stale, T-1125 circularity ruling):** the raw /// *unwarped* bilinear envelope feeds a provisional moisture/temperature → -/// [`derive_glaciation_grade_from_climate`] — the coast character reads +/// [`coast_invention::glacial_weight`] (the continuous twin of +/// [`derive_glaciation_grade_from_climate`]) — the coast character reads /// these stale drivers; it never reads the warped values it produces. The /// raw inputs are the coarse ~40–160 km/pixel envelope anyway, so one step /// of staleness is far below the signal's own resolution. @@ -1213,7 +1214,7 @@ struct InventedPrimitives { /// `derive_orbital_at_metres` called `derive_temperature_c` /// UNCONDITIONALLY — wrong on every non-airless body (the entire coastline /// population), silently producing a different `driver_temp` → -/// `driver_moisture` → `driver_glaciation` (a discrete band) → +/// `driver_moisture` → glaciation → /// `coast_character_at` → warp displacement than District/Quarter at the /// identical position, reintroducing the exact T-1160 defect class one /// step upstream of the fix that closed it for the sampling position @@ -1265,7 +1266,9 @@ fn invent_coastal_position( None => derive_temperature_c(&driver_params, climate, seed.seed()), }; let driver_moisture = derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, climate); - let driver_glaciation = derive_glaciation_grade_from_climate(driver_temp, driver_moisture); + // Continuous, not the categorical grade: the character must not step where + // the grade flips on an isotherm (T-1213 — see `coast_invention::glacial_weight`). + let driver_glacial_weight = coast_invention::glacial_weight(driver_temp, driver_moisture); // ── 2. Character: body personality envelope + position modulation. ────── let tectonic = derive_tectonic_class(body_params); @@ -1276,7 +1279,7 @@ fn invent_coastal_position( world_x_m, world_y_m, body_params.latitude_deg, - driver_glaciation, + driver_glacial_weight, driver_moisture, ); @@ -1608,6 +1611,7 @@ pub fn derive_district_profile( 0.0, // batch path — no octave cutoff, matches derive_district's default near_perennial_water, Some(region_cache), + true, // batch path: composition applies, as on the window path (T-1213) ); // D-256(c) binding requirement 1: basin_direction is inert to every other @@ -1940,6 +1944,7 @@ pub fn derive_at_metres( min_wavelength_m, near_perennial_water, None, // no pre-built region cache — on-demand on-the-fly derivation, exactly as before extraction + true, // metres-addressable path: composition applies (T-1213) ) } @@ -1963,8 +1968,14 @@ pub fn derive_at_metres( /// field is inert to every other field's derivation, proven in the D-256 /// ruling, so an override after the fact is exactly equivalent to threading it /// through). +/// +/// `invent_composition` (T-1213) is `true` on both production paths. It exists +/// as a parameter so the D-258 conservation gate can derive the SAME ground +/// with and without the fine tier and compare the verdicts — the only way to +/// test that composition preserves a summary is to know the summary it +/// started from. #[allow(clippy::too_many_arguments)] -fn derive_at_metres_with_riparian( +pub(crate) fn derive_at_metres_with_riparian( seed: SeedChain, body_id: &str, body_params: &BodyParams, @@ -1975,6 +1986,7 @@ fn derive_at_metres_with_riparian( min_wavelength_m: f64, near_perennial_water: bool, region_cache: Option<&BTreeMap>, + invent_composition: bool, ) -> DistrictProfile { // World metres -> fractional heightmap pixel + latitude. Mirrors // `derive_district`'s former inline mapping exactly, just keyed on @@ -2086,7 +2098,7 @@ fn derive_at_metres_with_riparian( near_perennial_water, lake_from_hydrology, lake_margin_q, - true, // metres-addressable path: composition applies (T-1213) + invent_composition, ) } diff --git a/server/src/atlas/river_course.rs b/server/src/atlas/river_course.rs index 31fe7c499..f490004e6 100644 --- a/server/src/atlas/river_course.rs +++ b/server/src/atlas/river_course.rs @@ -1081,11 +1081,11 @@ mod tests { use crate::atlas::coast_invention::{ body_coast_envelope, coast_character_at, coast_warp_px, }; - use crate::atlas::district_profile::{GlaciationGrade, TectonicClass}; + use crate::atlas::district_profile::TectonicClass; let params = test_params(); let env = body_coast_envelope(¶ms, TectonicClass::Stable); - let ch = coast_character_at(&env, 42, 0.0, 0.0, 20.0, GlaciationGrade::None, 50); + let ch = coast_character_at(&env, 42, 0.0, 0.0, 20.0, 0.0, 50); let n = 200; let mut course_vals = Vec::with_capacity(n); diff --git a/server/tests/golden/believability.json b/server/tests/golden/believability.json index 5beeaaebb..7f51b94ee 100644 --- a/server/tests/golden/believability.json +++ b/server/tests/golden/believability.json @@ -60,13 +60,13 @@ }, "slope_q": { "min": 0, - "max": 34, + "max": 33, "distinct": 22 }, "ocean_fraction_q": { "min": 0, "max": 100, - "distinct": 36 + "distinct": 37 }, "morphology_zones": 7, "vegetation_classes": 3, diff --git a/server/tests/golden/window_derivation_golden.json b/server/tests/golden/window_derivation_golden.json index b653fd242..1214f1969 100644 --- a/server/tests/golden/window_derivation_golden.json +++ b/server/tests/golden/window_derivation_golden.json @@ -515,11 +515,11 @@ "wx_m": 1200000, "wy_m": 8500000, "min_wl_m": 4096, - "morphology": 12, + "morphology": 8, "tectonic": 0, "glaciation": 0, "precipitation": 0, - "slope_q": 27, + "slope_q": 2, "elev_q": 76, "ocean_fraction_q": 0, "temperature_dc": -2147483648, @@ -536,7 +536,7 @@ "tectonic": 0, "glaciation": 0, "precipitation": 0, - "slope_q": 0, + "slope_q": 1, "elev_q": 79, "ocean_fraction_q": 0, "temperature_dc": -2147483648, @@ -773,12 +773,12 @@ "morphology": 15, "tectonic": 2, "glaciation": 0, - "precipitation": 1, - "slope_q": 28, + "precipitation": 2, + "slope_q": 3, "elev_q": 24, "ocean_fraction_q": 0, "temperature_dc": 691, - "moisture_q": 19, + "moisture_q": 49, "vegetation": 3 }, { @@ -790,12 +790,12 @@ "morphology": 15, "tectonic": 2, "glaciation": 0, - "precipitation": 4, - "slope_q": 0, + "precipitation": 2, + "slope_q": 1, "elev_q": 21, "ocean_fraction_q": 0, "temperature_dc": 707, - "moisture_q": 85, + "moisture_q": 55, "vegetation": 3 }, {