//! D-232 three-phase trait-template draw (T-994) — replaces the flat //! `flavor_index = splitmix64(seed ^ zone_type_hash) % trait_selection.len()` //! pick that shipped in `skeleton_gen.rs`. //! //! **Phase 1 — body vocabulary K-draw** ([`draw_body_vocabulary`]): hard-gate //! filter (`bulk_class`/`production_ubiquity`/`min_prosperity_bps`) → weight //! (`base_weight` × soft modifiers × hero-body bias) → a `SeedChain`-seeded //! weighted draw of K templates (K locked to `ComplexityTier`), coverage-aware //! over the body's actual district-type mix. Seeded from //! `SeedChain::for_body` (not the per-settlement chain) so every settlement on //! the same body draws the identical closed vocabulary (D-232's closed-vocabulary //! invariant). //! //! **Phase 2 — district-dominant pick** ([`pick_district_dominant_by_type`]): //! for each `DistrictType`, one template from the body vocabulary is chosen by //! `zone_affinity`, keyed by the settlement's D-243 2 048 m District cell so //! every settlement sharing a district independently derives the identical //! answer (no cross-settlement coordination needed — same seed, same key). //! //! **Phase 3 — within-template seed picks** is the existing per-building //! variation in `skeleton_gen.rs` (zone type, era, floor extent) — untouched by //! this ticket. //! //! This module is pure (no DB access) — callers pre-resolve the catalog + //! per-body bias via [`crate::atlas::trait_catalog_reader::TraitCatalogReader`] //! at L3→L4 dispatch time (D-225 pattern), then call these functions. use std::collections::BTreeMap; use crate::atlas::chunk_context::pos_to_id; use crate::atlas::trait_catalog_reader::{BiasKind, TraitBias, TraitTemplate}; use crate::atlas::trait_swerve::necessity_swerve; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{ ArchitectureFlavorRef, BulkClass, ComplexityTier, DistrictType, ProductionUbiquity, }; // `ComplexityTier` is body-consumed but fundamentally per-settlement (it derives // from a settlement's own population + the body's WorldTier, D-194/D-218) — a // body with settlements of mixed complexity has no single "body ComplexityTier". // `draw_body_vocabulary` therefore takes a raw `k: usize` (see // `VocabularyDrawInputs::k`) rather than a `&ComplexityTier`; callers aggregating // across a body's settlements compute `k` as the MAX of `complexity_k(tier)` over // every settlement (a smaller settlement drawing from a richer shared vocabulary // is harmless — phase 2's `zone_affinity` weighting still favours what that // settlement actually needs). /// All 9 `DistrictType` variants, in a fixed deterministic order (D-010) — the /// phase-2 pick resolves one dominant template per entry. const ALL_DISTRICT_TYPES: [DistrictType; 9] = [ DistrictType::LogisticsHub, DistrictType::Residential, DistrictType::Commercial, DistrictType::Industrial, DistrictType::Administrative, DistrictType::Entertainment, DistrictType::MixedUse, DistrictType::Transit, DistrictType::Specialized, ]; /// Stable 0..=8 ordinal for a `DistrictType` — the phase-2 seed's second-level id. fn district_type_ordinal(dt: &DistrictType) -> u64 { ALL_DISTRICT_TYPES.iter().position(|d| d == dt).unwrap_or(0) as u64 } /// K locked to `ComplexityTier` (D-232 round 3, Nigel's birthday math). pub fn complexity_k(complexity: &ComplexityTier) -> usize { match complexity { ComplexityTier::Full => 5, ComplexityTier::Moderate => 3, ComplexityTier::Minimal => 1, ComplexityTier::Empty => 0, } } /// Soft down-weight applied when a template's own `geographic_sector` column /// names a *different* corridor than the body's (basis points; 10 000 = 1.0×). /// /// Pragmatic placeholder — D-232 pins "soft, never a gate" but does not specify /// a magnitude; 4000 bps (0.4×) meaningfully narrows the pool toward the body's /// own corridor without approaching exclusion. Needs Nigel/Burnelli calibration /// once real multi-corridor bodies are authored. const SECTOR_MISMATCH_BPS: u64 = 4_000; // --------------------------------------------------------------------------- // Phase 1 — body vocabulary K-draw // --------------------------------------------------------------------------- /// Body-level inputs to the phase-1 K-draw (T-994). Grouped into a struct to /// keep the function signature under the clippy `too_many_arguments` threshold. pub struct VocabularyDrawInputs<'a> { /// Number of templates to draw. Callers derive this via [`complexity_k`]; /// see the module-level note on why this is a raw count, not a /// `&ComplexityTier`. pub k: usize, /// Hard gate. Currently a single settlement-level stub (D-233's dominant /// commodity derivation, #982, is design-blocked) rather than a body-wide /// aggregate — see the module-level note in `trait_catalog_reader.rs` on /// why `weight_mods.economic_role`/`founding_age` are similarly left /// un-aggregated. Revisit once #982 lands. pub dominant_bulk_class: &'a BulkClass, pub dominant_production_ubiquity: &'a ProductionUbiquity, /// MAX `prosperity_baseline_bps` across every settlement on the body — the /// gate is coverage-aware (a rich settlement's needs should not be excluded /// by a poor sibling's economics). pub max_prosperity_bps: u32, /// The body's system corridor (`star_systems.geographic_sector`). A soft /// weight only (D-232, PR #148 review note — two-part join with each /// template's own `geographic_sector` column). pub geographic_sector: Option<&'a str>, /// Every `DistrictType` present anywhere on the body (deduped). The draw /// guarantees ≥1 eligible template with nonzero `zone_affinity` for each. pub coverage_district_types: &'a [DistrictType], } /// Effective weight (basis points) for one template, given the body's /// geographic_sector and this body's hero bias (D-232). Never returns 0 — a /// zero weight would make the template undrawable by chance alone and unable /// to satisfy a coverage repair, defeating the "closed but always coherent" /// invariant; the floor mirrors the authored bias range (suppress ≥ 0.33×). fn effective_weight_bps( t: &TraitTemplate, bias_by_tag: &BTreeMap<&str, &TraitBias>, geographic_sector: Option<&str>, ) -> u64 { let mut w = t.base_weight as u64; // (a) geographic_sector COLUMN — soft pool-narrowing when the template is // pinned to a different corridor than the body's own. `None` on either side // (shared/cross-corridor template, or a body with no recorded sector) never // narrows (PR #148: the column is a hint, not a gate). if let (Some(sector), Some(body_sector)) = (t.geographic_sector.as_deref(), geographic_sector) { if sector != body_sector { w = (w * SECTOR_MISMATCH_BPS) / 10_000; } } // (b) weight_mods.geographic_sector — the template's own authored boost/cut // for this exact sector (two-part join per PR #148: (a) and (b) both apply). if let Some(body_sector) = geographic_sector { if let Some(mult) = t .weight_mods .get("geographic_sector") .and_then(|m| m.get(body_sector)) { w = (w * (*mult as u64)) / 10_000; } } // Hero-body wiki bias (boost/suppress; pin is handled separately as forced // inclusion, not a weight multiplier). if let Some(b) = bias_by_tag.get(t.tag.as_str()) { if let Some(mult) = b.weight_multiplier_bps { w = (w * (mult as u64)) / 10_000; } } w.max(1) } /// Whether `tag` (looked up in `catalog`) has nonzero `zone_affinity` for `dt`. fn covers_district_type(catalog: &[TraitTemplate], tag: &str, dt: &DistrictType) -> bool { catalog .iter() .find(|t| t.tag == tag) .and_then(|t| t.zone_affinity.get(dt)) .is_some_and(|w| *w > 0) } /// The D-233 hard-gate filter (D-232 two-tier eligibility, tier 1): a template /// excluded here is out of the pool entirely — for the phase-1 vocabulary draw /// AND the T-1003 swerve pools (the swerve is *cultural only*; a building's /// function still passes the economic hard gates, D-232). pub fn hard_gate_eligible<'a>( catalog: &'a [TraitTemplate], inputs: &VocabularyDrawInputs, ) -> Vec<&'a TraitTemplate> { catalog .iter() .filter(|t| { t.bulk_class_gate.is_empty() || t.bulk_class_gate.contains(inputs.dominant_bulk_class) }) .filter(|t| { t.production_ubiquity_gate.is_empty() || t.production_ubiquity_gate .contains(inputs.dominant_production_ubiquity) }) .filter(|t| t.min_prosperity_bps <= inputs.max_prosperity_bps) .collect() } /// Phase 1 (D-232): draw the body's closed K-template vocabulary. /// /// `chain` must be `SeedChain::for_body(world_seed, body_id)` — **not** a /// per-settlement chain — so every settlement on the body draws the identical /// vocabulary (the closed-vocabulary invariant this whole mechanism exists to /// protect). This function derives its own `SeedDomain::TraitVocabulary` /// sub-stream internally. /// /// Returns the selected tags in draw order (pins first, then the weighted /// draw, then any coverage-repair substitutions). Empty when `K == 0` /// (`ComplexityTier::Empty`) or the catalog has no hard-gate-eligible template. pub fn draw_body_vocabulary( catalog: &[TraitTemplate], bias: &[TraitBias], inputs: &VocabularyDrawInputs, chain: SeedChain, ) -> Vec { let k = inputs.k; if k == 0 || catalog.is_empty() { return Vec::new(); } let eligible = hard_gate_eligible(catalog, inputs); if eligible.is_empty() { return Vec::new(); } let bias_by_tag: BTreeMap<&str, &TraitBias> = bias.iter().map(|b| (b.template_tag.as_str(), b)).collect(); // ── Pins (mandatory, count toward K) ──────────────────────────────────── // Pins still pass the hard gates above — a hero pin represents an iconic // building for that body, but its *function* must still make economic // sense (channel separation, D-232/D-233). let mut selection: Vec<(String, u64, bool)> = Vec::new(); // (tag, weight, pinned) for t in &eligible { if matches!( bias_by_tag.get(t.tag.as_str()), Some(b) if b.bias_kind == BiasKind::Pin ) { selection.push((t.tag.clone(), 0, true)); } } // ── Weighted draw without replacement for the remaining slots ─────────── let mut pool: Vec<(&TraitTemplate, u64)> = eligible .iter() .filter(|t| !selection.iter().any(|(tag, _, _)| tag == &t.tag)) .map(|t| { ( *t, effective_weight_bps(t, &bias_by_tag, inputs.geographic_sector), ) }) .collect(); let mut rng = chain.derive(SeedDomain::TraitVocabulary, 0).atlas_rng(); let mut remaining = k.saturating_sub(selection.len()); while remaining > 0 && !pool.is_empty() { let total: u64 = pool.iter().map(|(_, w)| *w).sum(); let mut roll = (rng.next_u32() as u64) % total.max(1); let mut idx = 0; for (i, (_, w)) in pool.iter().enumerate() { if roll < *w { idx = i; break; } roll -= w; } let (picked, w) = pool.remove(idx); selection.push((picked.tag.clone(), w, false)); remaining -= 1; } // ── Coverage repair (D-232: "must cover the body's actual district-type // mix, not draw K templates that all starve the civic district") ──────── for dt in inputs.coverage_district_types { if selection .iter() .any(|(tag, _, _)| covers_district_type(catalog, tag, dt)) { continue; } // Find the best not-yet-selected eligible candidate covering `dt`. // `max_by_key` returns the LAST maximal element on ties — deterministic // given the catalog's stable tag-sorted order (D-010). let candidate = eligible .iter() .filter(|t| !selection.iter().any(|(tag, _, _)| tag == &t.tag)) .filter(|t| t.zone_affinity.get(dt).copied().unwrap_or(0) > 0) .map(|t| { ( *t, effective_weight_bps(t, &bias_by_tag, inputs.geographic_sector), ) }) .max_by_key(|(_, w)| *w); let Some((winner, w)) = candidate else { // No eligible template anywhere covers this district type — a // catalog content gap (the CI guardrails, V-TT-01/V-TT-02, are // meant to prevent this), not something the draw can fix. tracing::debug!(?dt, "no eligible trait template covers this DistrictType"); continue; }; // Swap out the lowest-weight non-pinned member to keep K fixed (D-232: // "K is not a range"); if every current member is pinned, push anyway // — the sparsity escape hatch (D-232: "reaches the full catalog" — // here, the wider hard-gate-eligible pool). let swap_idx = selection .iter() .enumerate() .filter(|(_, (_, _, pinned))| !pinned) .min_by_key(|(_, (_, w, _))| *w) .map(|(i, _)| i); match swap_idx { Some(i) => selection[i] = (winner.tag.clone(), w, false), None => selection.push((winner.tag.clone(), w, false)), } } selection.into_iter().map(|(tag, _, _)| tag).collect() } // --------------------------------------------------------------------------- // Phase 2 — district-dominant pick // --------------------------------------------------------------------------- /// Phase 2 (D-232): resolve the dominant template for every `DistrictType`, /// keyed by the settlement's D-243 2 048 m District cell. /// /// `body_chain` must be `SeedChain::for_body(world_seed, body_id)` — two /// settlements whose quarters share `district_pos` independently derive the /// identical dominant template for a given `DistrictType` (same seed, same /// key), which is exactly the "coherent 2 048 m area reads as one style" /// invariant — no cross-settlement coordination is needed. /// /// Pre-resolved at L3→L4 dispatch time (T-994), **not** inside `FillChunk` /// (T-987 keeps fill pure/cache-free) and not even inside the `GenerateSkeleton` /// Rayon task — the inputs (`trait_selection` + the catalog's `zone_affinity`) /// are already known once `trait_selection` is drawn, so resolving here keeps /// the Rayon task's `assign_block_tags` a cheap infallible `BTreeMap` lookup. /// /// Always returns all 9 `DistrictType` entries. A type with no covering /// candidate in `trait_selection` triggers the **sparsity escape hatch** /// (T-1003, D-232: "the SAME mechanism triggered by necessity rather than /// dice") — the pick reaches the full hard-gate-eligible catalog /// (`trait_swerve::necessity_swerve`) and records the result as an /// out-of-vocabulary `ArchitectureFlavorRef::Swerve`. Only when *nothing* /// eligible covers the type either (a catalog content gap the V-TT-01/V-TT-02 /// guardrails exist to prevent) does it fall back to `InVocabulary(0)` — the /// pre-T-994 degenerate behaviour. pub fn pick_district_dominant_by_type( catalog: &[TraitTemplate], eligible: &[&TraitTemplate], trait_selection: &[String], body_chain: SeedChain, district_pos: (i32, i32), ) -> BTreeMap { let mut out = BTreeMap::new(); let pos_id = pos_to_id(district_pos); for dt in &ALL_DISTRICT_TYPES { let candidates: Vec<(u8, u32)> = trait_selection .iter() .enumerate() .filter_map(|(i, tag)| { catalog .iter() .find(|t| &t.tag == tag) .and_then(|t| t.zone_affinity.get(dt)) .filter(|w| **w > 0) .map(|w| (i as u8, *w)) }) .collect(); if candidates.is_empty() { let picked = match necessity_swerve(eligible, |t| { t.zone_affinity.get(dt).copied().unwrap_or(0) > 0 }) { Some(tag) => ArchitectureFlavorRef::Swerve(tag), None => { tracing::debug!( ?dt, "no eligible template covers this DistrictType — catalog content gap" ); ArchitectureFlavorRef::InVocabulary(0) } }; out.insert(dt.clone(), picked); continue; } let picked = { let mut rng = body_chain .derive(SeedDomain::TraitDistrict, pos_id) .derive(SeedDomain::TraitDistrict, district_type_ordinal(dt)) .atlas_rng(); let total: u64 = candidates.iter().map(|(_, w)| *w as u64).sum(); let mut roll = (rng.next_u32() as u64) % total.max(1); let mut picked = candidates[0].0; for (idx, w) in &candidates { if roll < *w as u64 { picked = *idx; break; } roll -= *w as u64; } picked }; out.insert(dt.clone(), ArchitectureFlavorRef::InVocabulary(picked)); } out } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn tmpl( tag: &str, base_weight: u32, bulk_gate: &[BulkClass], min_prosperity_bps: u32, zone_affinity: &[(DistrictType, u32)], ) -> TraitTemplate { TraitTemplate { tag: tag.to_string(), corridor_pool: "cross_corridor".to_string(), geographic_sector: None, bulk_class_gate: bulk_gate.to_vec(), production_ubiquity_gate: Vec::new(), min_prosperity_bps, base_weight, weight_mods: BTreeMap::new(), zone_affinity: zone_affinity.iter().cloned().collect(), } } fn base_inputs<'a>( k: usize, dominant_bulk_class: &'a BulkClass, dominant_production_ubiquity: &'a ProductionUbiquity, coverage: &'a [DistrictType], ) -> VocabularyDrawInputs<'a> { VocabularyDrawInputs { k, dominant_bulk_class, dominant_production_ubiquity, max_prosperity_bps: 9_000, geographic_sector: None, coverage_district_types: coverage, } } #[test] fn complexity_k_matches_d232_table() { assert_eq!(complexity_k(&ComplexityTier::Full), 5); assert_eq!(complexity_k(&ComplexityTier::Moderate), 3); assert_eq!(complexity_k(&ComplexityTier::Minimal), 1); assert_eq!(complexity_k(&ComplexityTier::Empty), 0); } #[test] fn empty_complexity_draws_nothing() { let catalog = vec![tmpl( "a", 10_000, &[], 0, &[(DistrictType::Residential, 10_000)], )]; let inputs = base_inputs( complexity_k(&ComplexityTier::Empty), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(1, "Body")); assert!(sel.is_empty()); } #[test] fn k_locked_to_complexity_tier() { let catalog: Vec = (0..10) .map(|i| { tmpl( &format!("t{i}"), 10_000, &[], 0, &[(DistrictType::MixedUse, 10_000)], ) }) .collect(); let full = base_inputs( complexity_k(&ComplexityTier::Full), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let sel = draw_body_vocabulary(&catalog, &[], &full, SeedChain::for_body(1, "Body")); assert_eq!(sel.len(), 5); let minimal = base_inputs( complexity_k(&ComplexityTier::Minimal), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let sel = draw_body_vocabulary(&catalog, &[], &minimal, SeedChain::for_body(1, "Body")); assert_eq!(sel.len(), 1); } #[test] fn hard_gate_excludes_wrong_bulk_class() { let catalog = vec![ tmpl( "solid_only", 10_000, &[BulkClass::BulkSolid], 0, &[(DistrictType::Industrial, 10_000)], ), tmpl( "any_bulk", 10_000, &[], 0, &[(DistrictType::Residential, 10_000)], ), ]; let inputs = base_inputs( complexity_k(&ComplexityTier::Minimal), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(7, "Body")); assert_eq!(sel, vec!["any_bulk".to_string()]); } #[test] fn min_prosperity_gate_excludes_below_threshold() { let catalog = vec![tmpl( "expensive", 10_000, &[], 5_000, &[(DistrictType::Residential, 10_000)], )]; let mut inputs = base_inputs( complexity_k(&ComplexityTier::Minimal), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); inputs.max_prosperity_bps = 2_000; let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(7, "Body")); assert!(sel.is_empty()); } #[test] fn pin_forces_inclusion_and_counts_toward_k() { let catalog = vec![ tmpl( "hero_pin", 1, &[], 0, &[(DistrictType::Administrative, 10_000)], ), tmpl( "filler", 10_000, &[], 0, &[(DistrictType::MixedUse, 10_000)], ), ]; let bias = vec![TraitBias { template_tag: "hero_pin".to_string(), bias_kind: BiasKind::Pin, weight_multiplier_bps: None, }]; let inputs = base_inputs( complexity_k(&ComplexityTier::Minimal), // K=1 &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(3, "Body")); assert_eq!(sel, vec!["hero_pin".to_string()]); } #[test] fn coverage_repair_swaps_in_a_template_for_an_uncovered_district_type() { // Two templates only cover MixedUse; K=1 draw would starve Administrative // if it were present in the body's coverage — the repair pass must pull // in a template that covers it, even though it's not the highest weight. let catalog = vec![ tmpl( "mixed_a", 20_000, &[], 0, &[(DistrictType::MixedUse, 10_000)], ), tmpl( "mixed_b", 15_000, &[], 0, &[(DistrictType::MixedUse, 10_000)], ), tmpl( "civic", 5_000, &[], 0, &[(DistrictType::Administrative, 10_000)], ), ]; let coverage = vec![DistrictType::MixedUse, DistrictType::Administrative]; let inputs = base_inputs( complexity_k(&ComplexityTier::Minimal), // K=1 &BulkClass::NonPhysical, &ProductionUbiquity::Common, &coverage, ); let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(11, "Body")); assert_eq!(sel.len(), 1, "K stays fixed at 1 even after repair"); assert_eq!( sel[0], "civic", "the sole slot must cover Administrative since MixedUse alone starves it" ); } #[test] fn same_body_same_world_seed_draws_identical_vocabulary() { let catalog: Vec = (0..8) .map(|i| { tmpl( &format!("t{i}"), 10_000 + i * 500, &[], 0, &[(DistrictType::MixedUse, 10_000)], ) }) .collect(); let inputs = base_inputs( complexity_k(&ComplexityTier::Full), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let a = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c")); let b = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c")); assert_eq!(a, b, "identical inputs must draw the identical vocabulary"); } #[test] fn different_body_id_draws_different_vocabulary_stream() { let catalog: Vec = (0..12) .map(|i| { tmpl( &format!("t{i}"), 10_000, &[], 0, &[(DistrictType::MixedUse, 10_000)], ) }) .collect(); let inputs = base_inputs( complexity_k(&ComplexityTier::Full), &BulkClass::NonPhysical, &ProductionUbiquity::Common, &[], ); let a = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c")); let b = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1d")); assert_ne!(a, b, "distinct bodies must not share the exact same draw"); } // ── Phase 2 ─────────────────────────────────────────────────────────────── #[test] fn district_dominant_covers_all_nine_types() { let catalog = vec![tmpl( "generic", 10_000, &[], 0, &ALL_DISTRICT_TYPES.map(|dt| (dt, 10_000)), )]; let selection = vec!["generic".to_string()]; let map = pick_district_dominant_by_type( &catalog, &[], &selection, SeedChain::for_body(1, "Body"), (3, 5), ); assert_eq!(map.len(), 9); for dt in &ALL_DISTRICT_TYPES { assert_eq!(map.get(dt), Some(&ArchitectureFlavorRef::InVocabulary(0))); } } #[test] fn same_district_pos_same_body_gives_identical_dominant_pick() { let catalog = vec![ tmpl("a", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), tmpl("b", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), ]; let selection = vec!["a".to_string(), "b".to_string()]; let chain = SeedChain::for_body(9, "Body"); let m1 = pick_district_dominant_by_type(&catalog, &[], &selection, chain, (2, 2)); let m2 = pick_district_dominant_by_type(&catalog, &[], &selection, chain, (2, 2)); assert_eq!( m1.get(&DistrictType::Commercial), m2.get(&DistrictType::Commercial), "identical (body, district_pos) must derive the identical dominant pick" ); } #[test] fn different_district_pos_can_pick_differently() { // Not a strict guarantee for any two positions, but across a spread of // positions the picks must not all collapse to one constant index — // otherwise the seed derivation isn't actually keyed by position. let catalog = vec![ tmpl("a", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), tmpl("b", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), ]; let selection = vec!["a".to_string(), "b".to_string()]; let chain = SeedChain::for_body(9, "Body"); let picks: std::collections::BTreeSet = (0..20) .map(|i| { let map = pick_district_dominant_by_type( &catalog, &[], &selection, chain, (i, i * 3 + 1), ); match map.get(&DistrictType::Commercial) { Some(ArchitectureFlavorRef::InVocabulary(idx)) => *idx, other => panic!("expected an in-vocabulary pick, got {other:?}"), } }) .collect(); assert!( picks.len() > 1, "expected variation in the dominant pick across distinct district positions" ); } #[test] fn district_dominant_falls_back_to_zero_when_nothing_eligible_covers_type() { let catalog = vec![tmpl( "only_residential", 10_000, &[], 0, &[(DistrictType::Residential, 10_000)], )]; let selection = vec!["only_residential".to_string()]; let map = pick_district_dominant_by_type( &catalog, &[], &selection, SeedChain::for_body(1, "Body"), (0, 0), ); // Administrative has no candidate anywhere (empty eligible catalog) -> // degenerate InVocabulary(0) fallback, matching pre-T-994 behaviour. assert_eq!( map.get(&DistrictType::Administrative), Some(&ArchitectureFlavorRef::InVocabulary(0)) ); } #[test] fn district_dominant_necessity_swerve_reaches_eligible_catalog() { // The vocabulary only covers Residential; Administrative IS covered by an // eligible out-of-vocabulary template — the sparsity escape hatch (T-1003, // D-232 "same mechanism triggered by necessity") must surface it as a // Swerve rather than defaulting to index 0. let catalog = vec![ tmpl( "only_residential", 10_000, &[], 0, &[(DistrictType::Residential, 10_000)], ), tmpl( "civic_hall", 8_000, &[], 0, &[(DistrictType::Administrative, 10_000)], ), ]; let eligible: Vec<&TraitTemplate> = catalog.iter().collect(); let selection = vec!["only_residential".to_string()]; let map = pick_district_dominant_by_type( &catalog, &eligible, &selection, SeedChain::for_body(1, "Body"), (0, 0), ); assert_eq!( map.get(&DistrictType::Administrative), Some(&ArchitectureFlavorRef::Swerve("civic_hall".to_string())) ); assert_eq!( map.get(&DistrictType::Residential), Some(&ArchitectureFlavorRef::InVocabulary(0)), "covered types stay in-vocabulary" ); } }