Productionizes the T-1177 equilibrium solver: run_layer1 now solves hydrology once per body (~24ms, mirrors drainage::analyze) and carries it as TerrainAnalysis.hydrology; run_layer1_with_moisture threads the real body moisture ceiling (extracted derive_moisture_ceiling_q), with the T-1177 population-survey default as fallback. The resident rung-0 global tier does not exist yet (T-1181's scope) — hydrology rides TerrainAnalysis and lands in that tier for free when it is built (deviation recorded on the ticket). MorphologyZone::Lake is now sourced from the settled solver at derive time: a gridunit is Lake when bilinear-sampled filled surface exceeds bilinear-sampled original elevation at the sample's own (px, py) — the continuous comparison, so lake edges refine with rung like coastlines; never a discrete basin-cell projection. The gate sits strictly between OpenOcean (>= 80) and the old ocean_fraction heuristic (>= 60), which survives as the derive-fresh fallback when no solve is attached — byte-identical to pre-T-1184 output in that case. Static classification, distinct from the sim-state flooded plane; no endorheic bit (the drains-vs-closed cue is T-1185's outlet-course presence, per the D-227 amendment (4) sequencing). Zero new wire bytes. Acceptance: lake_classification_cache_hit_equals_cache_miss (solve twice independently, byte-identical zones, non-vacuous Lake hit) plus hydrology determinism tests. Golden fidelity: the window golden fixture now builds TerrainAnalysis through the production entry point (run_layer1_with_moisture, per-body), and a dedicated lake_bowl golden body pins the hydrology-sourced Lake path (morphology 1 at ocean_fraction_q 0 — provably not the heuristic); the 108 pre-existing golden rows are byte-identical (pure append). believability.json moved by one lake-shaped line (GJ338Bd voxel_relief_m 27->28, a correctly reclassified lake district leaving the dry-relief sample set). river_course and derivation-harness goldens unchanged. Full suite: 2114 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
803 lines
35 KiB
Rust
803 lines
35 KiB
Rust
//! Window-derivation golden regression (T-1162).
|
||
//!
|
||
//! Pins `derive_at_metres` (District/Quarter rungs) and
|
||
//! `derive_orbital_at_metres` (Region rung) output at a fixed
|
||
//! (seed, body, coords) sweep, across all three rung cutoffs — the window
|
||
//! path `layer_proxy::derive_window_cell` actually calls in production.
|
||
//! Mirrors `tests/derivation_harness.rs`'s golden pattern exactly (same
|
||
//! regen convention, same double-derive determinism check, same JSON-value
|
||
//! comparison so formatting drift doesn't false-positive).
|
||
//!
|
||
//! **T-1162 regen rationale:** this golden is generated AFTER the T-1162
|
||
//! octave-extension changes (extended coast-warp band, sub-district relief at
|
||
//! Quarter, vegetation patchiness) — it deliberately pins the NEW output.
|
||
//! There is no "pre-T-1162" golden to preserve: this file did not exist
|
||
//! before this ticket, so there is nothing to regress against except itself
|
||
//! from this point forward. Any future change to the coast warp, the voxel
|
||
//! relief band, the vegetation-patchiness field, or the MIN_WL_BANDS_M
|
||
//! quantization will change this golden's values — regenerate deliberately
|
||
//! (per the asset-pipeline discipline: source changes, not hand-edits).
|
||
//!
|
||
//! **Body coverage (Tyre, PR #194 review I4):** the sweep runs against THREE
|
||
//! bodies, not one — the original temperate/ocean/breathable body (unchanged
|
||
//! from the initial T-1162 landing), plus an airless/dry body (exercises
|
||
//! `vegetation_invention::VegetationEnvelope`'s `ceiling_q == 0` short
|
||
//! circuit at the full derivation-stack level) and a volcanic/high-tectonic
|
||
//! coastal body (exercises the ridged-warp/wide-`scatter_floor` branch of
|
||
//! `coast_invention`). The two new bodies' rows are APPENDED after the
|
||
//! original body's rows (never interleaved), so the original rows stay
|
||
//! byte-identical across the I4 regen — see `body_sweep_samples`'s doc.
|
||
//!
|
||
//! **T-1184 fixture fidelity fix (lead review, post-hydrology-productionization):**
|
||
//! `sample_ta` now builds its `TerrainAnalysis` via `run_layer1_with_moisture`
|
||
//! (solving settled hydrology and attaching it, exactly what
|
||
//! `TerrainAnalysisCache::get_or_derive` does in production once `body_params`
|
||
//! is `Some` — the ONLY path `derive_window_cell`'s callers reach post-T-1184),
|
||
//! not the bare `drainage::analyze` + `TerrainAnalysis::analyze` construction
|
||
//! this file used before, which silently pinned the fallback heuristic path
|
||
//! (`ta.hydrology == None`) that production no longer takes. Each body in the
|
||
//! sweep gets its OWN `ta`, solved with ITS OWN moisture ceiling
|
||
//! (`derive_moisture_ceiling_q(params)`) — mirrors production's per-`body_id`
|
||
//! `TerrainAnalysisCache` keying, since hydrology's endorheic/overflow split
|
||
//! (not lake EXTENT, which is moisture-independent) depends on the body's own
|
||
//! params. A FOURTH body (`lake_bowl`, a dedicated bowl-shaped heightmap with a
|
||
//! real filled basin at its centre) is appended for exactly this reason: the
|
||
//! original three bodies' `sample_hm()` gradient-plus-ripple heightmap
|
||
//! produces ZERO filled basins anywhere (verified before this fix — see the
|
||
//! coordinator's review), so without a dedicated lake fixture the hydrology
|
||
//! lake-sourcing gate would be wired but never pinned by any golden row.
|
||
//!
|
||
//! Run: `cargo test --test window_derivation_golden`
|
||
//! Regenerate: `UPDATE_GOLDEN=1 cargo test --test window_derivation_golden`
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use settled_reach_server::atlas::district_profile::{
|
||
derive_at_metres, derive_moisture_ceiling_q, derive_orbital_at_metres, BodyParams,
|
||
ClimateConstants,
|
||
};
|
||
use settled_reach_server::atlas::drainage;
|
||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||
use settled_reach_server::atlas::layer1::run_layer1_with_moisture;
|
||
use settled_reach_server::atlas::river_course;
|
||
use settled_reach_server::atlas::scale;
|
||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
||
|
||
const GOLDEN_FILE: &str = "tests/golden/window_derivation_golden.json";
|
||
|
||
/// Compact representation of a `DistrictProfile` sample for golden pinning.
|
||
/// Integer-discriminant fields only (D-010) — no float equality flakiness.
|
||
///
|
||
/// **No `body` field (Tyre, PR #194 I4 constraint):** the two new body rows
|
||
/// (I4) distinguish themselves via the `label` field's prefix instead of a
|
||
/// new struct field — adding a field here would change the JSON shape of
|
||
/// EVERY existing row (not just the new ones), which fails I4's explicit
|
||
/// "existing rows must stay byte-identical" requirement. `label` was always
|
||
/// a free-form string, so `"golden_body/coastal_a"` vs `"airless_dry/coastal_a"`
|
||
/// costs nothing structurally and keeps the diff a pure append.
|
||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq, Clone)]
|
||
struct GoldenSample {
|
||
label: String,
|
||
rung: String,
|
||
wx_m: i64,
|
||
wy_m: i64,
|
||
min_wl_m: i64,
|
||
morphology: u8,
|
||
tectonic: u8,
|
||
glaciation: u8,
|
||
precipitation: u8,
|
||
slope_q: i32,
|
||
elev_q: i32,
|
||
ocean_fraction_q: i32,
|
||
temperature_dc: i32, // deci-°C, i32::MIN sentinel for None (airless)
|
||
moisture_q: i32,
|
||
vegetation: u8,
|
||
}
|
||
|
||
fn sample_hm() -> BodyHeightmap {
|
||
// Deterministic gradient with enough variance for coast/relief/vegetation
|
||
// content to actually differ across the sweep positions — same shape
|
||
// convention as district_profile.rs's own test_hm / zoom_ladder_bench's
|
||
// bench_hm, sized a bit larger so the sweep coordinates land on distinct
|
||
// heightmap cells rather than a single interpolated patch.
|
||
let (w, h) = (128u32, 64u32);
|
||
let n = (w * h) as usize;
|
||
let data = (0..n)
|
||
.map(|i| {
|
||
let r = (i / w as usize) as f32 / h as f32;
|
||
let c = (i % w as usize) as f32 / w as f32;
|
||
// A gentle sine ripple on top of the linear gradient gives the
|
||
// coastline invention real slope/ocean-mask variance to warp.
|
||
let ripple = (c * std::f32::consts::TAU * 3.0).sin() * 0.08;
|
||
(r * 0.55 + c * 0.35 + ripple + 0.05).clamp(0.0, 1.0)
|
||
})
|
||
.collect();
|
||
BodyHeightmap {
|
||
body_id: "golden_body".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.32,
|
||
}
|
||
}
|
||
|
||
/// Builds a `TerrainAnalysis` the PRODUCTION way (T-1184 fixture-fidelity
|
||
/// fix): `run_layer1_with_moisture`, solving settled hydrology and attaching
|
||
/// it via `with_hydrology`, using the SAME moisture ceiling
|
||
/// `TerrainAnalysisCache::get_or_derive` derives from `body_params` in
|
||
/// production (`derive_moisture_ceiling_q(params)`) — not the bare
|
||
/// `drainage::analyze` + `TerrainAnalysis::analyze` construction this
|
||
/// function used before, which left `ta.hydrology == None` and silently
|
||
/// pinned the fallback heuristic path production no longer takes once
|
||
/// `body_params` is `Some` (the only case `derive_window_cell`'s callers
|
||
/// reach: `resolve_settlement_morphology_zone` short-circuits to `None` on a
|
||
/// missing `body_params`, and `DeriveWindow`'s `body_params` field is a
|
||
/// non-`Option` `Box<BodyParams>`).
|
||
fn sample_ta(hm: &BodyHeightmap, params: &BodyParams) -> TerrainAnalysis {
|
||
let moisture_q = derive_moisture_ceiling_q(params);
|
||
let (_l1, ta) = run_layer1_with_moisture(hm, moisture_q);
|
||
ta
|
||
}
|
||
|
||
fn sample_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// I4 (Tyre, PR #194 review): airless/dry body params — exercises
|
||
/// `vegetation_invention::VegetationEnvelope`'s `ceiling_q == 0` short
|
||
/// circuit (no water, no atmosphere → zero patchiness swing, per
|
||
/// `envelope_airless_or_dry_body_has_zero_ceiling`'s unit-level proof) at
|
||
/// the FULL derivation-stack level, which the unit test alone doesn't pin.
|
||
/// Also airless (`atmosphere: "none"`) so `temperature_c`/`vegetation_class`
|
||
/// take the `None`/`Absent` branches — a body-envelope regression that
|
||
/// invented forest on a bone-dry world would show up here as a NEW
|
||
/// non-Barren/non-Absent vegetation discriminant in the golden diff.
|
||
fn airless_dry_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("none".into()),
|
||
atmosphere: Some("none".into()),
|
||
planet_class: Some("arid".into()),
|
||
body_radius_km: Some(3_390.0), // Mars-scale, deliberately distinct from the wet body
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// I4 (Tyre, PR #194 review): volcanic/high-tectonic coastal body params —
|
||
/// exercises `coast_invention`'s ridged-warp + wide-`scatter_floor` branch
|
||
/// (`TectonicClass::Volcanic` → `tectonic_energy` near its ceiling in
|
||
/// `body_coast_envelope`, driving up `roughness`/`warp_amplitude_px`/
|
||
/// `scatter_floor` per that function's doc) — the coast-crinkle branch most
|
||
/// likely to visibly differ from the temperate body's gentler warp, and thus
|
||
/// the branch most likely to silently regress without dedicated coverage.
|
||
fn volcanic_coast_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("volcanic".into()),
|
||
tectonic_activity: Some("volcanic".into()),
|
||
body_radius_km: Some(6_000.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// T-1184 (lead review fixture-fidelity fix): a dedicated bowl-shaped
|
||
/// heightmap — high rim (0.9), low centre (0.15), same 128×64 dims as
|
||
/// `sample_hm()` — so the settled-hydrology solver produces a real, single
|
||
/// filled lake basin at the bowl's geometric centre. `sea_level = 0.05`
|
||
/// (well below the bowl's own lowest point, 0.15) keeps the ENTIRE grid dry
|
||
/// by the raw heightmap threshold, so a `Lake` verdict at the centre can only
|
||
/// be hydrology-sourced, never the pre-existing `ocean_fraction_q` heuristic.
|
||
///
|
||
/// Needed because NONE of `sample_hm()`/`airless_dry_params()`'s/
|
||
/// `volcanic_coast_params()`'s shared gradient-plus-ripple heightmap ever
|
||
/// forms an enclosed depression (verified: `solve()` returns zero basins on
|
||
/// it at any moisture input) — without this fixture, T-1184's hydrology
|
||
/// lake-sourcing gate would be wired into the derive core but pinned by no
|
||
/// golden row at all, exactly the "unpinned production path" gap the review
|
||
/// flagged.
|
||
fn lake_bowl_hm() -> BodyHeightmap {
|
||
let (w, h) = (128u32, 64u32);
|
||
let n = (w * h) as usize;
|
||
let cx = w as f32 / 2.0;
|
||
let cy = h as f32 / 2.0;
|
||
let max_r = cx.min(cy);
|
||
let data = (0..n)
|
||
.map(|i| {
|
||
let r = (i / w as usize) as f32;
|
||
let c = (i % w as usize) as f32;
|
||
let d = (((c - cx).powi(2) + (r - cy).powi(2)).sqrt() / max_r).min(1.0);
|
||
0.15 + d * 0.75
|
||
})
|
||
.collect();
|
||
BodyHeightmap {
|
||
body_id: "golden_body_lake_bowl".into(),
|
||
width: w,
|
||
height: h,
|
||
data,
|
||
sea_level: 0.05,
|
||
}
|
||
}
|
||
|
||
/// Params for the lake-bowl body — ocean/breathable/temperate at Earth
|
||
/// radius (matches `sample_params()`'s class so the lake row exercises the
|
||
/// same moisture ceiling as the original body, isolating the bowl geometry
|
||
/// as the one variable).
|
||
fn lake_bowl_params() -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some("ocean".into()),
|
||
atmosphere: Some("breathable".into()),
|
||
planet_class: Some("temperate".into()),
|
||
body_radius_km: Some(6371.0),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Pixel → world-metres, reproducing `district_profile::pixel_to_world_m`'s
|
||
/// formula (that function is `pub(crate)`, unreachable from this integration
|
||
/// test) — the SAME mapping `derive_at_metres`/`derive_orbital_at_metres`
|
||
/// compute inline for their own `(px, py)` derivation.
|
||
fn golden_pixel_to_world_m(px: f64, py: f64, w: u32, h: u32, radius_km: f64) -> (f64, f64) {
|
||
let wx = px / w as f64 * (std::f64::consts::TAU * radius_km * 1000.0);
|
||
let lat_frac = py / (h as f64 - 1.0) - 0.5;
|
||
(wx, lat_frac * (std::f64::consts::PI * radius_km * 1000.0))
|
||
}
|
||
|
||
/// Sweep positions for the lake-bowl body — just the bowl centre (world
|
||
/// metres for pixel (64, 32) at `body_radius_km = 6371.0`) plus a rim point
|
||
/// clearly outside the basin, so the golden also pins the "near but not in a
|
||
/// lake" boundary case at this fixture. Computed once via
|
||
/// `golden_pixel_to_world_m` so the position always matches the ACTUAL bowl
|
||
/// centre even if the bowl dimensions ever change, rather than a hand-copied
|
||
/// literal.
|
||
fn lake_bowl_sweep_positions() -> Vec<(&'static str, f64, f64)> {
|
||
let (w, h) = (128u32, 64u32);
|
||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||
let (centre_x, centre_y) = golden_pixel_to_world_m(cx, cy, w, h, 6371.0);
|
||
// A rim pixel (near the bowl edge, outside the filled basin) — same row,
|
||
// near the left edge of the bowl's radius.
|
||
let (rim_x, rim_y) = golden_pixel_to_world_m(4.0, cy, w, h, 6371.0);
|
||
vec![
|
||
("lake_centre", centre_x, centre_y),
|
||
("lake_rim", rim_x, rim_y),
|
||
]
|
||
}
|
||
|
||
/// Fixed sweep positions (world metres from origin) — a handful of points
|
||
/// spanning a coastal stretch (per the heightmap's ripple) plus a couple of
|
||
/// clearly inland/high-latitude points, so the golden exercises coast warp,
|
||
/// sub-district relief, and vegetation patchiness all at once.
|
||
fn sweep_positions() -> Vec<(&'static str, f64, f64)> {
|
||
vec![
|
||
("coastal_a", 2_000_000.0, 1_500_000.0),
|
||
("coastal_b", 2_050_000.0, 1_500_000.0),
|
||
("coastal_c", 2_100_000.0, 1_560_000.0),
|
||
("inland", 500_000.0, 3_000_000.0),
|
||
("high_lat", 1_200_000.0, 8_500_000.0),
|
||
// T-1170 A2 Discipline item 4: empirically verified (probe run against
|
||
// this fixture, `sample_hm()`/`sample_params()`) that `sample_hm()`
|
||
// produces a real river-cell chain around working-grid pixel
|
||
// (row=10, col=116) — NONE of the original five sweep positions
|
||
// (pixel cols ~1.6-6.7) land anywhere near it. This position converts
|
||
// that pixel to world metres (same `world_m_to_pixel` inverse the
|
||
// production mapping uses) so the golden sweep also exercises
|
||
// `derive_at_metres` genuinely close to invented river geometry —
|
||
// closing the "believability expected unchanged, verify, don't
|
||
// assume" discipline item for the district-profile-only fields this
|
||
// golden already pins (courses themselves are pinned separately
|
||
// below, `river_course_golden_regression`, since this sweep's
|
||
// `derive_at_metres` calls never touch `RiverCourse` at all).
|
||
("river_course", 36_277_344.8, -6_830_545.5),
|
||
]
|
||
}
|
||
|
||
fn derive_golden_sample(
|
||
label: &str,
|
||
rung: &str,
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
climate: &ClimateConstants,
|
||
wx: f64,
|
||
wy: f64,
|
||
min_wl_m: f64,
|
||
orbital: bool,
|
||
) -> GoldenSample {
|
||
let prof = if orbital {
|
||
derive_orbital_at_metres(seed, body_id, params, ta, wx, wy, climate)
|
||
} else {
|
||
derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wl_m, &[])
|
||
};
|
||
GoldenSample {
|
||
label: label.to_string(),
|
||
rung: rung.to_string(),
|
||
wx_m: wx as i64,
|
||
wy_m: wy as i64,
|
||
min_wl_m: min_wl_m as i64,
|
||
morphology: prof.morphology_zone as u8,
|
||
tectonic: prof.tectonic_class as u8,
|
||
glaciation: prof.glaciation_grade as u8,
|
||
precipitation: prof.precipitation_class as u8,
|
||
slope_q: prof.slope_q,
|
||
elev_q: prof.elev_q,
|
||
ocean_fraction_q: prof.ocean_fraction_q,
|
||
temperature_dc: prof
|
||
.temperature_c
|
||
.map(|t| (t * 10.0).round() as i32)
|
||
.unwrap_or(i32::MIN),
|
||
moisture_q: prof.moisture_q,
|
||
vegetation: prof.vegetation_class as u8,
|
||
}
|
||
}
|
||
|
||
/// District's real quantized `min_wl_m` band (`layer_proxy::MIN_WL_BANDS_M`'s
|
||
/// District entry — District's own Nyquist floor, `2 * DISTRICT_M` per the
|
||
/// PR #194 I1 dependency-direction fix: the RUNG authors the floor, and
|
||
/// `detail_scatter::OCTAVE_WAVELENGTHS_M`'s finest octave coinciding with it
|
||
/// is pinned by the `const` drift-guard next to `MIN_WL_BANDS_M`, not the
|
||
/// source of the value). A District-rung request in production quantizes to
|
||
/// exactly this value, so the golden pins the SAME cutoff a real window
|
||
/// request would actually carry; `golden_cutoffs_match_the_scale_ladder`
|
||
/// asserts the `2 × spacing` coupling for both this and `QUARTER_MIN_WL_M`.
|
||
const DISTRICT_MIN_WL_M: f64 = 4_096.0;
|
||
|
||
/// Quarter's real quantized `min_wl_m` band (`layer_proxy::MIN_WL_BANDS_M`'s
|
||
/// new T-1162 entry — Quarter's own Nyquist floor, `2 * QUARTER_M`).
|
||
const QUARTER_MIN_WL_M: f64 = 1_024.0;
|
||
|
||
/// Run the fixed sweep (every position × the three rung cutoffs — District /
|
||
/// Quarter use their REAL production `MIN_WL_BANDS_M` values / Region via
|
||
/// `derive_orbital_at_metres`, which takes no cutoff parameter — see its own
|
||
/// doc on why) for ONE body. Extracted (Tyre, PR #194 I4) so multiple bodies
|
||
/// can share the same sweep logic; `label_prefix` (empty for the original
|
||
/// body, non-empty for the I4 additions) is prepended to each row's `label`
|
||
/// so multi-body output stays distinguishable without a new struct field
|
||
/// (see [`GoldenSample`]'s doc on why no `body` field was added).
|
||
///
|
||
/// `positions` is now a parameter (T-1184) rather than always calling
|
||
/// `sweep_positions()` internally — the lake-bowl body needs its OWN sweep
|
||
/// (`lake_bowl_sweep_positions()`, a position that's actually inside its
|
||
/// basin), not the original three bodies' coastal/inland/river sweep, which
|
||
/// means nothing on the bowl fixture's geometry.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn body_sweep_samples(
|
||
label_prefix: &str,
|
||
seed: SeedChain,
|
||
body_id: &str,
|
||
params: &BodyParams,
|
||
ta: &TerrainAnalysis,
|
||
climate: &ClimateConstants,
|
||
positions: &[(&'static str, f64, f64)],
|
||
) -> Vec<GoldenSample> {
|
||
let mut out = Vec::new();
|
||
for &(label, wx, wy) in positions {
|
||
let label = format!("{label_prefix}{label}");
|
||
out.push(derive_golden_sample(
|
||
&label,
|
||
"district",
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
wx,
|
||
wy,
|
||
DISTRICT_MIN_WL_M,
|
||
false,
|
||
));
|
||
out.push(derive_golden_sample(
|
||
&label,
|
||
"quarter",
|
||
seed,
|
||
body_id,
|
||
params,
|
||
ta,
|
||
climate,
|
||
wx,
|
||
wy,
|
||
QUARTER_MIN_WL_M,
|
||
false,
|
||
));
|
||
out.push(derive_golden_sample(
|
||
&label, "region", seed, body_id, params, ta, climate, wx, wy, 0.0, true,
|
||
));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Build the full golden sample set: the ORIGINAL temperate/ocean/breathable
|
||
/// body's sweep first (byte-identical POSITIONS/seed/label to the pre-I4
|
||
/// `golden_samples` — the VALUES move under the T-1184 fixture-fidelity fix,
|
||
/// see below), THEN the I4 body rows, THEN the T-1184 lake-bowl body
|
||
/// (appended last, never interleaved) so the diff against the pre-fix golden
|
||
/// is a pure value-update-plus-append, not a reshuffle.
|
||
///
|
||
/// **T-1184: each body now gets its OWN `TerrainAnalysis`**, built via
|
||
/// `sample_ta(&hm, params)` — production-faithful (`run_layer1_with_moisture`
|
||
/// solving hydrology with THIS body's own moisture ceiling), mirroring
|
||
/// `TerrainAnalysisCache`'s real per-`body_id` keying. Before this fix all
|
||
/// three bodies shared ONE `ta` built from ONE call with no hydrology
|
||
/// attached at all — cheap to share when `TerrainAnalysis` was a pure
|
||
/// function of the heightmap alone, no longer correct now that hydrology's
|
||
/// endorheic/overflow split depends on the body's own `BodyParams`. This
|
||
/// doesn't change any EXISTING row's values on `sample_hm()` bodies (that
|
||
/// fixture has zero filled basins at any moisture input — hydrology only
|
||
/// changes the fallback-vs-sourced CODE PATH taken, not the OUTPUT, when
|
||
/// there's nothing to source) but is required for correctness going forward
|
||
/// and is what the lake-bowl body's own per-body `ta` depends on.
|
||
fn golden_samples() -> Vec<GoldenSample> {
|
||
let climate = ClimateConstants::default();
|
||
let mut out = Vec::new();
|
||
|
||
// Original body — same seed/body_id/labels/positions as pre-T-1184; `ta`
|
||
// is now built production-faithfully (see doc above) but this fixture has
|
||
// no basins, so the row VALUES are unaffected.
|
||
let hm = sample_hm();
|
||
let params = sample_params();
|
||
let ta = sample_ta(&hm, ¶ms);
|
||
out.extend(body_sweep_samples(
|
||
"",
|
||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7),
|
||
"golden_body",
|
||
¶ms,
|
||
&ta,
|
||
&climate,
|
||
&sweep_positions(),
|
||
));
|
||
|
||
// I4 addition 1: airless/dry — ceiling_q == 0 vegetation short-circuit.
|
||
// Same heightmap (sample_hm()) as the original body, but its OWN ta
|
||
// (moisture ceiling for an airless/dry body is 0, not the ocean body's
|
||
// moisture — matters for endorheic/overflow classification even though,
|
||
// again, this fixture has no basins to classify).
|
||
let airless_params = airless_dry_params();
|
||
let airless_ta = sample_ta(&hm, &airless_params);
|
||
out.extend(body_sweep_samples(
|
||
"airless_dry/",
|
||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 8),
|
||
"golden_body_airless_dry",
|
||
&airless_params,
|
||
&airless_ta,
|
||
&climate,
|
||
&sweep_positions(),
|
||
));
|
||
|
||
// I4 addition 2: volcanic/high-tectonic coast — ridged warp, wide scatter_floor.
|
||
let volcanic_params = volcanic_coast_params();
|
||
let volcanic_ta = sample_ta(&hm, &volcanic_params);
|
||
out.extend(body_sweep_samples(
|
||
"volcanic_coast/",
|
||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 9),
|
||
"golden_body_volcanic_coast",
|
||
&volcanic_params,
|
||
&volcanic_ta,
|
||
&climate,
|
||
&sweep_positions(),
|
||
));
|
||
|
||
// T-1184 addition: lake-bowl body — a dedicated heightmap WITH a real
|
||
// filled basin, so the settled-hydrology lake-sourcing gate this ticket
|
||
// adds is pinned by at least one golden row (the original three bodies'
|
||
// shared heightmap has zero basins at any sampled position — see this
|
||
// function's and `lake_bowl_hm`'s docs). Own heightmap, own params, own
|
||
// seed, own sweep (the bowl centre + a rim point, not the coastal/inland
|
||
// positions that mean nothing on this fixture's geometry).
|
||
let lake_hm = lake_bowl_hm();
|
||
let lake_params = lake_bowl_params();
|
||
let lake_ta = sample_ta(&lake_hm, &lake_params);
|
||
out.extend(body_sweep_samples(
|
||
"lake_bowl/",
|
||
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 10),
|
||
"golden_body_lake_bowl",
|
||
&lake_params,
|
||
&lake_ta,
|
||
&climate,
|
||
&lake_bowl_sweep_positions(),
|
||
));
|
||
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn window_derivation_golden_regression() {
|
||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||
let golden_path = manifest.join(GOLDEN_FILE);
|
||
|
||
// Double-derive determinism check (D-010) before ever touching the golden.
|
||
let run1 = golden_samples();
|
||
let run2 = golden_samples();
|
||
assert_eq!(
|
||
run1, run2,
|
||
"double-derivation mismatch — determinism is broken (D-010)"
|
||
);
|
||
|
||
let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n";
|
||
|
||
if std::env::var("UPDATE_GOLDEN").is_ok() {
|
||
std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden");
|
||
std::fs::write(&golden_path, &actual_json).expect("write golden");
|
||
eprintln!(
|
||
"Golden written: {} ({} bytes)",
|
||
golden_path.display(),
|
||
actual_json.len()
|
||
);
|
||
return;
|
||
}
|
||
|
||
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
|
||
panic!(
|
||
"Golden file not found: {}.\n\
|
||
First run: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n{e}",
|
||
golden_path.display()
|
||
)
|
||
});
|
||
|
||
let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual");
|
||
let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden");
|
||
|
||
if actual_v != golden_v {
|
||
panic!(
|
||
"Window-derivation golden mismatch — derivation chain changed.\n\
|
||
Update: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n\
|
||
Golden: {}\nActual: {}",
|
||
golden_json.trim(),
|
||
actual_json.trim()
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Cross-rung coherence sanity check on the golden's own fixed sweep: for
|
||
/// each coastal label, the Quarter-rung sample must differ from the
|
||
/// District-rung sample at the SAME position (the whole point of the
|
||
/// extension — Quarter sees finer content District's coarser cutoff
|
||
/// truncates). This is a structural companion to the golden file itself,
|
||
/// not a replacement for it — it fails loudly if the golden ever gets
|
||
/// regenerated with `min_wl_m` accidentally identical across rungs.
|
||
#[test]
|
||
fn quarter_and_district_rungs_diverge_at_the_same_position() {
|
||
let samples = golden_samples();
|
||
let mut any_diverged = false;
|
||
for (label, _, _) in sweep_positions() {
|
||
let district = samples
|
||
.iter()
|
||
.find(|s| s.label == label && s.rung == "district")
|
||
.unwrap();
|
||
let quarter = samples
|
||
.iter()
|
||
.find(|s| s.label == label && s.rung == "quarter")
|
||
.unwrap();
|
||
if district.elev_q != quarter.elev_q
|
||
|| district.slope_q != quarter.slope_q
|
||
|| district.moisture_q != quarter.moisture_q
|
||
{
|
||
any_diverged = true;
|
||
}
|
||
}
|
||
assert!(
|
||
any_diverged,
|
||
"no sweep position showed ANY difference between District and Quarter \
|
||
rungs — the T-1162 octave extension would be structurally inert"
|
||
);
|
||
}
|
||
|
||
/// `scale::DISTRICT_M` / `scale::QUARTER_M` sanity — documents WHY
|
||
/// `DISTRICT_MIN_WL_M`/`QUARTER_MIN_WL_M` are the cutoffs used above (each
|
||
/// rung's own Nyquist floor, `2 × <rung's spacing>`), so a future
|
||
/// scale-ladder change surfaces here. Pins BOTH rungs' coupling (Tyre, PR
|
||
/// #194 I1 — District's coupling was previously unpinned; only Quarter's
|
||
/// `2 × QUARTER_M` was checked) — this mirrors
|
||
/// `layer_proxy::MIN_WL_BANDS_M`'s own direct `2 × DISTRICT_M` / `2 ×
|
||
/// QUARTER_M` derivation, not `detail_scatter::OCTAVE_WAVELENGTHS_M`.
|
||
#[test]
|
||
fn golden_cutoffs_match_the_scale_ladder() {
|
||
assert_eq!(scale::DISTRICT_M, 2_048);
|
||
assert_eq!(scale::QUARTER_M, 512);
|
||
assert_eq!(2 * scale::DISTRICT_M, DISTRICT_MIN_WL_M as i32);
|
||
assert_eq!(2 * scale::QUARTER_M, QUARTER_MIN_WL_M as i32);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// River course golden (T-1170 A2, Discipline item 4)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Empirically verified (probe run against `sample_hm()`/`sample_params()`):
|
||
// this fixture body produces a real river-cell chain around working-grid
|
||
// pixel (row≈10, col=116) — the "river_course" sweep position above converts
|
||
// that pixel to world metres. This section pins the ACTUAL invented course
|
||
// geometry for an edge from that chain, at both District and Quarter station
|
||
// spacing, so courses themselves — not just the district-profile fields the
|
||
// main golden above covers — are regression-pinned.
|
||
//
|
||
// **Mouth coverage (T-1170 PR #197 review, Hoshe #2):** the sample set below
|
||
// also includes a real Mouth-terminus edge from `sample_hm()` (empirically
|
||
// probed: 4 mouth edges exist in this fixture) — `invent_course`'s raw output
|
||
// for that edge is pinned here (edge_id/class/terminus/points), closing the
|
||
// "zero Mouth coverage in any golden" gap at the invention layer. The FULL
|
||
// end-to-end path (invent → crop → `resolve_mouth_terminus` →
|
||
// `CourseTerminus::Mouth`) is covered separately and permanently by
|
||
// `layer_proxy::tests::all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none`
|
||
// (in-crate, since `crop_course_to_window`/`resolve_mouth_terminus` are
|
||
// private to `layer_proxy.rs` and unreachable from this integration test) —
|
||
// that test is the actual Hoshe #1 acceptance criterion (3/3 real mouths);
|
||
// this golden's job is regression-pinning the raw invented geometry, not
|
||
// re-proving the crop/resolve path.
|
||
|
||
const RIVER_COURSE_GOLDEN_FILE: &str = "tests/golden/river_course_golden.json";
|
||
|
||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone)]
|
||
struct GoldenCourseSample {
|
||
rung: String,
|
||
edge_id: u32,
|
||
class: u8,
|
||
terminus: String,
|
||
/// Points rounded to the nearest metre (D-010 integer boundary at the
|
||
/// golden-pinning layer — the production wire path itself rounds to
|
||
/// `i32` metres, `layer_proxy::crop_course_to_window`).
|
||
points: Vec<(i64, i64)>,
|
||
}
|
||
|
||
fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
|
||
let hm = sample_hm();
|
||
let params = sample_params();
|
||
let ta = sample_ta(&hm, ¶ms);
|
||
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7);
|
||
|
||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||
let edges = river_course::build_edges(&dr.river_network);
|
||
// Pick the interior edge whose upstream cell is closest to (row=10,
|
||
// col=116) — deterministic (BTreeMap-free linear scan, fixed tie-break
|
||
// by edge_id) rather than hardcoding an index that could silently shift
|
||
// if `build_edges`' ordering ever changes.
|
||
let target = edges
|
||
.iter()
|
||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Interior)
|
||
.min_by_key(|e| {
|
||
let dr = e.upstream.0 as i64 - 10;
|
||
let dc = e.upstream.1 as i64 - 116;
|
||
(dr * dr + dc * dc, e.edge_id)
|
||
})
|
||
.expect("sample_hm() fixture must have at least one interior river edge near (10, 116)");
|
||
|
||
// Hoshe #2: a real Mouth-terminus edge, deterministically selected as the
|
||
// lowest edge_id among the fixture's mouth edges (fixed tie-break, no
|
||
// hardcoded index).
|
||
let mouth_target = edges
|
||
.iter()
|
||
.filter(|e| e.terminus == river_course::EdgeTerminusKind::Mouth)
|
||
.min_by_key(|e| e.edge_id)
|
||
.expect("sample_hm() fixture must have at least one Mouth edge (empirically verified: 4)");
|
||
|
||
let mut out = Vec::new();
|
||
for (rung, spacing_m, min_wl_m) in [
|
||
("district", DISTRICT_MIN_WL_M, DISTRICT_MIN_WL_M),
|
||
("quarter", QUARTER_MIN_WL_M, QUARTER_MIN_WL_M),
|
||
] {
|
||
let course = river_course::invent_course(seed, target, &ta, ¶ms, spacing_m, min_wl_m);
|
||
out.push(GoldenCourseSample {
|
||
rung: rung.to_string(),
|
||
edge_id: course.edge_id,
|
||
class: course.class,
|
||
terminus: format!("{:?}", course.terminus),
|
||
points: course
|
||
.points
|
||
.iter()
|
||
.map(|p| (p.0.round() as i64, p.1.round() as i64))
|
||
.collect(),
|
||
});
|
||
|
||
let mouth_course =
|
||
river_course::invent_course(seed, mouth_target, &ta, ¶ms, spacing_m, min_wl_m);
|
||
out.push(GoldenCourseSample {
|
||
rung: format!("{rung}_mouth"),
|
||
edge_id: mouth_course.edge_id,
|
||
class: mouth_course.class,
|
||
terminus: format!("{:?}", mouth_course.terminus),
|
||
points: mouth_course
|
||
.points
|
||
.iter()
|
||
.map(|p| (p.0.round() as i64, p.1.round() as i64))
|
||
.collect(),
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn river_course_golden_regression() {
|
||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||
let golden_path = manifest.join(RIVER_COURSE_GOLDEN_FILE);
|
||
|
||
let run1 = river_course_golden_samples();
|
||
let run2 = river_course_golden_samples();
|
||
assert_eq!(
|
||
run1, run2,
|
||
"double-derivation mismatch — course invention determinism is broken (D-010/D-227)"
|
||
);
|
||
|
||
let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n";
|
||
|
||
if std::env::var("UPDATE_GOLDEN").is_ok() {
|
||
std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden");
|
||
std::fs::write(&golden_path, &actual_json).expect("write golden");
|
||
eprintln!(
|
||
"Golden written: {} ({} bytes)",
|
||
golden_path.display(),
|
||
actual_json.len()
|
||
);
|
||
return;
|
||
}
|
||
|
||
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
|
||
panic!(
|
||
"Golden file not found: {}.\n\
|
||
First run: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n{e}",
|
||
golden_path.display()
|
||
)
|
||
});
|
||
|
||
let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual");
|
||
let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden");
|
||
|
||
if actual_v != golden_v {
|
||
panic!(
|
||
"River-course golden mismatch — course invention changed.\n\
|
||
Update: UPDATE_GOLDEN=1 cargo test --test window_derivation_golden\n\
|
||
Golden: {}\nActual: {}",
|
||
golden_json.trim(),
|
||
actual_json.trim()
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The course golden's target edge must genuinely differ in point geometry
|
||
/// between District and Quarter station spacing (more, finer-spaced stations
|
||
/// at Quarter — Ruling 3b's cross-rung invariant) — otherwise the golden
|
||
/// would be pinning two identical rungs and the test would give false
|
||
/// confidence.
|
||
#[test]
|
||
fn river_course_rungs_have_different_station_counts() {
|
||
let samples = river_course_golden_samples();
|
||
let district = samples.iter().find(|s| s.rung == "district").unwrap();
|
||
let quarter = samples.iter().find(|s| s.rung == "quarter").unwrap();
|
||
assert!(
|
||
quarter.points.len() > district.points.len(),
|
||
"Quarter's finer station spacing must produce MORE points than District \
|
||
(district={}, quarter={})",
|
||
district.points.len(),
|
||
quarter.points.len()
|
||
);
|
||
}
|
||
|
||
/// **T-1170 PR #197 review, Hoshe #2 (golden coverage):** the pinned Mouth
|
||
/// edge samples must be genuine, non-degenerate courses (`terminus == "Mouth"`,
|
||
/// `points.len() >= 2`) — the direct golden-level check that the Hoshe #1 fix
|
||
/// (real seaward chord via `RiverNetwork::river_seaward`) reaches this fixture
|
||
/// too, not just the dedicated GJ1c acceptance test.
|
||
#[test]
|
||
fn river_course_mouth_samples_are_non_degenerate() {
|
||
let samples = river_course_golden_samples();
|
||
for rung in ["district_mouth", "quarter_mouth"] {
|
||
let sample = samples
|
||
.iter()
|
||
.find(|s| s.rung == rung)
|
||
.unwrap_or_else(|| panic!("missing golden sample for rung {rung}"));
|
||
assert_eq!(
|
||
sample.terminus, "Mouth",
|
||
"{rung}: build_edges must produce a Mouth-terminus RiverEdge for the pinned target"
|
||
);
|
||
assert!(
|
||
sample.points.len() >= 2,
|
||
"{rung}: Mouth edge invented a degenerate {}-point course — the chord-length fix \
|
||
(Hoshe #1) regressed for this fixture",
|
||
sample.points.len()
|
||
);
|
||
}
|
||
}
|