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
+174 -18
View File
@@ -21,6 +21,7 @@
use crate::atlas::block_irregularity::block_irregularity;
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::tile_condition::{tile_condition, TileCondition};
use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates};
use crate::seed::splitmix64;
use crate::seed::{SeedChain, SeedDomain};
use std::collections::BTreeMap;
@@ -199,7 +200,15 @@ fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> Setti
/// | Backwater | Full | Moderate |
/// | Passage | Moderate | Minimal |
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
///
/// `pub(crate)` (T-994): also called from `atlas::plugin`'s body-level dispatch
/// aggregation to compute each settlement's D-232 phase-1 K contribution
/// (`trait_draw::complexity_k`) ahead of `generate_quarter_skeleton` itself.
pub(crate) fn derive_complexity(
world_tier: &WorldTier,
pop_tier: u8,
population: i64,
) -> ComplexityTier {
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
return ComplexityTier::Empty;
@@ -334,6 +343,7 @@ fn build_block_grid(
BlockSkeleton {
position: (row as u8, col as u8),
zoning,
district_type: dt.clone(),
reservation,
// Density-based spacing; layout_mode offset/rotation applied in
// the street-network step (D-234).
@@ -863,7 +873,23 @@ fn assign_block_tags(
}
let prosperity = context.prosperity_baseline_bps;
let setting = &context.surrounding_biome;
let flavor_n = context.trait_selection.len().max(1) as u64;
// D-232 phase 2 (T-994): the dominant template for this block's DistrictType
// was already pre-resolved at L3→L4 dispatch time (`context.district_dominant_by_type`
// — see `atlas::trait_draw::pick_district_dominant_by_type`), keyed by the
// settlement's D-243 District cell so every block of this DistrictType across
// the whole quarter (and any sibling quarter in the same District) reads the
// identical template. Falls back to index 0 if the type has no entry (should
// not happen — the map always has all 9 DistrictType keys). Usually
// InVocabulary; already a Swerve when the sparsity escape hatch fired (T-1003).
let district_dominant = context
.district_dominant_by_type
.get(&block.district_type)
.cloned()
.unwrap_or(ArchitectureFlavorRef::InVocabulary(0));
let swerve_rates = SwerveRates {
foreign_bps: context.swerve_rates_bps.0,
heritage_bps: context.swerve_rates_bps.1,
};
let footprints = subdivide_block_footprints(
block.density_pct,
@@ -897,17 +923,26 @@ fn assign_block_tags(
);
let initial = initial_condition(prosperity, &era_cause);
let extent = floor_extent(block.density_pct, fp_chain.derive(SeedDomain::Block, 2));
// D-232: deterministic (seed + zone_type) → flavor index into the
// body's K-tag trait selection.
let flavor_seed = fp_chain.derive(SeedDomain::Block, 3).seed();
let flavor_index =
(splitmix64(flavor_seed ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8;
// T-1003 (D-232 deviation system): the rare per-building wildcard —
// its own seed domain off the footprint chain, so the roll can never
// correlate with the zone/era/extent draws above. Overwhelmingly
// None → the district-dominant template applies as usual.
let mut swerve_rng = fp_chain.derive(SeedDomain::TraitSwerve, 0).atlas_rng();
let flavor_ref = match roll_building_swerve(
swerve_rates,
&context.swerve_foreign_pool,
&context.swerve_heritage_pool,
&mut swerve_rng,
) {
Some(tag) => ArchitectureFlavorRef::Swerve(tag),
None => district_dominant.clone(),
};
BuildingPropertyTag {
zone_type_id,
footprint,
extent,
entry_class,
flavor_ref: ArchitectureFlavorRef { flavor_index },
flavor_ref,
era,
era_cause,
initial_condition: initial,
@@ -917,16 +952,6 @@ fn assign_block_tags(
.collect()
}
/// Stable hash of a zone-type id for the D-232 flavor draw (FNV-1a over bytes).
fn zone_type_hash(id: &ZoneTypeId) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in id.as_str().bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag
/// every non-reserved block's footprints. Keyed by 4×4 block grid position.
pub fn assign_all_block_tags(
@@ -1225,6 +1250,16 @@ mod tests {
trait_selection: Vec::new(),
dominant_bulk_class: BulkClass::NonPhysical,
dominant_production_ubiquity: ProductionUbiquity::Common,
// T-994 additions — sensible stubs for existing tests.
geographic_sector: None,
body_district_type_mix: Vec::new(),
settlement_district_pos: (0, 0),
district_dominant_by_type: BTreeMap::new(),
// T-1003 additions — zero rates / empty pools: no swerve in
// existing tests.
swerve_rates_bps: (0, 0),
swerve_foreign_pool: Vec::new(),
swerve_heritage_pool: Vec::new(),
}
}
@@ -1685,6 +1720,127 @@ mod tests {
assert_eq!(a, b);
}
// ── D-232 phase-2 dominant-template lookup (T-994) ───────────────────────
#[test]
fn flavor_ref_reads_the_pre_resolved_district_dominant_index() {
// The three-phase draw pre-resolves one dominant trait-selection index
// per DistrictType at dispatch time (context.district_dominant_by_type);
// assign_block_tags must be a pure lookup over it — no independent RNG
// draw of its own. Residential is unconditionally present (D-194 pop-tier
// guarantee, tier_guarantees(0).2 == 1) for a sub-1M-population city, so
// this isn't a vacuous check regardless of seed.
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["a".into(), "b".into(), "c".into()];
ctx.district_dominant_by_type.insert(
DistrictType::Residential,
ArchitectureFlavorRef::InVocabulary(2),
);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
let mut found_residential = false;
for (pos, block_tags) in &tags {
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
if block.district_type != DistrictType::Residential {
continue;
}
found_residential = true;
for tag in block_tags {
assert_eq!(
tag.flavor_ref,
ArchitectureFlavorRef::InVocabulary(2),
"Residential block {pos:?} should read the pre-resolved index"
);
}
}
assert!(
found_residential,
"expected at least one Residential block (D-194 pop-tier guarantee)"
);
}
#[test]
fn flavor_ref_falls_back_to_zero_for_unmapped_district_type() {
// An empty district_dominant_by_type (e.g. no trait catalog reader wired,
// or K=0) must not panic — every block falls back to index 0, matching
// the harmless pre-T-994 degenerate behaviour.
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
for block_tags in tags.values() {
for tag in block_tags {
assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0));
}
}
}
// ── D-232 deviation/swerve wildcard (T-1003) ─────────────────────────────
#[test]
fn swerve_wildcard_is_rare_deterministic_and_draws_from_the_pools() {
// With both pools populated and rates at the 300 bps cap (6% per
// building total), a full quarter must still be overwhelmingly
// district-dominant, any swerved building must carry a pool tag, and
// the whole assignment must be reproducible (D-010).
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["own".into()];
ctx.swerve_rates_bps = (300, 300);
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)");
let mut total = 0usize;
let mut swerved = 0usize;
for block_tags in tags_a.values() {
for tag in block_tags {
total += 1;
match &tag.flavor_ref {
ArchitectureFlavorRef::InVocabulary(_) => {}
ArchitectureFlavorRef::Swerve(t) => {
swerved += 1;
assert!(
t == "foreign_temple" || t == "old_hacienda",
"swerve must draw a pool tag, got '{t}'"
);
}
}
}
}
assert!(
total > 20,
"expected a meaningful building count, got {total}"
);
assert!(
swerved * 100 < total * 25,
"swerves must stay rare: {swerved}/{total}"
);
}
#[test]
fn zero_rates_never_swerve_even_with_populated_pools() {
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
ctx.trait_selection = vec!["own".into()];
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
// rates stay (0, 0) from make_context
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
for block_tags in tags.values() {
for tag in block_tags {
assert!(matches!(
tag.flavor_ref,
ArchitectureFlavorRef::InVocabulary(_)
));
}
}
}
// ── Street network (#957, D-234) ─────────────────────────────────────────
use crate::simulation::generator::AccessKind;