Merge remote-tracking branch 'origin/main' into t1181-step-canvas

This commit is contained in:
2026-07-25 03:13:18 +02:00
16 changed files with 1186 additions and 63 deletions
+4
View File
@@ -753,6 +753,7 @@ fn law_glaciation_grade_0_never_produces_fjord() {
60,
ocean_q,
55,
false, // T-1184: no hydrology solve in this synthetic-gate sweep
);
assert_ne!(
zone,
@@ -776,6 +777,7 @@ fn law_glaciation_grade_1_never_produces_fjord() {
60,
ocean_q,
55,
false, // T-1184: no hydrology solve in this synthetic-gate sweep
);
assert_ne!(
zone,
@@ -798,6 +800,7 @@ fn law_glaciation_grade_2_enables_fjord_with_correct_params() {
60,
25, // coastal
55,
false, // T-1184: no hydrology solve in this synthetic-gate test
);
assert_eq!(
zone,
@@ -817,6 +820,7 @@ fn law_glaciation_grade_4_also_enables_fjord() {
60,
25,
55,
false, // T-1184: no hydrology solve in this synthetic-gate test
);
assert_eq!(
zone,
+1 -1
View File
@@ -28,7 +28,7 @@
"morphology_zones": 9,
"vegetation_classes": 4,
"terrain_materials": 4,
"voxel_relief_m": 27,
"voxel_relief_m": 28,
"micro_habitat_distinct": 2
},
"coherence": {
@@ -916,5 +916,107 @@
"temperature_dc": 480,
"moisture_q": 47,
"vegetation": 3
},
{
"label": "lake_bowl/lake_centre",
"rung": "district",
"wx_m": 20015086,
"wy_m": 158849,
"min_wl_m": 4096,
"morphology": 1,
"tectonic": 0,
"glaciation": 0,
"precipitation": 3,
"slope_q": 4,
"elev_q": 0,
"ocean_fraction_q": 0,
"temperature_dc": 62,
"moisture_q": 56,
"vegetation": 6
},
{
"label": "lake_bowl/lake_centre",
"rung": "quarter",
"wx_m": 20015086,
"wy_m": 158849,
"min_wl_m": 1024,
"morphology": 1,
"tectonic": 0,
"glaciation": 0,
"precipitation": 3,
"slope_q": 5,
"elev_q": 0,
"ocean_fraction_q": 0,
"temperature_dc": 62,
"moisture_q": 56,
"vegetation": 6
},
{
"label": "lake_bowl/lake_centre",
"rung": "region",
"wx_m": 20015086,
"wy_m": 158849,
"min_wl_m": 0,
"morphology": 1,
"tectonic": 0,
"glaciation": 0,
"precipitation": 3,
"slope_q": 0,
"elev_q": 0,
"ocean_fraction_q": 0,
"temperature_dc": 62,
"moisture_q": 56,
"vegetation": 6
},
{
"label": "lake_bowl/lake_rim",
"rung": "district",
"wx_m": 1250942,
"wy_m": 158849,
"min_wl_m": 4096,
"morphology": 8,
"tectonic": 0,
"glaciation": 2,
"precipitation": 1,
"slope_q": 0,
"elev_q": 71,
"ocean_fraction_q": 0,
"temperature_dc": -120,
"moisture_q": 55,
"vegetation": 1
},
{
"label": "lake_bowl/lake_rim",
"rung": "quarter",
"wx_m": 1250942,
"wy_m": 158849,
"min_wl_m": 1024,
"morphology": 8,
"tectonic": 0,
"glaciation": 2,
"precipitation": 1,
"slope_q": 2,
"elev_q": 75,
"ocean_fraction_q": 0,
"temperature_dc": -120,
"moisture_q": 54,
"vegetation": 1
},
{
"label": "lake_bowl/lake_rim",
"rung": "region",
"wx_m": 1250942,
"wy_m": 158849,
"min_wl_m": 0,
"morphology": 8,
"tectonic": 0,
"glaciation": 2,
"precipitation": 1,
"slope_q": 0,
"elev_q": 70,
"ocean_fraction_q": 0,
"temperature_dc": -120,
"moisture_q": 54,
"vegetation": 1
}
]
+190 -20
View File
@@ -28,17 +28,38 @@
//! 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_orbital_at_metres, BodyParams, ClimateConstants,
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};
@@ -101,9 +122,22 @@ fn sample_hm() -> BodyHeightmap {
}
}
fn sample_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
TerrainAnalysis::analyze(hm, &dr)
/// 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 {
@@ -153,6 +187,88 @@ fn volcanic_coast_params() -> BodyParams {
}
}
/// 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,
@@ -244,6 +360,12 @@ const QUARTER_MIN_WL_M: f64 = 1_024.0;
/// 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,
@@ -252,9 +374,10 @@ fn body_sweep_samples(
params: &BodyParams,
ta: &TerrainAnalysis,
climate: &ClimateConstants,
positions: &[(&'static str, f64, f64)],
) -> Vec<GoldenSample> {
let mut out = Vec::new();
for (label, wx, wy) in sweep_positions() {
for &(label, wx, wy) in positions {
let label = format!("{label_prefix}{label}");
out.push(derive_golden_sample(
&label,
@@ -290,46 +413,93 @@ fn body_sweep_samples(
}
/// Build the full golden sample set: the ORIGINAL temperate/ocean/breathable
/// body's sweep first (byte-identical inputs to the pre-I4 `golden_samples`
/// — same seed, same `body_id`, same unprefixed labels, so its rows are
/// byte-identical in the regenerated fixture), THEN the two I4 body rows
/// appended after (never interleaved) so the diff against the pre-I4 golden
/// is a pure append, not a reshuffle.
/// 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 hm = sample_hm();
let ta = sample_ta(&hm);
let climate = ClimateConstants::default();
let mut out = Vec::new();
// Original body — UNCHANGED inputs from pre-I4 (T-1162 initial landing).
// 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, &params);
out.extend(body_sweep_samples(
"",
SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7),
"golden_body",
&sample_params(),
&params,
&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_dry_params(),
&ta,
&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_coast_params(),
&ta,
&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
@@ -475,8 +645,8 @@ struct GoldenCourseSample {
fn river_course_golden_samples() -> Vec<GoldenCourseSample> {
let hm = sample_hm();
let ta = sample_ta(&hm);
let params = sample_params();
let ta = sample_ta(&hm, &params);
let seed = SeedChain::root(0xC0FFEE_u64).derive(SeedDomain::Body, 7);
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);