Files
settled-reach/server/src/atlas/trait_exterior.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

444 lines
18 KiB
Rust

//! D-235 building-exterior visual grammar — the `BuildingExteriorTag`
//! derivation (T-988).
//!
//! **3-step derivation, era-free** (D-235 amendment / D-232 reframe — era is
//! never a material gate, it reads as maintenance/wear via the D-217
//! condition layer instead):
//!
//! 1. The resolved trait template's `visual_bundle` filters each axis
//! (`wall`/`roof`/`facade`/`street`) to its own coherent token set — a
//! template is drawn together, never mixed across axes (D-232).
//! 2. The building's own `zone_type_id` biases the pick within that filtered
//! set (`architecture_zone_bias.toml`, sparse — uniform where unauthored).
//! 3. The block's `density_pct` sets `setback_tier` ([`derive_setback_tier`]).
//!
//! `color` is seed-picked uniformly within the template's `color_register`
//! band (`color_register_bands.toml`), independently of the four token axes.
//!
//! Like [`crate::atlas::trait_swerve`] and [`crate::atlas::trait_draw`], this
//! module is pure: the template/zone-bias/color-band lookups are resolved by
//! the caller ([`crate::atlas::skeleton_gen::assign_block_tags`]) from data
//! already threaded onto the work item (`ExteriorCatalog`,
//! [`crate::atlas::trait_catalog_reader`]) — this module never touches
//! `systems.db`, and the per-building roll happens at `GenerateSkeleton` plan
//! time, never inside `FillChunk` (T-987 purity).
//!
//! All numbers are integer (D-010): weights are basis points, color
//! components are centidegrees/basis-points, and every RNG draw is an
//! independent [`SeedDomain::TraitExterior`] sub-chain per axis — sharing one
//! chain across axes previously correlated unrelated fields elsewhere in this
//! cascade (`skeleton_gen::assign_block_tags`'s own doc comment), so each of
//! wall/roof/facade/street/hue/sat/val gets its own derive here too.
use std::collections::BTreeMap;
use crate::atlas::trait_catalog_reader::{ColorBand, TraitTemplate, ZoneBias};
use crate::seed::{AtlasRng, SeedChain, SeedDomain};
use crate::simulation::generator::{
BuildingExteriorTag, FacadeRhythm, HsvColor, RoofForm, SetbackTier, StreetSurface, WallMaterial,
};
/// Neutral fallback color (mid-grey, zero saturation) — used when a template
/// has no resolvable `color_register` (missing template, unauthored register,
/// or a register absent from `color_register_bands` — a content gap the
/// importer's V-TT-07 guards against for real content, but generation must
/// still degrade gracefully rather than panic on a stale/foreign template).
const NEUTRAL_COLOR: HsvColor = HsvColor {
hue: 0,
sat: 0,
val: 5_000,
};
/// Step 3 (D-235): setback tier from a block's build density.
///
/// The five-tier vocabulary (`zero_lot | tight | standard | generous |
/// campus`) is fixed by D-235; these density-band **thresholds are
/// calibration placeholders** (T-988) pending Nigel/Araminta tuning — same
/// status as the swerve-rate constants (`trait_swerve`). Bands mirror the
/// density tables already used elsewhere in this cascade
/// (`skeleton_gen::floor_extent`, `roofed_coverage_pct`): Dense/Compressed
/// blocks (high `density_pct`) read tight/zero-lot; Frontier blocks (low
/// `density_pct`) read campus.
pub fn derive_setback_tier(density_pct: u8) -> SetbackTier {
match density_pct {
0..=20 => SetbackTier::Campus,
21..=45 => SetbackTier::Generous,
46..=65 => SetbackTier::Standard,
66..=85 => SetbackTier::Tight,
_ => SetbackTier::ZeroLot,
}
}
/// Weighted pick of one token from `tokens` (a template's filtered
/// `visual_bundle.<axis>` list), biased by `bias` (the zone-type's authored
/// overrides for this axis, `None`/missing-token defaulting to the uniform
/// 10 000 bps baseline per `architecture_zone_bias.toml`'s own model).
///
/// `None` only when `tokens` is empty (a template with zero tokens on this
/// axis — not expected from a well-formed catalog, but the caller must not
/// panic on a content gap or an unresolved template).
fn weighted_axis_pick<T: Copy + Ord>(
tokens: &[T],
bias: Option<&BTreeMap<T, u32>>,
rng: &mut AtlasRng,
) -> Option<T> {
if tokens.is_empty() {
return None;
}
let weight_of =
|t: &T| -> u64 { bias.and_then(|b| b.get(t)).copied().unwrap_or(10_000) as u64 };
let total: u64 = tokens.iter().map(weight_of).sum();
let mut roll = (rng.next_u32() as u64) % total.max(1);
for t in tokens {
let w = weight_of(t);
if roll < w {
return Some(*t);
}
roll -= w;
}
tokens.last().copied()
}
/// Uniform integer sample within the inclusive range `[lo, hi]` (D-010
/// integer-only). `lo` if the range is degenerate (`hi <= lo`).
fn sample_in_range(lo: u32, hi: u32, rng: &mut AtlasRng) -> u32 {
if hi <= lo {
return lo;
}
let span = (hi - lo) as u64 + 1;
lo + (rng.next_u32() as u64 % span) as u32
}
/// Color sub-step: seed-pick a single (hue, sat, val) point uniformly within
/// `color_register`'s band (D-235 — "always within the template's register").
/// Falls back to [`NEUTRAL_COLOR`] when the template has no register, or the
/// register isn't in `color_bands` (with a warning in the latter case — a
/// genuine content gap the importer's V-TT-07 guards against).
fn sample_color(
color_register: Option<&str>,
color_bands: &BTreeMap<String, ColorBand>,
chain: SeedChain,
) -> HsvColor {
let Some(register) = color_register else {
return NEUTRAL_COLOR;
};
let Some(band) = color_bands.get(register) else {
tracing::warn!(
register,
"color_register not found in color_register_bands — falling back to neutral"
);
return NEUTRAL_COLOR;
};
let mut hue_rng = chain.derive(SeedDomain::TraitExterior, 4).atlas_rng();
let mut sat_rng = chain.derive(SeedDomain::TraitExterior, 5).atlas_rng();
let mut val_rng = chain.derive(SeedDomain::TraitExterior, 6).atlas_rng();
HsvColor {
hue: sample_in_range(band.hue_min, band.hue_max, &mut hue_rng),
sat: sample_in_range(band.sat_min, band.sat_max, &mut sat_rng),
val: sample_in_range(band.val_min, band.val_max, &mut val_rng),
}
}
/// Derive the frozen [`BuildingExteriorTag`] for one building (T-988).
///
/// `template` is the building's resolved trait template — `None` when the
/// `flavor_ref` couldn't be resolved against the catalog (empty catalog,
/// out-of-bounds `InVocabulary` index, or a `Swerve` tag absent from the
/// catalog): every token axis degrades to `Generic`, color to
/// [`NEUTRAL_COLOR`], and `setback_tier` is still computed (it only needs
/// `density_pct`, not the template). `zone_bias` is the pre-looked-up
/// `(template_tag, zone_type_id)` entry from `ExteriorCatalog.zone_bias` —
/// `None` for the overwhelming majority of pairs (the table is sparse by
/// design), which is identical to an entry with no override for any token.
///
/// `chain` should be a per-footprint sub-chain (mirroring the `fp_chain` used
/// for `flavor_ref`/`zone_type_id`/era/extent in `assign_block_tags`) — this
/// function derives its own `SeedDomain::TraitExterior` streams, one per axis,
/// from it.
pub fn derive_building_exterior(
template: Option<&TraitTemplate>,
zone_bias: Option<&ZoneBias>,
color_bands: &BTreeMap<String, ColorBand>,
density_pct: u8,
chain: SeedChain,
) -> BuildingExteriorTag {
let setback_tier = derive_setback_tier(density_pct);
let Some(template) = template else {
tracing::debug!(
"no resolved trait template for this building — exterior falls back to Generic/neutral"
);
return BuildingExteriorTag {
wall_material: WallMaterial::default(),
roof_form: RoofForm::default(),
facade_rhythm: FacadeRhythm::default(),
setback_tier,
color: NEUTRAL_COLOR,
street_surface: StreetSurface::default(),
};
};
let vb = &template.visual_bundle;
let mut wall_rng = chain.derive(SeedDomain::TraitExterior, 0).atlas_rng();
let wall_material =
weighted_axis_pick(&vb.wall, zone_bias.map(|z| &z.wall), &mut wall_rng).unwrap_or_default();
let mut roof_rng = chain.derive(SeedDomain::TraitExterior, 1).atlas_rng();
let roof_form =
weighted_axis_pick(&vb.roof, zone_bias.map(|z| &z.roof), &mut roof_rng).unwrap_or_default();
let mut facade_rng = chain.derive(SeedDomain::TraitExterior, 2).atlas_rng();
let facade_rhythm =
weighted_axis_pick(&vb.facade, zone_bias.map(|z| &z.facade), &mut facade_rng)
.unwrap_or_default();
let mut street_rng = chain.derive(SeedDomain::TraitExterior, 3).atlas_rng();
let street_surface =
weighted_axis_pick(&vb.street, zone_bias.map(|z| &z.street), &mut street_rng)
.unwrap_or_default();
let color = sample_color(vb.color_register.as_deref(), color_bands, chain);
BuildingExteriorTag {
wall_material,
roof_form,
facade_rhythm,
setback_tier,
color,
street_surface,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn template_with_bundle(tag: &str, color_register: Option<&str>) -> TraitTemplate {
use crate::atlas::trait_catalog_reader::VisualBundle;
TraitTemplate {
tag: tag.to_string(),
corridor_pool: "cross_corridor".to_string(),
geographic_sector: None,
bulk_class_gate: Vec::new(),
production_ubiquity_gate: Vec::new(),
min_prosperity_bps: 0,
base_weight: 10_000,
weight_mods: BTreeMap::new(),
zone_affinity: BTreeMap::new(),
visual_bundle: VisualBundle {
wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall],
roof: vec![RoofForm::CorrugatedRoof],
facade: vec![FacadeRhythm::IndustrialGlazing, FacadeRhythm::RegularFacade],
street: vec![StreetSurface::HeavyHaul, StreetSurface::Paved],
color_register: color_register.map(str::to_string),
fallback: BTreeMap::new(),
},
}
}
fn oxide_band() -> BTreeMap<String, ColorBand> {
let mut m = BTreeMap::new();
m.insert(
"oxide_and_dust".to_string(),
ColorBand {
hue_min: 2000,
hue_max: 3000,
sat_min: 1800,
sat_max: 3200,
val_min: 3200,
val_max: 4800,
},
);
m
}
// ── derive_setback_tier ───────────────────────────────────────────────
#[test]
fn setback_tier_bands_match_density() {
assert_eq!(derive_setback_tier(10), SetbackTier::Campus);
assert_eq!(derive_setback_tier(30), SetbackTier::Generous);
assert_eq!(derive_setback_tier(50), SetbackTier::Standard);
assert_eq!(derive_setback_tier(75), SetbackTier::Tight);
assert_eq!(derive_setback_tier(95), SetbackTier::ZeroLot);
}
#[test]
fn setback_tier_dense_to_frontier_orders_tight_to_campus() {
// D-235: "density sets setback_tier (Dense -> zero_lot -> ... ->
// Frontier -> campus)".
assert!(derive_setback_tier(95) < derive_setback_tier(10));
}
// ── derive_building_exterior: template present ───────────────────────
#[test]
fn resolved_template_picks_only_from_its_own_visual_bundle() {
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
let bands = oxide_band();
for seed in 0..50u64 {
let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed));
assert!(matches!(
tag.wall_material,
WallMaterial::SteelFrame | WallMaterial::ConcreteWall
));
assert_eq!(tag.roof_form, RoofForm::CorrugatedRoof);
assert!(matches!(
tag.facade_rhythm,
FacadeRhythm::IndustrialGlazing | FacadeRhythm::RegularFacade
));
assert!(matches!(
tag.street_surface,
StreetSurface::HeavyHaul | StreetSurface::Paved
));
}
}
#[test]
fn color_samples_within_the_registers_band() {
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
let bands = oxide_band();
for seed in 0..50u64 {
let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed));
assert!((2000..=3000).contains(&tag.color.hue));
assert!((1800..=3200).contains(&tag.color.sat));
assert!((3200..=4800).contains(&tag.color.val));
}
}
#[test]
fn missing_color_register_falls_back_to_neutral() {
let t = template_with_bundle("no_register", None);
let tag =
derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1));
assert_eq!(tag.color, NEUTRAL_COLOR);
}
#[test]
fn unbanded_color_register_falls_back_to_neutral() {
// color_register is set but color_bands is empty (content gap) — must
// degrade, not panic.
let t = template_with_bundle("orphan", Some("nonexistent_register"));
let tag =
derive_building_exterior(Some(&t), None, &BTreeMap::new(), 50, SeedChain::root(1));
assert_eq!(tag.color, NEUTRAL_COLOR);
}
#[test]
fn no_template_falls_back_to_generic_everywhere_but_still_computes_setback() {
let tag = derive_building_exterior(None, None, &BTreeMap::new(), 90, SeedChain::root(1));
assert_eq!(tag.wall_material, WallMaterial::Generic);
assert_eq!(tag.roof_form, RoofForm::Generic);
assert_eq!(tag.facade_rhythm, FacadeRhythm::Generic);
assert_eq!(tag.street_surface, StreetSurface::Generic);
assert_eq!(tag.color, NEUTRAL_COLOR);
// setback_tier only needs density_pct, not the template.
assert_eq!(tag.setback_tier, SetbackTier::ZeroLot);
}
// ── zone bias skews the draw ──────────────────────────────────────────
#[test]
fn zone_bias_skews_the_weighted_pick() {
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
let bands = oxide_band();
let mut bias = ZoneBias::default();
// Overwhelming weight toward SteelFrame (vs. ConcreteWall's uniform
// 10_000 baseline) — the draw must skew heavily toward it.
bias.wall.insert(WallMaterial::SteelFrame, 200_000);
let mut steel = 0;
let mut concrete = 0;
for seed in 0..200u64 {
let tag =
derive_building_exterior(Some(&t), Some(&bias), &bands, 70, SeedChain::root(seed));
match tag.wall_material {
WallMaterial::SteelFrame => steel += 1,
WallMaterial::ConcreteWall => concrete += 1,
other => panic!("unexpected wall material {other:?}"),
}
}
assert!(
steel > concrete * 5,
"expected steel_frame to dominate under a 200_000 bps bias, got steel={steel} concrete={concrete}"
);
}
#[test]
fn no_zone_bias_is_uniform_across_a_large_sample() {
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
let bands = oxide_band();
let mut steel = 0;
let mut concrete = 0;
for seed in 0..400u64 {
let tag = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(seed));
match tag.wall_material {
WallMaterial::SteelFrame => steel += 1,
WallMaterial::ConcreteWall => concrete += 1,
other => panic!("unexpected wall material {other:?}"),
}
}
// Roughly even split expected (generous band — this is a uniformity
// sanity check, not a distribution test).
assert!(
(steel as i64 - concrete as i64).abs() < 120,
"expected roughly even split without bias, got steel={steel} concrete={concrete}"
);
}
// ── determinism + independence ────────────────────────────────────────
#[test]
fn same_chain_same_inputs_is_deterministic() {
let t = template_with_bundle("extraction_camp", Some("oxide_and_dust"));
let bands = oxide_band();
let a = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99));
let b = derive_building_exterior(Some(&t), None, &bands, 70, SeedChain::root(99));
assert_eq!(a, b);
}
#[test]
fn axes_draw_independent_entropy_not_a_shared_roll() {
// Regression guard for the "one shared chain correlates every field"
// bug class already fixed once in `assign_block_tags` — wall/roof/
// facade/street must vary independently across seeds, not lock-step.
let t = TraitTemplate {
visual_bundle: crate::atlas::trait_catalog_reader::VisualBundle {
wall: vec![WallMaterial::SteelFrame, WallMaterial::ConcreteWall],
roof: vec![RoofForm::FlatRoof, RoofForm::PitchedRoof],
facade: vec![FacadeRhythm::RegularFacade, FacadeRhythm::Colonnade],
street: vec![StreetSurface::Paved, StreetSurface::Cobble],
color_register: None,
fallback: BTreeMap::new(),
},
..template_with_bundle("mixed", None)
};
let mut saw_wall_a_roof_b = false;
let mut saw_wall_a_roof_a = false;
for seed in 0..100u64 {
let tag = derive_building_exterior(
Some(&t),
None,
&BTreeMap::new(),
50,
SeedChain::root(seed),
);
if tag.wall_material == WallMaterial::SteelFrame {
if tag.roof_form == RoofForm::PitchedRoof {
saw_wall_a_roof_b = true;
} else {
saw_wall_a_roof_a = true;
}
}
}
assert!(
saw_wall_a_roof_b && saw_wall_a_roof_a,
"wall and roof picks must vary independently, not track each other"
);
}
}