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>
822 lines
31 KiB
Rust
822 lines
31 KiB
Rust
//! 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<String> {
|
||
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<DistrictType, ArchitectureFlavorRef> {
|
||
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<TraitTemplate> = (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<TraitTemplate> = (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<TraitTemplate> = (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<u8> = (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"
|
||
);
|
||
}
|
||
}
|