Jeroen's Global map has been two coloured blocks all along. Not missing
hydrology, not a missing layer: serve_step_canvas_request zeroed Global's wire
extent, so every request arrived downstream as (0,0), clamped to (1,1), and
resolved to a 2x1 canvas.
That sentinel was correct when Global's size came from the body's region grid
and the client's extent field was meaningless. The D-255 extent inversion made
Global viewport-sized and this line silently outlived it. The commit titled
"size the Global rung to the viewport" was therefore correct and completely
unreachable — its tests passed by calling resolve_canvas_extent directly
rather than through the serve path, i.e. they tested the function that changed
instead of the path the data takes.
Two more places carried the same dead premise, both meaning the first canvas
ever built answered every later request and a resize could never take effect:
- GlobalTierCache keyed on body id alone. Now treats a size mismatch as a
miss, so the re-derive replaces it. Deliberately still ONE entry per body
rather than one per size: keying by size would make a tier that never
evicts accumulate an entry per viewport a player has ever used.
- The client's make_key collapsed Global's extent to a sentinel. Centre
stays collapsed — Global's canvas really is whole-body and origin-anchored
— but extent is now part of the key.
project.yaml 0.4.5 forces the 2x1 canvases already on disk to miss.
Verified through the capture harness, not by reasoning: server probe shows
req=(960,540) radius=6238.4 resolved=960x480, and Ferrath's Global now renders
continents, oceans, inland lakes and polar ice where it previously rendered
one solid rectangle.
Server suite green (45 binaries), client 1830 total / 1804 passed / 26 skipped.
Pair session with Jeroen, 2026-07-27.
Co-Authored-By: Claude <noreply@anthropic.com>
416 lines
15 KiB
Rust
416 lines
15 KiB
Rust
//! T-1181 acceptance gate — cache-hit derive path == cache-miss derive path,
|
|
//! byte-identical, for EVERY D-255(a) rung.
|
|
//!
|
|
//! This is the mandatory gate the ticket names as its most important
|
|
//! deliverable (D-227 amendment (3), D-255(f)): a step canvas is
|
|
//! `derive(seed, position)`, a pure function — evicting a cache entry and
|
|
//! re-deriving it must produce byte-identical output, never merely
|
|
//! "close enough." The same shape as T-1170's window-independence invariant.
|
|
//!
|
|
//! **What "cache-hit" vs "cache-miss" means here, precisely:** the SERVER
|
|
//! derive core (`step_canvas::build_step_canvas`) never itself reads a
|
|
//! cache — `StepCanvasCache`/`GlobalTierCache` are populated OUTSIDE the
|
|
//! derive core, by the gen_queue completion handler (`plugin.rs`). So
|
|
//! "cache-hit == cache-miss" is proven by construction (there is only ONE
|
|
//! code path that produces a canvas — `build_step_canvas` — and every
|
|
//! caller, whether serving a first request or a Nth repeat, calls it
|
|
//! identically) UNLESS a future change introduces a genuine second path
|
|
//! (e.g. D-255(f) mechanism-B acceleration reading a resident coarser
|
|
//! canvas as an input). This suite pins the invariant directly at the
|
|
//! derive-core level (call `build_step_canvas` twice, independently, same
|
|
//! inputs, assert byte-identical) AND at the cache level (insert once,
|
|
//! `get` it back via `StepCanvasCache`/`GlobalTierCache`, assert the
|
|
//! round-tripped bytes match a fresh independent derive) — so this test
|
|
//! remains the correctness gate even after a future acceleration path
|
|
//! lands, per D-227 amendment (3)'s "mandatory determinism test."
|
|
//!
|
|
//! Run: `cargo test --test step_canvas_acceptance_gate`
|
|
|
|
use settled_reach_server::atlas::body_world_state::RiverNetwork;
|
|
use settled_reach_server::atlas::district_profile::{BodyParams, ClimateConstants};
|
|
use settled_reach_server::atlas::drainage;
|
|
use settled_reach_server::atlas::features::TerrainAnalysis;
|
|
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
|
use settled_reach_server::atlas::step_canvas::{
|
|
build_step_canvas, decode_step_canvas, encode_step_canvas, BodyDrivingClockClass,
|
|
GlobalTierCache, StepCanvasCache, StepCanvasRung,
|
|
};
|
|
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
|
|
|
/// Same fixture-building convention `bmv_gridunit_bench.rs`/
|
|
/// `window_derivation_golden.rs` already establish — a deterministic
|
|
/// gradient heightmap, small enough to run every rung's canvas at a modest
|
|
/// (non-benchmark) extent in a plain `cargo test` run.
|
|
fn fixture_hm() -> BodyHeightmap {
|
|
let (w, h) = (128u32, 64u32);
|
|
let n = (w * h) as usize;
|
|
let data = (0..n)
|
|
.map(|i| {
|
|
let r = (i / w as usize) as f32 / h as f32;
|
|
let c = (i % w as usize) as f32 / w as f32;
|
|
let ripple = (c * std::f32::consts::TAU * 3.0).sin() * 0.08;
|
|
(r * 0.6 + c * 0.4 + ripple).clamp(0.0, 1.0)
|
|
})
|
|
.collect();
|
|
BodyHeightmap {
|
|
body_id: "gate-body".into(),
|
|
width: w,
|
|
height: h,
|
|
data,
|
|
sea_level: 0.3,
|
|
}
|
|
}
|
|
|
|
fn fixture_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
|
|
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
|
TerrainAnalysis::analyze(hm, &dr)
|
|
}
|
|
|
|
fn fixture_river_network(hm: &BodyHeightmap) -> RiverNetwork {
|
|
drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level).river_network
|
|
}
|
|
|
|
fn fixture_params() -> BodyParams {
|
|
BodyParams {
|
|
hydrosphere: Some("ocean".into()),
|
|
atmosphere: Some("breathable".into()),
|
|
planet_class: Some("temperate".into()),
|
|
body_radius_km: Some(6371.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// A small, fast canvas extent for the gate suite — the invariant being
|
|
/// tested (byte-identical repeat derivation) does not require production
|
|
/// canvas sizes; those are already covered by the workshop's dedicated
|
|
/// bench files (`bmv_gridunit_bench.rs`, `bmv_global_tier_bench.rs`).
|
|
const GATE_EXTENT: (u32, u32) = (24, 18);
|
|
|
|
/// Every D-255(a) rung, in ladder order — the gate's "for EVERY rung"
|
|
/// requirement, checked exhaustively rather than sampled.
|
|
const ALL_RUNGS: [StepCanvasRung; 6] = [
|
|
StepCanvasRung::Global,
|
|
StepCanvasRung::Region,
|
|
StepCanvasRung::District,
|
|
StepCanvasRung::Quarter,
|
|
StepCanvasRung::Block,
|
|
StepCanvasRung::Chunk,
|
|
];
|
|
|
|
/// Core gate: call `build_step_canvas` twice, independently (two fresh
|
|
/// `TerrainAnalysis`/`RiverNetwork` builds, not two reads of one shared
|
|
/// value — the strongest form of "cache-miss twice"), same
|
|
/// `(seed, body, position)` inputs, and assert byte-identical `RawStepCanvas`
|
|
/// output for every rung.
|
|
#[test]
|
|
fn cache_hit_equals_cache_miss_every_rung() {
|
|
let seed = SeedChain::root(0xACCE97_u64).derive(SeedDomain::Body, 1);
|
|
let climate = ClimateConstants::default();
|
|
let center = (12_288_i64, -8_192_i64); // an arbitrary non-origin centre
|
|
|
|
for rung in ALL_RUNGS {
|
|
// Independent build #1 ("cache-miss" run A) — fresh heightmap load,
|
|
// fresh drainage analysis, fresh TerrainAnalysis.
|
|
let hm_a = fixture_hm();
|
|
let ta_a = fixture_ta(&hm_a);
|
|
let rn_a = fixture_river_network(&hm_a);
|
|
let params_a = fixture_params();
|
|
let canvas_a = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms_a,
|
|
&ta_a,
|
|
&rn_a,
|
|
&[],
|
|
rung,
|
|
center,
|
|
GATE_EXTENT,
|
|
&climate,
|
|
0,
|
|
);
|
|
|
|
// Independent build #2 ("cache-miss" run B — simulating what a
|
|
// second, later request after eviction would recompute) — every
|
|
// input rebuilt from scratch again, not reused from run A.
|
|
let hm_b = fixture_hm();
|
|
let ta_b = fixture_ta(&hm_b);
|
|
let rn_b = fixture_river_network(&hm_b);
|
|
let params_b = fixture_params();
|
|
let canvas_b = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms_b,
|
|
&ta_b,
|
|
&rn_b,
|
|
&[],
|
|
rung,
|
|
center,
|
|
GATE_EXTENT,
|
|
&climate,
|
|
0,
|
|
);
|
|
|
|
assert_eq!(
|
|
canvas_a, canvas_b,
|
|
"rung {rung:?}: two independent derive_step_canvas builds diverged — \
|
|
D-227 purity violated (cache-hit/cache-miss byte-identity gate)"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The same gate at the ENCODED wire level (PNG-per-field round-trip) — a
|
|
/// separate property from the raw-derive gate above: even if the derive
|
|
/// core is pure, a non-deterministic or lossy encoder would still break the
|
|
/// "same request, same bytes on the wire" guarantee a client's persistent
|
|
/// cache (D-227 amendment (2)) depends on.
|
|
#[test]
|
|
fn encoded_canvas_round_trip_is_lossless_every_rung() {
|
|
let seed = SeedChain::root(0xACCE97_u64).derive(SeedDomain::Body, 2);
|
|
let climate = ClimateConstants::default();
|
|
let center = (2_048_i64, 4_096_i64);
|
|
|
|
let hm = fixture_hm();
|
|
let ta = fixture_ta(&hm);
|
|
let rn = fixture_river_network(&hm);
|
|
let params = fixture_params();
|
|
|
|
for rung in ALL_RUNGS {
|
|
let raw = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms,
|
|
&ta,
|
|
&rn,
|
|
&[],
|
|
rung,
|
|
center,
|
|
GATE_EXTENT,
|
|
&climate,
|
|
0,
|
|
);
|
|
let encoded = encode_step_canvas(&raw);
|
|
let decoded = decode_step_canvas(&encoded);
|
|
assert_eq!(
|
|
raw, decoded,
|
|
"rung {rung:?}: PNG-per-field encode/decode round-trip lost data"
|
|
);
|
|
|
|
// Encoding itself is deterministic — encode the SAME raw canvas
|
|
// twice and expect byte-identical PNG output (never "usually
|
|
// matches"). This is the property a persistent client cache
|
|
// (D-227 amendment (2)) needs: identical input bytes -> identical
|
|
// stored bytes, so a schema/version-tag comparison is even
|
|
// meaningful.
|
|
let encoded_again = encode_step_canvas(&raw);
|
|
assert_eq!(
|
|
encoded.morphology.png_bytes, encoded_again.morphology.png_bytes,
|
|
"rung {rung:?}: PNG encoder is non-deterministic on identical input"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The gate at the CACHE-TIER level: insert a derived canvas into
|
|
/// `GlobalTierCache`/`StepCanvasCache`, read it back, and confirm the
|
|
/// round-tripped bytes match a fresh independent derive — proving the cache
|
|
/// layer is a transparent accelerator (D-227 amendment (3): "an
|
|
/// optimization, not a semantic dependency") rather than a second source of
|
|
/// truth that could silently diverge from the derive core.
|
|
#[test]
|
|
fn global_tier_cache_round_trip_matches_fresh_derive() {
|
|
let seed = SeedChain::root(0xACCE97_u64).derive(SeedDomain::Body, 3);
|
|
let climate = ClimateConstants::default();
|
|
|
|
let hm = fixture_hm();
|
|
let ta = fixture_ta(&hm);
|
|
let rn = fixture_river_network(&hm);
|
|
let params = fixture_params();
|
|
|
|
let fresh_raw = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms,
|
|
&ta,
|
|
&rn,
|
|
&[],
|
|
StepCanvasRung::Global,
|
|
(0, 0),
|
|
GATE_EXTENT, // ignored for Global — extent is the body's region grid
|
|
&climate,
|
|
0,
|
|
);
|
|
let fresh_encoded = encode_step_canvas(&fresh_raw);
|
|
|
|
let mut cache = GlobalTierCache::new();
|
|
let gate_size = (fresh_encoded.width, fresh_encoded.height);
|
|
assert!(cache.get("gate-body", gate_size).is_none());
|
|
cache.insert("gate-body".to_string(), fresh_encoded.clone());
|
|
assert!(cache.contains("gate-body"));
|
|
|
|
let cached = cache
|
|
.get("gate-body", gate_size)
|
|
.expect("global tier cache hit after insert");
|
|
assert_eq!(
|
|
cached, &fresh_encoded,
|
|
"GlobalTierCache round-trip diverged from the freshly-derived canvas"
|
|
);
|
|
|
|
// Re-derive fresh AGAIN (simulating a cold-cache request that never
|
|
// touched this cache instance at all) and confirm it still matches the
|
|
// cached bytes — this is the eviction->recompute->byte-identical
|
|
// property D-227 requires.
|
|
let hm2 = fixture_hm();
|
|
let ta2 = fixture_ta(&hm2);
|
|
let rn2 = fixture_river_network(&hm2);
|
|
let params2 = fixture_params();
|
|
let recomputed_raw = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms2,
|
|
&ta2,
|
|
&rn2,
|
|
&[],
|
|
StepCanvasRung::Global,
|
|
(0, 0),
|
|
GATE_EXTENT,
|
|
&climate,
|
|
0,
|
|
);
|
|
let recomputed_encoded = encode_step_canvas(&recomputed_raw);
|
|
assert_eq!(
|
|
cached, &recomputed_encoded,
|
|
"eviction->recompute path diverged from the cached global-tier canvas"
|
|
);
|
|
}
|
|
|
|
/// Same cache-tier gate for `StepCanvasCache` (fixed rungs 1-5), across
|
|
/// every fixed rung.
|
|
#[test]
|
|
fn step_canvas_cache_round_trip_matches_fresh_derive_every_fixed_rung() {
|
|
let seed = SeedChain::root(0xACCE97_u64).derive(SeedDomain::Body, 4);
|
|
let climate = ClimateConstants::default();
|
|
let center = (512_i64, -1_024_i64);
|
|
let extent = GATE_EXTENT;
|
|
let min_wl_m = 0u32;
|
|
let body_class = BodyDrivingClockClass::Moonless;
|
|
|
|
for rung in ALL_RUNGS {
|
|
if rung.is_global() {
|
|
continue; // covered by global_tier_cache_round_trip_matches_fresh_derive
|
|
}
|
|
|
|
let hm = fixture_hm();
|
|
let ta = fixture_ta(&hm);
|
|
let rn = fixture_river_network(&hm);
|
|
let params = fixture_params();
|
|
let fresh_raw = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms,
|
|
&ta,
|
|
&rn,
|
|
&[],
|
|
rung,
|
|
center,
|
|
extent,
|
|
&climate,
|
|
min_wl_m,
|
|
);
|
|
let fresh_encoded = encode_step_canvas(&fresh_raw);
|
|
|
|
let mut cache = StepCanvasCache::new(8);
|
|
let key = ("gate-body".to_string(), rung, center, extent, min_wl_m);
|
|
assert!(cache.get(&key, 0, body_class).is_none());
|
|
cache.insert(key.clone(), fresh_encoded.clone(), 0);
|
|
|
|
let cached = cache
|
|
.get(&key, 1, body_class)
|
|
.unwrap_or_else(|| panic!("rung {rung:?}: cache hit expected after insert"));
|
|
assert_eq!(
|
|
cached, fresh_encoded,
|
|
"rung {rung:?}: StepCanvasCache round-trip diverged from the freshly-derived canvas"
|
|
);
|
|
|
|
// Independent re-derive (simulating eviction) still matches.
|
|
let hm2 = fixture_hm();
|
|
let ta2 = fixture_ta(&hm2);
|
|
let rn2 = fixture_river_network(&hm2);
|
|
let params2 = fixture_params();
|
|
let recomputed_raw = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms2,
|
|
&ta2,
|
|
&rn2,
|
|
&[],
|
|
rung,
|
|
center,
|
|
extent,
|
|
&climate,
|
|
min_wl_m,
|
|
);
|
|
let recomputed_encoded = encode_step_canvas(&recomputed_raw);
|
|
assert_eq!(
|
|
cached, recomputed_encoded,
|
|
"rung {rung:?}: eviction->recompute path diverged from the cached canvas"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Determinism across DIFFERENT positions is not the gate's job (that's
|
|
/// what makes derive believable, not what makes cache/derive agree) — but a
|
|
/// sanity check that two DIFFERENT centres do NOT produce identical output
|
|
/// is worth pinning here too, so this suite can't be satisfied by an
|
|
/// accidentally-constant derive function.
|
|
#[test]
|
|
fn different_centers_produce_different_canvases_sanity_check() {
|
|
let seed = SeedChain::root(0xACCE97_u64).derive(SeedDomain::Body, 5);
|
|
let climate = ClimateConstants::default();
|
|
|
|
let hm = fixture_hm();
|
|
let ta = fixture_ta(&hm);
|
|
let rn = fixture_river_network(&hm);
|
|
let params = fixture_params();
|
|
|
|
// Global ignores `center` by construction (whole-body canvas), so this
|
|
// sanity check only applies to fixed rungs.
|
|
for rung in [
|
|
StepCanvasRung::Region,
|
|
StepCanvasRung::District,
|
|
StepCanvasRung::Quarter,
|
|
StepCanvasRung::Block,
|
|
StepCanvasRung::Chunk,
|
|
] {
|
|
let canvas_a = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms,
|
|
&ta,
|
|
&rn,
|
|
&[],
|
|
rung,
|
|
(0, 0),
|
|
GATE_EXTENT,
|
|
&climate,
|
|
0,
|
|
);
|
|
let canvas_b = build_step_canvas(
|
|
seed,
|
|
"gate-body",
|
|
¶ms,
|
|
&ta,
|
|
&rn,
|
|
&[],
|
|
rung,
|
|
(200_000, 100_000),
|
|
GATE_EXTENT,
|
|
&climate,
|
|
0,
|
|
);
|
|
assert_ne!(
|
|
canvas_a.elev_q, canvas_b.elev_q,
|
|
"rung {rung:?}: two far-apart centres produced identical elev_q — \
|
|
suspiciously constant derive (this suite should not pass on a stub)"
|
|
);
|
|
}
|
|
}
|