Files
settled-reach/server/tests/cascade_golden.rs
T
jpmschweitzerandClaude Opus 4.7 a61d030b47 refactor(simulation): harden #952 cascade per QA + architecture review
Addresses the Hoshe (QA) + Tyre (architecture) review of the SeedChain/cascade
work:

- Golden was pinning an empty river network (128x64 produced 0 river cells).
  Bumped to 256x128, where GJ1c yields a real network (93 river cells, 19
  mouths) — Layer 1's rivers are now actually guarded, not just attractors.
- SeedChain::for_body(world_seed, body_id) + fnv1a_64: the single canonical
  body_id(String) -> u64 path (FNV-1a, the repo convention), so callers can't
  derive divergent worlds from the same seed via different ad-hoc hashes. The
  golden now uses it.
- Stability guards: seed_domain_discriminants_are_pinned test (CI fails if a
  SeedDomain tag is renumbered); AttractorType gains #[repr(u8)] + explicit
  discriminants (it's cast as a sort key in features.rs).
- Tests: SeedChain::root(0) non-degenerate; run_cascade error path (missing
  file -> Err, not panic).
- Comments: clarified the id=0 derivations (sibling separation is caller-side
  via the per-district/quarter chain; #957 threads the index), tightened the
  all_district_types reachability comment (it pins the seed-0 sequence, not a
  probabilistic claim), and noted the golden's WORLD_SEED is cosmetic at
  Layers 0-1 + the x86_64 f32 capture caveat.

Deferred with reason: the run_cascade -> CascadeInputs struct refactor (Tyre)
is left for #954 — designing Layer-2's context shape now would be later-phase
detail, and there's a single caller to migrate then. EntityRng keeps its
domainless combine (migrating is stream-changing) — noted in D-224.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:24:03 +02:00

112 lines
5.0 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.
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, 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(),
);
}
}