Files
settled-reach/server/tests/cascade_golden.rs
T
jpmschweitzerandClaude Opus 4.8 ab084269f3 feat(simulation): integer-deterministic Layer-3 settlement placement (#955)
Wire the existing attractor-matching engine (#919/#925) into the
generation cascade as Layer 3, and make the whole placement-scoring path
integer-deterministic.

Layer 3 (D-211):
- CascadeLayer::Settlement + Layer3Output (placements) on the snapshot;
  BodyWorldState gains a `placements` field (the D-203 hot cache).
- run_layer3 runs the five-phase match_cities against Layer-1 attractors
  via the authored D-195 compatibility matrix; pure function of
  (attractors, cities) — no RNG. cities are passed in by the caller so the
  cascade stays DB-free and testable. A `// cache seam` marks where a
  persistent cache wraps it later (#1021).
- gen_queue passes &[] for now (Topography needs no cities); the runtime
  settlement read (gen_queue/layer_proxy) is the #955 follow-on.

Integer determinism (D-010 / D-227 — D-195 amended):
- Wiring match_cities into the deterministic cascade made its f32 scoring
  a live cross-platform divergence risk (a near-tie comparison or the
  Hungarian's f32 reductions can round differently per platform → a
  different world from the same seed). Converted the entire path to
  integers: CompatibilityMatrix is a 0-100 affinity table; attractor
  strength is 0-100 and terrain cost is a percent (100 = baseline),
  quantized once at the Layer-1 feature boundary; cell_score, the
  Hungarian, and CityPlacement.score are i64. No f32 in any placement or
  ranking decision.
- Layer-1 golden fixture rebaked: confirmed selection/positions are
  unchanged (same 256 attractors, 93 river cells) — only the strength/cost
  representation changed.

Tests: lib green (1292); new settlement_layer_places_cities_deterministically
covers placement + determinism + propagation into BodyWorldState.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:30:57 +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(),
);
}
}