feat(simulation): D-232 three-phase trait draw + deviation/swerve system (T-994, T-1003)

T-994 — three-phase draw replacing the flat splitmix64 flavor pick:
- Phase 1 body K-draw (trait_draw.rs): hard gates -> soft weights (two-part
  geographic_sector join, PR #148) -> SeedChain::for_body-seeded weighted
  draw of K (5/3/1/0 by ComplexityTier), pins count toward K, coverage-aware
  over a new body-level district-type-mix aggregate threaded into
  CityGenerationContext at L3->L4 dispatch.
- Phase 2 district-dominant pick keyed by the D-243 2048m District cell —
  settlements sharing a cell independently derive the identical template.
  Resolved at dispatch, never in FillChunk (T-987 stays pure).
- trait_catalog_reader.rs: systems.db reader for trait_templates +
  atlas_body_trait_bias (D-225 resource pattern), registered in main.rs
  with graceful degradation.
- BlockSkeleton carries district_type; assign_block_tags is a pure lookup.

T-1003 — deviation/swerve system (trait_swerve.rs):
- ArchitectureFlavorRef is now InVocabulary(u8) | Swerve(tag).
- Rare per-building wildcard: foreign-import pool (other corridors +
  cross_corridor) driven by Epicenter/Passage tier, mixed faction,
  road-graph degree; heritage-callback pool (own-corridor heritage) driven
  by isolation, remote tier, founding_age_years. Integer bps, base 100,
  cap 300 per driver — placeholder constants pending calibration.
- Sparsity escape hatch = same mechanism, necessity-triggered at the
  phase-2 pick (deterministic max-weight over the hard-gate-eligible pool).
- New SeedDomains: TraitVocabulary(13) / TraitDistrict(14) / TraitSwerve(15).

Governance: D-232 amended (district pinned to the D-243 2048m tier — the
D-222/D-243 redefinition was never captured; swerve driver mappings +
placeholder rates recorded), D-229 flavor_ref field updated to the enum
shape, D-235 amended (T-995 vocabulary ratification note; rides here due
to single-file entanglement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 09:16:47 +02:00
co-authored by Claude Fable 5
parent 766ceb436c
commit 90655382eb
13 changed files with 2392 additions and 33 deletions
+232 -1
View File
@@ -19,15 +19,27 @@ use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
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::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus};
use crate::atlas::road_graph::{RoadGraph, RoadNode};
use crate::atlas::scale;
use crate::atlas::skeleton_gen::derive_complexity;
use crate::atlas::source_resolver::BodySourceResolverResource;
use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate};
use crate::atlas::trait_draw::{
complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type,
VocabularyDrawInputs,
};
use crate::atlas::trait_swerve::{
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
};
use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{MaintenanceAuthority, MorphologyZone};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::tick_phases::TickPhase;
@@ -100,6 +112,7 @@ fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
rng: Option<Res<SimRng>>,
) {
for completion in queue.drain_completions() {
@@ -116,6 +129,19 @@ fn drain_generation_completions(
let reader = &city_reader.0;
let world_seed = rng.seed();
let body_id = state.body_id.clone();
// ── 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
// build the body-wide coverage aggregate here and to build
// that placement's own skeleton work item below, so this
// dispatch pass makes exactly one `read_set` DB round trip per
// settlement (same as before this ticket).
let mut resolved: Vec<(&CityPlacement, CityEconomicReadSet)> = Vec::new();
let mut body_district_type_mix: std::collections::BTreeSet<DistrictType> =
Default::default();
let mut max_prosperity_bps: u32 = 0;
let mut max_k: usize = 0;
for placement in &state.placements {
let read_set = match reader.read_set(placement.city_id, world_seed) {
Ok(rs) => rs,
@@ -129,6 +155,91 @@ fn drain_generation_completions(
continue;
}
};
max_prosperity_bps =
max_prosperity_bps.max(read_set.prosperity_baseline_bps);
// Re-derives the identical DistrictType mix
// `generate_quarter_skeleton` computes later for this same
// placement (same chain, same inputs) — cheap (a 16-draw
// seeded LCG) and gives the aggregation loop the *actual*
// district-type mix rather than a proxy.
let mix_chain = SeedChain::for_body(world_seed, &body_id)
.derive(SeedDomain::Layer4Quarter, placement.city_id);
let mix = compute_district_mix(
read_set.population,
&read_set.economic_role,
&placement.political_archetype,
16,
mix_chain,
);
body_district_type_mix.extend(mix.districts);
// world_tier has no real derivation yet (city_context_reader's
// #TBD stub, always Waypoint) — tracked here via population
// tier alone so a future world_tier derivation slots into
// this MAX-K aggregate without revisiting this loop.
let tier = derive_complexity(
&WorldTier::Waypoint,
population_tier(read_set.population),
read_set.population,
);
max_k = max_k.max(complexity_k(&tier));
resolved.push((placement, read_set));
}
let body_district_type_mix: Vec<DistrictType> =
body_district_type_mix.into_iter().collect();
// Phase-1 K-draw (D-232): computed once, shared by every
// settlement on this body — the closed-vocabulary invariant.
// An absent reader (tests) degrades to an empty catalog:
// draw/pools/eligible all no-op identically.
let (catalog, bias): (Vec<TraitTemplate>, Vec<TraitBias>) =
match trait_catalog.as_ref() {
Some(tc) => (
tc.0.read_catalog().unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "trait catalog read failed — empty vocabulary");
Vec::new()
}),
tc.0.read_body_bias(&body_id).unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "trait bias read failed — no bias applied");
Vec::new()
}),
),
None => (Vec::new(), Vec::new()),
};
let body_sector: Option<&str> = resolved
.first()
.and_then(|(_, rs)| rs.geographic_sector.as_deref());
// dominant_bulk_class/dominant_production_ubiquity are
// #982 design-blocked stubs (always NonPhysical/Common) —
// see CityGenerationContext's field docs.
let inputs = VocabularyDrawInputs {
k: max_k,
dominant_bulk_class: &BulkClass::NonPhysical,
dominant_production_ubiquity: &ProductionUbiquity::Common,
max_prosperity_bps,
geographic_sector: body_sector,
coverage_district_types: &body_district_type_mix,
};
// Hard-gate-eligible pool — shared by the K-draw, the T-1003
// swerve pools, and the phase-2 necessity escape hatch (the
// swerve is cultural-only; the D-233 gates always hold).
let eligible = hard_gate_eligible(&catalog, &inputs);
let trait_selection = draw_body_vocabulary(
&catalog,
&bias,
&inputs,
SeedChain::for_body(world_seed, &body_id),
);
let swerve_pools = build_swerve_pools(&eligible, &trait_selection, body_sector);
let vocab = BodyVocabularyContext {
trait_selection: &trait_selection,
body_district_type_mix: &body_district_type_mix,
catalog: &catalog,
eligible: &eligible,
swerve_pools: &swerve_pools,
};
for (placement, read_set) in resolved {
queue.submit(
build_skeleton_work_item(
&body_id,
@@ -137,6 +248,7 @@ fn drain_generation_completions(
read_set,
&state.districts,
&state.road_graph,
&vocab,
),
GenPriority::Low,
);
@@ -195,6 +307,53 @@ fn drain_generation_completions(
}
}
/// Body-level D-232 trait-vocabulary draw outputs, threaded into
/// [`build_skeleton_work_item`] (T-994). Bundled into one struct purely to keep
/// that function's argument count under the clippy `too_many_arguments`
/// threshold — see its doc comment.
struct BodyVocabularyContext<'a> {
/// Phase-1 K-draw result (D-232), computed once per body by the caller —
/// identical for every settlement on the body (the closed-vocabulary
/// invariant). Empty when no `TraitCatalogReaderResource` is wired (tests)
/// or the body's K is 0 (`ComplexityTier::Empty` everywhere on the body).
trait_selection: &'a [String],
/// Every `DistrictType` present anywhere on the body (T-994 coverage
/// aggregate) — threaded onto `CityGenerationContext.body_district_type_mix`
/// verbatim (design point 4: visible on the context, not just consumed
/// internally by the draw).
body_district_type_mix: &'a [DistrictType],
/// The full trait-template catalog, needed to resolve phase 2
/// (`district_dominant_by_type`) for each settlement's District cell —
/// `zone_affinity` lives on the catalog row, not on `trait_selection`'s tags.
catalog: &'a [TraitTemplate],
/// Hard-gate-eligible subset of `catalog` (T-1003) — the phase-2 necessity
/// escape hatch reaches this pool when the vocabulary can't serve a district
/// type (the swerve is cultural-only; D-233 gates always hold).
eligible: &'a [&'a TraitTemplate],
/// Body-level T-1003 swerve candidate pools (foreign-import /
/// heritage-callback), cloned onto each settlement's context — the
/// per-building wildcard draws from these at `assign_block_tags` time.
swerve_pools: &'a SwervePools,
}
/// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's
/// centrality (high) / isolation (low) driver input. 0 when the city has no
/// node or no edges (matching `road_entry_directions_for_city`'s fallback).
fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
let Some(idx) = road_graph
.nodes
.iter()
.position(|n: &RoadNode| n.city_id == Some(city_id))
else {
return 0;
};
road_graph
.edges
.iter()
.filter(|e| e.from == idx || e.to == idx)
.count() as u32
}
/// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement
/// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set
/// (mirroring
@@ -223,6 +382,12 @@ fn drain_generation_completions(
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
/// city)` — not the `city_id * 10` placeholder.
///
/// `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
/// specific settlement's District cell. Bundled into one struct to keep this
/// function's argument count under the clippy `too_many_arguments` threshold.
///
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
fn build_skeleton_work_item(
body_id: &str,
@@ -231,6 +396,7 @@ fn build_skeleton_work_item(
read_set: CityEconomicReadSet,
districts: &BTreeMap<DistrictPos, DistrictProfile>,
road_graph: &RoadGraph,
vocab: &BodyVocabularyContext,
) -> 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
@@ -238,6 +404,8 @@ fn build_skeleton_work_item(
let economic_role = read_set.economic_role.clone();
let population = read_set.population;
let founding_age_years = read_set.founding_age_years;
// T-1003 driver input (cosmopolitanism) — captured here for the same reason.
let faction_mixed = read_set.dominant_faction.as_deref() == Some("mixed");
let mut context = context_from_read_set(placement.city_id, read_set);
@@ -276,6 +444,44 @@ fn build_skeleton_work_item(
}
};
// ── T-994 / D-232: three-phase trait-template draw ──────────────────────────
// Phase 1 (trait_selection) and its inputs (body_district_type_mix) were
// computed once per body by the caller (drain_generation_completions) — the
// closed-vocabulary invariant requires every settlement on the body to carry
// the identical `trait_selection`, so this function only threads it through,
// never re-derives it. Phase 2 (district_dominant_by_type) IS settlement-
// specific (keyed by this settlement's own District cell) and is resolved
// here, at dispatch time — not inside the GenerateSkeleton Rayon task, and
// never inside FillChunk (T-987 keeps fill pure/cache-free).
context.trait_selection = vocab.trait_selection.to_vec();
context.body_district_type_mix = vocab.body_district_type_mix.to_vec();
context.settlement_district_pos = district_pos;
context.district_dominant_by_type = pick_district_dominant_by_type(
vocab.catalog,
vocab.eligible,
vocab.trait_selection,
SeedChain::for_body(world_seed, body_id),
district_pos,
);
// ── T-1003 / D-232: deviation/swerve driver rates + candidate pools ─────────
// Pools are body-level (same eligible catalog + vocabulary everywhere on the
// body); the driver RATES are per-settlement — centrality/isolation from the
// road graph, cosmopolitanism from the faction read, conservatism from
// founding age. `context.world_tier` rides the reader's Waypoint stub today
// (same caveat as the K aggregation) — Epicenter/Passage multipliers activate
// once a real derivation lands.
let drivers = SwerveDrivers {
world_tier: &context.world_tier,
faction_mixed,
road_degree: road_degree_for_city(placement.city_id, road_graph),
founding_age_years,
};
let rates = compute_swerve_rates(&drivers);
context.swerve_rates_bps = (rates.foreign_bps, rates.heritage_bps);
context.swerve_foreign_pool = vocab.swerve_pools.foreign.clone();
context.swerve_heritage_pool = vocab.swerve_pools.heritage.clone();
// ── T-1043: road_entry_directions from road_graph ───────────────────────────
// Find this city's settlement node index in the road graph (O(n) scan on a
// small slice — settlement counts are single-digit to low hundreds per body).
@@ -542,6 +748,24 @@ mod tests {
dominant_faction: None,
founding_age_years: 200,
settlement_class: SettlementClass::PopulationBudget,
geographic_sector: None,
}
}
/// Empty D-232 draw context (T-994/T-1003) — no catalog reader wired,
/// matching the production behaviour when `TraitCatalogReaderResource` is
/// absent.
fn empty_vocab() -> BodyVocabularyContext<'static> {
static EMPTY_POOLS: SwervePools = SwervePools {
foreign: Vec::new(),
heritage: Vec::new(),
};
BodyVocabularyContext {
trait_selection: &[],
body_district_type_mix: &[],
catalog: &[],
eligible: &[],
swerve_pools: &EMPTY_POOLS,
}
}
@@ -601,6 +825,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
@@ -639,6 +864,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) else {
unreachable!()
};
@@ -699,6 +925,7 @@ mod tests {
sample_read_set(),
&districts,
&RoadGraph::default(),
&empty_vocab(),
) else {
panic!("expected GenerateSkeleton");
};
@@ -731,6 +958,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) else {
panic!("expected GenerateSkeleton")
};
@@ -918,6 +1146,7 @@ mod tests {
sample_read_set(),
&districts,
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
@@ -1188,6 +1417,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&road_graph,
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
@@ -1251,6 +1481,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");