fix(simulation): one absolute-metre derive core (D-256, T-1174)

derive_district_profile is now a thin wrapper over the shared
derive_at_metres_with_riparian core at survey-cell-centre world metres
— one derive core, two position sets. The batch pseudo-grid and the
true D-243 district grid were two coordinate systems sharing one bare
(i32,i32) type; the new SurveyCellPos newtype re-keys every batch
product (BodyWorldState.districts, Layer1Output.survey_basin_dirs) so
the compiler rejects cross-namespace passing.

Fixes two latent same-position divergences the T-1174 investigation
surfaced: three inconsistent latitude conventions collapse into the
core's single inverse mapping, and the region-climate baseline now
floor-divides true world metres instead of collapsing the whole body
onto region (0,0)'s baseline — batch climate becomes latitude/region
graded (D-245 direction: every changed believability metric increased).

Binding preservations per D-256(c): basin_direction rides a post-call
override with the true L1 D8 survey-cell aggregate (layer1's map
re-keyed to SurveyCellPos, identity lookup — a floor-divide lookup
against the pseudo-keyed map would have silently defaulted every cell
North); the riparian verdict comes from near_perennial_water_at, never
the empty-slice default (which would have flipped riverside
vegetation_class).

Quarter-skeleton morphology_zone now resolves at the settlement's
exact world position via derive_at_metres at work-item execution
(where TerrainAnalysisCache lives), replacing the survey-cell-centre
map lookup (D-256(d)); settlement_district_pos fixed to true-district
floor-division in passing (same doc/impl mismatch class). Second
pixel-vs-metre conflation fixed in aliveness_probe's anchor-walk math.

Window path byte-unchanged (window_derivation_golden 6/6 byte-
identical); derivation_harness golden untouched; believability golden
regenerated. Full lib + integration suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:40:13 +02:00
co-authored by Claude Fable 5
parent 35a4137304
commit 933e1f4ee4
15 changed files with 1272 additions and 479 deletions
+244 -121
View File
@@ -12,6 +12,7 @@ use bevy_ecs::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::atlas::atlas_data_proxy::{
handle_city_names_request, handle_star_map_request, StarMapDataPath,
@@ -25,7 +26,7 @@ use crate::atlas::city_context_reader::{
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
};
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
use crate::atlas::district_profile::{self, BodyParams, DistrictPos};
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer_proxy::{
handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus, DistrictWindowCache,
@@ -51,7 +52,7 @@ use crate::bridge::{
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
BulkClass, DistrictType, MaintenanceAuthority, ProductionUbiquity, WorldTier,
};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
@@ -227,6 +228,7 @@ fn drain_generation_completions(
mut window_cache: ResMut<DistrictWindowCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
) {
for completion in queue.drain_completions() {
@@ -244,6 +246,47 @@ fn drain_generation_completions(
let world_seed = rng.seed();
let body_id = state.body_id.clone();
// D-256(d): body physical params, re-read here (same pattern
// as `serve_atlas_requests`'s `params_reader` — a cheap DB
// row read on the main thread, mirroring the `AnalyzeBody`
// dispatch-time precedent at T-1023's original call site) so
// `run_work_item`'s exact-position morphology_zone derive
// has the settlement's body radius. `None` on a read
// failure or absent reader — the exact-position resolution
// then skips and `context.morphology_zone` stays at its
// `AlluvialPlain` stub (same fallback as an empty district
// grid pre-D-256).
let dispatch_body_params: Option<Box<BodyParams>> = match body_params_reader
.as_ref()
{
Some(reader) => reader
.0
.read_body_params(&body_id)
.map(Box::new)
.map(Some)
.unwrap_or_else(|e| {
tracing::warn!(
body_id = %body_id,
error = %e,
"L3→L4 dispatch: body_params read failed — morphology_zone stays AlluvialPlain"
);
None
}),
None => None,
};
// D-256(d): shared per-body heightmap for `run_work_item`'s
// `TerrainAnalysisCache::get_or_derive` — built once from
// data already in memory (no disk re-read), `Arc`'d so every
// settlement dispatched below clones a pointer.
let dispatch_heightmap =
std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap {
body_id: body_id.clone(),
width: state.heightmap_width,
height: state.heightmap_height,
data: state.heightmap.clone(),
sea_level: state.sea_level,
});
// ── T-994 (D-232): body-level aggregation for the phase-1
// trait-vocabulary K-draw ───────────────────────────────────
// Read each placement's D-199 read-set once — reused both to
@@ -351,9 +394,12 @@ fn drain_generation_completions(
world_seed,
placement,
read_set,
&state.districts,
state.heightmap_width,
state.heightmap_height,
dispatch_body_params.as_deref(),
&state.road_graph,
&vocab,
Arc::clone(&dispatch_heightmap),
),
GenPriority::Low,
);
@@ -549,9 +595,14 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
/// - `founding_orientation` — from the attractor-matched placement (D-213).
/// - `political_archetype` — from the attractor-matched placement (D-214, T-1039),
/// replacing the `Commission` stub in `context_from_read_set`.
/// - `morphology_zone` — from the `DistrictProfile` covering this placement's
/// heightmap-grid pixel, via `state.districts` (D-239 §6, T-1039). Falls back
/// to `AlluvialPlain` when the district grid is empty (unit tests, early cascade).
/// - `morphology_zone` — **NOT resolved here** (D-256(d), T-1174). It stays at
/// `context_from_read_set`'s `AlluvialPlain` stub through this function; the
/// work item instead carries `settlement_world_m`/`body_params`/`body_seed`/
/// `heightmap` so `run_work_item` can resolve it via an exact-position
/// `derive_at_metres` call during execution, where `TerrainAnalysis` is
/// reachable (`BodyWorldState` drops it, D-203/T-1048) — a survey cell's
/// centre (the pre-D-256 lookup key) can be hundreds of km from a
/// settlement near the cell's edge.
/// - `road_entry_directions` — derived from `state.road_graph`: for each road edge
/// incident on this city, the compass octant (0=N…7=NW) of the bearing from the
/// city toward the far endpoint, de-duplicated per octant and ordered by descending
@@ -568,6 +619,15 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
/// city)` — not the `city_id * 10` placeholder.
///
/// `heightmap_width`/`heightmap_height` are the body's working-grid dims
/// (`BodyWorldState.heightmap_width`/`heightmap_height`) — used to convert the
/// placement's pixel position to world metres via `pixel_to_world_m` (D-256(b)'s
/// bridge function), for both `settlement_world_m` and the true `DistrictPos`
/// used by `settlement_district_pos`/`pick_district_dominant_by_type` (D-256(a):
/// `DistrictPos` canonically means the true D-243 grid — the pre-D-256
/// `heightmap_pixel_to_district` conversion actually returned a survey-raster
/// position mislabeled as a district).
///
/// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994):
/// `trait_selection` (phase 1, computed once per body by the caller) and the
/// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this
@@ -575,14 +635,18 @@ fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
/// function's argument count under the clippy `too_many_arguments` threshold.
///
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
#[allow(clippy::too_many_arguments)]
fn build_skeleton_work_item(
body_id: &str,
world_seed: u64,
placement: &CityPlacement,
read_set: CityEconomicReadSet,
districts: &BTreeMap<DistrictPos, DistrictProfile>,
heightmap_width: u32,
heightmap_height: u32,
body_params: Option<&BodyParams>,
road_graph: &RoadGraph,
vocab: &BodyVocabularyContext,
heightmap: Arc<crate::atlas::heightmap::BodyHeightmap>,
) -> GenWorkItem {
// The D-199 raw fields ride alongside the context (generate_quarter_skeleton
// takes them separately), so capture them before context_from_read_set consumes
@@ -602,33 +666,24 @@ fn build_skeleton_work_item(
// Replaces the `Commission` stub that `context_from_read_set` leaves.
context.political_archetype = placement.political_archetype;
// ── T-1039 / D-239 §6: morphology_zone from covering DistrictProfile ───────
// Convert the placement's working-grid pixel position to a DistrictPos using
// the canonical scale constant — no hardcoded magic numbers here.
let district_pos = scale::heightmap_pixel_to_district(placement.position);
context.morphology_zone = match districts.get(&district_pos) {
Some(d) => d.morphology_zone,
None => {
// An empty grid is the expected params-missing / early-cascade case
// (debug); a miss against a *populated* grid means the pixel→DistrictPos
// conversion is off — a real bug worth a warning, not a silent wrong
// topology.
if districts.is_empty() {
tracing::debug!(
city_id = placement.city_id,
?district_pos,
"morphology_zone fallback to AlluvialPlain: district grid not built for this body"
);
} else {
tracing::warn!(
city_id = placement.city_id,
?district_pos,
"morphology_zone fallback to AlluvialPlain: pos not in populated district grid — check pixel→district convention"
);
}
MorphologyZone::AlluvialPlain
}
};
// ── D-256(d): settlement world metres + true DistrictPos ────────────────────
// `pixel_to_world_m` is the SAME bridge function the survey raster uses
// (D-256(b)) — converting the placement's working-grid pixel (row, col) to
// world metres, then floor-dividing by DISTRICT_M for the true D-243 cell.
// `morphology_zone` itself is NOT resolved here — see this function's doc
// and `run_work_item`'s `GenerateSkeleton` arm (D-256(d)); it stays at the
// `context_from_read_set` `AlluvialPlain` stub through this function.
let (world_x_m, world_y_m) = district_profile::pixel_to_world_m(
placement.position.1 as f64,
placement.position.0 as f64,
heightmap_width as usize,
heightmap_height as usize,
body_params.and_then(|p| p.body_radius_km),
);
let district_pos: DistrictPos = (
(world_x_m / scale::DISTRICT_M as f64).floor() as i32,
(world_y_m / scale::DISTRICT_M as f64).floor() as i32,
);
// ── T-994 / D-232: three-phase trait-template draw ──────────────────────────
// Phase 1 (trait_selection) and its inputs (body_district_type_mix) were
@@ -677,8 +732,8 @@ fn build_skeleton_work_item(
// ── Canonical quarter id (D-194/D-230) ────────────────────────────────────
// Deterministic + namespace-isolated per (world_seed, body, city).
// SeedChain is Copy, so `chain.seed()` leaves `chain` usable for the work item.
let chain = SeedChain::for_body(world_seed, body_id)
.derive(SeedDomain::Layer4Quarter, placement.city_id);
let body_seed = SeedChain::for_body(world_seed, body_id);
let chain = body_seed.derive(SeedDomain::Layer4Quarter, placement.city_id);
let quarter_id = chain.seed();
GenWorkItem::GenerateSkeleton {
@@ -691,6 +746,10 @@ fn build_skeleton_work_item(
population,
founding_age_years,
exterior_catalog: vocab.exterior_catalog.clone(),
settlement_world_m: (world_x_m, world_y_m),
body_params: body_params.cloned().map(Box::new),
body_seed,
heightmap,
}
}
@@ -1064,6 +1123,23 @@ mod tests {
}
}
/// D-256(d) test fixture: a minimal 64×32 working-grid heightmap
/// (matching `district_profile::tests::test_hm`'s shape) wrapped in the
/// `Arc` `build_skeleton_work_item`/`GenWorkItem::GenerateSkeleton` carry
/// for the exact-position `morphology_zone` resolution. `body_params:
/// None` (the common case in these queue-mechanics-focused tests) skips
/// that resolution entirely, so the heightmap content is inert — flat
/// data is enough to satisfy the type.
fn sample_heightmap() -> Arc<crate::atlas::heightmap::BodyHeightmap> {
Arc::new(crate::atlas::heightmap::BodyHeightmap {
body_id: "test".into(),
width: 64,
height: 32,
data: vec![0.5; 64 * 32],
sea_level: 0.3,
})
}
fn sample_placement(city_id: u64, orientation: FoundingOrientation) -> CityPlacement {
CityPlacement {
city_id,
@@ -1252,9 +1328,12 @@ mod tests {
42,
&sample_placement(city_id, FoundingOrientation::Cardinal),
read_set_with(6_000, 500_000, founding_age),
&BTreeMap::new(),
64,
32,
None,
&road_graph,
&vocab,
sample_heightmap(),
) else {
panic!("expected GenerateSkeleton");
};
@@ -1303,9 +1382,12 @@ mod tests {
42,
&placement,
sample_read_set(),
&BTreeMap::new(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
@@ -1342,9 +1424,12 @@ mod tests {
99,
&placement,
sample_read_set(),
&BTreeMap::new(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
) else {
unreachable!()
};
@@ -1355,57 +1440,36 @@ mod tests {
assert_ne!(qid(3), qid(4));
}
// ── T-1039: political_archetype + morphology_zone threading ───────────────
// ── T-1039 / D-256(d): political_archetype + morphology_zone threading ────
/// Verify that `build_skeleton_work_item` threads the placement's
/// `political_archetype` (replacing the `Commission` stub) and looks up
/// `morphology_zone` from the district grid.
/// `political_archetype` (replacing the `Commission` stub). `morphology_zone`
/// is NOT resolved at this dispatch-time function any more (D-256(d)) — it
/// stays at the `context_from_read_set` `AlluvialPlain` stub here; see
/// `run_work_item_resolves_morphology_zone_at_exact_settlement_position`
/// below for the execution-time resolution this ticket moved it to.
#[test]
fn threads_political_archetype_and_morphology_zone() {
use crate::atlas::district_profile::{
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
};
use crate::atlas::scale::BasinDirection;
fn threads_political_archetype() {
use crate::simulation::generator::MorphologyZone;
// A Corporate archetype placement in a Fjord district.
let placement = sample_placement_with_archetype(
42,
FoundingOrientation::Coastal { facing_degrees: 90 },
PoliticalArchetype::Corporate,
ArrangementPattern::CampusGrid,
);
// CityPlacement.position = (10, 20) → district_pos = (col/8, row/8) = (20/8, 10/8) = (2, 1)
let district_pos = scale::heightmap_pixel_to_district(placement.position);
assert_eq!(district_pos, (2, 1));
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
districts.insert(
district_pos,
DistrictProfile {
morphology_zone: MorphologyZone::Fjord,
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
glaciation_grade: GlaciationGrade::Moderate,
precipitation_class: PrecipitationClass::Temperate,
slope_q: 60,
elev_q: 50,
ocean_fraction_q: 10,
river_threshold: 200,
temperature_c: Some(8.0),
moisture_q: 55,
vegetation_class: VegetationClass::Scrub,
basin_direction: BasinDirection::North,
},
);
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
"TestBody",
1,
&placement,
sample_read_set(),
&districts,
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
) else {
panic!("expected GenerateSkeleton");
};
@@ -1416,36 +1480,97 @@ mod tests {
PoliticalArchetype::Corporate,
"political_archetype must be threaded from placement (T-1039)"
);
// Morphology zone must come from the DistrictProfile.
assert_eq!(
context.morphology_zone,
MorphologyZone::Fjord,
"morphology_zone must be looked up from DistrictProfile (T-1039)"
);
// morphology_zone is untouched by build_skeleton_work_item post-D-256(d).
assert_eq!(context.morphology_zone, MorphologyZone::AlluvialPlain);
}
/// When no district grid is available (empty districts map), morphology_zone
/// falls back to AlluvialPlain (the safe mesh-topology default).
/// D-256(d): `resolve_settlement_morphology_zone` (the function
/// `run_work_item`'s `GenerateSkeleton` arm calls) returns `None` when no
/// `body_params` is supplied (no DB row for this body — the same
/// condition the pre-D-256 "empty district grid" fallback covered), so
/// the caller leaves `context.morphology_zone` at its `AlluvialPlain`
/// stub.
#[test]
fn morphology_zone_fallback_when_district_missing() {
use crate::simulation::generator::MorphologyZone;
fn resolve_settlement_morphology_zone_none_when_body_params_absent() {
use crate::atlas::gen_queue::resolve_settlement_morphology_zone;
use std::sync::Mutex;
let placement = sample_placement(1, FoundingOrientation::Cardinal);
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
let heightmap = sample_heightmap();
let cache = Arc::new(Mutex::new(
crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4),
));
let zone = resolve_settlement_morphology_zone(
&cache,
"BodyX",
0,
&placement,
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) else {
panic!("expected GenerateSkeleton")
None,
(0.0, 0.0),
SeedChain::for_body(0, "BodyX"),
&heightmap,
);
assert_eq!(zone, None, "no body_params → no resolution, caller keeps the stub");
}
/// D-256(d): with real `body_params`, `resolve_settlement_morphology_zone`
/// resolves at the settlement's EXACT world position via `derive_at_metres`
/// — not from a survey-cell lookup. Ground truth: an independent
/// `derive_at_metres` call at the same position must agree bit-for-bit.
#[test]
fn resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position() {
use crate::atlas::district_profile::{self, BodyParams, ClimateConstants};
use crate::atlas::gen_queue::resolve_settlement_morphology_zone;
use std::sync::Mutex;
let heightmap = sample_heightmap();
let body_params = BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
};
let placement = sample_placement(1, FoundingOrientation::Cardinal);
let (world_x_m, world_y_m) = district_profile::pixel_to_world_m(
placement.position.1 as f64,
placement.position.0 as f64,
heightmap.width as usize,
heightmap.height as usize,
body_params.body_radius_km,
);
let body_seed = SeedChain::for_body(0, "TestBody");
let cache = Arc::new(Mutex::new(
crate::atlas::gen_queue::TerrainAnalysisCache::new_for_test(4),
));
let zone = resolve_settlement_morphology_zone(
&cache,
"TestBody",
Some(&body_params),
(world_x_m, world_y_m),
body_seed,
&heightmap,
);
// Ground truth: derive_at_metres at the SAME settlement world metres,
// via the terrain cache's own re-derive path (run_layer1) so the
// TerrainAnalysis is byte-identical to what the resolver used.
let (_l1, ta) = crate::atlas::layer1::run_layer1(&heightmap);
let expected = district_profile::derive_at_metres(
body_seed,
"TestBody",
&body_params,
&ta,
world_x_m,
world_y_m,
&ClimateConstants::default(),
0.0,
&[],
);
assert_eq!(
context.morphology_zone,
MorphologyZone::AlluvialPlain,
"should fall back to AlluvialPlain when district grid empty"
zone,
Some(expected.morphology_zone),
"resolve_settlement_morphology_zone must match derive_at_metres at the \
settlement's exact world position (D-256(d))"
);
}
@@ -1577,14 +1702,20 @@ mod tests {
/// Acceptance test for T-1039: a Corporate coastal placement in a Fjord district
/// dispatches a work item whose context uses Ribbon topology (Fjord) and
/// Corporate (CampusGrid) layout — not mesh+Commission.
///
/// D-256(d): `morphology_zone` is no longer resolved by
/// `build_skeleton_work_item` (that now happens at execution time via
/// `resolve_settlement_morphology_zone`, tested directly elsewhere against
/// real terrain — see
/// `resolve_settlement_morphology_zone_matches_derive_at_metres_at_exact_position`).
/// This test's actual subject is `generate_quarter_skeleton`'s downstream
/// Fjord+Corporate behavior, so it overrides `context.morphology_zone`
/// directly on the built context — exactly what `run_work_item` does in
/// production once the exact-position resolution completes.
#[test]
fn corporate_fjord_placement_uses_ribbon_topology_not_mesh_commission() {
use crate::atlas::district_profile::{
DistrictProfile, GlaciationGrade, PrecipitationClass, VegetationClass,
};
use crate::atlas::scale::BasinDirection;
use crate::atlas::skeleton_gen::generate_quarter_skeleton;
use crate::simulation::generator::AccessKind;
use crate::simulation::generator::{AccessKind, MorphologyZone};
// CorpTerritory → Corporate archetype; CoastalAccess attractor.
let placement = sample_placement_with_archetype(
@@ -1595,25 +1726,6 @@ mod tests {
PoliticalArchetype::Corporate,
ArrangementPattern::CampusGrid,
);
let district_pos = scale::heightmap_pixel_to_district(placement.position);
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
districts.insert(
district_pos,
DistrictProfile {
morphology_zone: MorphologyZone::Fjord,
tectonic_class: crate::atlas::district_profile::TectonicClass::Stable,
glaciation_grade: GlaciationGrade::Moderate,
precipitation_class: PrecipitationClass::SemiArid,
slope_q: 70,
elev_q: 30,
ocean_fraction_q: 20,
river_threshold: 200,
temperature_c: Some(5.0),
moisture_q: 35,
vegetation_class: VegetationClass::Barren,
basin_direction: BasinDirection::North,
},
);
let GenWorkItem::GenerateSkeleton {
context,
@@ -1628,13 +1740,18 @@ mod tests {
7,
&placement,
sample_read_set(),
&districts,
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
};
let mut context = context;
context.morphology_zone = MorphologyZone::Fjord;
// Verify the context is correctly wired before skeleton generation.
assert_eq!(context.political_archetype, PoliticalArchetype::Corporate);
@@ -1913,9 +2030,12 @@ mod tests {
42,
&placement,
sample_read_set(),
&BTreeMap::new(),
64,
32,
None,
&road_graph,
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");
@@ -1977,9 +2097,12 @@ mod tests {
42,
&placement,
sample_read_set(),
&BTreeMap::new(),
64,
32,
None,
&RoadGraph::default(),
&empty_vocab(),
sample_heightmap(),
)
else {
panic!("expected GenerateSkeleton");