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>
429 lines
18 KiB
Rust
429 lines
18 KiB
Rust
//! 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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
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);
|
||
}
|
||
}
|