Files
settled-reach/server/tests/cascade_golden.rs
T
jpmschweitzerandClaude Fable 5 8da9670e0f feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)
One commit for two tickets whose changes share the bridge/plugin
plumbing files. T-1169 connects the three dormant feature-name pieces:
atlas_feature_names populated at regen (17,891 rows — 15,190 mountain,
2,701 river — via populate_atlas_feature_names mirroring the city-names
importer; systems.db regenerated, stamp fresh), attach_feature_names
wired into the cascade's Topography block with name pools threaded
DB-free through AnalyzeBody (D-225 pattern) and assignments stored on
Layer1Output/BodyWorldState for future consumers, and a
FeatureNamesRequest/Response read proxy as the bridge's 7th tagged
envelope (D-236 pattern, both SimBridge impls). Client label DRAW is
deliberately NOT here — implementation proved both river and mountain
labels need a wire-carried position (the pool is position-free; course
polylines aren't correlated with the named attractors by construction) —
deferred to T-1195's single design pass. cascade_layer1 golden re-pinned
(additive feature_names field).

T-1159 retires the legacy u32 granularity field fully shadowed by
window_granularity_v2: AtlasLayerRequest.window_granularity,
DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and
resolve_window_granularity are gone server-side; client encode paths and
the caller-less atlas_window_cache legacy key component dropped;
msgpack fixtures regenerated; the T-1150 aliasing regression test now
drives through the surviving enum field. The district_window carrier
itself survives byte-compatible per D-255(c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:10:27 +02:00

223 lines
12 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).
//!
//! **Fourth deliberate re-pin (T-1169, D-223):** `Layer1Output` gained an
//! additive `feature_names: Vec<FeatureNameAssignment>` field (river-mouth/
//! alpine-peak name assignments from the new `attach_feature_names` cascade
//! wiring). This fixture calls `run_cascade_from_heightmap` with empty
//! `river_names`/`mountain_names` pools (no DB access from this test), so
//! the field serializes as `[]` — a pure additive-shape change, zero effect
//! on every pre-existing field.
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(),
);
}
}