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:
+232
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user