feat(simulation): feature-name pipeline wired + legacy window_granularity u32 retired (T-1169, T-1159)

One commit for two tickets whose changes share the bridge/plugin
plumbing files. T-1169 connects the three dormant feature-name pieces:
atlas_feature_names populated at regen (17,891 rows — 15,190 mountain,
2,701 river — via populate_atlas_feature_names mirroring the city-names
importer; systems.db regenerated, stamp fresh), attach_feature_names
wired into the cascade's Topography block with name pools threaded
DB-free through AnalyzeBody (D-225 pattern) and assignments stored on
Layer1Output/BodyWorldState for future consumers, and a
FeatureNamesRequest/Response read proxy as the bridge's 7th tagged
envelope (D-236 pattern, both SimBridge impls). Client label DRAW is
deliberately NOT here — implementation proved both river and mountain
labels need a wire-carried position (the pool is position-free; course
polylines aren't correlated with the named attractors by construction) —
deferred to T-1195's single design pass. cascade_layer1 golden re-pinned
(additive feature_names field).

T-1159 retires the legacy u32 granularity field fully shadowed by
window_granularity_v2: AtlasLayerRequest.window_granularity,
DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and
resolve_window_granularity are gone server-side; client encode paths and
the caller-less atlas_window_cache legacy key component dropped;
msgpack fixtures regenerated; the T-1150 aliasing regression test now
drives through the surviving enum field. The district_window carrier
itself survives byte-compatible per D-255(c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:10:27 +02:00
co-authored by Claude Fable 5
parent befdb689c1
commit 8da9670e0f
28 changed files with 1244 additions and 467 deletions
+145 -3
View File
@@ -147,9 +147,14 @@ impl CascadeSnapshot {
/// `terrain_analysis` (transient, ~2 MB) is **dropped here** — it is not
/// persisted on `BodyWorldState` per the D-203/T-1048 size budget.
pub fn into_body_world_state(self) -> BodyWorldState {
let (river_network, drainage_basins, attractors) = match self.layer1 {
Some(l1) => (l1.river_network, l1.drainage_basins, l1.attractors),
None => (RiverNetwork::default(), Vec::new(), Vec::new()),
let (river_network, drainage_basins, attractors, feature_names) = match self.layer1 {
Some(l1) => (
l1.river_network,
l1.drainage_basins,
l1.attractors,
l1.feature_names,
),
None => (RiverNetwork::default(), Vec::new(), Vec::new(), Vec::new()),
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let districts = self
@@ -170,6 +175,7 @@ impl CascadeSnapshot {
river_network,
drainage_basins,
attractors,
feature_names,
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
@@ -224,12 +230,20 @@ fn run_layer3(
/// character (#956). `None` → `FrontierUnclaimed`.
/// `body_params` supplies the physical parameters needed for the DistrictProfile
/// layer (T-1023); `None` → district layer skips (empty `districts` map).
/// `river_names`/`mountain_names` are the body's reserved-name pools (T-1169,
/// D-223, from `atlas_feature_names`), supplied by the caller — mirrors
/// `cities`' own pre-resolved, DB-free-cascade pattern. Empty slices are the
/// correct input for a body with no reserved names, or a caller (tests,
/// `aliveness_probe`) that hasn't pre-resolved them; `attach_feature_names`
/// degrades gracefully (every attractor position simply gets no name).
pub fn run_cascade_from_heightmap(
body_seed: SeedChain,
heightmap: BodyHeightmap,
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> CascadeSnapshot {
let mut snapshot = CascadeSnapshot {
@@ -272,6 +286,31 @@ pub fn run_cascade_from_heightmap(
for basin in &mut l1.drainage_basins {
basin.territorial_status = territorial_status.clone();
}
// T-1169: attach reserved names (D-223) to the strongest river-mouth
// and alpine-peak attractors. Cheap (two sorts + zips over the
// already-computed attractor list, no new terrain work) and
// deterministic given the caller-supplied pools — mirrors the
// TerritorialStatus stamp above in running once, right after Layer 1
// produces the attractors this reads.
let (river_assignments, mountain_assignments) =
layer1::attach_feature_names(&l1, river_names, mountain_names);
l1.feature_names = river_assignments
.into_iter()
.map(|(position, name)| layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::River,
})
.chain(
mountain_assignments
.into_iter()
.map(|(position, name)| layer1::FeatureNameAssignment {
position,
name,
feature_type: layer1::FeatureNameType::Mountain,
}),
)
.collect();
snapshot.layer1 = Some(l1);
snapshot.terrain_analysis = Some(ta);
}
@@ -456,6 +495,8 @@ pub fn run_cascade(
cities: &[CityRecord],
dominant_faction: Option<&str>,
body_params: Option<&BodyParams>,
river_names: &[String],
mountain_names: &[String],
up_to: CascadeLayer,
) -> Result<CascadeSnapshot, HeightmapLoadError> {
// Layer 0 — the cascade's input; always loaded.
@@ -466,6 +507,8 @@ pub fn run_cascade(
cities,
dominant_faction,
body_params,
river_names,
mountain_names,
up_to,
))
}
@@ -508,6 +551,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert_eq!(snap.body_id, "test_body");
@@ -525,12 +570,91 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
assert_eq!(l1.body_id, "test_body");
}
/// T-1169: `run_cascade_from_heightmap` attaches reserved names to the
/// strongest river-mouth/alpine attractors when the caller supplies name
/// pools — proves the cascade wiring (`attach_feature_names` call site
/// inside the Topography block), not just the function in isolation
/// (`layer1::tests` already covers `attach_feature_names` itself).
#[test]
fn topography_layer_attaches_feature_names_when_pools_supplied() {
let river_names = vec!["Kaltfluss".to_string(), "Silberbach".to_string()];
let mountain_names = vec!["Wiesenbach".to_string()];
let snap = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None, // body_params
&river_names,
&mountain_names,
CascadeLayer::Topography,
);
let l1 = snap.layer1.expect("Layer 1 should have run");
// The test heightmap's `0.6r + 0.4c` ramp crosses sea_level=0.3,
// producing real river-mouth/coastal attractors — assert against
// WHATEVER attach_feature_names actually paired, not a hardcoded
// count (the exact attractor set is an implementation detail of
// feature extraction, not this test's concern).
let river_attractor_count = l1
.attractors
.iter()
.filter(|a| {
a.attractor_type == crate::simulation::generator::AttractorType::RiverMouth
})
.count();
let expected_river_assignments = river_attractor_count.min(river_names.len());
let actual_river_assignments = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.count();
assert_eq!(
actual_river_assignments, expected_river_assignments,
"every river mouth (up to pool size) must get a name"
);
if expected_river_assignments > 0 {
let names: std::collections::BTreeSet<&str> = l1
.feature_names
.iter()
.filter(|f| f.feature_type == crate::atlas::layer1::FeatureNameType::River)
.map(|f| f.name.as_str())
.collect();
assert!(
names.iter().all(|n| river_names.contains(&n.to_string())),
"assigned names must come from the supplied pool"
);
}
// No pools supplied -> no assignments (the pre-wiring default).
let snap_no_pools = run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&[],
None,
None,
&[],
&[],
CascadeLayer::Topography,
);
assert!(
snap_no_pools
.layer1
.expect("Layer 1 should have run")
.feature_names
.is_empty(),
"empty pools must yield zero assignments"
);
}
#[test]
fn cascade_is_deterministic() {
let extract = |s: CascadeSnapshot| {
@@ -546,6 +670,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
let b = extract(run_cascade_from_heightmap(
@@ -554,6 +680,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Topography,
));
assert_eq!(
@@ -586,6 +714,8 @@ mod tests {
&[],
None,
None, // body_params
&[],
&[],
CascadeLayer::Heightmap,
);
assert!(res.is_err(), "missing heightmap must Err, not panic");
@@ -609,6 +739,8 @@ mod tests {
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::DistrictProfile,
)
};
@@ -668,6 +800,8 @@ mod tests {
&cities,
Some("concord_assembly"),
None, // body_params
&[],
&[],
CascadeLayer::Settlement,
)
};
@@ -756,6 +890,8 @@ mod tests {
&cities,
Some("independent"),
None, // body_params — road graph needs none
&[],
&[],
CascadeLayer::RoadGraph,
)
};
@@ -860,6 +996,8 @@ mod tests {
&cities,
Some("independent"),
None,
&[],
&[],
CascadeLayer::RoadGraph,
);
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
@@ -927,6 +1065,8 @@ mod tests {
&[],
None,
Some(&params),
&[],
&[],
CascadeLayer::Region,
)
};
@@ -980,6 +1120,8 @@ mod tests {
&[],
None,
None,
&[],
&[],
CascadeLayer::Region,
);
assert!(no_params.layer_region.is_none());