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
+428
View File
@@ -0,0 +1,428 @@
//! 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);
}
}