Files
settled-reach/server/src/atlas/trait_draw.rs
T
jpmschweitzerandClaude Fable 5 204359927a feat(simulation): BuildingExteriorTag grammar + Layer-5 material fill + ops-surface (T-988/T-959/T-1097)
T-988 — BuildingExteriorTag {wall_material, roof_form, facade_rhythm,
setback_tier, color:HsvColor, street_surface}. New trait_exterior.rs:
3-step era-free derivation (visual_bundle filter -> zone-bias weighted
pick -> density->setback), color seed-sampled within the register band.
Resolved at GenerateSkeleton plan time (T-994 precedent), frozen on
BuildingPropertyTag; FillChunk only reads it. Four append-only
integer-discriminant enums (WallMaterial/RoofForm/FacadeRhythm/
StreetSurface), unknown token -> axis Generic + warn. trait_catalog_reader
now parses visual_bundle + the two new tables (OnceLock-cached).
New SeedDomain::TraitExterior (per-axis sub-chains — no cross-field
correlation).

T-959 — FillChunk reads the frozen exterior tag: wall_material/roof_form
onto Wall/Roof shell voxels (FilledChunk.surface_material). Additive,
ShellVoxel untouched, all shell tests pass. (Interstitial-fill-from-setback
split to T-1098 — needs a FillChunk block-metadata + geometry design pass;
nothing consumes FillChunk until Phase 5.)

T-1097 — BlockSkeleton.interstitial_character (OpenSpace|OperationsSurface),
D-233 bulk-driven: bulk-industry blocks' non-roofed remainder tags as built
economic infrastructure, not generic open space.

cargo check --all-targets clean; 1666 lib tests pass; no golden impact
(harnesses top out at CascadeLayer::RoadGraph, unreachable by Layer 4/5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:42:20 +02:00

1057 lines
40 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.
///
/// **Heritage templates never enter the ordinary lottery** (PR #173 review T1):
/// D-232's corridor two-part pool reserves the `heritage` sub-pool for the
/// remoteness dial — the T-1003 heritage-callback swerve — so the ordinary
/// weighted draw and coverage repair filter it out. A heritage template still
/// reaches a body via an authored hero **pin** (an explicit wiki decision) or
/// via the swerve/necessity paths, which read the full eligible set.
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). Pins may be heritage templates:
// an authored pin is an explicit wiki decision, not a lottery outcome.
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));
}
}
// "Pins count toward K" (D-232) presumes pins ≤ K. Authoring more pins
// than the body's K is a wiki-bias content error (the V-TT-05 importer
// guardrail bounds it); if it slips through anyway, keep every pin
// (authored intent outranks the tier budget) but say so loudly — the
// K-locked invariant is violated by data, not by this draw.
if selection.len() > k {
tracing::warn!(
pins = selection.len(),
k,
"atlas_body_trait_bias pins exceed this body's K — vocabulary exceeds tier budget"
);
}
// ── Weighted draw without replacement for the remaining slots ───────────
// Heritage templates are excluded from the ordinary lottery — D-232
// reserves the heritage sub-pool for the remoteness dial (T-1003 swerve).
let mut pool: Vec<(&TraitTemplate, u64)> = eligible
.iter()
.filter(|t| t.corridor_pool != "heritage")
.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`.
// Heritage templates stay excluded here too (same T1 rule as the
// lottery). `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| t.corridor_pool != "heritage")
.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"). When every current member is pinned there is no
// slot to repair into — leave the type uncovered and let phase 2's
// necessity_swerve (the REAL sparsity escape hatch, out-of-vocabulary
// by design) serve it, rather than growing trait_selection past K
// (PR #173 review T2).
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 => {
tracing::debug!(
?dt,
"all K slots pinned — leaving district type to the phase-2 necessity swerve"
);
}
}
}
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(),
visual_bundle: Default::default(),
}
}
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"
);
}
fn tmpl_in_pool(
tag: &str,
corridor_pool: &str,
base_weight: u32,
zone_affinity: &[(DistrictType, u32)],
) -> TraitTemplate {
TraitTemplate {
corridor_pool: corridor_pool.to_string(),
..tmpl(tag, base_weight, &[], 0, zone_affinity)
}
}
// ── T1 (PR #173): heritage sub-pool stays out of the ordinary lottery ────
#[test]
fn heritage_templates_never_enter_the_ordinary_lottery() {
// One heritage template with overwhelming weight + one modest baseline.
// Across many bodies the heritage tag must never be drawn — it is
// reserved for the remoteness dial (T-1003) and hero pins.
let catalog = vec![
tmpl_in_pool(
"old_hacienda",
"heritage",
1_000_000,
&[(DistrictType::MixedUse, 10_000)],
),
tmpl_in_pool(
"plain_baseline",
"baseline",
1_000,
&[(DistrictType::MixedUse, 10_000)],
),
];
let inputs = base_inputs(
complexity_k(&ComplexityTier::Minimal), // K=1: one slot, worst case
&BulkClass::NonPhysical,
&ProductionUbiquity::Common,
&[],
);
for body in 0..50 {
let sel = draw_body_vocabulary(
&catalog,
&[],
&inputs,
SeedChain::for_body(9, &format!("Body{body}")),
);
assert_eq!(
sel,
vec!["plain_baseline".to_string()],
"heritage must never win the ordinary lottery (body {body})"
);
}
}
#[test]
fn heritage_template_reaches_selection_via_pin() {
let catalog = vec![
tmpl_in_pool(
"old_hacienda",
"heritage",
1,
&[(DistrictType::MixedUse, 10_000)],
),
tmpl_in_pool(
"plain_baseline",
"baseline",
10_000,
&[(DistrictType::MixedUse, 10_000)],
),
];
let bias = vec![TraitBias {
template_tag: "old_hacienda".to_string(),
bias_kind: BiasKind::Pin,
weight_multiplier_bps: None,
}];
let inputs = base_inputs(
complexity_k(&ComplexityTier::Minimal),
&BulkClass::NonPhysical,
&ProductionUbiquity::Common,
&[],
);
let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(3, "Body"));
assert_eq!(
sel,
vec!["old_hacienda".to_string()],
"an authored hero pin overrides the heritage lottery exclusion"
);
}
#[test]
fn coverage_repair_skips_heritage_candidates() {
// Only a heritage template covers Administrative — the repair pass must
// NOT pull it in; the type stays uncovered for the phase-2 necessity
// swerve to serve out-of-vocabulary.
let catalog = vec![
tmpl_in_pool(
"plain_baseline",
"baseline",
10_000,
&[(DistrictType::MixedUse, 10_000)],
),
tmpl_in_pool(
"heritage_civic",
"heritage",
10_000,
&[(DistrictType::Administrative, 10_000)],
),
];
let coverage = vec![DistrictType::MixedUse, DistrictType::Administrative];
let inputs = base_inputs(
complexity_k(&ComplexityTier::Minimal),
&BulkClass::NonPhysical,
&ProductionUbiquity::Common,
&coverage,
);
let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(5, "Body"));
assert_eq!(
sel,
vec!["plain_baseline".to_string()],
"coverage repair must not draft heritage templates"
);
}
// ── T2 + H4 (PR #173): the pins/K seam ───────────────────────────────────
#[test]
fn coverage_repair_never_grows_selection_past_k_when_all_slots_pinned() {
// K=1, the single slot is a pin covering only MixedUse; Administrative
// is in the coverage set and coverable by a baseline template. The old
// behaviour pushed a second entry past K; now the type is left to the
// phase-2 necessity swerve and the vocabulary stays exactly the pin.
let catalog = vec![
tmpl_in_pool(
"hero_pin",
"baseline",
10_000,
&[(DistrictType::MixedUse, 10_000)],
),
tmpl_in_pool(
"civic",
"baseline",
10_000,
&[(DistrictType::Administrative, 10_000)],
),
];
let bias = vec![TraitBias {
template_tag: "hero_pin".to_string(),
bias_kind: BiasKind::Pin,
weight_multiplier_bps: None,
}];
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, &bias, &inputs, SeedChain::for_body(7, "Body"));
assert_eq!(
sel,
vec!["hero_pin".to_string()],
"K is not a range — repair must not grow the vocabulary past K"
);
}
#[test]
fn pins_exceeding_k_are_all_kept() {
// Two authored pins on a K=1 body: a content error the V-TT-05 importer
// guardrail bounds, but if it slips through, authored intent outranks
// the tier budget — both pins survive (with a runtime warning).
let catalog = vec![
tmpl_in_pool("pin_a", "baseline", 10, &[(DistrictType::MixedUse, 10_000)]),
tmpl_in_pool(
"pin_b",
"baseline",
10,
&[(DistrictType::Residential, 10_000)],
),
];
let bias = vec![
TraitBias {
template_tag: "pin_a".to_string(),
bias_kind: BiasKind::Pin,
weight_multiplier_bps: None,
},
TraitBias {
template_tag: "pin_b".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(7, "Body"));
assert_eq!(sel.len(), 2, "both authored pins are kept");
assert!(sel.contains(&"pin_a".to_string()) && sel.contains(&"pin_b".to_string()));
}
#[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"
);
}
}