//! D-232 deviation/swerve system (T-1003) — the rare per-building wildcard that //! draws a COHERENT whole template from *outside* the body's closed K-vocabulary. //! //! **Three sources, two opposed active drivers (D-232):** //! - *foreign import* — another corridor's grammar (that corridor's baseline + //! the shared cross-corridor pool), driven **up** by cosmopolitanism / //! centrality / transit / Epicenter tier; //! - *heritage callback* — the body's own corridor **heritage sub-pool**, driven //! **up** by remoteness / isolation / conservatism; //! - the passive *past-vogue holdover* is **not** drawn here — it rides the //! D-217 wear/era condition layer (`era_cause`), no new mechanism (D-232: //! "the temporal sibling of the spatial swerve"). //! //! The swerve is **cultural only**: candidate pools are built from the //! hard-gate-*eligible* catalog (D-233 economic gates still hold — "function //! still passes the normal economic hard gates; never an axis-scramble"). //! //! The **sparsity escape hatch is the same mechanism** triggered by necessity //! rather than dice ([`necessity_swerve`]): when the closed vocabulary genuinely //! cannot serve a district type, the phase-2 dominant pick reaches the full //! eligible catalog (see `trait_draw::pick_district_dominant_by_type`). //! //! Like `trait_draw`, this module is pure — the driver rates and candidate //! pools are resolved once per settlement at L3→L4 dispatch time //! (`atlas::plugin`) and threaded through `CityGenerationContext`; the //! per-building roll happens in `skeleton_gen::assign_block_tags` off the //! footprint's own `SeedChain` (never in `FillChunk` — T-987 keeps fill pure). //! //! All numbers are integer basis points (D-010). Every constant below is a //! **T-1003 refinement placeholder** (base 100 bps ≈ 1 %/building, hard cap //! 300 bps per driver) — needs Nigel/Burnelli calibration once real //! multi-corridor bodies are authored, same status as //! `trait_draw::SECTOR_MISMATCH_BPS`. use crate::atlas::trait_catalog_reader::TraitTemplate; use crate::seed::AtlasRng; use crate::simulation::generator::WorldTier; /// Baseline per-building wildcard chance (bps of 10 000) before driver scaling. const SWERVE_BASE_BPS: u32 = 100; /// Hard cap per driver after scaling (refinement: "hard cap 300 bps"). const SWERVE_DRIVER_CAP_BPS: u32 = 300; // ── Foreign-import driver multipliers (bps, 10 000 = 1.0×) ────────────────── /// Epicenter tier — the cosmopolitan hub end of the dial. const FOREIGN_EPICENTER_MULT_BPS: u32 = 20_000; /// Passage tier — the refinement's "transit" input. const FOREIGN_PASSAGE_MULT_BPS: u32 = 15_000; /// `dominant_faction == "mixed"` — the refinement's "cosmopolitanism" input. const FOREIGN_MIXED_FACTION_MULT_BPS: u32 = 15_000; /// Per road/rail-graph link (centrality), additive on the multiplier. const FOREIGN_PER_ROAD_DEGREE_BPS: u32 = 1_000; /// Degree contribution cap — beyond 5 links a hub is a hub. const FOREIGN_ROAD_DEGREE_CAP: u32 = 5; // ── Heritage-callback driver multipliers ───────────────────────────────────── /// Road/rail degree ≤ 1 — the refinement's "isolation" input. const HERITAGE_ISOLATED_MULT_BPS: u32 = 20_000; /// Waypoint/Backwater tier — remoteness proxy. (The refinement floated a /// `star_systems.dist_ly` percentile; that column is not in the D-199 read-set /// today, and tier + graph degree are the in-world signals the percentile was /// approximating. Slot a distance band in here if a reader field ever lands.) const HERITAGE_REMOTE_TIER_MULT_BPS: u32 = 15_000; /// `founding_age_years ≥ 300` — the refinement's "conservatism" input. const HERITAGE_OLD_FOUNDING_MULT_BPS: u32 = 15_000; /// `founding_age_years ≥ 150` (and < 300). const HERITAGE_MID_FOUNDING_MULT_BPS: u32 = 12_500; /// Per-settlement driver inputs, all resolvable at L3→L4 dispatch time from /// data that exists today (T-1003 refinement: "mappings to REAL fields"). /// /// `world_tier` currently rides the `city_context_reader` Waypoint stub in /// production (same caveat as the T-994 `max_k` aggregation) — the Epicenter/ /// Passage multipliers activate for real once a `world_tier` derivation lands. pub struct SwerveDrivers<'a> { pub world_tier: &'a WorldTier, /// `dominant_faction == Some("mixed")` (cosmopolitanism). pub faction_mixed: bool, /// This settlement's node degree in the T-1038 road/rail graph /// (centrality high — isolation low). pub road_degree: u32, /// D-199 field 5 (conservatism: older settlements reach back harder). pub founding_age_years: u32, } /// Per-building swerve chances (bps of 10 000), one per active driver. A /// driver whose candidate pool is empty contributes no roll mass — enforced /// inside [`roll_building_swerve`], not here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SwerveRates { pub foreign_bps: u32, pub heritage_bps: u32, } /// Scale the base rate by the two opposed driver stacks (D-232). A settlement /// can plausibly score on both (an old, well-connected regional town keeps a /// nonzero heritage pull) — the drivers oppose in *what they favour*, not as a /// zero-sum split. pub fn compute_swerve_rates(drivers: &SwerveDrivers) -> SwerveRates { let mut foreign_mult: u64 = 10_000; match drivers.world_tier { WorldTier::Epicenter => foreign_mult = FOREIGN_EPICENTER_MULT_BPS as u64, WorldTier::Passage => foreign_mult = FOREIGN_PASSAGE_MULT_BPS as u64, _ => {} } if drivers.faction_mixed { foreign_mult = foreign_mult * FOREIGN_MIXED_FACTION_MULT_BPS as u64 / 10_000; } foreign_mult += (drivers.road_degree.min(FOREIGN_ROAD_DEGREE_CAP) * FOREIGN_PER_ROAD_DEGREE_BPS) as u64; let mut heritage_mult: u64 = 10_000; if drivers.road_degree <= 1 { heritage_mult = HERITAGE_ISOLATED_MULT_BPS as u64; } if matches!( drivers.world_tier, WorldTier::Waypoint | WorldTier::Backwater ) { heritage_mult = heritage_mult * HERITAGE_REMOTE_TIER_MULT_BPS as u64 / 10_000; } if drivers.founding_age_years >= 300 { heritage_mult = heritage_mult * HERITAGE_OLD_FOUNDING_MULT_BPS as u64 / 10_000; } else if drivers.founding_age_years >= 150 { heritage_mult = heritage_mult * HERITAGE_MID_FOUNDING_MULT_BPS as u64 / 10_000; } SwerveRates { foreign_bps: ((SWERVE_BASE_BPS as u64 * foreign_mult / 10_000) as u32) .min(SWERVE_DRIVER_CAP_BPS), heritage_bps: ((SWERVE_BASE_BPS as u64 * heritage_mult / 10_000) as u32) .min(SWERVE_DRIVER_CAP_BPS), } } /// The two out-of-vocabulary candidate pools, weighted by `base_weight` /// (hero-body bias intentionally not applied — bias shapes the body's *own* /// vocabulary, D-232; a swerve is by definition from elsewhere/elsewhen). /// Tags, not indices: the swerve result is recorded as /// `ArchitectureFlavorRef::Swerve(tag)`, re-derivable like everything else. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SwervePools { /// Foreign import: eligible templates outside the vocabulary from *another* /// corridor's grammar or the shared `cross_corridor` pool. pub foreign: Vec<(String, u32)>, /// Heritage callback: eligible `heritage`-pool templates of the body's own /// corridor (or sector-unpinned heritage entries). pub heritage: Vec<(String, u32)>, } /// Partition the hard-gate-eligible catalog (minus the body's own vocabulary) /// into the two swerve pools (D-232 corridor = two-part pool). /// /// `eligible` must already have passed the D-233 hard gates /// (`trait_draw::hard_gate_eligible`) — the cultural-only rule. pub fn build_swerve_pools( eligible: &[&TraitTemplate], trait_selection: &[String], body_sector: Option<&str>, ) -> SwervePools { let mut pools = SwervePools::default(); for t in eligible { if trait_selection.iter().any(|tag| tag == &t.tag) { continue; // in-vocabulary — the closed draw already covers it } let entry = (t.tag.clone(), t.base_weight.max(1)); match t.corridor_pool.as_str() { // Another corridor's baseline grammar, or the shared cross-corridor // pool that D-232 says "feeds the foreign-import swerves". "cross_corridor" => pools.foreign.push(entry), "baseline" => match (t.geographic_sector.as_deref(), body_sector) { (Some(sector), Some(own)) if sector != own => pools.foreign.push(entry), (Some(_), None) => pools.foreign.push(entry), _ => {} // own-corridor (or unpinned) baseline — not foreign }, // The body's own corridor heritage sub-pool ("the remoteness dial // draws specifically from it"). Other corridors' heritage is *not* // a foreign-import source — D-232 scopes foreign import to grammar, // heritage callback to one's own past. "heritage" => match (t.geographic_sector.as_deref(), body_sector) { (Some(sector), Some(own)) if sector == own => pools.heritage.push(entry), (None, _) => pools.heritage.push(entry), _ => {} }, other => { tracing::debug!(tag = %t.tag, corridor_pool = %other, "unknown corridor_pool — excluded from swerve pools"); } } } pools } /// Weighted pick from one pool. `None` on an empty pool. fn pick_weighted(pool: &[(String, u32)], rng: &mut AtlasRng) -> Option { if pool.is_empty() { return None; } let total: u64 = pool.iter().map(|(_, w)| *w as u64).sum(); let mut roll = (rng.next_u32() as u64) % total.max(1); for (tag, w) in pool { if roll < *w as u64 { return Some(tag.clone()); } roll -= *w as u64; } pool.last().map(|(tag, _)| tag.clone()) } /// The per-building wildcard roll (D-232 deviation system). One `u32` roll in /// `[0, 10 000)`: below `foreign_bps` → foreign-import pick; below /// `foreign_bps + heritage_bps` → heritage-callback pick; otherwise `None` /// (the overwhelmingly common case — the building takes the district-dominant /// template as usual). An empty pool's driver contributes no roll mass — a hit /// would have nothing to draw. /// /// Takes the pools as slices (the `CityGenerationContext` fields) so per-block /// callers never construct anything. `rng` must be a footprint-scoped stream /// (`SeedDomain::TraitSwerve` off the footprint's own chain) so the roll is /// deterministic per building (D-010) and uncorrelated with the zone/era/ /// extent draws. pub fn roll_building_swerve( rates: SwerveRates, foreign_pool: &[(String, u32)], heritage_pool: &[(String, u32)], rng: &mut AtlasRng, ) -> Option { let foreign_bps = if foreign_pool.is_empty() { 0 } else { rates.foreign_bps }; let heritage_bps = if heritage_pool.is_empty() { 0 } else { rates.heritage_bps }; let total = foreign_bps + heritage_bps; if total == 0 { return None; } let roll = rng.next_u32() % 10_000; if roll < foreign_bps { pick_weighted(foreign_pool, rng) } else if roll < total { pick_weighted(heritage_pool, rng) } else { None } } /// The sparsity escape hatch (D-232: "the SAME mechanism triggered by necessity /// rather than dice"): when zero vocabulary templates serve a district type at /// phase-2 dominant-pick time, reach the full hard-gate-eligible catalog for /// the best-weighted template that covers it. Deterministic (max-weight, /// last-on-tie over the catalog's stable tag-sorted order — D-010), no dice: /// necessity is not random. pub fn necessity_swerve( eligible: &[&TraitTemplate], covers: impl Fn(&TraitTemplate) -> bool, ) -> Option { eligible .iter() .filter(|t| covers(t)) .max_by_key(|t| t.base_weight) .map(|t| t.tag.clone()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::seed::{SeedChain, SeedDomain}; use std::collections::BTreeMap; fn tmpl(tag: &str, pool: &str, sector: Option<&str>, base_weight: u32) -> TraitTemplate { TraitTemplate { tag: tag.to_string(), corridor_pool: pool.to_string(), geographic_sector: sector.map(str::to_string), bulk_class_gate: Vec::new(), production_ubiquity_gate: Vec::new(), min_prosperity_bps: 0, base_weight, weight_mods: BTreeMap::new(), zone_affinity: BTreeMap::new(), } } fn quiet_drivers() -> SwerveDrivers<'static> { SwerveDrivers { world_tier: &WorldTier::Regional, faction_mixed: false, road_degree: 2, founding_age_years: 50, } } #[test] fn baseline_rates_are_the_base_bps() { let r = compute_swerve_rates(&quiet_drivers()); assert_eq!( r.foreign_bps, SWERVE_BASE_BPS + 2 * FOREIGN_PER_ROAD_DEGREE_BPS / 100 ); assert_eq!(r.heritage_bps, SWERVE_BASE_BPS); } #[test] fn epicenter_hub_boosts_foreign_and_caps() { let drivers = SwerveDrivers { world_tier: &WorldTier::Epicenter, faction_mixed: true, road_degree: 9, founding_age_years: 50, }; let r = compute_swerve_rates(&drivers); assert_eq!( r.foreign_bps, SWERVE_DRIVER_CAP_BPS, "×2.0 ×1.5 + degree hits the cap" ); assert_eq!(r.heritage_bps, SWERVE_BASE_BPS); } #[test] fn isolated_old_backwater_boosts_heritage_and_caps() { let drivers = SwerveDrivers { world_tier: &WorldTier::Backwater, faction_mixed: false, road_degree: 1, founding_age_years: 400, }; let r = compute_swerve_rates(&drivers); assert_eq!( r.heritage_bps, SWERVE_DRIVER_CAP_BPS, "×2.0 ×1.5 ×1.5 hits the cap" ); } #[test] fn pools_partition_by_corridor_and_exclude_vocabulary() { let own = tmpl("own_baseline", "baseline", Some("east_reach"), 10_000); let foreign = tmpl("west_baseline", "baseline", Some("west_reach"), 10_000); let shared = tmpl("shared", "cross_corridor", None, 10_000); let own_heritage = tmpl("east_temple", "heritage", Some("east_reach"), 10_000); let foreign_heritage = tmpl("west_hacienda", "heritage", Some("west_reach"), 10_000); let in_vocab = tmpl("in_vocab", "cross_corridor", None, 10_000); let eligible: Vec<&TraitTemplate> = vec![ &own, &foreign, &shared, &own_heritage, &foreign_heritage, &in_vocab, ]; let pools = build_swerve_pools(&eligible, &["in_vocab".to_string()], Some("east_reach")); let foreign_tags: Vec<&str> = pools.foreign.iter().map(|(t, _)| t.as_str()).collect(); let heritage_tags: Vec<&str> = pools.heritage.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(foreign_tags, ["west_baseline", "shared"]); assert_eq!( heritage_tags, ["east_temple"], "other corridors' heritage is not drawn" ); } #[test] fn empty_pools_never_swerve_regardless_of_rates() { let rates = SwerveRates { foreign_bps: 10_000, heritage_bps: 10_000, }; let mut rng = SeedChain::root(1) .derive(SeedDomain::TraitSwerve, 0) .atlas_rng(); for _ in 0..100 { assert_eq!(roll_building_swerve(rates, &[], &[], &mut rng), None); } } #[test] fn swerve_is_rare_and_deterministic() { let shared = tmpl("shared", "cross_corridor", None, 10_000); let temple = tmpl("temple", "heritage", None, 10_000); let eligible: Vec<&TraitTemplate> = vec![&shared, &temple]; let pools = build_swerve_pools(&eligible, &[], None); let rates = SwerveRates { foreign_bps: 100, heritage_bps: 100, }; // 2% total let count_hits = || { let mut hits = 0; for i in 0..10_000u64 { let mut rng = SeedChain::root(7) .derive(SeedDomain::TraitSwerve, i) .atlas_rng(); if roll_building_swerve(rates, &pools.foreign, &pools.heritage, &mut rng).is_some() { hits += 1; } } hits }; let hits = count_hits(); assert_eq!(hits, count_hits(), "same seeds → same swerves (D-010)"); // 2% nominal over 10k rolls — generous band, this is a rarity check // not a distribution test. assert!( (100..=400).contains(&hits), "expected ~200 swerves in 10k rolls, got {hits}" ); } #[test] fn necessity_swerve_picks_max_weight_covering_template() { let a = tmpl("light", "baseline", None, 5_000); let b = tmpl("heavy", "baseline", None, 9_000); let c = tmpl("heavier_but_not_covering", "baseline", None, 12_000); let eligible: Vec<&TraitTemplate> = vec![&a, &b, &c]; let picked = necessity_swerve(&eligible, |t| t.tag != "heavier_but_not_covering"); assert_eq!(picked.as_deref(), Some("heavy")); assert_eq!(necessity_swerve(&eligible, |_| false), None); } }