//! 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` 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. //! //! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1 //! commit's "pure reclassification" framing overclaimed — `attractors` is //! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks //! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from //! `river_network.mouths`), which shifts the water-distance field, which //! feeds `RawAttractor::strength` scoring in `features::extract_attractors`. //! This is a principled, in-scope cascade (the pole-edge cells genuinely //! aren't water-distance sources anymore) — not a bug — but it is a REAL //! field-level change, not a no-op re-tagging. Restated accurately here so //! the record doesn't imply byte-identical attractor output. //! //! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):** //! `RiverNetwork` gained a second additive field, `river_seaward: //! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel //! river cells, captured in the SAME `extract_river_network` pass (the //! elevation check that already computes `(nr, nc)` to decide the MOUTH //! sentinel). Fixes a second instance of the discard-then-need-it-later //! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8 //! pointers: `river_course::build_edges` needs a real seaward cell to give //! Mouth edges a non-degenerate chord (see `river_course.rs`'s //! `build_edges` doc and `layer_proxy::tests:: //! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full //! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by //! this second re-pin (93/3/256) — purely the new parallel array, 3 non- //! placeholder entries (one per real mouth). 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 0–1 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(), ); } }