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
+132 -7
View File
@@ -97,7 +97,8 @@ pub enum GenWorkItem {
/// `context` is the D-199 economic read-set pre-resolved at dispatch time.
/// All 6 required fields must be populated before this item is submitted
/// (D-199: "Missing fields abort the task … generation does not proceed with
/// partial context").
/// partial context") — EXCEPT `morphology_zone`, which is resolved during
/// execution (see below, D-256(d)).
///
/// `body_id` routes the resulting `SkeletonGenerated` completion into the
/// correct `BodyWorldState` cache entry (D-230).
@@ -112,6 +113,11 @@ pub enum GenWorkItem {
city_id: u64,
body_id: String,
/// D-199 economic read-set + all other context fields.
///
/// `context.morphology_zone` still starts life as
/// `context_from_read_set`'s `AlluvialPlain` stub at dispatch time — it
/// is overwritten during execution (D-256(d), see `settlement_world_m`
/// below), not by `build_skeleton_work_item` as it was pre-D-256.
context: Box<CityGenerationContext>,
/// Stable content-addressable quarter id (D-194/D-230).
quarter_id: u64,
@@ -130,6 +136,33 @@ pub enum GenWorkItem {
/// regardless of its contents' size (same reasoning already documented
/// for `FillChunk.block_tags` below), so this needs no `Box`.
exterior_catalog: ExteriorCatalog,
/// D-256(d) exact-position `morphology_zone` judgment inputs — the
/// settlement's own world metres (NOT the survey cell's centre, which
/// can be hundreds of km off for a settlement near a survey-cell edge)
/// and body params, resolved during `run_work_item` execution (where
/// the terrain cache is reachable, unlike `BodyWorldState`'s
/// D-203/T-1048 dropped `TerrainAnalysis`) via `derive_at_metres`.
/// `None` body_params (no DB row for this body) skips the resolution
/// and leaves `context.morphology_zone` at its `AlluvialPlain` stub —
/// the same fallback the pre-D-256 dispatch-time lookup used for an
/// empty district grid.
settlement_world_m: (f64, f64),
body_params: Option<Box<BodyParams>>,
/// `SeedChain::for_body(world_seed, body_id)` — the SAME body-scoped
/// chain `derive_all_districts`/`derive_district_profile` use, distinct
/// from `chain` (the quarter-level chain derived further for skeleton
/// RNG). Needed because `derive_at_metres`'s domain-separated warp
/// fields must key on the same seed the rest of the cascade uses for
/// this body, not a chain re-derived from the quarter seed.
body_seed: SeedChain,
/// Shared per-body working-grid heightmap (D-256(d)) — an `Arc` so
/// every settlement dispatched for the same `BodyAnalyzed` completion
/// clones a pointer, not the multi-hundred-KB `Vec<f32>`. Reconstructed
/// once at dispatch time from `BodyWorldState.heightmap`/dims/`sea_level`
/// (already in memory — no disk re-read) so `run_work_item` can feed
/// [`TerrainAnalysisCache::get_or_derive`] without touching the
/// filesystem on the Rayon worker thread.
heightmap: std::sync::Arc<crate::atlas::heightmap::BodyHeightmap>,
},
/// Derive the building shell for one 64 m chunk of an existing quarter
/// (D-230 derive phase, T-987).
@@ -617,8 +650,12 @@ impl Default for GenerationQueue {
/// itself is small (a `RiverNetwork` + basin list + attractor list, not the
/// full grid) relative to `TerrainAnalysis`'s ~1.5–2 MB of dense per-cell
/// Vecs.
/// `pub(crate)` (D-256(d), T-1174): `plugin.rs`'s tests construct one
/// directly to unit-test `resolve_settlement_morphology_zone` without
/// spinning up a full `GenerationQueue`. Otherwise entirely internal to this
/// module's Rayon-thread execution path.
#[derive(Debug)]
struct TerrainAnalysisCache {
pub(crate) struct TerrainAnalysisCache {
entries: std::collections::BTreeMap<String, (Layer1Output, TerrainAnalysis, u64)>,
/// Monotonic access counter (substitutes for `BodyWorldStateCache`'s
/// `SimTick` — there is no tick concept on a background Rayon thread).
@@ -639,6 +676,13 @@ impl TerrainAnalysisCache {
}
}
/// Test-only constructor alias (D-256(d)) — `new` stays private-module
/// idiomatic; this is the `pub(crate)` door for `plugin.rs`'s tests.
#[cfg(test)]
pub(crate) fn new_for_test(capacity: usize) -> Self {
Self::new(capacity)
}
/// Look up a cached `(Layer1Output, TerrainAnalysis)` pair for `body_id`,
/// re-deriving via `run_layer1` on a miss and inserting the result
/// (evicting the LRU entry first if at capacity). Bumps the access clock
@@ -688,6 +732,46 @@ impl TerrainAnalysisCache {
}
}
/// D-256(d): resolve a settlement's `MorphologyZone` at its EXACT world
/// position via `derive_at_metres`, using the terrain cache to reach a
/// `TerrainAnalysis` without a disk re-read (`heightmap` is already in
/// memory, reconstructed once at dispatch time from `BodyWorldState`'s
/// cached working-grid data).
///
/// Returns `None` when `body_params` is absent (no DB row for this body) —
/// the caller then leaves `context.morphology_zone` at its dispatch-time
/// `AlluvialPlain` stub, the same fallback the pre-D-256 dispatch-time
/// district-grid lookup used for an empty grid.
///
/// Extracted from `run_work_item`'s `GenerateSkeleton` arm so the resolution
/// itself is unit-testable without going through the full skeleton-generation
/// pipeline (`QuarterSkeleton` does not expose `morphology_zone` directly —
/// it only affects derived fields like `layout_mode`/corridors).
pub(crate) fn resolve_settlement_morphology_zone(
terrain_cache: &Arc<Mutex<TerrainAnalysisCache>>,
body_id: &str,
body_params: Option<&BodyParams>,
settlement_world_m: (f64, f64),
body_seed: SeedChain,
heightmap: &crate::atlas::heightmap::BodyHeightmap,
) -> Option<crate::simulation::generator::MorphologyZone> {
let params = body_params?;
let (_l1, ta) = terrain_cache.lock().unwrap().get_or_derive(body_id, heightmap);
let climate = ClimateConstants::default();
let profile = crate::atlas::district_profile::derive_at_metres(
body_seed,
body_id,
params,
&ta,
settlement_world_m.0,
settlement_world_m.1,
&climate,
0.0,
&[],
);
Some(profile.morphology_zone)
}
// ---------------------------------------------------------------------------
// Work execution stub
// ---------------------------------------------------------------------------
@@ -761,13 +845,36 @@ fn run_work_item(
population,
founding_age_years,
exterior_catalog,
settlement_world_m,
body_params,
body_seed,
heightmap,
} => {
// D-256(d): resolve morphology_zone at the settlement's EXACT world
// position, here at execution time — this is where TerrainAnalysis
// is reachable (BodyWorldState drops it, D-203/T-1048). A survey
// cell's centre can be hundreds of km from a settlement near its
// edge; the exact-position derive closes that annotation-vs-canvas
// disagreement class before T-1181/T-1182 draw batch-judged
// annotations onto window-derived canvases.
let mut resolved_context = (**context).clone();
if let Some(zone) = resolve_settlement_morphology_zone(
terrain_cache,
body_id,
body_params.as_deref(),
*settlement_world_m,
*body_seed,
heightmap,
) {
resolved_context.morphology_zone = zone;
}
// Build the Phase 1 skeleton from the pre-resolved D-199 context.
// `economic_role`, `population`, and `founding_age_years` are the
// D-199 raw fields carried alongside the context because
// `generate_quarter_skeleton` accepts them as separate parameters.
let skeleton = generate_quarter_skeleton(
context,
&resolved_context,
*population,
economic_role,
*quarter_id,
@@ -780,7 +887,7 @@ fn run_work_item(
// same pass.
let block_tags = assign_all_block_tags(
&skeleton,
context,
&resolved_context,
economic_role,
*founding_age_years,
*chain,
@@ -824,9 +931,13 @@ fn run_work_item(
Ok(hm) => {
// Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the
// window derive must run on the SAME working-grid resolution the
// whole-body cascade uses, or district positions between the two
// views would disagree (derive_district maps DistrictPos through
// ta.w/ta.h, T-1137 decision note).
// whole-body cascade uses, or the TRUE DistrictPos → world-metres
// mapping between the two views would disagree (derive_district
// maps DistrictPos through ta.w/ta.h, T-1137 decision note). This
// is resolution consistency (ta.w/ta.h must match), a DIFFERENT
// concern from D-256's survey-raster/true-district namespace
// collision — both views here already address the true D-243
// grid, so D-256 doesn't touch this comment's claim.
let working = if hm.width > GRID_W || hm.height > GRID_H {
hm.downsample(GRID_W, GRID_H)
} else {
@@ -990,6 +1101,20 @@ mod tests {
population: 500_000,
founding_age_years: 200,
exterior_catalog: ExteriorCatalog::default(),
// These tests exercise queue mechanics, not the D-256(d)
// exact-position resolution — `body_params: None` skips it
// entirely (the same fallback path an empty district grid used
// pre-D-256), so the position/heightmap values below are inert.
settlement_world_m: (0.0, 0.0),
body_params: None,
body_seed: SeedChain::for_body(42 + city_id, &format!("TestBody{city_id}")),
heightmap: std::sync::Arc::new(crate::atlas::heightmap::BodyHeightmap {
body_id: format!("TestBody{city_id}"),
width: 1,
height: 1,
data: vec![0.5],
sea_level: 0.3,
}),
}
}