Files
settled-reach/server/tests/cascade_golden.rs
T
jpmschweitzerandClaude Fable 5 1d2cac9e65 fix(simulation): PR #202 review round — spill cell always wired + golden truth
Hoshe finding 1 (live-firing on GJ1c: 2 spill collisions + 1 i==1
collision among 51 Overflow basins): adjacency adjudicated
INSUFFICIENT for the cue — a course's visible anchor is its upstream
cell, so nothing pre-existing belongs to the lake unless wired. The
spill cell (outlet_path[0]) now always gets a real entry: appended
when new, OVERWRITTEN IN PLACE when it collided with an existing river
cell (append would duplicate edge_id; the hydrology solve is the more
authoritative downstream answer for that cell than flat D8
extraction). Interior stop-on-collision stays, now provably safe.
Internal lookup is a dense Vec<Option<usize>>, never iterated (D-010).
Two non-vacuous regression tests prove the cue through build_edges
output; end-to-end on GJ1c all 51 Overflow basins now build a readable
edge (was: one silently missing).

Hoshe finding 2: both doc sites now state the fallback-vs-production
split explicitly (fallback moisture 55: 51/2; production GJ1c moisture
80: 53/53 all-Overflow) — the golden's Endorheic pair is a
fallback-constant artifact, not a fact about GJ1c.

Golden re-regenerated: river_cells 143->192, position-identity diff
purely additive (zero removed, one legitimate in-place overwrite at
the spill-collision cell); attractors/basins/mouths/confluences
byte-identical. Suites: hydrology 26/26, full lib 1943, cascade_golden
1/1, window goldens + believability untouched green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 09:04:54 +02:00

207 lines
11 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.
//!
//! **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).
//!
//! **Third deliberate re-pin (T-1185, D-227 amendment (4) — basin-outlet →
//! D8 river-network wiring):** `run_layer1`/`run_layer1_with_moisture` now
//! extends the D8-extracted `RiverNetwork` with every `BasinOutcome::
//! Overflow` basin's `outlet_path` as new river cells (the endorheic cue:
//! outflow-course PRESENCE). `attractors` (256) and `drainage_basins` are
//! UNCHANGED — the extension runs strictly after
//! `features::extract_attractors`, by design, so basin-outlet cells never
//! perturb geographic attractor placement. `mouths`/`confluences` also
//! unchanged (T-1185 never rewrites those arrays).
//!
//! **This golden pins the FALLBACK moisture path, not production — stated
//! explicitly so nobody cites its basin-outcome split as what a player
//! actually sees (PR #202 review, Hoshe finding 2).**
//! `run_cascade_from_heightmap`'s `body_params: None` argument here means
//! `run_layer1`'s `DEFAULT_HYDROLOGY_MOISTURE_Q = 55` fallback is what
//! solves this fixture's hydrology, not GJ1c's REAL body params
//! (`wiki/star-systems/GJ-1/bodies/GJ1c/index.md`: `hydrosphere:
//! liquid_water`, `atmosphere: standard` → `derive_moisture_ceiling_q`
//! yields **moisture_q=80**, well above `ENDORHEIC_MOISTURE_CEILING=60`).
//! At the fallback `moisture_q=55` this fixture's 53 real working-grid
//! basins split 51 Overflow / 2 Endorheic; verified directly (a scratch
//! probe, not committed) that at the PRODUCTION `moisture_q=80` the SAME
//! 53 basins are **53/53 all-Overflow — zero Endorheic** (moisture is the
//! only thing that moves; `filled_scaled`/basin geometry itself is
//! moisture-independent, per `run_layer1_with_moisture_changes_
//! endorheic_split_not_lake_extent`'s own invariant). A materially
//! different picture: on the real body, every one of these basins shows an
//! exit river on the map — this fixture happening to include 2 Endorheic
//! basins is an artifact of testing at the body-agnostic fallback
//! constant, not a fact about GJ1c itself.
//!
//! **Cell count (post PR #202 review fix — the i==1/spill-collision
//! regression, Hoshe finding 1):** `river_cells` 93→192 (+99). Every one of
//! the 51 real `Overflow` basins now has its spill cell wired as a real
//! river-network entry, unconditionally — the earlier landing (143, now
//! superseded) silently dropped a basin's ENTIRE outlet whenever
//! `outlet_path[1]` collided with a pre-existing river cell (the loop broke
//! on its first iteration having pushed nothing), which on THIS fixture
//! happened for 1 basin outright and would have made it visually
//! indistinguishable from Endorheic — exactly the cue this ticket must
//! never break. Verified directly against this fixture (scratch probe, not
//! committed): all 51 real `Overflow` basins now build into a real,
//! readable `RiverEdge` via `river_course::build_edges` — zero basins
//! missing an outlet edge. One pre-existing baseline cell (`(28, 14)`) is
//! overwritten in place (its `river_downstream` sentinel changes from
//! `RIVER_DOWNSTREAM_EDGE_DRAIN` to a real D8 direction) rather than
//! duplicated — the other real spill-cell collision on this fixture lands
//! among the newly-appended range, not the original 93-cell baseline.
//! Confirmed by direct position-identity diff (not raw array-index
//! comparison, which is misleading once the fix reshuffles positions
//! within each basin's block): zero positions are ever REMOVED between the
//! pre-fix and post-fix goldens, only added-or-overwritten — the fix is
//! additive at the position level, exactly as designed. No duplicate
//! `(row, col)` positions exist in the final array (verified — the
//! `edge_ids_are_unique` invariant `build_edges` depends on holds).
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(),
);
}
}