Files
settled-reach/server/src/atlas/trait_swerve.rs
T
jpmschweitzerandClaude Fable 5 90655382eb 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>
2026-07-08 09:16:47 +02:00

429 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}