Files
settled-reach/server/src/atlas/trait_draw.rs
T
jpmschweitzerandClaude Fable 5 90655382eb 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>
2026-07-08 09:16:47 +02:00

822 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"
);
}
}