diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 8137afc9b..3a706fbfe 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -432,12 +432,24 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void ## not a specific whole-body layer to be cached first). Omitted by every ## whole-body-layer caller (show_body()'s existing request), so their wire ## traffic is byte-unchanged. +## +## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the +## zoom-ladder quarter rung — district (0/omitted) stays the default for +## every caller in this codebase today; requesting quarter granularity is +## T-1153's job, not wired here. func request_atlas_layers( - body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0 + body_id: String, + up_to: String = "Topography", + window_center: Variant = null, + window_n: int = 0, + window_granularity: int = 0, + window_min_wl_m: int = 0 ) -> void: if test_mode or _bridge == null or state != ConnectionState.CONNECTED: return - var bytes := Protocol.encode_atlas_layer_request(body_id, up_to, window_center, window_n) + var bytes := Protocol.encode_atlas_layer_request( + body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m + ) if bytes.is_empty(): return var err: int = _bridge.send_message(bytes) diff --git a/client/scripts/protocol/atlas_map_protocol.gd b/client/scripts/protocol/atlas_map_protocol.gd index bbda6245a..c38818582 100644 --- a/client/scripts/protocol/atlas_map_protocol.gd +++ b/client/scripts/protocol/atlas_map_protocol.gd @@ -31,18 +31,32 @@ class_name AtlasMapProtocol ## [1, DISTRICT_WINDOW_MAX_N] itself and never trusts the wire value; the ## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on ## the regional-window viewer, not duplicated into the codec. +## +## `window_granularity`/`window_min_wl_m` (T-1150): the derivation-granularity +## axis (district=1/omitted vs. quarter=4) and the octave cutoff, in whole +## metres. Both OMITTED (not sent as 0) when at their default — this is +## struct/key plumbing only (T-1150 scope): no caller in this codebase +## requests quarter granularity yet (that's T-1153); this function just makes +## it possible to ask, byte-compatible with every existing caller that +## doesn't pass them. static func encode_atlas_layer_request( mp, body_id: String, up_to: String = "Topography", window_center: Variant = null, - window_n: int = 0 + window_n: int = 0, + window_granularity: int = 0, + window_min_wl_m: int = 0 ) -> PackedByteArray: var msg := {"body_id": body_id, "up_to": up_to} if window_center != null: var center: Vector2i = window_center msg["window_center"] = [center.x, center.y] msg["window_n"] = window_n + if window_granularity != 0: + msg["window_granularity"] = window_granularity + if window_min_wl_m != 0: + msg["window_min_wl_m"] = window_min_wl_m var result = mp.encode(msg) if result.status != null: push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index c9989a350..9a089ee9f 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -780,14 +780,19 @@ static func encode_request_bookmark_catalog() -> PackedByteArray: ## windowed district-resolution regional-map query — see ## atlas_map_protocol.gd's encode_atlas_layer_request doc for the wire shape. ## Omitted callers (every whole-body-layer call site predating T-1138) are -## byte-unchanged. +## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same +## byte-compatibility contract, see atlas_map_protocol.gd. static func encode_atlas_layer_request( body_id: String, up_to: String = "Topography", window_center: Variant = null, - window_n: int = 0 + window_n: int = 0, + window_granularity: int = 0, + window_min_wl_m: int = 0 ) -> PackedByteArray: - return _amp().encode_atlas_layer_request(_mp(), body_id, up_to, window_center, window_n) + return _amp().encode_atlas_layer_request( + _mp(), body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m + ) ## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary diff --git a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack index ccd64e073..32c85b742 100644 Binary files a/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack and b/client/tests/fixtures/msgpack/atlas_response_ready_with_window.msgpack differ diff --git a/client/tests/test_atlas_data_delivery.gd b/client/tests/test_atlas_data_delivery.gd index 7c84fa5aa..1fa7aef11 100644 --- a/client/tests/test_atlas_data_delivery.gd +++ b/client/tests/test_atlas_data_delivery.gd @@ -214,6 +214,32 @@ func test_encode_atlas_layer_request_carries_window_params() -> void: assert_that(decoded.value.get("window_n")).is_equal(32) +## T-1150: window_granularity/window_min_wl_m are OMITTED (not sent as 0) +## when at their default — a windowed request that doesn't pass them (every +## pre-T-1150 window caller) is byte-identical to pre-T-1150 wire traffic, +## same contract as window_center/window_n's own default-omission above. +func test_encode_atlas_layer_request_omits_granularity_and_min_wl_by_default() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(140, 260), 32 + ) + var decoded = Messagepack.decode(bytes) + assert_bool(decoded.value.has("window_granularity")).is_false() + assert_bool(decoded.value.has("window_min_wl_m")).is_false() + + +## T-1150: a quarter-granularity request with an octave cutoff carries both +## new fields verbatim, unclamped (the server owns +## resolve_window_granularity()/clamp_window_n() — never trusted from the +## wire, same posture as window_n). +func test_encode_atlas_layer_request_carries_granularity_and_min_wl() -> void: + var bytes := Protocol.encode_atlas_layer_request( + "GJ1c", "Topography", Vector2i(140, 260), 32, 4, 512 + ) + var decoded = Messagepack.decode(bytes) + assert_that(decoded.value.get("window_granularity")).is_equal(4) + assert_that(decoded.value.get("window_min_wl_m")).is_equal(512) + + ## §2: district_window is a distinct payload (echoes center/n for the ## client's staleness guard) but the codec passthrough is the same shape as ## every sibling layer — raw.get(), no reshaping. Field types follow the @@ -224,6 +250,8 @@ func test_atlas_response_district_window_passthrough() -> void: var window := { "center": [140, 260], "n": 32, + "granularity": 4, # T-1150: quarter granularity, passed through same as every other field + "min_wl_m": 512, "morphology": PackedByteArray([8, 14, 0, 5]), "elev_q": PackedByteArray([40, 62, 5, 88]), "temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60], diff --git a/client/tests/test_atlas_window_cache.gd b/client/tests/test_atlas_window_cache.gd index 1471f7823..3fdc61d02 100644 --- a/client/tests/test_atlas_window_cache.gd +++ b/client/tests/test_atlas_window_cache.gd @@ -119,3 +119,64 @@ func test_clear_empties_the_cache() -> void: cache.clear() assert_int(cache.size()).is_equal(0) assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false() + + +# ============================================================================= +# granularity / min_wl_m (T-1150, zoom ladder design doc §3 aliasing risk) +# ============================================================================= + + +## **MANDATORY aliasing regression (client half, T-1150):** a granularity-4 +## (quarter) key and a granularity-1 (district) key at the IDENTICAL +## (body_id, center, n) must be DISTINCT cache keys — this is what prevents a +## quarter-spacing request from silently reading (or overwriting) a +## district-spacing window's cache entry, and vice versa. +func test_make_key_distinguishes_granularity_at_identical_body_center_n() -> void: + var k_district := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY + ) + var k_quarter := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 4) + assert_str(k_district).is_not_equal(k_quarter) + + +## Same aliasing risk, the other new axis: two requests identical except for +## `min_wl_m` (the octave cutoff) must not collide either — different cutoffs +## are different derived payloads (T-1149/T-1150). +func test_make_key_distinguishes_min_wl_m_at_identical_body_center_n_granularity() -> void: + var k_uncut := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0 + ) + var k_cut := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 512 + ) + assert_str(k_uncut).is_not_equal(k_cut) + + +## Omitting granularity/min_wl_m (every pre-T-1150 call site) must produce the +## SAME key as passing the explicit district/no-cutoff defaults — byte/string +## compatibility for existing callers, not just "doesn't crash". +func test_omitted_granularity_and_min_wl_m_match_explicit_district_defaults() -> void: + var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32) + var k_explicit := AtlasWindowCache.make_key( + "GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0 + ) + assert_str(k_omitted).is_equal(k_explicit) + + +## End-to-end through put()/get_window()/has() (not just make_key() in +## isolation): a quarter-granularity window and a district-granularity window +## at the identical (body, center, n) must both be independently retrievable, +## neither one clobbering or masking the other. +func test_district_and_quarter_windows_coexist_at_identical_body_center_n() -> void: + var cache := AtlasWindowCache.new() + var district_window := {"granularity": AtlasWindowCache.DISTRICT_GRANULARITY, "id": "district"} + var quarter_window := {"granularity": 4, "id": "quarter"} + + cache.put("GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY) + cache.put("GJ1c", Vector2i(10, 20), 32, quarter_window, 4) + + assert_int(cache.size()).is_equal(2) + assert_that( + cache.get_window("GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY) + ).is_equal(district_window) + assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32, 4)).is_equal(quarter_window) diff --git a/client/ui/implant/apps/atlas/atlas_window_cache.gd b/client/ui/implant/apps/atlas/atlas_window_cache.gd index 0d36ec498..dfdec3785 100644 --- a/client/ui/implant/apps/atlas/atlas_window_cache.gd +++ b/client/ui/implant/apps/atlas/atlas_window_cache.gd @@ -1,13 +1,22 @@ extends RefCounted ## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226 -## T-1124 amendment §4 "Client cache policy"). +## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the +## granularity/min_wl axes). ## -## Keyed on (body_id, center, n) — D-227's determinism guarantee (same seed + -## body + position -> same derived output, always) means a previously-fetched -## window is valid FOREVER for that body+seed. This is an LRU-evict-only -## cache: no freshness check, no TTL, no invalidation path at all. The only -## reason an entry ever leaves is capacity pressure. +## Keyed on (body_id, center, n, granularity, min_wl_m) — D-227's determinism +## guarantee (same seed + body + position + derivation params -> same derived +## output, always) means a previously-fetched window is valid FOREVER for +## that body+seed. This is an LRU-evict-only cache: no freshness check, no +## TTL, no invalidation path at all. The only reason an entry ever leaves is +## capacity pressure. +## +## granularity/min_wl_m default to DISTRICT_GRANULARITY/0 (district spacing, +## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps +## its existing key shape and cache behavior unchanged. This is the client +## half of the mandatory aliasing fix (T-1150 design doc §3): a +## quarter-granularity request and a district-granularity request at the +## identical (body, center, n) MUST NOT collide on the same cache slot. ## ## Godot's Dictionary preserves insertion order, so "move to the end on ## touch, evict from the front on overflow" is the whole LRU implementation — @@ -20,6 +29,10 @@ extends RefCounted const DEFAULT_MAX_ENTRIES: int = 24 +## Mirrors the server's WINDOW_GRANULARITY_DISTRICT (layer_proxy.rs) — the +## default granularity every pre-T-1150 caller implicitly requests. +const DISTRICT_GRANULARITY: int = 1 + var _max_entries: int = DEFAULT_MAX_ENTRIES var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary @@ -28,19 +41,34 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void: _max_entries = maxi(1, max_entries) -## Build the cache key from the three fields D-227 makes sufficient: -## body_id (which world+body), center (a [row, col] pair or Vector2i), and n -## (window side length). String-keyed rather than a nested Dictionary/Array -## key — Godot Dictionary keys compare by value for primitives but a -## consistent stringification sidesteps any Vector2i-vs-Array identity -## mismatch between what a caller happens to hand in. -static func make_key(body_id: String, center: Vector2i, n: int) -> String: - return "%s:%d,%d:%d" % [body_id, center.x, center.y, n] +## Build the cache key from the five fields D-227 + T-1150 make sufficient: +## body_id (which world+body), center (a [row, col] pair or Vector2i), n +## (window extent in districts), granularity (district=1 / quarter=4), and +## min_wl_m (the octave cutoff, 0 = none). String-keyed rather than a nested +## Dictionary/Array key — Godot Dictionary keys compare by value for +## primitives but a consistent stringification sidesteps any +## Vector2i-vs-Array identity mismatch between what a caller happens to hand +## in. +static func make_key( + body_id: String, + center: Vector2i, + n: int, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> String: + return "%s:%d,%d:%d:%d:%d" % [body_id, center.x, center.y, n, granularity, min_wl_m] -## True if a window is already cached for this exact (body, center, n). -func has(body_id: String, center: Vector2i, n: int) -> bool: - return _entries.has(make_key(body_id, center, n)) +## True if a window is already cached for this exact (body, center, n, +## granularity, min_wl_m). +func has( + body_id: String, + center: Vector2i, + n: int, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> bool: + return _entries.has(make_key(body_id, center, n, granularity, min_wl_m)) ## Fetch a cached window, touching it (move-to-most-recently-used). Returns @@ -48,8 +76,14 @@ func has(body_id: String, center: Vector2i, n: int) -> bool: ## response, which is a different concept (§1: an as-yet-underived window is ## carried as `district_window: None` inside a `Ready` AtlasLayerResponse, ## not a cache state). -func get_window(body_id: String, center: Vector2i, n: int) -> Variant: - var key := make_key(body_id, center, n) +func get_window( + body_id: String, + center: Vector2i, + n: int, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> Variant: + var key := make_key(body_id, center, n, granularity, min_wl_m) if not _entries.has(key): return null var value: Variant = _entries[key] @@ -61,8 +95,15 @@ func get_window(body_id: String, center: Vector2i, n: int) -> Variant: ## Store a window, evicting the least-recently-used entry(ies) if over ## capacity. Overwriting an existing key also counts as a touch. -func put(body_id: String, center: Vector2i, n: int, window: Dictionary) -> void: - var key := make_key(body_id, center, n) +func put( + body_id: String, + center: Vector2i, + n: int, + window: Dictionary, + granularity: int = DISTRICT_GRANULARITY, + min_wl_m: int = 0 +) -> void: + var key := make_key(body_id, center, n, granularity, min_wl_m) if _entries.has(key): _entries.erase(key) _entries[key] = window diff --git a/client/ui/implant/apps/atlas/atlas_window_request.gd b/client/ui/implant/apps/atlas/atlas_window_request.gd index c37ef8002..28ea6ed88 100644 --- a/client/ui/implant/apps/atlas/atlas_window_request.gd +++ b/client/ui/implant/apps/atlas/atlas_window_request.gd @@ -41,11 +41,20 @@ const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5 const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES +## T-1150 struct/key plumbing: this viewer only ever REQUESTS district +## granularity today (requesting quarter is T-1153's job) — these constants +## exist so the cache key / staleness guard below are granularity-aware from +## day one, not bolted on later. +const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY +const DEFAULT_MIN_WL_M: int = 0 + var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref) var _cache = null # AtlasWindowCache var _body_id: String = "" var _center: Vector2i = Vector2i.ZERO var _n: int = DISTRICT_WINDOW_DEFAULT_N +var _granularity: int = DEFAULT_GRANULARITY +var _min_wl_m: int = DEFAULT_MIN_WL_M var _pending: bool = false var _retries: int = 0 var _debounce_timer: Timer = null @@ -89,9 +98,11 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF _body_id = body_id _center = center _n = n + _granularity = DEFAULT_GRANULARITY + _min_wl_m = DEFAULT_MIN_WL_M _debounce_timer.stop() # a direct request supersedes any pending debounced one - var cached: Variant = _cache.get_window(body_id, center, n) + var cached: Variant = _cache.get_window(body_id, center, n, _granularity, _min_wl_m) if cached != null: _pending = false _retries = 0 @@ -100,7 +111,7 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF _pending = true _retries = 0 - SimBridge.request_atlas_layers(body_id, "Topography", center, n) + SimBridge.request_atlas_layers(body_id, "Topography", center, n, _granularity, _min_wl_m) ## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not @@ -112,6 +123,8 @@ func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WIND _body_id = body_id _center = center _n = n + _granularity = DEFAULT_GRANULARITY + _min_wl_m = DEFAULT_MIN_WL_M _debounce_timer.start() @@ -122,10 +135,11 @@ func _on_debounce_timeout() -> void: ## Handle an AtlasLayerResponse (routed by the owning viewer from its own ## SimBridge.atlas_layers_received subscription — this object has no signal ## connection of its own, matching atlas_generation_proxy.gd's on_response() -## shape). Ignores responses for a stale body/center/n (the player panned or -## navigated away while a request was in flight) — the echoed center/n IS the -## staleness guard (§2), compared here against what THIS object most recently -## asked for. +## shape). Ignores responses for a stale body/center/n/granularity/min_wl_m +## (the player panned or navigated away while a request was in flight, or a +## different rung's derive answers a request for a different rung, T-1150) — +## the echoed fields ARE the staleness guard (§2, extended T-1150), compared +## here against what THIS object most recently asked for. func on_response(response: Dictionary) -> void: if str(response.get("body_id", "")) != _body_id: return @@ -150,12 +164,19 @@ func on_response(response: Dictionary) -> void: var w: Dictionary = window var echoed_center := _vec_from_center(w.get("center", [0, 0])) var echoed_n := int(w.get("n", 0)) - if echoed_center != _center or echoed_n != _n: - return # stale — answers a window we've since panned away from (§2) + var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY)) + var echoed_min_wl_m := int(w.get("min_wl_m", 0)) + if ( + echoed_center != _center + or echoed_n != _n + or echoed_granularity != _granularity + or echoed_min_wl_m != _min_wl_m + ): + return # stale — answers a window we've since panned away from, or a different rung (§2/T-1150) _pending = false _retries = 0 - _cache.put(_body_id, _center, _n, w) + _cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m) window_ready.emit(w) @@ -164,7 +185,9 @@ func _schedule_retry() -> void: timer.timeout.connect( func() -> void: if _pending: - SimBridge.request_atlas_layers(_body_id, "Topography", _center, _n) + SimBridge.request_atlas_layers( + _body_id, "Topography", _center, _n, _granularity, _min_wl_m + ) ) diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 79b88fefe..ea26c32f8 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -203,10 +203,17 @@ pub enum GenWorkItem { /// reason `AnalyzeBody.body_params` is boxed. body_params: Box, /// Window centre + side length in districts. `n` is ALREADY clamped to - /// `[1, DISTRICT_WINDOW_MAX_N]` by the caller (`handle_atlas_request`) + /// `[1, DISTRICT_WINDOW_MAX_N]` AND the granularity-aware + /// `WIRE_CAP_CELLS` ceiling by the caller (`handle_atlas_request`) /// before this item is built — never trusted from the wire again here. center: DistrictPos, n: u32, + /// Derivation granularity (T-1150) — `WINDOW_GRANULARITY_DISTRICT` (1) + /// or `WINDOW_GRANULARITY_QUARTER` (4). Already resolved via + /// `resolve_window_granularity` by the caller. + granularity: u32, + /// Octave cutoff in whole metres (T-1149/T-1150), `0` = no cutoff. + min_wl_m: u32, }, } @@ -218,14 +225,23 @@ impl GenWorkItem { } } - /// Coalescing key for `DeriveWindow` items only — `(connection, body)`. + /// Coalescing key for `DeriveWindow` items only — `(connection, body, + /// granularity)` (T-1150, design doc §3 [SOFT] recommendation, extending + /// T-1137's `(connection, body)`). `granularity` is part of the key so an + /// in-flight district-spacing (granularity 1) pan-burst is never + /// superseded by an unrelated quarter-spacing (granularity 4) request for + /// the same connection+body, and vice versa — the two rungs are separate + /// in-flight derives, not competing updates to the same one. /// `None` for every other variant (they don't coalesce this way). - pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str)> { + pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, u32)> { if let GenWorkItem::DeriveWindow { - body_id, conn_id, .. + body_id, + conn_id, + granularity, + .. } = self { - Some((*conn_id, body_id)) + Some((*conn_id, body_id, *granularity)) } else { None } @@ -403,11 +419,15 @@ impl GenerationQueue { } /// Submit a `DeriveWindow` item with per-connection coalescing (D-226 - /// T-1124 amendment §1, "recommended"): if a `DeriveWindow` item for the - /// SAME `(connection, body)` is still sitting in the pending queue - /// (not yet dispatched to a Rayon worker), it is replaced in place by the - /// new one — a pan-burst that queues several window requests for the same - /// connection+body before the first is dispatched collapses to one derive. + /// T-1124 amendment §1, "recommended"; extended T-1150 to key on + /// granularity too): if a `DeriveWindow` item for the SAME `(connection, + /// body, granularity)` is still sitting in the pending queue (not yet + /// dispatched to a Rayon worker), it is replaced in place by the new one + /// — a pan-burst that queues several window requests for the same + /// connection+body+granularity before the first is dispatched collapses + /// to one derive. A district-spacing and quarter-spacing request for the + /// same connection+body do NOT coalesce with each other — they're + /// separate in-flight derives, not competing updates to the same rung. /// /// Deliberately does **not** attempt to cancel an item already dispatched /// to a Rayon worker (no cancellation channel exists, and the amendment @@ -419,12 +439,12 @@ impl GenerationQueue { /// `window_supersede_key()` returns `None`). pub fn submit_window(&self, item: GenWorkItem, priority: GenPriority) { if let Some(key) = item.window_supersede_key() { - let key = (key.0, key.1.to_string()); + let key = (key.0, key.1.to_string(), key.2); let mut pending = self.pending.lock().unwrap(); pending.retain(|q| { q.item .window_supersede_key() - .map(|k| (k.0, k.1.to_string()) != key) + .map(|k| (k.0, k.1.to_string(), k.2) != key) .unwrap_or(true) }); let pos = pending @@ -747,6 +767,8 @@ fn run_work_item( body_params, center, n, + granularity, + min_wl_m, } => match load_heightmap_png(heightmap_path, body_id, *sea_level) { Ok(hm) => { // Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the @@ -780,6 +802,8 @@ fn run_work_item( *center, *n, &climate, + *granularity, + *min_wl_m, ); GenCompletion::WindowDerived { body_id: body_id.clone(), @@ -1137,6 +1161,8 @@ mod tests { }), center, n: 4, + granularity: crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, } } diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index 5735a1301..5281ee990 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -39,6 +39,53 @@ const DEFAULT_SEA_LEVEL: f32 = 0.3; /// caller clamps to `[1, DISTRICT_WINDOW_MAX_N]` before deriving. pub const DISTRICT_WINDOW_MAX_N: u32 = 64; +/// [`AtlasLayerRequest::window_granularity`] encoding (T-1150, zoom ladder +/// design doc §3/§5): the number of derived cells per district side. `0` on +/// the wire (the `#[serde(default)]` absent case) and `1` both mean district +/// spacing (2,048 m/cell, [`DISTRICT_WINDOW_MAX_N`]'s existing behavior, +/// byte-compatible with every pre-T-1150 caller). `4` means quarter spacing +/// (512 m/cell, D-243) — Option B from the design doc: full reclassification +/// at the finer spacing via `derive_at_metres`, not a coarser-cell +/// interpolation. No other values are legal; `resolve_window_granularity` +/// clamps unrecognized values down to district (never trust the wire, same +/// discipline as `window_n`). +pub const WINDOW_GRANULARITY_DISTRICT: u32 = 1; +pub const WINDOW_GRANULARITY_QUARTER: u32 = 4; + +/// Server-side wire-size ceiling (T-1150, design doc §3 "Cell-count cap"): +/// `window_n² × granularity² ≤ WIRE_CAP_CELLS`. At `WIRE_CAP_CELLS = 4,096`, +/// district `n=64` (the existing [`DISTRICT_WINDOW_MAX_N`] cap) sits exactly +/// at the ceiling (64² × 1² = 4,096), and quarter mode is clamped to +/// `n=16` districts across (16² × 4² = 4,096 — matching the design doc's +/// worked example, "Quarter, capped to same cell budget (n≈16 districts +/// across)") — this is the "extent shrinks as granularity refines, payload +/// stays ~constant" rule the design doc requires, enforced here (never +/// trusted from the wire) rather than merely asserted. +pub const WIRE_CAP_CELLS: u32 = 4_096; + +/// Resolve a wire-supplied `window_granularity` value to one of the two legal +/// granularities, clamping anything else down to district spacing — **never +/// trust the wire** (same posture as `window_n`/`normalize_window_center`). +fn resolve_window_granularity(raw: u32) -> u32 { + if raw == WINDOW_GRANULARITY_QUARTER { + WINDOW_GRANULARITY_QUARTER + } else { + WINDOW_GRANULARITY_DISTRICT + } +} + +/// Clamp `window_n` against BOTH the existing per-axis cap +/// ([`DISTRICT_WINDOW_MAX_N`]) and the granularity-aware wire-size ceiling +/// ([`WIRE_CAP_CELLS`]) — `window_n² × granularity² ≤ WIRE_CAP_CELLS` (T-1150 +/// design doc §3). Applied AFTER the per-axis clamp so a request that already +/// satisfies `DISTRICT_WINDOW_MAX_N` still shrinks further at granularity 4. +fn clamp_window_n(raw_n: u32, granularity: u32) -> u32 { + let n = raw_n.clamp(1, DISTRICT_WINDOW_MAX_N); + let g = granularity.max(1); + let cap_n = (WIRE_CAP_CELLS as f64).sqrt() / g as f64; + n.min(cap_n.floor().max(1.0) as u32) +} + /// A client request for a body's generation layers (D-225), extended with an /// optional district-resolution window query (D-226 T-1124 amendment §1, T-1137). /// @@ -61,6 +108,24 @@ pub struct AtlasLayerRequest { /// from the wire** (D-226 T-1124 amendment §4). #[serde(default)] pub window_n: u32, + /// Window derivation granularity (T-1150, zoom ladder design doc §3/§5). + /// `0` (absent, `#[serde(default)]`) or `1` = district spacing (2,048 m, + /// today's behavior, byte-compatible with every pre-T-1150 caller); `4` = + /// quarter spacing (512 m, D-243). See [`WINDOW_GRANULARITY_DISTRICT`] / + /// [`WINDOW_GRANULARITY_QUARTER`]. Resolved via + /// [`resolve_window_granularity`] — **never trusted from the wire**, + /// unrecognized values fall back to district. + #[serde(default)] + pub window_granularity: u32, + /// Octave cutoff for the invented-terrain scatter (T-1149's + /// `min_wavelength_m`), in whole metres. `0` (absent) = no cutoff = the + /// pre-T-1150 behavior. Threaded straight to `derive_at_metres` as + /// `min_wl as f64` — no client-side quantization band is enforced here + /// (the design doc's §5 quantized-band gap-fix is a client-request-shaping + /// concern; the server takes whatever whole-metre value it's given and + /// keys the cache on it verbatim, same posture as `window_n`). + #[serde(default)] + pub window_min_wl_m: u32, } /// Status of a layer response (D-225). @@ -268,23 +333,40 @@ pub fn build_region_grid( // --------------------------------------------------------------------------- /// The requested district window: an `n × n` grid of TRUE 2 km districts -/// centred on `center`, derived on-demand via `district_profile::derive_district` -/// (D-226 T-1124 amendment §2). **Echoes `center`/`n` back** — this is the -/// client's race-condition guard, not a convenience field: because -/// `derive_district` is pure and deterministic (D-227), the same `(center, n)` -/// query always yields the same payload, so the echoed tuple *is* the -/// cache/staleness key the client compares against its most recently requested -/// window (`body_id` disambiguation rides the enclosing `AtlasLayerResponse`, -/// not the echo — see the amendment). +/// (or, at `granularity = 4`, an effective `(4n) × (4n)` grid of 512 m +/// quarters covering the SAME world extent — see `granularity` doc below), +/// derived on-demand via `district_profile::derive_district`/`derive_at_metres` +/// (D-226 T-1124 amendment §2, T-1150). **Echoes `center`/`n`/`granularity` +/// back** — this is the client's race-condition guard, not a convenience +/// field: because the derivation is pure and deterministic (D-227), the same +/// `(center, n, granularity, min_wl_m)` query always yields the same payload, +/// so the echoed tuple *is* the cache/staleness key the client compares +/// against its most recently requested window (`body_id` disambiguation +/// rides the enclosing `AtlasLayerResponse`, not the echo — see the +/// amendment). /// -/// All six arrays are dense row-major `n × n` (`i = row * n + col`), matching -/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell wire -/// cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead (D-226 -/// T-1124 amendment §4). +/// All six arrays are dense row-major (`i = row * side + col`, where `side` +/// is `n` at district granularity or `4n` at quarter granularity), matching +/// the `DistrictGridLayer`/`RegionGridLayer` indexing convention. Per-cell +/// wire cost is 7 bytes (1+1+2+1+1+1) before MessagePack framing overhead +/// (D-226 T-1124 amendment §4). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DistrictWindowLayer { pub center: DistrictPos, + /// Window extent in DISTRICTS — this does NOT change with granularity + /// (T-1150 design doc §2: "the window's `n` stays the DISTRICT extent"). + /// The derived cell grid's actual side length is `n * granularity`. pub n: u32, + /// Derivation granularity (T-1150): [`WINDOW_GRANULARITY_DISTRICT`] (1) + /// or [`WINDOW_GRANULARITY_QUARTER`] (4). Echoed so the client's cache + /// key and staleness guard can distinguish a district-spacing window from + /// a quarter-spacing window requested at the identical `(center, n)`. + pub granularity: u32, + /// The `min_wavelength_m` octave cutoff (T-1149) this window was derived + /// with, in whole metres (`0` = no cutoff). Echoed for the same reason as + /// `granularity` — two windows at identical `(center, n, granularity)` + /// but different cutoffs are NOT the same payload and must not alias. + pub min_wl_m: u32, /// `MorphologyZone` discriminant, the frozen 17-zone vocabulary (D-239 §6). pub morphology: Vec, /// 0-100, matches `DistrictGridLayer.elev_q` encoding. @@ -305,11 +387,16 @@ pub struct DistrictWindowLayer { pub glaciation: Vec, } -/// Key for the server-side window derive cache (T-1137): `(body_id, center, n)`. -/// D-227 purity means a cached window is valid forever for a given body+seed — -/// no staleness/TTL invalidation is needed, only a bound on unbounded growth -/// (see [`DistrictWindowCache`]). -pub type DistrictWindowKey = (String, DistrictPos, u32); +/// Key for the server-side window derive cache (T-1137, extended T-1150): +/// `(body_id, center, n, granularity, min_wl_m)`. D-227 purity means a cached +/// window is valid forever for a given body+seed — no staleness/TTL +/// invalidation is needed, only a bound on unbounded growth (see +/// [`DistrictWindowCache`]). `granularity`/`min_wl_m` MUST be part of the key +/// — the design doc's aliasing risk (§3): a granularity-4 request at the same +/// `(body, center, n)` as a granularity-1 request is a DIFFERENT payload and +/// must land in a different cache slot, never silently overwrite or be served +/// by the other. +pub type DistrictWindowKey = (String, DistrictPos, u32, u32, u32); /// Bounded LRU-ish cache of completed district-window derives (T-1137), a /// sibling to [`BodyWorldStateCache`] rather than a field on it: windows are @@ -375,16 +462,150 @@ impl DistrictWindowCache { } } -/// Build a [`DistrictWindowLayer`] by deriving every district in the -/// `n × n` window around `center` (T-1137). Mirrors +/// One derived cell's packed wire fields — the per-cell output of the window +/// loop body, shared between the serial and parallel builders (T-1151) so the +/// packing logic can never drift between them. +struct WindowCell { + morphology: u8, + elev_q: u8, + temp_dc: i16, + moisture_q: u8, + vegetation: u8, + glaciation: u8, +} + +/// Derive one window cell at `(row, col)` and pack its wire fields. Pure +/// (D-227) — the whole reason row-chunked `par_iter` (T-1151) is safe: every +/// cell is an independent function of its own world-metre position, nothing +/// shared mutably. +/// +/// `step_m` is the metre spacing between cells (T-1150): `DISTRICT_M` at +/// district granularity, `QUARTER_M` at quarter granularity — the caller +/// picks it, this function is granularity-agnostic (it only knows metres). +/// `half_cells` is HALF the cell-grid side (`side / 2`, already in the +/// caller's cell units, not districts), so `center` (a `DistrictPos`, always +/// district-scale) is converted to a world-metre origin once by the caller +/// and offset here in `step_m` units — this is what makes the quarter grid +/// cover the SAME world rect as the district grid at 4x the cell density +/// (design doc §2 Option B). +#[allow(clippy::too_many_arguments)] +fn derive_window_cell( + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + climate: &crate::atlas::district_profile::ClimateConstants, + center_world_m: (f64, f64), + half_cells: i32, + step_m: f64, + min_wavelength_m: f64, + row: i32, + col: i32, +) -> WindowCell { + // Row 0 = northmost, matching aliveness_probe's render_window_panels + // (derive_at_metres maps negative wy to negative lat_frac = north). + let wx = center_world_m.0 + (col - half_cells) as f64 * step_m; + let wy = center_world_m.1 + (row - half_cells) as f64 * step_m; + let prof = crate::atlas::district_profile::derive_at_metres( + seed, + body_id, + params, + ta, + wx, + wy, + climate, + min_wavelength_m, + ); + WindowCell { + morphology: prof.morphology_zone as u8, + elev_q: prof.elev_q.clamp(0, 100) as u8, + temp_dc: match prof.temperature_c { + Some(t) => ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16, + None => REGION_TEMP_NONE_DC, + }, + moisture_q: prof.moisture_q.clamp(0, 100) as u8, + vegetation: prof.vegetation_class as u8, + glaciation: prof.glaciation_grade as u8, + } +} + +/// Scatter a computed row of [`WindowCell`]s into the six flat output arrays +/// at row-major offset `row * side`. +#[allow(clippy::too_many_arguments)] +fn scatter_row( + row_cells: &[WindowCell], + row: i32, + side: i32, + morphology: &mut [u8], + elev_q: &mut [u8], + temp_dc: &mut [i16], + moisture_q: &mut [u8], + vegetation: &mut [u8], + glaciation: &mut [u8], +) { + let base = (row * side) as usize; + for (col, cell) in row_cells.iter().enumerate() { + let i = base + col; + morphology[i] = cell.morphology; + elev_q[i] = cell.elev_q; + temp_dc[i] = cell.temp_dc; + moisture_q[i] = cell.moisture_q; + vegetation[i] = cell.vegetation; + glaciation[i] = cell.glaciation; + } +} + +/// Resolve `center` (a district-grid position) to its world-metre origin — +/// shared by both window builders so the district->metres convention can +/// never drift between them. Mirrors `derive_district`'s own quantization +/// (`district_profile.rs`) exactly: `dm = DISTRICT_M`, `(dx*dm, dy*dm)`. +fn center_to_world_m(center: DistrictPos) -> (f64, f64) { + let dm = DISTRICT_M as f64; + (center.0 as f64 * dm, center.1 as f64 * dm) +} + +/// Cell step size in metres for a given granularity (T-1150): district +/// spacing (2,048 m) or quarter spacing (512 m). Any other value resolves to +/// district spacing (mirrors [`resolve_window_granularity`]'s fallback). +fn step_m_for_granularity(granularity: u32) -> f64 { + if granularity == WINDOW_GRANULARITY_QUARTER { + crate::atlas::scale::QUARTER_M as f64 + } else { + DISTRICT_M as f64 + } +} + +/// Build a [`DistrictWindowLayer`] by deriving every cell in the window +/// around `center` (T-1137, extended T-1150). Mirrors /// `aliveness_probe::render_window_panels`'s derive loop exactly (the probe /// this design promotes to a served layer, D-226 T-1124 amendment §2) — same -/// row-major indexing, same `derive_district` call per cell. +/// row-major indexing, same per-cell derive call. /// -/// `n` MUST already be clamped to `[1, DISTRICT_WINDOW_MAX_N]` by the caller — -/// this function trusts it verbatim (the clamp is `handle_atlas_request`'s -/// job, applied once at the wire boundary, not re-checked on every internal -/// caller per the existing codebase convention of clamping at the edge). +/// `n` MUST already be clamped by the caller ([`clamp_window_n`], +/// `[1, DISTRICT_WINDOW_MAX_N]` AND the granularity-aware `WIRE_CAP_CELLS` +/// ceiling) — this function trusts it verbatim (the clamp is +/// `handle_atlas_request`'s job, applied once at the wire boundary, not +/// re-checked on every internal caller per the existing codebase convention +/// of clamping at the edge). +/// +/// `n` is always the window extent in DISTRICTS (design doc §2 Option B: "the +/// window's `n` stays the DISTRICT extent"). At `granularity = 1` the derived +/// cell grid is `n × n` districts; at `granularity = 4` it is `(4n) × (4n)` +/// quarters covering the SAME world rect — full reclassification at the finer +/// spacing (`derive_at_metres` with `min_wavelength_m` matching the rung), +/// never a coarser-cell interpolation. +/// +/// **Row-chunked `par_iter` (T-1151):** each cell is a pure function of its +/// own position (D-227), so rows can derive in parallel with no shared +/// mutable state. Chunking by ROW (not per-cell) amortizes Rayon's own +/// task-dispatch overhead against the ~1.2 µs/cell derive cost (design doc +/// §7: naive per-cell parallelization risks the dispatch overhead itself +/// costing more than the work) — one Rayon task per row means `side` tasks of +/// `side` cells each, not `side²` tasks of one cell each. [`build_district_window_layer_serial`] +/// is kept alongside this as the golden-comparison baseline (T-1151 +/// acceptance: bit-identical serial vs. parallel output, exact row-major +/// array ordering preserved either way). +#[allow(clippy::too_many_arguments)] pub fn build_district_window_layer( seed: SeedChain, body_id: &str, @@ -393,41 +614,143 @@ pub fn build_district_window_layer( center: DistrictPos, n: u32, climate: &crate::atlas::district_profile::ClimateConstants, + granularity: u32, + min_wl_m: u32, ) -> DistrictWindowLayer { - let n_i = n as i32; - let half = n_i / 2; - let cells = (n * n) as usize; + use rayon::prelude::*; + + let side = (n * granularity.max(1)) as i32; + let half = side / 2; + let step_m = step_m_for_granularity(granularity); + let min_wavelength_m = min_wl_m as f64; + let center_world_m = center_to_world_m(center); + let cells = (side * side) as usize; let mut morphology = vec![0u8; cells]; let mut elev_q = vec![0u8; cells]; let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells]; let mut moisture_q = vec![0u8; cells]; let mut vegetation = vec![0u8; cells]; let mut glaciation = vec![0u8; cells]; - for row in 0..n_i { - for col in 0..n_i { - // Row 0 = northmost, matching aliveness_probe's render_window_panels - // (derive_district maps negative wy to negative lat_frac = north). - let dp = (center.0 - half + col, center.1 - half + row); - let prof = crate::atlas::district_profile::derive_district( - seed, body_id, params, ta, dp, climate, - ); - let i = (row * n_i + col) as usize; - morphology[i] = prof.morphology_zone as u8; - elev_q[i] = prof.elev_q.clamp(0, 100) as u8; - temp_dc[i] = match prof.temperature_c { - Some(t) => { - ((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16 - } - None => REGION_TEMP_NONE_DC, - }; - moisture_q[i] = prof.moisture_q.clamp(0, 100) as u8; - vegetation[i] = prof.vegetation_class as u8; - glaciation[i] = prof.glaciation_grade as u8; - } + + // One Rayon task per row: derive_window_cell(row, ..) for every col, then + // scatter that row's results into the flat arrays. Row order in the + // output collection is preserved by `par_iter` (it yields in index + // order), so the scatter below reproduces the exact row-major layout the + // serial loop produces. + let rows: Vec> = (0..side) + .into_par_iter() + .map(|row| { + (0..side) + .map(|col| { + derive_window_cell( + seed, + body_id, + params, + ta, + climate, + center_world_m, + half, + step_m, + min_wavelength_m, + row, + col, + ) + }) + .collect() + }) + .collect(); + + for (row, row_cells) in rows.into_iter().enumerate() { + scatter_row( + &row_cells, + row as i32, + side, + &mut morphology, + &mut elev_q, + &mut temp_dc, + &mut moisture_q, + &mut vegetation, + &mut glaciation, + ); + } + + DistrictWindowLayer { + center, + n, + granularity, + min_wl_m, + morphology, + elev_q, + temp_dc, + moisture_q, + vegetation, + glaciation, + } +} + +/// Serial twin of [`build_district_window_layer`] (T-1151) — the pre-parallel +/// row/col double loop, kept ONLY as the golden-comparison baseline for the +/// bit-identical serial-vs-parallel test. Not used by production callers. +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +fn build_district_window_layer_serial( + seed: SeedChain, + body_id: &str, + params: &crate::atlas::district_profile::BodyParams, + ta: &crate::atlas::features::TerrainAnalysis, + center: DistrictPos, + n: u32, + climate: &crate::atlas::district_profile::ClimateConstants, + granularity: u32, + min_wl_m: u32, +) -> DistrictWindowLayer { + let side = (n * granularity.max(1)) as i32; + let half = side / 2; + let step_m = step_m_for_granularity(granularity); + let min_wavelength_m = min_wl_m as f64; + let center_world_m = center_to_world_m(center); + let cells = (side * side) as usize; + let mut morphology = vec![0u8; cells]; + let mut elev_q = vec![0u8; cells]; + let mut temp_dc = vec![REGION_TEMP_NONE_DC; cells]; + let mut moisture_q = vec![0u8; cells]; + let mut vegetation = vec![0u8; cells]; + let mut glaciation = vec![0u8; cells]; + for row in 0..side { + let row_cells: Vec = (0..side) + .map(|col| { + derive_window_cell( + seed, + body_id, + params, + ta, + climate, + center_world_m, + half, + step_m, + min_wavelength_m, + row, + col, + ) + }) + .collect(); + scatter_row( + &row_cells, + row, + side, + &mut morphology, + &mut elev_q, + &mut temp_dc, + &mut moisture_q, + &mut vegetation, + &mut glaciation, + ); } DistrictWindowLayer { center, n, + granularity, + min_wl_m, morphology, elev_q, temp_dc, @@ -842,8 +1165,10 @@ fn normalize_window_center(params: &BodyParams, center: DistrictPos) -> District /// processed the completion (the existing D-225 poll-and-recheck-cache /// pattern every other layer already uses, not a push). /// -/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` here — the ONE place -/// that clamp is applied; nothing downstream re-checks the wire value. +/// `window_n` is clamped to `[1, DISTRICT_WINDOW_MAX_N]` AND the +/// granularity-aware `WIRE_CAP_CELLS` ceiling here — the ONE place that clamp +/// is applied; nothing downstream re-checks the wire value. `window_granularity` +/// is resolved via [`resolve_window_granularity`] at the same boundary (T-1150). #[allow(clippy::too_many_arguments)] fn serve_district_window( req: &AtlasLayerRequest, @@ -855,7 +1180,9 @@ fn serve_district_window( conn_id: ConnectionId, ) -> Option { let raw_center = req.window_center?; - let n = req.window_n.clamp(1, DISTRICT_WINDOW_MAX_N); + let granularity = resolve_window_granularity(req.window_granularity); + let n = clamp_window_n(req.window_n, granularity); + let min_wl_m = req.window_min_wl_m; // body_params is needed to normalize the centre BEFORE either cache key // exists (T-1142) — read it first, unconditionally (not gated on a cache @@ -892,7 +1219,11 @@ fn serve_district_window( ); } - let key: DistrictWindowKey = (req.body_id.clone(), center, n); + // T-1150: granularity + min_wl_m are part of the cache key — a + // granularity-4 request at the same (body, center, n) as a granularity-1 + // request is a DIFFERENT payload and must never alias onto the same slot + // (design doc §3's aliasing risk, the mandatory regression test below). + let key: DistrictWindowKey = (req.body_id.clone(), center, n, granularity, min_wl_m); if let Some(layer) = window_cache.get(&key) { return Some(layer.clone()); } @@ -923,6 +1254,8 @@ fn serve_district_window( body_params: Box::new(body_params), center, n, + granularity, + min_wl_m, }, GenPriority::Immediate, ); @@ -1298,11 +1631,22 @@ mod tests { let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); let n = 4u32; - let layer = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (10, -5), n, &climate); + let layer = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (10, -5), + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!(layer.center, (10, -5)); assert_eq!(layer.n, n); + assert_eq!(layer.granularity, WINDOW_GRANULARITY_DISTRICT); + assert_eq!(layer.min_wl_m, 0); let cells = (n * n) as usize; assert_eq!(layer.morphology.len(), cells); assert_eq!(layer.elev_q.len(), cells); @@ -1341,8 +1685,17 @@ mod tests { let climate = crate::atlas::district_profile::ClimateConstants::default(); let seed = SeedChain::root(1).derive(SeedDomain::Body, 1); - let layer = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (0, 0), 1, &climate); + let layer = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (0, 0), + 1, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!(layer.n, 1); assert_eq!(layer.morphology.len(), 1); assert_eq!(layer.elev_q.len(), 1); @@ -1366,16 +1719,85 @@ mod tests { let seed = SeedChain::root(7).derive(SeedDomain::Body, 3); let n = 8u32; - let first = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); - let second = - build_district_window_layer(seed, "test_body", ¶ms, &ta, (3, -2), n, &climate); + let first = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (3, -2), + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + let second = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + (3, -2), + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!( first, second, "two full derive passes over the same (center, n) must be byte-identical (D-010/D-227)" ); } + /// T-1151 acceptance: the row-chunked `par_iter` window build + /// ([`build_district_window_layer`]) must be bit-identical to the + /// pre-parallel serial baseline ([`build_district_window_layer_serial`]) + /// — same inputs, same output, exact row-major array ordering preserved. + /// Run at a window size large enough (16×16 = 256 cells) to actually + /// exercise multiple Rayon-dispatched rows, not just n=1. + #[test] + fn build_district_window_layer_parallel_matches_serial() { + let hm = window_test_hm(); + let ta = window_test_ta(&hm); + let params = window_test_params(); + let climate = crate::atlas::district_profile::ClimateConstants::default(); + let seed = SeedChain::root(13).derive(SeedDomain::Body, 4); + + let n = 16u32; + let center = (5, -9); + let parallel = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + let serial = build_district_window_layer_serial( + seed, + "test_body", + ¶ms, + &ta, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + assert_eq!( + parallel, serial, + "row-chunked par_iter window build must be bit-identical to the serial baseline" + ); + // Row-major ordering check, explicit (not just struct equality): the + // parallel path collects one Vec per row via par_iter, + // which preserves index order (`par_iter().map(...).collect()` is + // order-preserving), but pin the ordering assumption directly too. + assert_eq!(parallel.morphology.len(), (n * n) as usize); + assert_eq!(parallel.center, center); + assert_eq!(parallel.n, n); + } + /// FULL-PATH determinism (PR #187 review — Tyre C3, binding, load-bearing /// for save-file lineage under D-227): the test above reuses ONE `ta` for /// both passes, which only proves `build_district_window_layer` (the @@ -1420,10 +1842,28 @@ mod tests { // Now the FULL path: pack a DistrictWindowLayer from each independent // TerrainAnalysis and confirm the complete served payload agrees. - let window_from_pass1 = - build_district_window_layer(seed, "test_body", ¶ms, &ta_pass1, center, n, &climate); - let window_from_pass2 = - build_district_window_layer(seed, "test_body", ¶ms, &ta_pass2, center, n, &climate); + let window_from_pass1 = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta_pass1, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); + let window_from_pass2 = build_district_window_layer( + seed, + "test_body", + ¶ms, + &ta_pass2, + center, + n, + &climate, + WINDOW_GRANULARITY_DISTRICT, + 0, + ); assert_eq!( window_from_pass1, window_from_pass2, "two independent run_layer1 derivations from the same (seed, heightmap) \ @@ -1440,12 +1880,14 @@ mod tests { #[test] fn district_window_cache_insert_get_and_evict() { let mut cache = DistrictWindowCache::new(2); - let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4); - let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4); - let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4); + let key_a: DistrictWindowKey = ("Alpha".into(), (0, 0), 4, WINDOW_GRANULARITY_DISTRICT, 0); + let key_b: DistrictWindowKey = ("Beta".into(), (1, 1), 4, WINDOW_GRANULARITY_DISTRICT, 0); + let key_c: DistrictWindowKey = ("Gamma".into(), (2, 2), 4, WINDOW_GRANULARITY_DISTRICT, 0); let mk = |center, n| DistrictWindowLayer { center, n, + granularity: WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, morphology: vec![0; (n * n) as usize], elev_q: vec![0; (n * n) as usize], temp_dc: vec![REGION_TEMP_NONE_DC; (n * n) as usize], @@ -1491,6 +1933,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((0, 0)), window_n: DISTRICT_WINDOW_MAX_N * 10, // wildly over the wire — must clamp, not trust + window_granularity: 0, + window_min_wl_m: 0, }; let resp = handle_atlas_request( @@ -1527,6 +1971,78 @@ mod tests { ); } + // ------------------------------------------------------------------- + // resolve_window_granularity / clamp_window_n (T-1150) + // ------------------------------------------------------------------- + + #[test] + fn resolve_window_granularity_maps_known_values() { + assert_eq!(resolve_window_granularity(0), WINDOW_GRANULARITY_DISTRICT); + assert_eq!( + resolve_window_granularity(WINDOW_GRANULARITY_DISTRICT), + WINDOW_GRANULARITY_DISTRICT + ); + assert_eq!( + resolve_window_granularity(WINDOW_GRANULARITY_QUARTER), + WINDOW_GRANULARITY_QUARTER + ); + } + + /// Never trust the wire: an unrecognized granularity value (garbage, or a + /// future rung not yet implemented) falls back to district, never panics + /// or propagates un-vetted. + #[test] + fn resolve_window_granularity_unknown_value_falls_back_to_district() { + for garbage in [2, 3, 5, 100, u32::MAX] { + assert_eq!( + resolve_window_granularity(garbage), + WINDOW_GRANULARITY_DISTRICT, + "unrecognized granularity {garbage} must fall back to district" + ); + } + } + + /// District granularity: the per-axis DISTRICT_WINDOW_MAX_N cap alone + /// governs (64² × 1² = 4,096 = WIRE_CAP_CELLS exactly, so the cap is + /// never tighter than DISTRICT_WINDOW_MAX_N at granularity 1). + #[test] + fn clamp_window_n_district_granularity_uses_per_axis_cap() { + assert_eq!( + clamp_window_n(DISTRICT_WINDOW_MAX_N * 10, WINDOW_GRANULARITY_DISTRICT), + DISTRICT_WINDOW_MAX_N + ); + assert_eq!(clamp_window_n(32, WINDOW_GRANULARITY_DISTRICT), 32); + assert_eq!(clamp_window_n(0, WINDOW_GRANULARITY_DISTRICT), 1); + } + + /// Quarter granularity: the wire-size ceiling bites BEFORE the per-axis + /// cap — a request for n=64 at granularity 4 would derive 256×256=65,536 + /// cells (16x over budget), so it must clamp down to n=16 + /// (16² × 4² = 4,096 = WIRE_CAP_CELLS exactly — matching the design + /// doc's §3 worked example, "Quarter, capped to same cell budget + /// (n≈16 districts across)"), never to the raw DISTRICT_WINDOW_MAX_N=64. + #[test] + fn clamp_window_n_quarter_granularity_uses_wire_cap_not_per_axis_cap() { + let clamped = clamp_window_n(DISTRICT_WINDOW_MAX_N, WINDOW_GRANULARITY_QUARTER); + assert_eq!( + clamped, 16, + "quarter granularity must clamp n to keep (n*granularity)^2 <= WIRE_CAP_CELLS" + ); + assert!( + clamped * clamped * WINDOW_GRANULARITY_QUARTER * WINDOW_GRANULARITY_QUARTER + <= WIRE_CAP_CELLS, + "clamped cell count must never exceed WIRE_CAP_CELLS" + ); + } + + /// A small requested `n` at quarter granularity is left unclamped when it + /// already fits the budget (the cap must not be a flat floor/ceiling + /// substitution — only trims when the request would actually overflow). + #[test] + fn clamp_window_n_quarter_granularity_leaves_small_n_unclamped() { + assert_eq!(clamp_window_n(8, WINDOW_GRANULARITY_QUARTER), 8); + } + // ------------------------------------------------------------------- // normalize_window_center (T-1142 — letterbox-click out-of-range bug) // ------------------------------------------------------------------- @@ -1653,6 +2169,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((12276, 3021)), window_n: 4, + window_granularity: 0, + window_min_wl_m: 0, }; let resp1 = handle_atlas_request( &insane_req, @@ -1678,7 +2196,10 @@ mod tests { for c in completions { if let GenCompletion::WindowDerived { body_id, layer } = c { if body_id == "SmallMoon" { - window_cache.insert((body_id, layer.center, layer.n), *layer); + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); } } } @@ -1698,6 +2219,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((4, 383)), // the hand-computed canonical twin window_n: 4, + window_granularity: 0, + window_min_wl_m: 0, }; let resp2 = handle_atlas_request( &sane_twin_req, @@ -1750,6 +2273,154 @@ mod tests { ); } + /// **MANDATORY aliasing regression (T-1150, design doc §3's flagged + /// aliasing risk):** a granularity-4 (quarter) request and a + /// granularity-1 (district) request at the IDENTICAL `(body, center, n)` + /// must produce DISTINCT `DistrictWindowCache` entries and correct + /// per-granularity payloads — never silently alias onto the same slot + /// and serve one rung's data for the other's request. + #[test] + fn granularity_4_and_granularity_1_requests_produce_distinct_cache_entries() { + let mut cache = BodyWorldStateCache::new(CACHE_CAPACITY); + let mut window_cache = DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY); + let (_db, resolver, params_reader, _root) = + resolver_and_params_reader_with_radius("AliasBody", 6371.0); + // 3 threads: BOTH DeriveWindow items (district + quarter) need to + // dispatch concurrently with the AnalyzeBody item the first request's + // whole-body cache miss also enqueues (handle_atlas_request always + // fires an AnalyzeBody alongside the window derive on a cold body) — + // 2 threads left one DeriveWindow stuck behind AnalyzeBody within the + // single drain_completions() call below. + let queue = GenerationQueue::with_threads(3); + + let center = Some((10, -5)); + let n = 4u32; + + let district_req = AtlasLayerRequest { + body_id: "AliasBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: center, + window_n: n, + window_granularity: WINDOW_GRANULARITY_DISTRICT, + window_min_wl_m: 0, + }; + let quarter_req = AtlasLayerRequest { + body_id: "AliasBody".to_string(), + up_to: CascadeLayer::Topography, + window_center: center, + window_n: n, + window_granularity: WINDOW_GRANULARITY_QUARTER, + window_min_wl_m: 0, + }; + + // Fire both requests — same (body, center, n), different granularity. + handle_atlas_request( + &district_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + handle_atlas_request( + &quarter_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 1, + test_conn_id(), + ); + + std::thread::sleep(Duration::from_millis(300)); + let completions = queue.drain_completions(); + for c in completions { + if let GenCompletion::WindowDerived { body_id, layer } = c { + if body_id == "AliasBody" { + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); + } + } + } + + assert_eq!( + window_cache.len(), + 2, + "district and quarter requests at the SAME (body, center, n) must occupy \ + TWO distinct cache entries, not alias onto one" + ); + + // Re-request both — each must now hit ITS OWN cached entry and return + // the CORRECT per-granularity payload (not the other rung's data). + let district_resp = handle_atlas_request( + &district_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + let quarter_resp = handle_atlas_request( + &quarter_req, + &mut cache, + &mut window_cache, + &queue, + &resolver, + None, + Some(¶ms_reader), + 42, + 2, + test_conn_id(), + ); + + let district_layer = district_resp + .district_window + .expect("district request must hit its own cached entry"); + let quarter_layer = quarter_resp + .district_window + .expect("quarter request must hit its own cached entry"); + + assert_eq!(district_layer.granularity, WINDOW_GRANULARITY_DISTRICT); + assert_eq!(quarter_layer.granularity, WINDOW_GRANULARITY_QUARTER); + // n echoes the DISTRICT extent unchanged at both granularities + // (design doc §2: "the window's n stays the DISTRICT extent"). + assert_eq!(district_layer.n, n); + assert_eq!(quarter_layer.n, n); + // The derived CELL GRID differs: n×n at district, (4n)×(4n) at quarter. + assert_eq!(district_layer.morphology.len(), (n * n) as usize); + assert_eq!( + quarter_layer.morphology.len(), + (n * WINDOW_GRANULARITY_QUARTER * n * WINDOW_GRANULARITY_QUARTER) as usize + ); + // Correct per-granularity payload, not the other rung's data reused: + // the quarter grid must show finer per-cell VARIATION than a naive + // 4x-repeat of the district grid would (Option B — full + // reclassification at 512 m, not a coarser-cell interpolation). + let quarter_elev_range = { + let min = quarter_layer.elev_q.iter().min().copied().unwrap_or(0); + let max = quarter_layer.elev_q.iter().max().copied().unwrap_or(0); + max - min + }; + assert!( + quarter_elev_range > 0, + "quarter-granularity window must show real sub-district elevation \ + variation, not a blocky repeat of the district cells" + ); + } + /// The echoed `center` on `DistrictWindowLayer` is the NORMALIZED value, /// not the raw wire value — the client's D-227 staleness guard (D-226 /// T-1124 amendment §2) must see what was ACTUALLY derived, so it can @@ -1768,6 +2439,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: Some((12276, 3021)), // raw, out-of-range window_n: 4, + window_granularity: 0, + window_min_wl_m: 0, }; handle_atlas_request( &insane_req, @@ -1846,6 +2519,8 @@ mod tests { let window = DistrictWindowLayer { center: (10, -5), n: 2, + granularity: WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, morphology: vec![0, 8, 14, 16], elev_q: vec![0, 45, 98, 60], temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], @@ -1902,6 +2577,14 @@ mod tests { assert_eq!(decoded.up_to, CascadeLayer::Topography); assert_eq!(decoded.window_center, None); assert_eq!(decoded.window_n, 0); + assert_eq!( + decoded.window_granularity, 0, + "T-1150: absent window_granularity decodes to 0 (district), byte-compatible" + ); + assert_eq!( + decoded.window_min_wl_m, 0, + "T-1150: absent window_min_wl_m decodes to 0 (no cutoff), byte-compatible" + ); } /// T-1119: `build_quarter_footprint_layer` returns `None` when no @@ -2517,6 +3200,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, } } diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index a24ea3a3d..1a49e946c 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -413,7 +413,10 @@ fn drain_generation_completions( // NEXT poll (the existing D-225 re-request loop) hits // `handle_atlas_request`'s window branch, which finds this // entry via `DistrictWindowCache::get` and serves it. - window_cache.insert((body_id, layer.center, layer.n), *layer); + window_cache.insert( + (body_id, layer.center, layer.n, layer.granularity, layer.min_wl_m), + *layer, + ); } } } @@ -904,6 +907,8 @@ mod tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }, )])); world.insert_resource(AtlasResponseBuffer::default()); diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 4ecd73c32..be787b510 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -1201,6 +1201,8 @@ mod inbound_tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }; let frame = rmp_serde::to_vec_named(&req).unwrap(); assert!( @@ -1246,6 +1248,8 @@ mod inbound_tests { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }) .unwrap(); let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap(); diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index d54d769ce..ff213b725 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -371,6 +371,8 @@ fn single_tick_drains_all_ready_inbound_frames() { up_to: CascadeLayer::Topography, window_center: None, window_n: 0, + window_granularity: 0, + window_min_wl_m: 0, }; let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize"); write_framed(&mut stream, &payload).expect("write atlas frame"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 6eabe2667..c43192109 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -7,6 +7,7 @@ use settled_reach_server::atlas::layer_proxy::{ AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry, QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode, SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC, + WINDOW_GRANULARITY_DISTRICT, }; use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState}; use settled_reach_server::atlas::road_graph::RoadNodeKind; @@ -725,6 +726,8 @@ fn generate_atlas_layer_response_fixtures() { let window = DistrictWindowLayer { center: (10, -5), n: 2, + granularity: WINDOW_GRANULARITY_DISTRICT, + min_wl_m: 0, morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland elev_q: vec![0, 45, 98, 60], temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], // 20.5°C, 15.0°C, airless sentinel, 8.0°C