feat(simulation): add Layer-2 inter-settlement road/rail graph (T-1038)

New deterministic cascade layer (CascadeLayer::RoadGraph) building the
transport topology between Layer-3 settlement placements, stored on
BodyWorldState.road_graph (session-cached, never serialized).

- MST force-connect (strict Kruskal over squared distance) + secondary
  links across a 30-60% body-scale band where the MST detours >1.5x.
- A* over the Layer-1 terrain-cost field on a coarse ~64-wide routing grid:
  ocean/lake impassable, river corridors 1/2 cost, slope >=35deg (D-210
  roughness ~0.7) 10x, else the D-210 terrain_modification_cost. Columns
  wrap (equirectangular); integer costs + index tie-break keep A*
  deterministic.
- MaintenanceAuthority per edge (new generator.rs enum) from endpoint
  political archetypes + haul length; waypoint nodes at long-edge midpoints;
  named-route identity join (designed-for, pool empty post-D-223); junction
  detection (high_connectivity_junctions, degree>=3).

Wired into the cascade (gen_queue requests CascadeLayer::RoadGraph). Adds
Copy to PoliticalArchetype. 10 road_graph unit tests + a cascade e2e test;
full suite + clippy --all-targets -D warnings green.

Deferred to T-1076 (gated on the D-242 settlement model): the RailHeadFacing
junction pass (needs a D-213 FoundingOrientation amendment + Layer-4 handling)
and the hub-selection refinement (needs baked population/specialization).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:13:26 +02:00
co-authored by Claude Opus 4.8
parent 301be77b49
commit c0d4ac89d1
7 changed files with 1249 additions and 28 deletions
+6
View File
@@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityPlacement;
use crate::atlas::region_profile::{RegionPos, RegionProfile};
use crate::atlas::road_graph::RoadGraph;
use crate::simulation::generator::{
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
};
@@ -84,6 +85,10 @@ pub struct BodyWorldState {
/// Settlement placements (D-211, #955). Attractor-matched city positions.
/// Empty until the Layer-3 placement task completes.
pub placements: Vec<CityPlacement>,
/// Inter-settlement road/rail graph (D-211, T-1038). Session-cached, never
/// serialized — re-derived from `(placements, terrain, seed)` on resume.
/// Empty until the Layer-2 road task completes (needs placements + terrain).
pub road_graph: RoadGraph,
/// Quarter-level world state, keyed by `QuarterId` (D-230).
///
/// Populated by `GenCompletion::SkeletonGenerated` after the plan phase
@@ -216,6 +221,7 @@ mod tests {
drainage_basins: vec![],
attractors: vec![],
placements: vec![],
road_graph: RoadGraph::default(),
quarters: BTreeMap::new(),
regions: BTreeMap::new(),
last_accessed: tick,
+145 -18
View File
@@ -28,6 +28,7 @@ use crate::atlas::features::TerrainAnalysis;
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
use crate::atlas::layer1::{self, Layer1Output};
use crate::atlas::region_profile::{self, BodyParams, RegionPos, RegionProfile};
use crate::atlas::road_graph::{self, RoadGraph};
use crate::seed::SeedChain;
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
@@ -49,6 +50,12 @@ pub enum CascadeLayer {
/// `(seed, body_params, terrain_analysis)`. Appended after Settlement so
/// declaration order (= Ord) is preserved — never reorder (D-010).
RegionProfile,
/// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure function
/// of `(Layer-3 placements, Layer-1 terrain)`; RNG-free. Semantically "Layer
/// 2", but it depends only on Settlement + Topography, so it is **appended
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
/// the RegionProfile layer; requesting it runs RegionProfile first, harmlessly).
RoadGraph,
}
/// Output of the cascade for one body, up to the requested layer (#952).
@@ -70,6 +77,9 @@ pub struct CascadeSnapshot {
/// RegionProfile layer — ~1 km carriers. `Some` once
/// [`CascadeLayer::RegionProfile`] has run (T-1023, D-239 §1).
pub layer_region: Option<LayerRegionOutput>,
/// Layer 2 — inter-settlement road/rail graph. `Some` once
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
pub road_graph: Option<RoadGraph>,
}
/// RegionProfile layer output (T-1023, D-239 §1): per-region (~1 km) terrain
@@ -98,6 +108,7 @@ impl CascadeSnapshot {
};
let placements = self.layer3.map(|l3| l3.placements).unwrap_or_default();
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
let road_graph = self.road_graph.unwrap_or_default();
BodyWorldState {
body_id: self.body_id,
heightmap: self.heightmap.data,
@@ -107,6 +118,7 @@ impl CascadeSnapshot {
drainage_basins,
attractors,
placements,
road_graph,
quarters: std::collections::BTreeMap::new(),
regions,
last_accessed: 0,
@@ -173,6 +185,7 @@ pub fn run_cascade_from_heightmap(
layer1: None,
layer3: None,
layer_region: None,
road_graph: None,
};
// TerritorialStatus is derived once per body from the system's dominant
@@ -210,28 +223,35 @@ pub fn run_cascade_from_heightmap(
snapshot.layer3 = Some(l3);
}
// RegionProfile layer (T-1023, D-239 §1) — pure derivation from body params +
// terrain analysis. Needs a TerrainAnalysis, which needs a drainage pass.
// Layer 1 already ran drainage inside run_layer1, but neither the drainage
// result nor the TerrainAnalysis is stored on Layer1Output, so we re-run both
// here. Pure → determinism preserved, but the drainage re-run is NOT free at
// the ~6 000-regions/body working scale (D-203).
// RegionProfile (T-1023, D-239 §1) and RoadGraph (Layer 2, T-1038) both need a
// TerrainAnalysis, which needs a drainage pass. Layer 1 already ran drainage
// inside run_layer1, but neither result is stored on Layer1Output, so we re-run
// both here once and share them. Pure → determinism preserved, but the drainage
// re-run is NOT free at the ~6 000-regions/body working scale (D-203).
// PERF/TODO(T-1044): cache TerrainAnalysis on Layer1Output to drop this
// redundant drainage pass, and validate the combined cost against the D-239 §10
// ~45 ms/body budget in the T-1031 verification harness. This is now a LIVE
// ~45 ms/body budget in the T-1031 verification harness. This is a LIVE
// production cost: T-1032 wired the real body_params read, so every analyzed
// body runs this path. If body_params is None, the region layer is skipped
// (e.g. unit tests without DB).
if up_to >= CascadeLayer::RegionProfile {
// body runs this path.
//
// The terrain analysis is computed only when it will actually be used:
// body_params present (RegionProfile) or RoadGraph requested. RegionProfile
// skips silently without body_params (e.g. unit tests without DB), but the
// road graph needs no body params, so RoadGraph runs regardless.
if up_to >= CascadeLayer::RegionProfile
&& (body_params.is_some() || up_to >= CascadeLayer::RoadGraph)
{
use crate::atlas::drainage;
let dr = drainage::analyze(
&snapshot.heightmap.data,
snapshot.heightmap.width,
snapshot.heightmap.height,
snapshot.heightmap.sea_level,
);
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
// RegionProfile layer — pure derivation from body params + terrain.
if let Some(params) = body_params {
use crate::atlas::drainage;
let dr = drainage::analyze(
&snapshot.heightmap.data,
snapshot.heightmap.width,
snapshot.heightmap.height,
snapshot.heightmap.sea_level,
);
let ta = TerrainAnalysis::analyze(&snapshot.heightmap, &dr);
// ~8 cells per region on a 128×64 working grid → ~80×32 = ~2 560 regions;
// at full working resolution the budget is ~6 000/body (D-203).
const CELLS_PER_REGION: usize = 8;
@@ -239,6 +259,33 @@ pub fn run_cascade_from_heightmap(
region_profile::derive_all_regions(body_seed, params, &ta, CELLS_PER_REGION);
snapshot.layer_region = Some(LayerRegionOutput { regions });
}
// Layer 2 — inter-settlement road/rail graph (D-211, T-1038). Pure
// function of (Layer-3 placements, Layer-1 terrain). The named-route pool
// is empty for now (atlas_roads/atlas_railroads carry no rows post-D-223),
// so the named-route identity join is a designed-for no-op.
if up_to >= CascadeLayer::RoadGraph {
let placements = snapshot
.layer3
.as_ref()
.map(|l3| l3.placements.as_slice())
.unwrap_or(&[]);
let river_cells = snapshot
.layer1
.as_ref()
.map(|l1| l1.river_network.river_cells.as_slice())
.unwrap_or(&[]);
let graph = road_graph::build_road_graph(
placements,
&ta,
river_cells,
snapshot.heightmap.width,
snapshot.heightmap.height,
&territorial_status,
&[],
);
snapshot.road_graph = Some(graph);
}
}
snapshot
@@ -371,6 +418,7 @@ mod tests {
assert!(CascadeLayer::Heightmap < CascadeLayer::Topography);
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
assert!(CascadeLayer::Settlement < CascadeLayer::RegionProfile);
assert!(CascadeLayer::RegionProfile < CascadeLayer::RoadGraph);
}
#[test]
@@ -511,4 +559,83 @@ mod tests {
placement_count
);
}
/// Layer 2 — road graph runs through the full cascade, connects the placed
/// cities, is deterministic, and propagates into BodyWorldState (T-1038).
#[test]
fn road_graph_layer_connects_cities_deterministically() {
use crate::atlas::attractor_matching::CityRecord;
use crate::atlas::road_graph::RoadNodeKind;
use crate::simulation::generator::SettlementClass;
// Several inland cities (the slope heightmap is land away from the low
// corner) so the MST has real edges to route.
let cities: Vec<CityRecord> = [
(1u64, "A", 800_000i64),
(2, "B", 400_000),
(3, "C", 200_000),
(4, "D", 150_000),
]
.iter()
.map(|(id, name, pop)| CityRecord {
city_id: *id,
name: (*name).into(),
settlement_class: SettlementClass::PopulationBudget,
population: *pop,
economic_role: "manufacturing".into(),
})
.collect();
let run = || {
run_cascade_from_heightmap(
body_seed(),
test_heightmap(),
&cities,
Some("independent"),
None, // body_params — road graph needs none
CascadeLayer::RoadGraph,
)
};
let snap = run();
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
let settlements = graph
.nodes
.iter()
.filter(|n| n.kind == RoadNodeKind::Settlement)
.count();
assert_eq!(settlements, cities.len(), "one road node per placed city");
assert!(
!graph.edges.is_empty(),
"placed cities on shared land must be connected"
);
// Every edge endpoint is a settlement node and the path snaps to it.
for e in &graph.edges {
assert!(e.from < e.to);
assert_eq!(graph.nodes[e.from].position, *e.path.first().unwrap());
assert_eq!(graph.nodes[e.to].position, *e.path.last().unwrap());
}
// Determinism: identical inputs → identical graph.
let key = |s: &CascadeSnapshot| {
let g = s.road_graph.as_ref().unwrap();
(
g.nodes
.iter()
.map(|n| (n.city_id, n.position, n.degree))
.collect::<Vec<_>>(),
g.edges
.iter()
.map(|e| (e.from, e.to, e.length_cells, e.maintenance))
.collect::<Vec<_>>(),
)
};
assert_eq!(key(&snap), key(&run()), "road graph must be deterministic");
// Propagates into the hot-cache BodyWorldState.
let edge_count = graph.edges.len();
assert_eq!(
snap.into_body_world_state().road_graph.edges.len(),
edge_count
);
}
}
+7 -9
View File
@@ -374,15 +374,13 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
} else {
hm
};
// Run through RegionProfile (T-1023, D-239 §1): includes Settlement
// and all prior layers. RegionProfile > Settlement in CascadeLayer ord
// so Settlement also runs when body_params is Some. When body_params
// is None the cascade falls back to Settlement as the terminal layer.
let up_to = if body_params.is_some() {
CascadeLayer::RegionProfile
} else {
CascadeLayer::Settlement
};
// Run the full cascade through RoadGraph (Layer 2, T-1038), the
// terminal layer. It subsumes Settlement, RegionProfile (T-1023),
// and all prior layers. RegionProfile derivation still gates on
// body_params internally (skipped when absent — e.g. a body with no
// params row), but the road graph needs no body params, so it runs
// for every analyzed body.
let up_to = CascadeLayer::RoadGraph;
let snapshot = run_cascade_from_heightmap(
*body_seed,
working,
+1
View File
@@ -270,6 +270,7 @@ mod tests {
drainage_basins: vec![],
attractors: vec![],
placements: vec![],
road_graph: crate::atlas::road_graph::RoadGraph::default(),
quarters: std::collections::BTreeMap::new(),
regions: std::collections::BTreeMap::new(),
last_accessed: 0,
+1
View File
@@ -20,6 +20,7 @@ pub mod layer1;
pub mod layer_proxy;
pub mod plugin;
pub mod region_profile;
pub mod road_graph;
pub mod skeleton_gen;
pub mod source_resolver;
pub mod subbiome;
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -373,7 +373,7 @@ pub enum SettlementClass {
/// Dominant power structure of a settlement and its physical spatial expression.
/// Derived from TerritorialStatus + economic_role at generation time.
/// Source: D-214
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum PoliticalArchetype {
/// Top-down Commission planning; rectilinear, institutional core, radial-core arrangement.
Commission,
@@ -406,6 +406,29 @@ pub enum FoundingOrientation {
Free { bearing_degrees: u16 },
}
/// Who maintains an inter-settlement road/rail edge — readable in the road's
/// condition (Ozzie: "you can tell who controls territory by the state of the
/// roads"). Derived at Layer-2 road generation from the endpoint settlements'
/// political archetypes + the edge's haul length (no separate authoring).
/// Five-value vocabulary frozen by the cascade workshop (workshop-outcomes.md
/// "OQ-R3-4"); see [`crate::atlas::road_graph::maintenance_authority`].
/// Source: D-211 (Layer-2 transport), D-212 (territorial status input).
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MaintenanceAuthority {
/// Government-built/maintained — a Commission (or strategic Military) endpoint.
Administrative,
/// Corporation-maintained — a Corporate endpoint (e.g. road to a corp mine).
Corporate,
/// Locally maintained short links between neighbouring small settlements.
Communal,
/// Long-haul inter-regional routes between distant non-state settlements.
Trade,
/// Roads that exist but are no longer maintained (a Derelict-province
/// endpoint, or runtime abandonment). Not reachable from the current
/// authored data alone (D-212 Derelict is deferred) — wired for forward use.
Abandoned,
}
/// Territory control status for a province (drainage basin). Priority-ordered derivation.
/// Source: D-212 (amended 2026-06-05 — see `AutonomistHeld` + the dominant_faction
/// mapping note on the variant docs and in `attractor_matching::territorial_status_from_faction`).