feat(simulation): step-canvas serving — tagged envelope (D-255, T-1181)

The D-225-discharging wire migration per D-255(c): a new
server/src/atlas/step_canvas.rs subsystem (six-rung StepCanvasRung
ladder — Global variable-extent rung 0 through Chunk 64m — the
StepCanvasRequest/StepCanvasResponse tagged envelope extending the
proven ShapeProbe discriminated-shape pattern, PNG-per-field dense +
MessagePack-native sparse encoding per the T-1179 measured table, and
both server cache tiers: the structurally keep-always GlobalTierCache
and the dual-axis StepCanvasCache with storage TTLs per rung and
SIM_STATE_TTL clock-bucket staleness per the D-227 amendment (1)
formula). Wired through bridge/{mod,tcp,local}.rs (sixth demux shape,
send_step_canvas_response mirroring the five existing senders),
gen_queue.rs (DeriveStepCanvas work item with per-connection-per-rung
coalescing, reusing the shared TerrainAnalysisCache), and plugin.rs
(serve/complete systems, lazy D-206 rung-0 population).

Acceptance gate (mandatory per D-227 amendment (3)):
tests/step_canvas_acceptance_gate.rs — cache-hit == cache-miss
byte-identical for every rung, lossless encode round-trips, cache
round-trips vs fresh derive, distinct-center sanity. 5/5 pass.

Station-spacing cap ADOPTED: course stations floored to District
spacing (2,048 m) at finer rungs — the S2-measured +38-87% chunk/block
course cost had zero display benefit at the same station density
(COURSE_STATION_SPACING_FLOOR_M).

Documented honest gaps, not shortcuts: settlement_id is a proximity
approximation (no footprint polygons exist yet); glaciation/flooded_q
sim-state planes are wire-shape-ready D-253 stubs; rung-0 uses signed
equator-anchored rows (the canonical D-256 core convention — T-1186's
wrong-latitude behavior applies unchanged and unfixed here, by
instruction).

Legacy district_window carrier byte-unchanged: window_derivation_golden
6/6 byte-identical, all district_window suites pass unmodified. Full
suite at implementation time: 2127 passed across 45 binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 03:13:08 +02:00
co-authored by Claude Fable 5
parent 5151c9010f
commit cd2f342421
12 changed files with 2699 additions and 23 deletions
@@ -1854,3 +1854,5 @@ MINOR CODE NITS:
D-256/T-1174 finding (2026-07-25): the believability harness golden is INSENSITIVE to a total basin_direction regression an all-North basin collapse (every survey cell defaulting) produced a byte-identical believability.json at current sample density (64/2048 districts; basin only reaches voxel meander phase/channel width, too localized for the coarse aggregate stats). The cross-namespace keying seam is now covered by the restored derive_all_districts_threads_supplied_basin_directions test (production-shaped survey keys), but the harness itself has no basin-sensitive metric. Candidate hardening: a basin-direction distribution stat (distinct directions >= 2 on a drained body) or a voxel-transect metric that moves with meander phase.', NULL, '2026-07-24 22:38:59', '2026-07-24 22:38:59.482', '2026-07-24 22:38:59.482', NULL, '11374469110761a33a33b8210ad53928', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FRXGSXBZKXTHR7MR5K9H2HRG', 'status', 'in_progress', 'review', NULL, '2026-07-24 23:18:26', '2026-07-24 23:18:26.774', '2026-07-24 23:18:26.774', NULL, '0e4aa2b6429cbcae6e99807975ff69cf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'status', 'backlog', 'in_progress', NULL, '2026-07-24 23:52:05', '2026-07-24 23:52:05.692', '2026-07-24 23:52:05.692', NULL, 'f675cb4c0fd898dad72722cbee9572ed', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'status', 'in_progress', 'review', NULL, '2026-07-25 00:26:42', '2026-07-25 00:26:42.634', '2026-07-25 00:26:42.634', NULL, 'ada454394fd3e1eb062c1ed4b7a0356f', 2) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -2691,3 +2691,5 @@ D-255 (2026-07-24): the tagged-envelope step-canvas serving (T-1181) depends on
RULING (2026-07-24): D-256 claimed and written on branch t1174-derive-sampling (rides the PR; syncs to main on merge). Canonical convention = derive(seed, absolute world metres) via the derive_at_metres family; batch 64x32 pseudo-grid re-scoped as a survey raster with a real SurveyCellPos newtype; derive_district_profile becomes a thin wrapper over an extracted riparian-parameterized core at survey-cell-centre positions (basin_direction preserved by post-call override; near_perennial_water_at plumbed, never the empty-slice default); skeleton morphology judgments move to exact-position derive; step canvases fenced to the window family. LayerRegionOutput rebuild deferred to T-1181 (tripwire verified: sole reader is the region_grid overlay); voxel carrier deferred to Phase 5. Consults: Tyre APPROVE-WITH-CHANGES, Dudley FEASIBLE-WITH-CHANGES (both incorporated). Settlement/road positions do NOT move (L1 attractor-sourced; T-1075 bake orthogonal); believability goldens regen, gates unchanged; window goldens must stay byte-green.', 'review', 'high', NULL, 'server', NULL, '2026-07-23 11:36:27.743', '2026-07-24 23:18:26.773', NULL, '7442e6e6c69c84c53bc577c96a0a2b79', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FSCXX7E3BXT7WG0BYDB6PJA0', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Region-baseline latitude is pole-anchored while the derive core keys equator-anchored signed regions — northern hemisphere clamps to +90', 'Found during the PR #199 (T-1174/D-256) review fix round, PRE-EXISTING on the window path since the region climate stack (T-1078/T-1113) was wired into derive_at_metres. region_profile::region_centre_latitude_deg (region_profile.rs ~270-280) maps region row 0 to the north pole (+90) with lat_frac clamped to [0,1] — a pole-anchored, non-negative row convention. But the derive core (derive_at_metres_with_riparian, district_profile.rs ~1788) keys regions by floor-dividing EQUATOR-anchored world metres (negative wy = north), producing signed region rows (Earth-class: -49..+49). Consequence: every northern-hemisphere region has negative row -> region_centre_y_m negative -> lat_frac clamps to 0 -> baseline latitude +90 (polar) regardless of true latitude; southern-hemisphere rows 0..~48 read as compressed NORTHERN latitudes ~89..~0.7 (south pole reads as equator). Affects the region temperature baseline (and glaciation/moisture components derived from it) for the window path at every rung, and post-D-256 the batch/survey path inherits the same keys (still an improvement over the pre-D-256 body-wide region-(0,0) baseline, which read ~+89 everywhere). NOT touched in PR #199 to keep the review round scoped; goldens currently pin the wrong-latitude values. FIX SHAPE: make region_centre_latitude_deg mirror the derive core''s inverse mapping (equator-anchored signed rows: lat = -((ry+0.5)*REGION_M/meridian_m clamped to [-0.5,0.5])*180), audit the OTHER convention''s callers (the D-255 rung-0 canvas row space and the collapsed LayerRegionOutput builder use non-negative pole-anchored rows - decide ONE convention per D-256''s one-inverse-mapping principle, likely at the T-1181 rung-0 rebuild), regen affected goldens, and re-run the believability direction check. Related: D-256, D-243, T-1181, T-1078.', 'backlog', 'high', NULL, 'server', NULL, '2026-07-24 23:30:51.889', '2026-07-24 23:30:51.889', NULL, '6a7b5705a768da90b74449383d5a127b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Step-canvas serving: StepCanvasRequest/Response tagged envelope (D-255 wire migration)', 'The D-225-discharging migration per D-255(c): one new Inbound variant (StepCanvasRequest, required marker field step_canvas, extending the proven star_map/city_names/browse ShapeProbe pattern in server/src/bridge/mod.rs) + a dedicated StepCanvasResponse (NOT a field on AtlasLayerResponse — the D-226 §2 ceiling is re-scoped to the legacy carrier). Serves all six rungs of the D-255(a) ladder — rung-0 Global variable-extent is a field value, not a different shape; fixed rungs viewport-sized per D-255(b). Encoding: PNG-per-field dense (8 fields: 6 static + glaciation/flooded sim-state L8 planes) + MessagePack-native sparse (courses, cliffs) per the T-1179 measured table. SERVER CACHE TIERS ARE THIS TICKET''S SCOPE TOO (SI gap 3): the D-203-shaped resident global tier (~8.85MB population-wide, measured) + TTL(detail, time, distance) for finer rungs + SIM_STATE_TTL[field] = 1x the field''s own fastest driving clock-bucket per body class (flooded: tidal bucket moon-bearing / seasonal moonless; glaciation: seasonal — D-227 amendment (1), Dudley+Araminta joint formula). ACCEPTANCE GATE (SI gap 2, mandatory per D-227 amendment (3)): byte-identical output test — cache-hit derive path == cache-miss derive path for every rung; the seed-chaining cache-accelerated-pure-function correctness gate, same shape as T-1170''s window-independence invariant. Implementation note carried from measurement S2: courses cost +38-87% at chunk/block spacing (station count scales inversely with rung spacing — 1,732 vs 867 points/course measured); a station-spacing cap independent of rung spacing is a named candidate optimization, decision at implementation. Design sources: dudley-round2.md §(a), tyre-round2.md §(b), D-255(c)(d)(f). Legacy district_window survives byte-unchanged until its consumer is replaced.', 'in_progress', 'high', NULL, 'server', 'D-255', '2026-07-24 07:11:27.800', '2026-07-24 23:52:05.687', NULL, '047e4a20983d55f97bb41ae909addbba', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FS5XQW73B1P8WKR2BCZBFXZW', 'story', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Step-canvas serving: StepCanvasRequest/Response tagged envelope (D-255 wire migration)', 'The D-225-discharging migration per D-255(c): one new Inbound variant (StepCanvasRequest, required marker field step_canvas, extending the proven star_map/city_names/browse ShapeProbe pattern in server/src/bridge/mod.rs) + a dedicated StepCanvasResponse (NOT a field on AtlasLayerResponse — the D-226 §2 ceiling is re-scoped to the legacy carrier). Serves all six rungs of the D-255(a) ladder — rung-0 Global variable-extent is a field value, not a different shape; fixed rungs viewport-sized per D-255(b). Encoding: PNG-per-field dense (8 fields: 6 static + glaciation/flooded sim-state L8 planes) + MessagePack-native sparse (courses, cliffs) per the T-1179 measured table. SERVER CACHE TIERS ARE THIS TICKET''S SCOPE TOO (SI gap 3): the D-203-shaped resident global tier (~8.85MB population-wide, measured) + TTL(detail, time, distance) for finer rungs + SIM_STATE_TTL[field] = 1x the field''s own fastest driving clock-bucket per body class (flooded: tidal bucket moon-bearing / seasonal moonless; glaciation: seasonal — D-227 amendment (1), Dudley+Araminta joint formula). ACCEPTANCE GATE (SI gap 2, mandatory per D-227 amendment (3)): byte-identical output test — cache-hit derive path == cache-miss derive path for every rung; the seed-chaining cache-accelerated-pure-function correctness gate, same shape as T-1170''s window-independence invariant. Implementation note carried from measurement S2: courses cost +38-87% at chunk/block spacing (station count scales inversely with rung spacing — 1,732 vs 867 points/course measured); a station-spacing cap independent of rung spacing is a named candidate optimization, decision at implementation. Design sources: dudley-round2.md §(a), tyre-round2.md §(b), D-255(c)(d)(f). Legacy district_window survives byte-unchanged until its consumer is replaced.', 'review', 'high', NULL, 'server', 'D-255', '2026-07-24 07:11:27.800', '2026-07-25 00:26:42.634', NULL, '403b79fde0dcd81c48718da87bde22e9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+171
View File
@@ -258,6 +258,48 @@ pub enum GenWorkItem {
/// special-cased away.
min_wl_m: u32,
},
/// Derive a D-255(a) step canvas (T-1181, the tagged-envelope migration).
///
/// **Binding serving model — same as `DeriveWindow`:** step canvases ride
/// this SAME Rayon queue, never inline on the `PreInput` drain (D-255(d):
/// "never inline here"). Reuses the SAME `TerrainAnalysisCache` LRU
/// `DeriveWindow` already populates — a step-canvas request and a legacy
/// `district_window` request for the same body share one
/// `run_layer1`-derived `(Layer1Output, TerrainAnalysis)` cache entry
/// (keyed on `body_id` alone), so a player already panning the legacy
/// window viewer and then opening the new stepped map (or vice versa)
/// pays the ~45 ms `run_layer1` cost at most once per body, not once per
/// carrier.
DeriveStepCanvas {
body_id: String,
/// Coalescing/routing key — NOT used by `run_work_item` itself (the
/// derive is connection-agnostic), only by
/// `GenerationQueue::submit_step_canvas` to decide which still-pending
/// item a new one for the same connection+body+rung supersedes.
conn_id: ConnectionId,
heightmap_path: PathBuf,
sea_level: f32,
body_seed: SeedChain,
body_params: Box<BodyParams>,
/// The body's placed settlements, pre-resolved at dispatch time from
/// `BodyWorldState.placements` if already cached (empty otherwise —
/// `settlement_id` coverage is simply all-zero until the body's own
/// `AnalyzeBody` cascade has placed settlements; see
/// `step_canvas::serve_step_canvas_request`'s doc).
placements: Vec<crate::atlas::attractor_matching::CityPlacement>,
/// The D-255(a) rung this canvas targets.
rung: crate::atlas::step_canvas::StepCanvasRung,
/// World-metre centre (ignored for `StepCanvasRung::Global`, whose
/// canvas is whole-body/origin-anchored).
center: (i64, i64),
/// Canvas pixel budget for every FIXED rung (ignored for `Global`,
/// whose extent is the body's own region grid).
extent: (u32, u32),
/// Octave cutoff in whole metres, already quantized by the caller
/// (`step_canvas::quantize_min_wl_m_for_rung`) — never a raw wire
/// value.
min_wl_m: u32,
},
}
impl GenWorkItem {
@@ -293,6 +335,29 @@ impl GenWorkItem {
None
}
}
/// Coalescing key for `DeriveStepCanvas` items only — `(connection, body,
/// rung)` (T-1181, mirroring `window_supersede_key`'s exact reasoning):
/// a pan-burst that queues several step-canvas requests for the same
/// connection+body+rung before the first is dispatched collapses to one
/// derive. A different rung for the same connection+body does NOT
/// coalesce — every rung is a separate in-flight derive, not a competing
/// update to the same one. `None` for every other variant.
pub fn step_canvas_supersede_key(
&self,
) -> Option<(ConnectionId, &str, crate::atlas::step_canvas::StepCanvasRung)> {
if let GenWorkItem::DeriveStepCanvas {
body_id,
conn_id,
rung,
..
} = self
{
Some((*conn_id, body_id, *rung))
} else {
None
}
}
}
// ---------------------------------------------------------------------------
@@ -352,6 +417,22 @@ pub enum GenCompletion {
/// boxing rationale).
layer: Box<DistrictWindowLayer>,
},
/// A D-255(a) step canvas finished deriving (T-1181). The main thread
/// inserts `canvas` into `GlobalTierCache` (rung `Global`) or
/// `StepCanvasCache` (every other rung), keyed as
/// `serve_step_canvas_request` documents — NOT pushed into any in-flight
/// response, same re-poll-and-hit-cache model as `WindowDerived`.
StepCanvasDerived {
body_id: String,
rung: crate::atlas::step_canvas::StepCanvasRung,
center: (i64, i64),
extent: (u32, u32),
min_wl_m: u32,
/// Boxed to keep `GenCompletion` variant sizes balanced — an
/// `EncodedStepCanvas` carries eight PNG/msgpack-encoded fields,
/// comparable to `WindowDerived`'s own boxing rationale.
canvas: Box<crate::atlas::step_canvas::EncodedStepCanvas>,
},
/// Work item failed — body_id or city_id for logging.
Failed { item: GenWorkItem, reason: String },
}
@@ -523,6 +604,36 @@ impl GenerationQueue {
}
}
/// Submit a `DeriveStepCanvas` item with per-connection-per-rung
/// coalescing (T-1181, mirroring `submit_window`'s exact discipline): if
/// an item for the SAME `(connection, body, rung)` is still pending
/// (not yet dispatched), it is replaced in place — a step-cross burst
/// that queues several requests for the same connection+body+rung before
/// the first is dispatched collapses to one derive. `item` MUST be a
/// `DeriveStepCanvas` variant; any other variant falls through to plain
/// `submit` with no coalescing.
pub fn submit_step_canvas(&self, item: GenWorkItem, priority: GenPriority) {
if let Some(key) = item.step_canvas_supersede_key() {
let key = (key.0, key.1.to_string(), key.2);
let mut pending = self.pending.lock().unwrap();
pending.retain(|q| {
q.item
.step_canvas_supersede_key()
.map(|k| (k.0, k.1.to_string(), k.2) != key)
.unwrap_or(true)
});
let pos = pending
.iter()
.position(|q| q.priority > priority)
.unwrap_or(pending.len());
pending.insert(pos, QueuedWork { priority, item });
drop(pending);
self.dispatch_next();
} else {
self.submit(item, priority);
}
}
/// Drain all completed items from the channel and dispatch pending work.
///
/// Call once per tick from the main thread. Returns all completions
@@ -988,6 +1099,66 @@ fn run_work_item(
reason: format!("heightmap load failed: {e}"),
},
},
GenWorkItem::DeriveStepCanvas {
body_id,
conn_id: _, // routing-only (queue-level coalescing); the derive itself is connection-agnostic
heightmap_path,
sea_level,
body_seed,
body_params,
placements,
rung,
center,
extent,
min_wl_m,
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
Ok(hm) => {
// Same GRID_W×GRID_H downsample AnalyzeBody/DeriveWindow apply
// (D-202) — the true DistrictPos<->world-metres mapping must
// stay consistent across every derive path.
let working = if hm.width > GRID_W || hm.height > GRID_H {
hm.downsample(GRID_W, GRID_H)
} else {
hm
};
// Same per-body TerrainAnalysisCache LRU DeriveWindow already
// populates (T-1137 binding decision, extended here) — keyed
// on body_id alone, so a step-canvas request and a legacy
// district_window request for the same body share one
// ~45 ms run_layer1 re-derive, never pay it twice.
let (l1, ta) = terrain_cache
.lock()
.unwrap()
.get_or_derive(body_id, &working);
let climate = ClimateConstants::default();
let raw = crate::atlas::step_canvas::build_step_canvas(
*body_seed,
body_id,
body_params,
&ta,
&l1.river_network,
placements,
*rung,
*center,
*extent,
&climate,
*min_wl_m,
);
let canvas = crate::atlas::step_canvas::encode_step_canvas(&raw);
GenCompletion::StepCanvasDerived {
body_id: body_id.clone(),
rung: *rung,
center: *center,
extent: *extent,
min_wl_m: *min_wl_m,
canvas: Box::new(canvas),
}
}
Err(e) => GenCompletion::Failed {
item: item.clone(),
reason: format!("heightmap load failed: {e}"),
},
},
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAutho
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
/// (the loader prefers the chunk; this is only the floor).
const DEFAULT_SEA_LEVEL: f32 = 0.3;
pub(crate) const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// Hard server-side clamp on [`AtlasLayerRequest::window_n`] (D-226 T-1124
/// amendment §4, binding numbers). 64×64 districts ≈ 131 km per side — the
@@ -484,7 +484,7 @@ const _: () =
/// to the coarser/lower band, i.e. `<=` on the running best distance) keeps
/// the mapping total and deterministic for any `u32` input, including values
/// far outside the octave range (e.g. `u32::MAX` snaps to the coarsest band).
fn quantize_min_wl_m(raw: u32) -> u32 {
pub(crate) fn quantize_min_wl_m(raw: u32) -> u32 {
let raw_f = raw as f64;
let mut best = MIN_WL_BANDS_M[0];
let mut best_dist = (raw_f - best).abs();
+1
View File
@@ -35,6 +35,7 @@ pub mod scale;
pub mod shell;
pub mod skeleton_gen;
pub mod source_resolver;
pub mod step_canvas;
pub mod subbiome;
pub mod tile_condition;
pub mod trait_catalog_reader;
+101 -1
View File
@@ -36,6 +36,10 @@ use crate::atlas::road_graph::{RoadGraph, RoadNode};
use crate::atlas::scale;
use crate::atlas::skeleton_gen::derive_complexity;
use crate::atlas::source_resolver::BodySourceResolverResource;
use crate::atlas::step_canvas::{
serve_step_canvas_request, GlobalTierCache, StepCanvasCache, StepCanvasResponse,
StepCanvasStatus, STEP_CANVAS_CACHE_CAPACITY,
};
use crate::atlas::trait_catalog_reader::{
ExteriorCatalog, TraitBias, TraitCatalogReaderResource, TraitTemplate,
};
@@ -49,6 +53,7 @@ use crate::atlas::trait_swerve::{
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
@@ -66,6 +71,8 @@ impl Plugin for GenerationPlugin {
app.insert_resource(GenerationQueue::new())
.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY))
.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY))
.insert_resource(GlobalTierCache::new())
.insert_resource(StepCanvasCache::new(STEP_CANVAS_CACHE_CAPACITY))
.add_systems(
Update,
drain_generation_completions.in_set(TickPhase::PreInput),
@@ -76,7 +83,11 @@ impl Plugin for GenerationPlugin {
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput));
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
serve_step_canvas_requests.in_set(TickPhase::PreInput),
);
}
}
@@ -218,6 +229,68 @@ pub fn serve_browse_requests(
}
}
/// Drain inbound step-canvas requests and serve each through the proxy
/// (T-1181, D-255(c)/(d)): Global rung → the always-keep `GlobalTierCache`;
/// every fixed rung → the dual-axis-evicted `StepCanvasCache`. Cache hit →
/// Ready, miss → resolve + enqueue (`GenWorkItem::DeriveStepCanvas`) +
/// Pending — same D-225 poll/cache/enqueue model `serve_atlas_requests`
/// already uses for `district_window`.
fn serve_step_canvas_requests(
mut requests: ResMut<StepCanvasRequestBuffer>,
mut responses: ResMut<StepCanvasResponseBuffer>,
mut global_cache: ResMut<GlobalTierCache>,
mut canvas_cache: ResMut<StepCanvasCache>,
body_state_cache: Res<BodyWorldStateCache>,
queue: Res<GenerationQueue>,
resolver: Option<Res<BodySourceResolverResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
if requests.0.is_empty() {
return;
}
let world_seed = rng.as_ref().map(|r| r.seed()).unwrap_or(0);
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
let params_reader = body_params_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for (conn_id, req) in pending {
// Read-only lookup (peek, no LRU bump — this proxy is not the
// canonical "this body was visited" signal, serve_atlas_requests'
// own cache.get already owns that) for settlement_id coverage
// (step_canvas::serve_step_canvas_request's doc). Empty when the
// body isn't cached yet or has no placements — settlement_id then
// reads all-zero on the derived canvas, not an error.
let placements: &[crate::atlas::attractor_matching::CityPlacement] = body_state_cache
.peek(&req.body_id)
.map(|s| s.placements.as_slice())
.unwrap_or(&[]);
let resp = match resolver.as_ref() {
Some(r) => serve_step_canvas_request(
&req,
&mut global_cache.as_mut(),
&mut canvas_cache.as_mut(),
&queue,
&r.0,
params_reader,
placements,
world_seed,
tick,
conn_id,
),
None => StepCanvasResponse {
body_id: req.body_id.clone(),
rung: req.rung,
center: req.center,
min_wl_m: req.min_wl_m,
status: StepCanvasStatus::Error("no body source resolver".to_string()),
canvas: None,
},
};
responses.0.push((conn_id, resp));
}
}
/// Drain finished background work each tick and apply it to the cache (D-206).
///
/// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap
@@ -226,10 +299,13 @@ fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
mut window_cache: ResMut<DistrictWindowCache>,
mut global_tier_cache: ResMut<GlobalTierCache>,
mut step_canvas_cache: ResMut<StepCanvasCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
for completion in queue.drain_completions() {
match completion {
@@ -470,6 +546,28 @@ fn drain_generation_completions(
*layer,
);
}
GenCompletion::StepCanvasDerived {
body_id,
rung,
center,
extent,
min_wl_m,
canvas,
} => {
// T-1181, D-255(d): cache the completed canvas — NOT pushed
// into any in-flight response (same re-poll-and-hit-cache
// model WindowDerived above uses). Global (rung 0) goes to
// the always-keep GlobalTierCache; every fixed rung goes to
// the dual-axis-evicted StepCanvasCache.
if rung.is_global() {
global_tier_cache.as_mut().insert(body_id, *canvas);
} else {
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
step_canvas_cache
.as_mut()
.insert((body_id, rung, center, extent, min_wl_m), *canvas, tick);
}
}
}
}
}
@@ -926,6 +1024,8 @@ mod tests {
world.insert_resource(GenerationQueue::new());
world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY));
world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY));
world.insert_resource(GlobalTierCache::new());
world.insert_resource(StepCanvasCache::new(STEP_CANVAS_CACHE_CAPACITY));
world.resource::<GenerationQueue>().submit(
GenWorkItem::AnalyzeBody {
File diff suppressed because it is too large Load Diff
+11
View File
@@ -6,6 +6,7 @@ use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::atlas::step_canvas::StepCanvasResponse;
use crate::bridge::framing::{read_framed, write_framed};
use std::fs;
use std::io::{BufReader, BufWriter};
@@ -177,6 +178,16 @@ impl SimBridge for LocalBridge {
write_framed(writer.get_mut(), &payload)?;
Ok(())
}
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
Ok(())
}
}
impl Drop for LocalBridge {
+106 -19
View File
@@ -11,6 +11,7 @@ use crate::atlas::atlas_data_proxy::{
};
use crate::atlas::browse_proxy::{BrowseRequest, BrowseResponse};
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
use crate::atlas::step_canvas::{StepCanvasRequest, StepCanvasResponse};
use crate::bridge::tcp::TcpBridge;
pub mod debug;
@@ -64,15 +65,22 @@ pub enum BridgeError {
/// frames that carry more than one shape's discriminators outright (PR #176
/// review H1). `AtlasLayerRequest` itself is untouched byte-for-byte.
///
/// **Ceiling (D-225 trajectory):** [`BrowseRequest`] (T-1131) is the FIFTH
/// map shape and, per the ceiling this doc already called at four, the last
/// one this hand-rolled scheme should ever carry — it stays at five only
/// because six entity kinds x two forms were folded into ONE new shape
/// (`browse`'s own internal `kind`/`query` enums pick the sub-behavior,
/// exactly as `AtlasLayerRequest.up_to: CascadeLayer` already does) rather
/// than added as twelve more top-level shapes. The next genuinely NEW
/// inbound shape (a sixth) must migrate the channel to the tagged-envelope
/// framing D-225 deferred — do not add a sixth probe.
/// **Ceiling (D-225 trajectory, DISCHARGED by D-255(c)):** [`BrowseRequest`]
/// (T-1131) was the FIFTH map shape and, per the ceiling this doc used to
/// call at four, the last one this hand-rolled scheme was meant to carry.
/// [`StepCanvasRequest`] (T-1181) is that predicted SIXTH shape — the
/// step-canvas payload is genuinely new (D-255(c): 21×-563× the legacy
/// `district_window` carrier's ~30 KB reference, a cell-count gap no
/// encoding closes), so per this doc's own prior instruction it extends the
/// SAME tagged-marker pattern one more time (`step_canvas: bool`) rather
/// than inventing a parallel envelope format — this IS the tagged-envelope
/// migration D-225's 2026-06-12 amendment deferred, now executed. The
/// pattern generalizes cleanly because every one of the five prior shapes
/// already carries (or, for `AtlasLayerRequest`, is disambiguated against)
/// exactly this kind of required marker field — there is no natural ceiling
/// on the DEMUX mechanism itself, only a discipline reminder that a new
/// shape should justify why it can't ride an existing one (as
/// `StepCanvasRequest` does, D-255(c)).
#[derive(Debug)]
pub enum Inbound {
/// A batch of player inputs (the gameplay path).
@@ -86,6 +94,9 @@ pub enum Inbound {
/// A data-browser request — one of the six D-254 §4 v1 entity kinds
/// (T-1131).
BrowseRequest(BrowseRequest),
/// A D-255(a) step-canvas data-canvas request (T-1181, the D-225
/// tagged-envelope migration, executed).
StepCanvasRequest(StepCanvasRequest),
}
/// Key-presence probe for the defensive multi-shape check in
@@ -99,13 +110,15 @@ struct ShapeProbe {
star_map: Option<serde::de::IgnoredAny>,
city_names: Option<serde::de::IgnoredAny>,
browse: Option<serde::de::IgnoredAny>,
step_canvas: Option<serde::de::IgnoredAny>,
}
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131).
/// Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) →
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`) →
/// `BrowseRequest` (map, `browse` discriminator).
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131,
/// T-1181). Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest`
/// (map, `body_id`+`up_to`) → `StarMapRequest` (map, `star_map`
/// discriminator) → `CityNamesRequest` (map, `city_names` discriminator +
/// `body_id`) → `BrowseRequest` (map, `browse` discriminator)
/// `StepCanvasRequest` (map, `step_canvas` discriminator).
///
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
@@ -113,7 +126,7 @@ struct ShapeProbe {
/// more than one shape — e.g. a buggy encoder emitting
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
/// satisfying none of the five shapes is a genuinely malformed input frame.
/// satisfying none of the six shapes is a genuinely malformed input frame.
pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
return Ok(Inbound::Inputs(inputs));
@@ -127,19 +140,22 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
let star_map = probe.star_map.is_some();
let city_names = probe.city_names.is_some();
let browse = probe.browse.is_some();
let step_canvas = probe.step_canvas.is_some();
let shapes = usize::from(atlas)
+ usize::from(star_map)
+ usize::from(city_names)
+ usize::from(browse);
+ usize::from(browse)
+ usize::from(step_canvas);
if shapes > 1 {
let dump_len = payload.len().min(256);
tracing::error!(
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}, step_canvas={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
shapes,
atlas,
star_map,
city_names,
browse,
step_canvas,
dump_len,
payload.len(),
&payload[..dump_len]
@@ -159,8 +175,11 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
if let Ok(req) = rmp_serde::from_slice::<CityNamesRequest>(payload) {
return Ok(Inbound::CityNamesRequest(req));
}
match rmp_serde::from_slice::<BrowseRequest>(payload) {
Ok(req) => Ok(Inbound::BrowseRequest(req)),
if let Ok(req) = rmp_serde::from_slice::<BrowseRequest>(payload) {
return Ok(Inbound::BrowseRequest(req));
}
match rmp_serde::from_slice::<StepCanvasRequest>(payload) {
Ok(req) => Ok(Inbound::StepCanvasRequest(req)),
Err(e) => {
let dump_len = payload.len().min(256);
tracing::error!(
@@ -212,6 +231,9 @@ pub trait SimBridge: Send + Sync {
/// Send a browse response to the client (T-1131).
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError>;
/// Send a step-canvas response to the client (T-1181, D-255(c)).
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> Result<(), BridgeError>;
}
/// Identifies one connection for response-tagging and role-lookup purposes
@@ -477,6 +499,27 @@ impl BridgeResource {
}
}
}
/// Send a step-canvas response to exactly the connection that requested
/// it (T-1181 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names/browse).
pub fn send_step_canvas_response_to(
&self,
id: ConnectionId,
resp: &StepCanvasResponse,
) -> Result<(), BridgeError> {
match self.connection(id) {
Some(c) => c.bridge.send_step_canvas_response(resp),
None => {
tracing::debug!(
"step canvas response for {} dropped — connection {:?} no longer present",
resp.body_id,
id
);
Ok(())
}
}
}
}
/// Tracks whether the protocol handshake has been sent (#555).
@@ -529,6 +572,7 @@ pub fn receive_bridge_inputs(
mut star_map_requests: ResMut<StarMapRequestBuffer>,
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
mut browse_requests: ResMut<BrowseRequestBuffer>,
mut step_canvas_requests: ResMut<StepCanvasRequestBuffer>,
time: Option<Res<crate::simulation::time::SimulationTime>>,
) {
let Some(mut bridge) = bridge else { return };
@@ -574,6 +618,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::BrowseRequest(req))) => {
browse_requests.0.push((player_id, req));
}
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_requests.0.push((player_id, req));
}
// No complete frame ready — the backlog is drained.
Ok(None) => break,
Err(BridgeError::Disconnected) => {
@@ -688,6 +735,9 @@ pub fn receive_bridge_inputs(
Ok(Some(Inbound::BrowseRequest(req))) => {
browse_requests.0.push((reader_id, req));
}
Ok(Some(Inbound::StepCanvasRequest(req))) => {
step_canvas_requests.0.push((reader_id, req));
}
Ok(None) => break,
Err(BridgeError::Disconnected) => {
tracing::info!("Reader connection {:?} disconnected", reader_id);
@@ -932,6 +982,37 @@ pub fn send_browse_responses(
}
}
/// Inbound step-canvas requests routed off the bridge (T-1181), drained by
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
#[derive(Resource, Default)]
pub struct StepCanvasRequestBuffer(pub Vec<(ConnectionId, StepCanvasRequest)>);
/// Outbound step-canvas responses, filled by the proxy serve system and
/// flushed to the client in `PostSnapshot` (T-1181). Connection-tagged
/// (D-254 §2).
#[derive(Resource, Default)]
pub struct StepCanvasResponseBuffer(pub Vec<(ConnectionId, StepCanvasResponse)>);
/// Flush buffered step-canvas responses to their requesting connections
/// (T-1181 — same per-connection routing D-254 §2 established for
/// atlas/star-map/city-names/browse). A failed send is logged but not fatal.
pub fn send_step_canvas_responses(
bridge: Option<Res<BridgeResource>>,
mut buffer: ResMut<StepCanvasResponseBuffer>,
) {
let Some(bridge) = bridge else { return };
for (id, resp) in buffer.0.drain(..) {
if let Err(e) = bridge.send_step_canvas_response_to(id, &resp) {
tracing::warn!(
"failed to send step canvas response for {} to {:?}: {}",
resp.body_id,
id,
e
);
}
}
}
/// Holds the server's TCP listener for accepting connections AFTER the
/// first Player connection (D-254 §2, T-1130).
///
@@ -1091,6 +1172,8 @@ impl Plugin for BridgePlugin {
.init_resource::<CityNamesResponseBuffer>()
.init_resource::<BrowseRequestBuffer>()
.init_resource::<BrowseResponseBuffer>()
.init_resource::<StepCanvasRequestBuffer>()
.init_resource::<StepCanvasResponseBuffer>()
.init_resource::<ConnectionListener>()
.init_resource::<PendingConnections>()
// Multi-connection accept-loop (D-254 §2, T-1130) — must run
@@ -1119,6 +1202,10 @@ impl Plugin for BridgePlugin {
Update,
send_browse_responses.in_set(TickPhase::PostSnapshot),
)
.add_systems(
Update,
send_step_canvas_responses.in_set(TickPhase::PostSnapshot),
)
// Debug commands — Snapshot phase
.add_systems(
Update,
+15
View File
@@ -7,6 +7,7 @@ use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
use crate::atlas::browse_proxy::BrowseResponse;
use crate::atlas::layer_proxy::AtlasLayerResponse;
use crate::atlas::step_canvas::StepCanvasResponse;
use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator};
use std::io::BufWriter;
use std::net::{SocketAddr, TcpListener, TcpStream};
@@ -328,6 +329,20 @@ impl SimBridge for TcpBridge {
result?;
Ok(())
}
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(resp)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
let stream = writer.get_mut();
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
let result = write_framed(stream, &payload);
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
result?;
Ok(())
}
}
/// A connection that has been TCP-accepted but has not yet completed the
+5 -1
View File
@@ -343,6 +343,7 @@ fn single_tick_drains_all_ready_inbound_frames() {
use settled_reach_server::bridge::{
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, BrowseRequestBuffer,
CityNamesRequestBuffer, HandshakeState, ServerRunning, StarMapRequestBuffer,
StepCanvasRequestBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -404,6 +405,7 @@ fn single_tick_drains_all_ready_inbound_frames() {
world.init_resource::<StarMapRequestBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world.init_resource::<StepCanvasRequestBuffer>();
world
.run_system_once(receive_bridge_inputs)
@@ -501,7 +503,7 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, BrowseRequestBuffer,
BrowseResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, ConnectionListener,
HandshakeState, PendingConnections, ServerRunning, SnapshotBuffer, StarMapRequestBuffer,
StarMapResponseBuffer,
StarMapResponseBuffer, StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -527,6 +529,8 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr
world.init_resource::<CityNamesResponseBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world.init_resource::<BrowseResponseBuffer>();
world.init_resource::<StepCanvasRequestBuffer>();
world.init_resource::<StepCanvasResponseBuffer>();
world.init_resource::<SnapshotBuffer>();
(world, addr)
}
+401
View File
@@ -0,0 +1,401 @@
//! 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",
&params_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",
&params_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",
&params,
&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",
&params,
&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();
assert!(cache.get("gate-body").is_none());
cache.insert("gate-body".to_string(), fresh_encoded.clone());
assert!(cache.contains("gate-body"));
let cached = cache
.get("gate-body")
.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",
&params2,
&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", &params, &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", &params2, &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",
&params,
&ta,
&rn,
&[],
rung,
(0, 0),
GATE_EXTENT,
&climate,
0,
);
let canvas_b = build_step_canvas(
seed,
"gate-body",
&params,
&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)"
);
}
}