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>
453 lines
21 KiB
Rust
453 lines
21 KiB
Rust
//! 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<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.
|
||
//
|
||
// 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
|
||
);
|
||
}
|
||
}
|