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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user