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