Files
settled-reach/server/tests/cascade_golden.rs
T
jpmschweitzerandClaude Fable 5 0fea69feb2 feat(simulation): T-1170 A1 — river_downstream pointers; pole-edge drains are not mouths
RiverNetwork gains river_downstream: Vec<u8> (serde-default, parallel
to river_cells): values 0-7 index drainage::D8 (row,col deltas, N/S/E/
W/NE/NW/SE/SW order); sentinels MOUTH=8, EDGE_DRAIN=9, TERMINAL=10
(reserved — the future endorheic-basin hook, Ruling 2c/7b). Captured
in extract_river_network's existing pass (fdir already in scope, one
map, no new grid pass). Pole-edge D8 exits reclassify as EDGE_DRAIN
and leave the mouths list (Ruling 3f); flat-peak interior no-outflow
cells get EDGE_DRAIN too. cascade_golden deliberately re-pinned: GJ1c
mouths 19->3 — sixteen were pole-edge artifacts, exactly Jeroen's
'circles with no sea in sight'; river_cells/attractors counts
unchanged (pure reclassification + additive field). 21/21 drainage
tests incl. mouth-sentinel/mouths-list bijection on the real fixture
and a synthetic pole-draining-grid case.

Tickets: T-1170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:39:47 +02:00

125 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Golden-seed regression for the Layer 0→1 generation cascade (#952, D-200, D-224).
//!
//! Pins two artifacts for one fixed input heightmap, in a single diffable JSON
//! golden (matching the `golden_suite.rs` convention):
//! - **Layer 0** — SHA-256 of the source `heightmap.png` bytes. Flips if the
//! Python heightmap generator (or the committed file) changes.
//! - **Layer 1** — the serialized `Layer1Output` of the cascade run on a
//! downsampled copy. Flips if the Rust drainage / feature / sub-biome code
//! changes.
//!
//! The input is a real committed body heightmap (the #963 bake output), so this
//! is an end-to-end determinism guard. The cascade is downsampled to keep the
//! golden small while still exercising the full Layer-1 pipeline.
//!
//! Regenerate after an intended change:
//! UPDATE_GOLDEN=1 cargo test --test cascade_golden
//!
//! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a
//! different architecture could in principle round differently — regenerate
//! per-arch if CI ever moves off x86_64.
//!
//! **Deliberate re-pin (T-1170 Ruling 2a/2c/3f, A1):** `RiverNetwork` gained
//! an additive `river_downstream: Vec<u8>` field (per-`river_cells`-entry D8
//! downstream pointer + MOUTH/EDGE_DRAIN sentinel), and `extract_river_network`
//! stopped classifying pole-edge D8 exits (flow running off the grid's
//! top/bottom row) as `mouths` — they are grid artifacts, not river-meets-sea
//! events (Ruling 3f, Jeroen's capture question). On this fixture (GJ1c,
//! 256×128 downsample) that drops `mouths` from 19 to 3: 16 of the 19 were
//! pole-edge exits (now `RIVER_DOWNSTREAM_EDGE_DRAIN`), leaving the 3 real
//! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors`
//! counts are unchanged (93/256) — this is a pure re-classification + one new
//! additive field, not a drainage-algorithm change.
use std::path::PathBuf;
use serde_json::{json, Value};
use settled_reach_server::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use settled_reach_server::atlas::heightmap::load_heightmap_png;
use settled_reach_server::seed::SeedChain;
use sha2::{Digest, Sha256};
/// Source heightmap — a real committed body, relative to the server manifest dir.
const SOURCE_HEIGHTMAP: &str = "../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png";
/// Downsample target: large enough that GJ1c's drainage produces a real river
/// network (not just attractors), small enough for a compact golden.
const DOWNSAMPLE: (u32, u32) = (256, 128);
/// World seed for the run. Cosmetic here — Layers 01 are RNG-free, so changing
/// it does not change the golden; it is carried only to exercise the SeedChain
/// contract end-to-end (D-224). Seed-sensitivity gets pinned once Layer 3+ lands.
const WORLD_SEED: u64 = 42;
const GOLDEN: &str = "tests/golden/cascade_layer1.json";
#[test]
fn cascade_layer0_to_1_matches_golden() {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let src = manifest.join(SOURCE_HEIGHTMAP);
// ── Layer 0 — load + hash the source heightmap.png. ─────────────────────
let png_bytes = std::fs::read(&src)
.unwrap_or_else(|e| panic!("read source heightmap {}: {e}", src.display()));
let mut hasher = Sha256::new();
hasher.update(&png_bytes);
let sha = format!("{:x}", hasher.finalize());
let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode source heightmap");
// ── Layer 1 — downsample, then run the cascade to topography. ───────────
let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1);
let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c");
let snapshot =
run_cascade_from_heightmap(body_seed, small, &[], None, None, CascadeLayer::Topography);
let layer1 = snapshot.layer1.expect("Layer 1 ran");
let actual = json!({
"source_heightmap": SOURCE_HEIGHTMAP,
"source_sha256": sha,
"downsample": [DOWNSAMPLE.0, DOWNSAMPLE.1],
"layer1": serde_json::to_value(&layer1).expect("serialize Layer1Output"),
});
let actual_json = serde_json::to_string_pretty(&actual).expect("format JSON") + "\n";
let golden_path = manifest.join(GOLDEN);
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 not found: {}. Run: UPDATE_GOLDEN=1 cargo test --test cascade_golden\n{e}",
golden_path.display()
)
});
// Compare as parsed JSON so whitespace/formatting never causes spurious diffs.
let actual_v: Value = serde_json::from_str(&actual_json).expect("reparse actual JSON");
let golden_v: Value = serde_json::from_str(&golden_json).expect("parse golden JSON");
if actual_v != golden_v {
let count =
|v: &Value, ptr: &str| v.pointer(ptr).and_then(Value::as_array).map_or(0, Vec::len);
panic!(
"Cascade golden mismatch.\n \
source_sha256: golden={} actual={}\n \
river_cells: golden={} actual={}\n \
attractors: golden={} actual={}\n\
To update: UPDATE_GOLDEN=1 cargo test --test cascade_golden\n Golden: {}",
golden_v["source_sha256"],
actual_v["source_sha256"],
count(&golden_v, "/layer1/river_network/river_cells"),
count(&actual_v, "/layer1/river_network/river_cells"),
count(&golden_v, "/layer1/attractors"),
count(&actual_v, "/layer1/attractors"),
golden_path.display(),
);
}
}