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
+4 -1
View File
@@ -1605,7 +1605,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- `footprint: TileRect` — integer tile-space rect within the 128-tile block (D-010 integer-only).
- `extent: FloorExtent` — floor/basement extent; **resolves Q-104** (below).
- `entry_class: BuildingEntryClass` — physical access character: `Public | Commercial | Restricted | BreachOnly`. **Named `BuildingEntryClass`, not "access tier"**, to avoid colliding with D-028's relational dialogue layers. Derived from `zone_type × layout_mode (D-096) × prosperity (D-197)`: Commission-Grid → formal/logged/corporate-or-authority credentials; Organic → social/reputation/unlogged credentials. U-curve degrade: low prosperity on a normally-Commercial zone → `BreachOnly` (derelict).
- `flavor_ref: ArchitectureFlavorRef { flavor_index: u8 }` — index into the body's flavor profile (D-232); deterministic `(seed + zone_type) → index`; no rolling-economy read. (The selection mechanism is D-232's weighted `allow`/`block` filter; the tag records the chosen index for Phase-6 to read cold.)
- `flavor_ref: ArchitectureFlavorRef` — which trait template characterizes this building (D-232); no rolling-economy read. *(Amended 2026-07-08, T-994/T-1003: now an enum, not a bare index — `InVocabulary(u8)` (index into the body's closed `trait_selection`, resolved via the phase-2 district-dominant pick) `| Swerve(tag)` (the rare out-of-vocabulary deviation draw, or the sparsity escape hatch). The original `(seed + zone_type) → index` mechanism was the pre-three-phase-draw stopgap.)*
- `era: ConstructionEra` (`Founding | Established | Modern | Derelict`) + `era_cause: EraCause` — feeds ZonePalette modifier axis C (D-101) and sets the D-217 condition floor. Derived from `founding_age_years + prosperity_baseline + seed`; a body carries mixed-era buildings (founding period anchors the distribution; seed scatters outliers).
- `initial_condition: TileCondition` — frozen-amber snapshot from `prosperity_baseline` (D-197/D-217). The rolling condition overlay (D-198) paints *over* this; it never mutates the tag.
- **`FloorExtent { base_floor: i8, floor_count: u8, heights: FloorHeightProfile }`** where `FloorHeightProfile = Uniform(u8) | Variable(Vec<u8>)`. **Q-104 resolution (the D-110 ↔ D-227 bridge):** two pure functions — `floor_at_voxel_z(z) -> Option<i8>` and `voxel_range_for_floor(f) -> Option<(i32,i32)>` — map D-110 floor-index addressing onto D-227 physical voxel-z. Default `Uniform(3)` (3 voxels ≈ 3 m/floor, per Jeroen); a cathedral/hangar is `Uniform(10)`; a mixed-use stack is `Variable([5,3,3,3,3])`. The `Variable` branch carries per-floor memory only when floors actually differ.
@@ -1660,6 +1660,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Catalog population:** ~2535 templates at floor, 4045 target. Core hand-curated (Miri authors cultural meaning + eligibility; Araminta authors the matching `visual_bundle`), then a *bounded* Gemma corpus-distillation pass (one read of all wiki, propose new templates, human-gated) — not per-body generation.
- **Channel separation from D-233 (held):** D-233 decides **what** a building is (vocabulary, coverage); D-232 decides **how** it is characterized. Same inputs (e.g. `bulk_class`) used non-conflictingly — D-233 as hard function gate, D-232 as soft cultural weight. They compose at fill.
- **Amended 2026-05-31 ([D-237](#d-237) — authored specialization layer):** the template draw now reads an authored `cultural_specialization` (new column on `system_economy`) that **selects/biases the template pool** when a system's cultural character diverges from its corridor baseline. The field carries two value sub-types in one column — *activity/character* values (`agrarian`, `industrial_heritage`, `institutional`, `scholarly`, etc.) and *heritage* values (`scottish`, `vietnamese`, `zulu`, `french_provencal`, etc.); heritage values take precedence where present. This is a Phase-4 correctness fix, not just flavor: without it, a system whose founding heritage diverges from the corridor (e.g. Vietnamese-founded Dài Lộ in the east_reach Korean/Japanese corridor) draws the wrong cultural templates. `NULL` = use the existing corridor-pool algorithm unchanged. Consistent with the held channel separation — `cultural_specialization` is a D-232 cultural-weight input, never a D-233 function gate. Singular landmarks (e.g. Groombridge's GSH within a `financial_hub` district) are expressed via D-222 multi-block reservation + an `atlas_body_trait_bias` hero pin, not the system-level field.
- **Amended 2026-07-08 (T-1003 — deviation/swerve system implemented, driver inputs pinned):** the two opposed drivers now map to real fields (`server/src/atlas/trait_swerve.rs`): *foreign-import* scaled up by `WorldTier::Epicenter`/`Passage` (transit), `dominant_faction = "mixed"` (cosmopolitanism), and road/rail-graph node degree (centrality); *heritage-callback* scaled up by road-graph isolation (degree ≤ 1), remote tier (Waypoint/Backwater), and `founding_age_years` bands (conservatism). Rates are integer bps: base **100 bps/building** per driver, hard cap **300 bps** — placeholder constants pending Nigel/Burnelli calibration; a `dist_ly`-percentile remoteness input is deferred until the read-set carries it. The wildcard result is recorded as `ArchitectureFlavorRef::Swerve(tag)` (out-of-vocabulary by construction); pools are hard-gate-eligible only (cultural-only rule held). The sparsity escape hatch is implemented as the same mechanism, necessity-triggered at the phase-2 district-dominant pick (deterministic max-weight, no dice). The passive past-vogue holdover stays on the D-217 condition layer, as decided.
- **Amended 2026-07-08 (T-994 — phase-2 "district" pinned to the D-243 tier):** the word "district" in the phase-2 *district-dominant* draw means the **D-243 2 048 m District cell** (4 quarters), **not** the 512 m Quarter — this record was written after D-222 renamed the 512 m unit to Quarter, but the pin was never made explicit and the shipped code had no District-tier representation at all (caught in the 2026-07-07 /whats-next refinement; the "one template per district, applied whole" composition rule reads at 2 048 m). Implementation (T-994): the dominant template per `(DistrictType)` is pre-resolved at L3→L4 dispatch time, seeded by `(SeedChain::for_body, district-cell position, district type)` — so any settlements whose quarters share a District cell independently derive the identical dominant template with no cross-settlement coordination, and `assign_block_tags` is a pure lookup. Phase 1's body K-draw is likewise seeded from `SeedChain::for_body` (never the per-settlement chain), preserving the closed-vocabulary invariant.
- **Supersedes (architecture/generator domain only):** **D-104** (`HeritageGrammarOverlay` + per-root data → the catalog + `allow`/`block`), **D-105** (heritage-root→informal-zone lookup → flavor-filtered selection; the three zone *types* survive), **D-101 modifier axis A** (`HeritageRoot` → catalog draw; axes B/C + faction/climate/condition/season unchanged), **D-107** (per-root trauma decay → per-template/condition; the "trauma intensifies culture" principle survives). Also retires the round-2 `atlas_body_culture` / `atlas_body_culture_era` tables.
- **Storage:** `trait_templates` (the catalog) + `atlas_body_trait_bias` (sparse, hero bodies). The per-body draw result lives as `trait_selection: Vec<String>` on the skeleton — **re-derivable** from catalog + economics + bias + `SeedChain`, no Gemma in the hot path. `CityGenerationContext` carries `trait_selection` + `morphology_zone` (replacing the round-2 `flavor_profile`; D-199 amend).
- **Source-location deferred:** the human-authored *source* home (catalog file + `bias.json`) rides on **Q-107** (wiki → Atlas content-set consolidation). The generator-facing tables are invariant to it, so the fill seam is unblocked regardless.
@@ -1713,6 +1715,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Exterior vocabulary** (extends D-228's FloorMaterial axis): `WallMaterial` (stone_cut/stone_rough/fired_brick/clay_render/heavy_timber/pile_timber/timber_frame/reinforced_concrete/corrugated_metal/steel_panel/composite_panel/precision_glass/smart_facade), `RoofForm` (pitched_steep/pitched_shallow/flat/composite_curved/corrugate_shed/dome), `FacadeRhythm` (bay_window/grid_panel/solid_punched/arcade/open_front/blind_wall), `StreetSurface` (cobble/packed_earth/poured_slab/elevated_boardwalk/dock_plank/rail_embedded — derives from district + density + template, the same filter as buildings, for a consistent world). **Color** is template-bounded (cultural palette cue), seed-selected within range — always within the template's register.
- **Fallback hierarchy (the incremental-content mechanism, D-232).** Every specific texture/material token declares a **generic parent it degrades to**: `temple_wall_wood` → generic `wood_wall` placeholder until the specific asset ships, then it **upgrades in place**. The *logical* token a building uses is fixed at generation (deterministic, frozen); only its rendered *fidelity* sharpens as themes/textures are patched in. This is where incremental content lives — an asset-resolution concern, not a generation one — so no catalog versioning is needed. (`era_fallback` from the round-2 draft survives only in this generalized form — a fallback chain, not a tech ladder.)
- Worked example: a fjord port (template `fjord_maritime` → stone base + steep roof + zero-lot + solid-punched + cobble) and a delta port (template `river_delta` → pile-timber + shallow roof + arcade + boardwalk) share density and zone types yet read as completely different cities.
- **Amended 2026-07-07 (T-995 — ObjectTag vocabulary ratified, resolves Q-049):** the `WallMaterial`/`RoofForm`/`FacadeRhythm`/`StreetSurface` example token lists above never shipped. The canonical ObjectTag vocabulary is the shipped 28-template `architecture_trait_catalog.toml` (T-1005) material palette, now formalized as a machine-readable registry at `wiki/economics/object_tag_vocabulary.toml`, importer-validated by `economy_import/traits.py` (V-TT-03 existence, V-TT-04 fallback-graph). The ratified tags, by axis: **wall** (10) — `concrete_wall stone_wall brick_wall rendered_wall stucco_wall timber_wall rammed_earth_wall steel_frame glass_curtain_wall composite_panel`; **roof** (7) — `flat_roof pitched_roof corrugated_roof clay_tile_roof terraced_roof vaulted_roof green_roof`; **facade** (8) — `regular_facade ornamental_facade industrial_glazing arcade_facade shuttered_facade screen_facade colonnade lattice_screen`; **street** (7) — `paved cobble packed_earth canal_way elevated_walkway heavy_haul boardwalk`; plus the four fallback-terminal **generic** placeholders — `generic_wall generic_roof generic_facade generic_street`. Every specific tag's registry entry declares its generic fallback parent directly (the per-template `fallback` maps in the catalog remain illustrative/non-exhaustive documentation, not the validated source).
- **Formally retires** Araminta's generator-architecture Round-4 `D-READY-9` ten-root heritage-modifier TOML system — superseded by the D-232 trait-template catalog (body-specific draw, allow/block-bounded, no root taxonomy).
- **Rationale:** The template is the differentiator, but it needs a concrete visual vocabulary to act on, filtered consistently across walls, roofs, facades, *and* streets or the world reads incoherent. The "old quarter vs new development" texture comes from **wear + occasional past-vogue holdover** (D-232), not from a material-technology ladder — because in a post-space-travel setting there is no such ladder. The fallback hierarchy lets the logical world be complete and frozen at launch while the art catches up over patches.
- **Implementation:** Phase 4+ (the token logic + Atlas-level data); textured render + the bulk of the theme library are Phase 5+ and post-launch, behind the fallback chain. `ObjectTag`/material vocabulary is Miri + Araminta co-maintained.
+78 -4
View File
@@ -23,9 +23,18 @@
//! - `founding_orientation` — Cardinal stub (attractor matching deferred)
//! - `world_tier` — Waypoint default (`system_economy.economic_tier` derivation deferred)
//! - `morphology_zone` — AlluvialPlain default (D-228 deferred)
//! - `trait_selection` — empty (trait catalog #1005 deferred)
//! - `trait_selection` — empty (the D-232 phase-1 K-draw runs at L3→L4 dispatch
//! time, T-994 — see `atlas::plugin::build_skeleton_work_item` — this reader
//! has no body-wide coverage view)
//! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked)
//! - `dominant_production_ubiquity` — Common default (#982 design-blocked)
//! - `body_district_type_mix` / `settlement_district_pos` / `district_dominant_by_type`
//! — T-994 additions, all require body-wide aggregation this per-city reader
//! doesn't have; overridden at L3→L4 dispatch time
//!
//! **Field read, not deferred (T-994 addition):** `geographic_sector` — from
//! `star_systems.geographic_sector` (via `bodies.system_id`). A single
//! per-system value, unlike the four fields above.
//!
//! **Prosperity derivation (D-197, partial — integer basis points, D-010):**
//! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)`
@@ -38,6 +47,7 @@
//! Read-only `systems.db` access follows the same pattern as
//! [`crate::atlas::source_resolver::BodySourceResolver`].
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
@@ -90,6 +100,10 @@ pub struct CityEconomicReadSet {
pub founding_age_years: u32,
/// D-199 field 6.
pub settlement_class: SettlementClass,
/// Not a D-199 field — T-994 (D-232) addition. This city's system corridor
/// (`star_systems.geographic_sector`). `None` if unset. A soft weight on the
/// trait-template draw only, never a gate.
pub geographic_sector: Option<String>,
}
// ---------------------------------------------------------------------------
@@ -136,26 +150,31 @@ impl CityContextReader {
// atlas_city_names carries economic_role, population, settlement_class.
// bodies carries founding_age_years (via the city's body_id).
// system_factions carries dominant_faction (via bodies.system_id).
// star_systems carries geographic_sector (via bodies.system_id, T-994).
//
// settlement_class is nullable (NULL until attractor placement runs).
// dominant_faction is nullable (some systems have no faction data).
// founding_age_years is nullable (uninhabited bodies).
// geographic_sector is nullable (some systems have no recorded corridor).
let row: rusqlite::Result<(
Option<String>, // acn.economic_role
i64, // acn.population
Option<String>, // acn.settlement_class
Option<i64>, // b.founding_age_years
Option<String>, // sf.dominant_faction
Option<String>, // ss.geographic_sector
)> = conn.query_row(
"SELECT
acn.economic_role,
acn.population,
acn.settlement_class,
b.founding_age_years,
sf.dominant_faction
sf.dominant_faction,
ss.geographic_sector
FROM atlas_city_names AS acn
JOIN bodies AS b ON b.body_id = acn.body_id
LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id
LEFT JOIN star_systems AS ss ON ss.system_id = b.system_id
WHERE acn.id = ?1",
[city_id as i64],
|row| {
@@ -165,6 +184,7 @@ impl CityContextReader {
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
},
);
@@ -183,6 +203,7 @@ impl CityContextReader {
settlement_class_opt,
founding_age_opt,
dominant_faction,
geographic_sector,
) = row;
let economic_role = economic_role_opt.ok_or(CityContextReadError::MissingField {
@@ -209,6 +230,7 @@ impl CityContextReader {
dominant_faction,
founding_age_years,
settlement_class,
geographic_sector,
})
}
@@ -486,12 +508,33 @@ pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGener
world_tier: WorldTier::Waypoint,
// morphology_zone: Layer-1 output, D-228 deferred.
morphology_zone: MorphologyZone::AlluvialPlain,
// trait_selection: trait catalog #1005 deferred.
// trait_selection: the D-232 phase-1 K-draw runs at L3→L4 dispatch time
// (build_skeleton_work_item, T-994) — this reader has no body-wide view
// (coverage aggregate, catalog reader) so it is left empty here and
// overridden by the caller, same pattern as morphology_zone/political_archetype.
trait_selection: Vec::new(),
// dominant_bulk_class: dominant-commodity derivation #982 design-blocked.
dominant_bulk_class: BulkClass::NonPhysical,
// dominant_production_ubiquity: same blocker as above.
dominant_production_ubiquity: ProductionUbiquity::Common,
// ── T-994 additions ────────────────────────────────────────────────
// geographic_sector: the one D-199-style read-set field this ticket adds
// (star_systems.geographic_sector via the city's system_id) — real value,
// not a stub.
geographic_sector: rs.geographic_sector,
// body_district_type_mix / settlement_district_pos / district_dominant_by_type:
// all three require body-wide aggregation (every settlement's district
// mix, the settlement's world position) that this per-city reader doesn't
// have. Overridden at L3→L4 dispatch time (build_skeleton_work_item).
body_district_type_mix: Vec::new(),
settlement_district_pos: (0, 0),
district_dominant_by_type: BTreeMap::new(),
// ── T-1003 additions — same dispatch-time override story as above:
// driver rates need the road graph, pools need the resolved catalog.
swerve_rates_bps: (0, 0),
swerve_foreign_pool: Vec::new(),
swerve_heritage_pool: Vec::new(),
}
}
@@ -539,7 +582,7 @@ mod tests {
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE star_systems (system_id TEXT PRIMARY KEY);
"CREATE TABLE star_systems (system_id TEXT PRIMARY KEY, geographic_sector TEXT);
CREATE TABLE bodies (
body_id TEXT PRIMARY KEY,
system_id TEXT NOT NULL,
@@ -734,6 +777,37 @@ mod tests {
assert_eq!(rs.founding_age_years, 450);
// Field 6 — settlement_class
assert!(matches!(rs.settlement_class, SettlementClass::NameLocked));
// geographic_sector: absent in this fixture (no UPDATE below) -> None.
assert_eq!(rs.geographic_sector, None);
}
// ─── geographic_sector (T-994) ────────────────────────────────────────────
#[test]
fn read_set_threads_geographic_sector_from_star_systems() {
let (db, city_id) = make_test_db(
"GJ8d",
"GJ-8",
"financial",
1_000_000,
Some("NameLocked"),
Some(100),
None,
);
let conn = Connection::open(&db).expect("reopen");
conn.execute(
"UPDATE star_systems SET geographic_sector = 'east_reach' WHERE system_id = 'GJ-8'",
[],
)
.expect("set sector");
drop(conn);
let reader = CityContextReader::open(&db).expect("open");
let rs = reader.read_set(city_id as u64, 42).expect("read set");
assert_eq!(rs.geographic_sector.as_deref(), Some("east_reach"));
let ctx = context_from_read_set(city_id as u64, rs);
assert_eq!(ctx.geographic_sector.as_deref(), Some("east_reach"));
}
#[test]
+8 -1
View File
@@ -578,6 +578,13 @@ mod tests {
trait_selection: vec![],
dominant_bulk_class: BulkClass::NonPhysical,
dominant_production_ubiquity: ProductionUbiquity::Common,
geographic_sector: None,
body_district_type_mix: vec![],
settlement_district_pos: (0, 0),
district_dominant_by_type: Default::default(),
swerve_rates_bps: (0, 0),
swerve_foreign_pool: vec![],
swerve_heritage_pool: vec![],
}),
quarter_id: city_id * 10,
chain: SeedChain::root(42 + city_id),
@@ -710,7 +717,7 @@ mod tests {
heights: FloorHeightProfile::Uniform(3),
},
entry_class: BuildingEntryClass::Public,
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
flavor_ref: ArchitectureFlavorRef::InVocabulary(0),
era: ConstructionEra::Founding,
era_cause: EraCause::Original,
initial_condition: TileCondition::Intact,
+3
View File
@@ -31,6 +31,9 @@ pub mod skeleton_gen;
pub mod source_resolver;
pub mod subbiome;
pub mod tile_condition;
pub mod trait_catalog_reader;
pub mod trait_draw;
pub mod trait_swerve;
pub mod voxel;
pub use plugin::GenerationPlugin;
+232 -1
View File
@@ -19,15 +19,27 @@ use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
use crate::atlas::city_context_reader::{
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
};
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus};
use crate::atlas::road_graph::{RoadGraph, RoadNode};
use crate::atlas::scale;
use crate::atlas::skeleton_gen::derive_complexity;
use crate::atlas::source_resolver::BodySourceResolverResource;
use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate};
use crate::atlas::trait_draw::{
complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type,
VocabularyDrawInputs,
};
use crate::atlas::trait_swerve::{
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
};
use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{MaintenanceAuthority, MorphologyZone};
use crate::simulation::generator::{
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
};
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
use crate::tick_phases::TickPhase;
@@ -100,6 +112,7 @@ fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
rng: Option<Res<SimRng>>,
) {
for completion in queue.drain_completions() {
@@ -116,6 +129,19 @@ fn drain_generation_completions(
let reader = &city_reader.0;
let world_seed = rng.seed();
let body_id = state.body_id.clone();
// ── T-994 (D-232): body-level aggregation for the phase-1
// trait-vocabulary K-draw ───────────────────────────────────
// Read each placement's D-199 read-set once — reused both to
// build the body-wide coverage aggregate here and to build
// that placement's own skeleton work item below, so this
// dispatch pass makes exactly one `read_set` DB round trip per
// settlement (same as before this ticket).
let mut resolved: Vec<(&CityPlacement, CityEconomicReadSet)> = Vec::new();
let mut body_district_type_mix: std::collections::BTreeSet<DistrictType> =
Default::default();
let mut max_prosperity_bps: u32 = 0;
let mut max_k: usize = 0;
for placement in &state.placements {
let read_set = match reader.read_set(placement.city_id, world_seed) {
Ok(rs) => rs,
@@ -129,6 +155,91 @@ fn drain_generation_completions(
continue;
}
};
max_prosperity_bps =
max_prosperity_bps.max(read_set.prosperity_baseline_bps);
// Re-derives the identical DistrictType mix
// `generate_quarter_skeleton` computes later for this same
// placement (same chain, same inputs) — cheap (a 16-draw
// seeded LCG) and gives the aggregation loop the *actual*
// district-type mix rather than a proxy.
let mix_chain = SeedChain::for_body(world_seed, &body_id)
.derive(SeedDomain::Layer4Quarter, placement.city_id);
let mix = compute_district_mix(
read_set.population,
&read_set.economic_role,
&placement.political_archetype,
16,
mix_chain,
);
body_district_type_mix.extend(mix.districts);
// world_tier has no real derivation yet (city_context_reader's
// #TBD stub, always Waypoint) — tracked here via population
// tier alone so a future world_tier derivation slots into
// this MAX-K aggregate without revisiting this loop.
let tier = derive_complexity(
&WorldTier::Waypoint,
population_tier(read_set.population),
read_set.population,
);
max_k = max_k.max(complexity_k(&tier));
resolved.push((placement, read_set));
}
let body_district_type_mix: Vec<DistrictType> =
body_district_type_mix.into_iter().collect();
// Phase-1 K-draw (D-232): computed once, shared by every
// settlement on this body — the closed-vocabulary invariant.
// An absent reader (tests) degrades to an empty catalog:
// draw/pools/eligible all no-op identically.
let (catalog, bias): (Vec<TraitTemplate>, Vec<TraitBias>) =
match trait_catalog.as_ref() {
Some(tc) => (
tc.0.read_catalog().unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "trait catalog read failed — empty vocabulary");
Vec::new()
}),
tc.0.read_body_bias(&body_id).unwrap_or_else(|e| {
tracing::warn!(body_id = %body_id, error = %e, "trait bias read failed — no bias applied");
Vec::new()
}),
),
None => (Vec::new(), Vec::new()),
};
let body_sector: Option<&str> = resolved
.first()
.and_then(|(_, rs)| rs.geographic_sector.as_deref());
// dominant_bulk_class/dominant_production_ubiquity are
// #982 design-blocked stubs (always NonPhysical/Common) —
// see CityGenerationContext's field docs.
let inputs = VocabularyDrawInputs {
k: max_k,
dominant_bulk_class: &BulkClass::NonPhysical,
dominant_production_ubiquity: &ProductionUbiquity::Common,
max_prosperity_bps,
geographic_sector: body_sector,
coverage_district_types: &body_district_type_mix,
};
// Hard-gate-eligible pool — shared by the K-draw, the T-1003
// swerve pools, and the phase-2 necessity escape hatch (the
// swerve is cultural-only; the D-233 gates always hold).
let eligible = hard_gate_eligible(&catalog, &inputs);
let trait_selection = draw_body_vocabulary(
&catalog,
&bias,
&inputs,
SeedChain::for_body(world_seed, &body_id),
);
let swerve_pools = build_swerve_pools(&eligible, &trait_selection, body_sector);
let vocab = BodyVocabularyContext {
trait_selection: &trait_selection,
body_district_type_mix: &body_district_type_mix,
catalog: &catalog,
eligible: &eligible,
swerve_pools: &swerve_pools,
};
for (placement, read_set) in resolved {
queue.submit(
build_skeleton_work_item(
&body_id,
@@ -137,6 +248,7 @@ fn drain_generation_completions(
read_set,
&state.districts,
&state.road_graph,
&vocab,
),
GenPriority::Low,
);
@@ -195,6 +307,53 @@ fn drain_generation_completions(
}
}
/// Body-level D-232 trait-vocabulary draw outputs, threaded into
/// [`build_skeleton_work_item`] (T-994). Bundled into one struct purely to keep
/// that function's argument count under the clippy `too_many_arguments`
/// threshold — see its doc comment.
struct BodyVocabularyContext<'a> {
/// Phase-1 K-draw result (D-232), computed once per body by the caller —
/// identical for every settlement on the body (the closed-vocabulary
/// invariant). Empty when no `TraitCatalogReaderResource` is wired (tests)
/// or the body's K is 0 (`ComplexityTier::Empty` everywhere on the body).
trait_selection: &'a [String],
/// Every `DistrictType` present anywhere on the body (T-994 coverage
/// aggregate) — threaded onto `CityGenerationContext.body_district_type_mix`
/// verbatim (design point 4: visible on the context, not just consumed
/// internally by the draw).
body_district_type_mix: &'a [DistrictType],
/// The full trait-template catalog, needed to resolve phase 2
/// (`district_dominant_by_type`) for each settlement's District cell —
/// `zone_affinity` lives on the catalog row, not on `trait_selection`'s tags.
catalog: &'a [TraitTemplate],
/// Hard-gate-eligible subset of `catalog` (T-1003) — the phase-2 necessity
/// escape hatch reaches this pool when the vocabulary can't serve a district
/// type (the swerve is cultural-only; D-233 gates always hold).
eligible: &'a [&'a TraitTemplate],
/// Body-level T-1003 swerve candidate pools (foreign-import /
/// heritage-callback), cloned onto each settlement's context — the
/// per-building wildcard draws from these at `assign_block_tags` time.
swerve_pools: &'a SwervePools,
}
/// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's
/// centrality (high) / isolation (low) driver input. 0 when the city has no
/// node or no edges (matching `road_entry_directions_for_city`'s fallback).
fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
let Some(idx) = road_graph
.nodes
.iter()
.position(|n: &RoadNode| n.city_id == Some(city_id))
else {
return 0;
};
road_graph
.edges
.iter()
.filter(|e| e.from == idx || e.to == idx)
.count() as u32
}
/// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement
/// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set
/// (mirroring
@@ -223,6 +382,12 @@ fn drain_generation_completions(
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
/// city)` — not the `city_id * 10` placeholder.
///
/// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994):
/// `trait_selection` (phase 1, computed once per body by the caller) and the
/// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this
/// specific settlement's District cell. Bundled into one struct to keep this
/// function's argument count under the clippy `too_many_arguments` threshold.
///
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
fn build_skeleton_work_item(
body_id: &str,
@@ -231,6 +396,7 @@ fn build_skeleton_work_item(
read_set: CityEconomicReadSet,
districts: &BTreeMap<DistrictPos, DistrictProfile>,
road_graph: &RoadGraph,
vocab: &BodyVocabularyContext,
) -> GenWorkItem {
// The D-199 raw fields ride alongside the context (generate_quarter_skeleton
// takes them separately), so capture them before context_from_read_set consumes
@@ -238,6 +404,8 @@ fn build_skeleton_work_item(
let economic_role = read_set.economic_role.clone();
let population = read_set.population;
let founding_age_years = read_set.founding_age_years;
// T-1003 driver input (cosmopolitanism) — captured here for the same reason.
let faction_mixed = read_set.dominant_faction.as_deref() == Some("mixed");
let mut context = context_from_read_set(placement.city_id, read_set);
@@ -276,6 +444,44 @@ fn build_skeleton_work_item(
}
};
// ── T-994 / D-232: three-phase trait-template draw ──────────────────────────
// Phase 1 (trait_selection) and its inputs (body_district_type_mix) were
// computed once per body by the caller (drain_generation_completions) — the
// closed-vocabulary invariant requires every settlement on the body to carry
// the identical `trait_selection`, so this function only threads it through,
// never re-derives it. Phase 2 (district_dominant_by_type) IS settlement-
// specific (keyed by this settlement's own District cell) and is resolved
// here, at dispatch time — not inside the GenerateSkeleton Rayon task, and
// never inside FillChunk (T-987 keeps fill pure/cache-free).
context.trait_selection = vocab.trait_selection.to_vec();
context.body_district_type_mix = vocab.body_district_type_mix.to_vec();
context.settlement_district_pos = district_pos;
context.district_dominant_by_type = pick_district_dominant_by_type(
vocab.catalog,
vocab.eligible,
vocab.trait_selection,
SeedChain::for_body(world_seed, body_id),
district_pos,
);
// ── T-1003 / D-232: deviation/swerve driver rates + candidate pools ─────────
// Pools are body-level (same eligible catalog + vocabulary everywhere on the
// body); the driver RATES are per-settlement — centrality/isolation from the
// road graph, cosmopolitanism from the faction read, conservatism from
// founding age. `context.world_tier` rides the reader's Waypoint stub today
// (same caveat as the K aggregation) — Epicenter/Passage multipliers activate
// once a real derivation lands.
let drivers = SwerveDrivers {
world_tier: &context.world_tier,
faction_mixed,
road_degree: road_degree_for_city(placement.city_id, road_graph),
founding_age_years,
};
let rates = compute_swerve_rates(&drivers);
context.swerve_rates_bps = (rates.foreign_bps, rates.heritage_bps);
context.swerve_foreign_pool = vocab.swerve_pools.foreign.clone();
context.swerve_heritage_pool = vocab.swerve_pools.heritage.clone();
// ── T-1043: road_entry_directions from road_graph ───────────────────────────
// Find this city's settlement node index in the road graph (O(n) scan on a
// small slice — settlement counts are single-digit to low hundreds per body).
@@ -542,6 +748,24 @@ mod tests {
dominant_faction: None,
founding_age_years: 200,
settlement_class: SettlementClass::PopulationBudget,
geographic_sector: None,
}
}
/// Empty D-232 draw context (T-994/T-1003) — no catalog reader wired,
/// matching the production behaviour when `TraitCatalogReaderResource` is
/// absent.
fn empty_vocab() -> BodyVocabularyContext<'static> {
static EMPTY_POOLS: SwervePools = SwervePools {
foreign: Vec::new(),
heritage: Vec::new(),
};
BodyVocabularyContext {
trait_selection: &[],
body_district_type_mix: &[],
catalog: &[],
eligible: &[],
swerve_pools: &EMPTY_POOLS,
}
}
@@ -601,6 +825,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
@@ -639,6 +864,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) else {
unreachable!()
};
@@ -699,6 +925,7 @@ mod tests {
sample_read_set(),
&districts,
&RoadGraph::default(),
&empty_vocab(),
) else {
panic!("expected GenerateSkeleton");
};
@@ -731,6 +958,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
) else {
panic!("expected GenerateSkeleton")
};
@@ -918,6 +1146,7 @@ mod tests {
sample_read_set(),
&districts,
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
@@ -1188,6 +1417,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&road_graph,
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
@@ -1251,6 +1481,7 @@ mod tests {
sample_read_set(),
&BTreeMap::new(),
&RoadGraph::default(),
&empty_vocab(),
)
else {
panic!("expected GenerateSkeleton");
+1 -1
View File
@@ -308,7 +308,7 @@ mod tests {
heights: FloorHeightProfile::Uniform(3),
},
entry_class: BuildingEntryClass::Public,
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
flavor_ref: ArchitectureFlavorRef::InVocabulary(0),
era: ConstructionEra::Founding,
era_cause: EraCause::Original,
initial_condition: TileCondition::Intact,
+174 -18
View File
@@ -21,6 +21,7 @@
use crate::atlas::block_irregularity::block_irregularity;
use crate::atlas::district_mix::{compute_district_mix, population_tier};
use crate::atlas::tile_condition::{tile_condition, TileCondition};
use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates};
use crate::seed::splitmix64;
use crate::seed::{SeedChain, SeedDomain};
use std::collections::BTreeMap;
@@ -199,7 +200,15 @@ fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> Setti
/// | Backwater | Full | Moderate |
/// | Passage | Moderate | Minimal |
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
///
/// `pub(crate)` (T-994): also called from `atlas::plugin`'s body-level dispatch
/// aggregation to compute each settlement's D-232 phase-1 K contribution
/// (`trait_draw::complexity_k`) ahead of `generate_quarter_skeleton` itself.
pub(crate) fn derive_complexity(
world_tier: &WorldTier,
pop_tier: u8,
population: i64,
) -> ComplexityTier {
// Ghost stub threshold: pop < 5000 on Waypoint → Empty.
if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) {
return ComplexityTier::Empty;
@@ -334,6 +343,7 @@ fn build_block_grid(
BlockSkeleton {
position: (row as u8, col as u8),
zoning,
district_type: dt.clone(),
reservation,
// Density-based spacing; layout_mode offset/rotation applied in
// the street-network step (D-234).
@@ -863,7 +873,23 @@ fn assign_block_tags(
}
let prosperity = context.prosperity_baseline_bps;
let setting = &context.surrounding_biome;
let flavor_n = context.trait_selection.len().max(1) as u64;
// D-232 phase 2 (T-994): the dominant template for this block's DistrictType
// was already pre-resolved at L3→L4 dispatch time (`context.district_dominant_by_type`
// — see `atlas::trait_draw::pick_district_dominant_by_type`), keyed by the
// settlement's D-243 District cell so every block of this DistrictType across
// the whole quarter (and any sibling quarter in the same District) reads the
// identical template. Falls back to index 0 if the type has no entry (should
// not happen — the map always has all 9 DistrictType keys). Usually
// InVocabulary; already a Swerve when the sparsity escape hatch fired (T-1003).
let district_dominant = context
.district_dominant_by_type
.get(&block.district_type)
.cloned()
.unwrap_or(ArchitectureFlavorRef::InVocabulary(0));
let swerve_rates = SwerveRates {
foreign_bps: context.swerve_rates_bps.0,
heritage_bps: context.swerve_rates_bps.1,
};
let footprints = subdivide_block_footprints(
block.density_pct,
@@ -897,17 +923,26 @@ fn assign_block_tags(
);
let initial = initial_condition(prosperity, &era_cause);
let extent = floor_extent(block.density_pct, fp_chain.derive(SeedDomain::Block, 2));
// D-232: deterministic (seed + zone_type) → flavor index into the
// body's K-tag trait selection.
let flavor_seed = fp_chain.derive(SeedDomain::Block, 3).seed();
let flavor_index =
(splitmix64(flavor_seed ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8;
// T-1003 (D-232 deviation system): the rare per-building wildcard —
// its own seed domain off the footprint chain, so the roll can never
// correlate with the zone/era/extent draws above. Overwhelmingly
// None → the district-dominant template applies as usual.
let mut swerve_rng = fp_chain.derive(SeedDomain::TraitSwerve, 0).atlas_rng();
let flavor_ref = match roll_building_swerve(
swerve_rates,
&context.swerve_foreign_pool,
&context.swerve_heritage_pool,
&mut swerve_rng,
) {
Some(tag) => ArchitectureFlavorRef::Swerve(tag),
None => district_dominant.clone(),
};
BuildingPropertyTag {
zone_type_id,
footprint,
extent,
entry_class,
flavor_ref: ArchitectureFlavorRef { flavor_index },
flavor_ref,
era,
era_cause,
initial_condition: initial,
@@ -917,16 +952,6 @@ fn assign_block_tags(
.collect()
}
/// Stable hash of a zone-type id for the D-232 flavor draw (FNV-1a over bytes).
fn zone_type_hash(id: &ZoneTypeId) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in id.as_str().bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag
/// every non-reserved block's footprints. Keyed by 4×4 block grid position.
pub fn assign_all_block_tags(
@@ -1225,6 +1250,16 @@ mod tests {
trait_selection: Vec::new(),
dominant_bulk_class: BulkClass::NonPhysical,
dominant_production_ubiquity: ProductionUbiquity::Common,
// T-994 additions — sensible stubs for existing tests.
geographic_sector: None,
body_district_type_mix: Vec::new(),
settlement_district_pos: (0, 0),
district_dominant_by_type: BTreeMap::new(),
// T-1003 additions — zero rates / empty pools: no swerve in
// existing tests.
swerve_rates_bps: (0, 0),
swerve_foreign_pool: Vec::new(),
swerve_heritage_pool: Vec::new(),
}
}
@@ -1685,6 +1720,127 @@ mod tests {
assert_eq!(a, b);
}
// ── D-232 phase-2 dominant-template lookup (T-994) ───────────────────────
#[test]
fn flavor_ref_reads_the_pre_resolved_district_dominant_index() {
// The three-phase draw pre-resolves one dominant trait-selection index
// per DistrictType at dispatch time (context.district_dominant_by_type);
// assign_block_tags must be a pure lookup over it — no independent RNG
// draw of its own. Residential is unconditionally present (D-194 pop-tier
// guarantee, tier_guarantees(0).2 == 1) for a sub-1M-population city, so
// this isn't a vacuous check regardless of seed.
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["a".into(), "b".into(), "c".into()];
ctx.district_dominant_by_type.insert(
DistrictType::Residential,
ArchitectureFlavorRef::InVocabulary(2),
);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
let mut found_residential = false;
for (pos, block_tags) in &tags {
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
if block.district_type != DistrictType::Residential {
continue;
}
found_residential = true;
for tag in block_tags {
assert_eq!(
tag.flavor_ref,
ArchitectureFlavorRef::InVocabulary(2),
"Residential block {pos:?} should read the pre-resolved index"
);
}
}
assert!(
found_residential,
"expected at least one Residential block (D-194 pop-tier guarantee)"
);
}
#[test]
fn flavor_ref_falls_back_to_zero_for_unmapped_district_type() {
// An empty district_dominant_by_type (e.g. no trait catalog reader wired,
// or K=0) must not panic — every block falls back to index 0, matching
// the harmless pre-T-994 degenerate behaviour.
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
for block_tags in tags.values() {
for tag in block_tags {
assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0));
}
}
}
// ── D-232 deviation/swerve wildcard (T-1003) ─────────────────────────────
#[test]
fn swerve_wildcard_is_rare_deterministic_and_draws_from_the_pools() {
// With both pools populated and rates at the 300 bps cap (6% per
// building total), a full quarter must still be overwhelmingly
// district-dominant, any swerved building must carry a pool tag, and
// the whole assignment must be reproducible (D-010).
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
ctx.trait_selection = vec!["own".into()];
ctx.swerve_rates_bps = (300, 300);
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)");
let mut total = 0usize;
let mut swerved = 0usize;
for block_tags in tags_a.values() {
for tag in block_tags {
total += 1;
match &tag.flavor_ref {
ArchitectureFlavorRef::InVocabulary(_) => {}
ArchitectureFlavorRef::Swerve(t) => {
swerved += 1;
assert!(
t == "foreign_temple" || t == "old_hacienda",
"swerve must draw a pool tag, got '{t}'"
);
}
}
}
}
assert!(
total > 20,
"expected a meaningful building count, got {total}"
);
assert!(
swerved * 100 < total * 25,
"swerves must stay rare: {swerved}/{total}"
);
}
#[test]
fn zero_rates_never_swerve_even_with_populated_pools() {
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
ctx.trait_selection = vec!["own".into()];
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
// rates stay (0, 0) from make_context
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
for block_tags in tags.values() {
for tag in block_tags {
assert!(matches!(
tag.flavor_ref,
ArchitectureFlavorRef::InVocabulary(_)
));
}
}
}
// ── Street network (#957, D-234) ─────────────────────────────────────────
use crate::simulation::generator::AccessKind;
+521
View File
@@ -0,0 +1,521 @@
//! Build-time reader for the D-232 architecture-flavor trait-template catalog
//! (T-994) — `trait_templates` (the shared catalog) + `atlas_body_trait_bias`
//! (sparse per-body hero pins/boosts/suppressions).
//!
//! Read-only `systems.db` access, following the same pattern as
//! [`crate::atlas::city_context_reader::CityContextReader`]: opened once at
//! server startup, queried at L3→L4 dispatch time so the generation cascade
//! stays DB-free downstream (D-225).
//!
//! The catalog itself (`trait_templates`) is baked by
//! `tooling/economy-db/economy_import/traits.py` from
//! `wiki/economics/architecture_trait_catalog.toml` (#993, #1005) — see
//! `.claude/rules/asset-pipeline.md`. This reader only *consumes* the baked
//! table; it never writes to `systems.db`.
//!
//! **Scope (T-994):** only the fields the three-phase draw mechanism needs are
//! parsed — `allow_tags`/`block_tags`/`era_scope`/`visual_bundle`/
//! `cultural_description` are D-235 (visual bundle resolution) territory and are
//! left unparsed here.
use std::path::Path;
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, OpenFlags};
use thiserror::Error;
use crate::simulation::generator::{BulkClass, DistrictType, ProductionUbiquity};
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
#[derive(Debug, Error)]
pub enum TraitCatalogReadError {
#[error("systems.db error: {0}")]
Db(String),
}
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
/// One holistic template from the D-232 catalog (`trait_templates` row).
///
/// Fields mirror `wiki/economics/architecture_trait_catalog.toml` /
/// `server/data/systems-schema.sql`; JSON-text columns are parsed eagerly so
/// downstream draw logic (`atlas::trait_draw`) never touches raw JSON.
#[derive(Debug, Clone, PartialEq)]
pub struct TraitTemplate {
pub tag: String,
/// `baseline` | `heritage` | `cross_corridor` — informational (T-994 does not
/// implement the heritage-callback / cross-corridor swerve dials; that's
/// T-1003's deviation system).
pub corridor_pool: String,
/// The corridor this template is authored for; `None` = shared/cross-corridor.
/// A SOFT pool-narrowing hint (D-232) — never a hard gate.
pub geographic_sector: Option<String>,
/// Hard gate: eligible `BulkClass`es. Empty = eligible for all.
pub bulk_class_gate: Vec<BulkClass>,
/// Hard gate: eligible `ProductionUbiquity` values. Empty = eligible for all.
pub production_ubiquity_gate: Vec<ProductionUbiquity>,
/// Hard gate: minimum prosperity in basis points (D-010 integer).
pub min_prosperity_bps: u32,
/// Base weight in basis points (D-010 integer; 10 000 = 1.0×).
pub base_weight: u32,
/// Soft weight modifiers: dimension → value → multiplier_bps.
/// Dimensions per the catalog: `economic_role`, `dominant_faction`,
/// `founding_age`, `geographic_sector`, `morphology_zone`. T-994 applies only
/// `geographic_sector` (the one dimension with an unambiguous single
/// body-level value); the others are per-settlement and D-232 round 3 doesn't
/// specify how a multi-settlement body should combine them for one shared
/// vocabulary draw — parsed here for completeness, left un-applied.
pub weight_mods: std::collections::BTreeMap<String, std::collections::BTreeMap<String, u32>>,
/// `DistrictType` → weight_bps. The phase-2 dominant-template pick's sole
/// input (D-232: "district-dominant by zone_affinity").
pub zone_affinity: std::collections::BTreeMap<DistrictType, u32>,
}
/// Bias kind on an `atlas_body_trait_bias` row (D-232 hero-body wiki bias).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BiasKind {
/// Mandatory — counts toward K, forces inclusion in `trait_selection`.
Pin,
/// Weight multiplier > 1×, ≤ 3× (`weight_multiplier_bps` 10001..=30000).
Boost,
/// Weight multiplier < 1×, ≥ 0.33× — never 0 (`weight_multiplier_bps` 3300..=9999).
Suppress,
}
/// One sparse per-body bias row (hero bodies only, ~3040 per D-232).
#[derive(Debug, Clone, PartialEq)]
pub struct TraitBias {
pub template_tag: String,
pub bias_kind: BiasKind,
/// `None` for `Pin` (no multiplier — pins are forced, not weighted).
pub weight_multiplier_bps: Option<u32>,
}
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
/// Reads the D-232 trait-template catalog + per-body bias from `systems.db`.
///
/// Analogous to [`crate::atlas::city_context_reader::CityContextReader`]: a
/// read-only connection opened once at server startup.
pub struct TraitCatalogReader {
conn: Arc<Mutex<Connection>>,
}
impl TraitCatalogReader {
/// Open a read-only connection to `systems_db`.
pub fn open(systems_db: &Path) -> Result<Self, TraitCatalogReadError> {
let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Read the full shared catalog, ordered by `tag` (D-010 — deterministic
/// iteration order for the weighted draw's tie-breaks).
pub fn read_catalog(&self) -> Result<Vec<TraitTemplate>, TraitCatalogReadError> {
let conn = self
.conn
.lock()
.map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT tag, corridor_pool, geographic_sector, bulk_class_gate,
production_ubiquity_gate, min_prosperity_bps, base_weight,
weight_mods, zone_affinity
FROM trait_templates
ORDER BY tag",
)
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i64>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, Option<String>>(8)?,
))
})
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
let (
tag,
corridor_pool,
geographic_sector,
bulk_class_gate_json,
production_ubiquity_gate_json,
min_prosperity_bps,
base_weight,
weight_mods_json,
zone_affinity_json,
) = r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
out.push(TraitTemplate {
bulk_class_gate: parse_bulk_class_gate(bulk_class_gate_json.as_deref(), &tag),
production_ubiquity_gate: parse_production_ubiquity_gate(
production_ubiquity_gate_json.as_deref(),
&tag,
),
min_prosperity_bps: min_prosperity_bps.max(0) as u32,
base_weight: base_weight.max(0) as u32,
weight_mods: parse_weight_mods(weight_mods_json.as_deref(), &tag),
zone_affinity: parse_zone_affinity(zone_affinity_json.as_deref(), &tag),
tag,
corridor_pool,
geographic_sector,
});
}
Ok(out)
}
/// Read the sparse hero-body bias rows for `body_id` (empty for the ~240
/// non-hero bodies, D-232).
pub fn read_body_bias(&self, body_id: &str) -> Result<Vec<TraitBias>, TraitCatalogReadError> {
let conn = self
.conn
.lock()
.map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?;
let mut stmt = conn
.prepare(
"SELECT template_tag, bias_kind, weight_multiplier_bps
FROM atlas_body_trait_bias
WHERE body_id = ?1
ORDER BY template_tag",
)
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
let rows = stmt
.query_map([body_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<i64>>(2)?,
))
})
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
let mut out = Vec::new();
for r in rows {
let (template_tag, kind_str, mult) =
r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
let Some(bias_kind) = parse_bias_kind(&kind_str) else {
tracing::warn!(
body_id,
template_tag,
kind = kind_str,
"unrecognized bias_kind — skipping row"
);
continue;
};
out.push(TraitBias {
template_tag,
bias_kind,
weight_multiplier_bps: mult.map(|m| m.max(0) as u32),
});
}
Ok(out)
}
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
fn parse_bias_kind(s: &str) -> Option<BiasKind> {
match s {
"pin" => Some(BiasKind::Pin),
"boost" => Some(BiasKind::Boost),
"suppress" => Some(BiasKind::Suppress),
_ => None,
}
}
fn parse_bulk_class(s: &str) -> Option<BulkClass> {
match s {
"BulkSolid" => Some(BulkClass::BulkSolid),
"BulkLiquid" => Some(BulkClass::BulkLiquid),
"PrecisionDense" => Some(BulkClass::PrecisionDense),
"Perishable" => Some(BulkClass::Perishable),
"NonPhysical" => Some(BulkClass::NonPhysical),
_ => None,
}
}
fn parse_production_ubiquity(s: &str) -> Option<ProductionUbiquity> {
match s {
"Ubiquitous" => Some(ProductionUbiquity::Ubiquitous),
"Common" => Some(ProductionUbiquity::Common),
"Specialist" => Some(ProductionUbiquity::Specialist),
"MonopolySource" => Some(ProductionUbiquity::MonopolySource),
_ => None,
}
}
/// `DistrictType` from its exact catalog string (matches the Rust enum variant
/// names 1:1 — see `architecture_trait_catalog.toml`'s canonical-enum comment).
pub(crate) fn parse_district_type(s: &str) -> Option<DistrictType> {
match s {
"LogisticsHub" => Some(DistrictType::LogisticsHub),
"Residential" => Some(DistrictType::Residential),
"Commercial" => Some(DistrictType::Commercial),
"Industrial" => Some(DistrictType::Industrial),
"Administrative" => Some(DistrictType::Administrative),
"Entertainment" => Some(DistrictType::Entertainment),
"MixedUse" => Some(DistrictType::MixedUse),
"Transit" => Some(DistrictType::Transit),
"Specialized" => Some(DistrictType::Specialized),
_ => None,
}
}
fn parse_bulk_class_gate(json: Option<&str>, tag: &str) -> Vec<BulkClass> {
let Some(json) = json else { return Vec::new() };
let raw: Vec<String> = serde_json::from_str(json).unwrap_or_default();
raw.iter()
.filter_map(|s| {
let parsed = parse_bulk_class(s);
if parsed.is_none() {
tracing::warn!(tag, value = s, "unrecognized bulk_class_gate value");
}
parsed
})
.collect()
}
fn parse_production_ubiquity_gate(json: Option<&str>, tag: &str) -> Vec<ProductionUbiquity> {
let Some(json) = json else { return Vec::new() };
let raw: Vec<String> = serde_json::from_str(json).unwrap_or_default();
raw.iter()
.filter_map(|s| {
let parsed = parse_production_ubiquity(s);
if parsed.is_none() {
tracing::warn!(
tag,
value = s,
"unrecognized production_ubiquity_gate value"
);
}
parsed
})
.collect()
}
fn parse_weight_mods(
json: Option<&str>,
tag: &str,
) -> std::collections::BTreeMap<String, std::collections::BTreeMap<String, u32>> {
let Some(json) = json else {
return Default::default();
};
match serde_json::from_str::<
std::collections::BTreeMap<String, std::collections::BTreeMap<String, u32>>,
>(json)
{
Ok(m) => m,
Err(e) => {
tracing::warn!(tag, error = %e, "malformed weight_mods JSON — treating as empty");
Default::default()
}
}
}
fn parse_zone_affinity(
json: Option<&str>,
tag: &str,
) -> std::collections::BTreeMap<DistrictType, u32> {
let Some(json) = json else {
return Default::default();
};
let raw: std::collections::BTreeMap<String, u32> = match serde_json::from_str(json) {
Ok(m) => m,
Err(e) => {
tracing::warn!(tag, error = %e, "malformed zone_affinity JSON — treating as empty");
return Default::default();
}
};
raw.into_iter()
.filter_map(|(k, v)| match parse_district_type(&k) {
Some(dt) => Some((dt, v)),
None => {
tracing::warn!(tag, key = k, "unrecognized zone_affinity DistrictType key");
None
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Bevy resource wrapper
// ---------------------------------------------------------------------------
/// Bevy `Resource` wrapper — `Res<TraitCatalogReaderResource>` in systems.
/// Mirrors `CityContextReaderResource`.
#[derive(bevy_ecs::prelude::Resource)]
pub struct TraitCatalogReaderResource(pub TraitCatalogReader);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn make_test_db() -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_traitcat_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE trait_templates (
tag TEXT PRIMARY KEY,
label TEXT NOT NULL,
cultural_description TEXT,
corridor_pool TEXT NOT NULL DEFAULT 'baseline',
geographic_sector TEXT,
bulk_class_gate TEXT,
production_ubiquity_gate TEXT,
min_prosperity_bps INTEGER NOT NULL DEFAULT 0,
base_weight INTEGER NOT NULL DEFAULT 10000,
weight_mods TEXT,
zone_affinity TEXT,
allow_tags TEXT,
block_tags TEXT,
era_scope TEXT,
visual_bundle TEXT
);
CREATE TABLE atlas_body_trait_bias (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
template_tag TEXT NOT NULL,
bias_kind TEXT NOT NULL,
weight_multiplier_bps INTEGER,
note TEXT
);",
)
.expect("create tables");
conn.execute(
"INSERT INTO trait_templates
(tag, label, corridor_pool, geographic_sector, bulk_class_gate,
production_ubiquity_gate, min_prosperity_bps, base_weight,
weight_mods, zone_affinity)
VALUES ('generic_baseline', 'Generic Baseline', 'cross_corridor', NULL,
NULL, NULL, 0, 8000, NULL,
'{\"Residential\":10000,\"Commercial\":10000,\"MixedUse\":11000}')",
[],
)
.expect("insert generic_baseline");
conn.execute(
"INSERT INTO trait_templates
(tag, label, corridor_pool, geographic_sector, bulk_class_gate,
production_ubiquity_gate, min_prosperity_bps, base_weight,
weight_mods, zone_affinity)
VALUES ('extraction_camp', 'Extraction Camp', 'cross_corridor', NULL,
'[\"BulkSolid\",\"BulkLiquid\"]', '[\"MonopolySource\",\"Specialist\"]',
0, 12000,
'{\"economic_role\":{\"mining\":20000},\"geographic_sector\":{\"east_reach\":13000}}',
'{\"Industrial\":20000,\"LogisticsHub\":13000}')",
[],
)
.expect("insert extraction_camp");
conn.execute(
"INSERT INTO atlas_body_trait_bias (body_id, template_tag, bias_kind, weight_multiplier_bps)
VALUES ('HeroBody', 'generic_baseline', 'pin', NULL)",
[],
)
.expect("insert bias");
conn.execute(
"INSERT INTO atlas_body_trait_bias (body_id, template_tag, bias_kind, weight_multiplier_bps)
VALUES ('HeroBody', 'extraction_camp', 'boost', 20000)",
[],
)
.expect("insert bias 2");
drop(conn);
path
}
#[test]
fn read_catalog_parses_gates_and_maps() {
let db = make_test_db();
let reader = TraitCatalogReader::open(&db).expect("open");
let catalog = reader.read_catalog().expect("read catalog");
assert_eq!(catalog.len(), 2);
// Ordered by tag: extraction_camp < generic_baseline.
assert_eq!(catalog[0].tag, "extraction_camp");
assert_eq!(
catalog[0].bulk_class_gate,
vec![BulkClass::BulkSolid, BulkClass::BulkLiquid]
);
assert_eq!(
catalog[0].production_ubiquity_gate,
vec![
ProductionUbiquity::MonopolySource,
ProductionUbiquity::Specialist
]
);
assert_eq!(
catalog[0].zone_affinity.get(&DistrictType::Industrial),
Some(&20000)
);
assert_eq!(
catalog[0]
.weight_mods
.get("geographic_sector")
.and_then(|m| m.get("east_reach")),
Some(&13000)
);
assert_eq!(catalog[1].tag, "generic_baseline");
assert!(catalog[1].bulk_class_gate.is_empty());
assert_eq!(
catalog[1].zone_affinity.get(&DistrictType::MixedUse),
Some(&11000)
);
}
#[test]
fn read_body_bias_returns_sparse_rows() {
let db = make_test_db();
let reader = TraitCatalogReader::open(&db).expect("open");
let bias = reader.read_body_bias("HeroBody").expect("read bias");
assert_eq!(bias.len(), 2);
assert_eq!(bias[0].template_tag, "extraction_camp");
assert_eq!(bias[0].bias_kind, BiasKind::Boost);
assert_eq!(bias[0].weight_multiplier_bps, Some(20000));
assert_eq!(bias[1].template_tag, "generic_baseline");
assert_eq!(bias[1].bias_kind, BiasKind::Pin);
assert_eq!(bias[1].weight_multiplier_bps, None);
}
#[test]
fn read_body_bias_empty_for_non_hero_body() {
let db = make_test_db();
let reader = TraitCatalogReader::open(&db).expect("open");
let bias = reader
.read_body_bias("SomeOrdinaryBody")
.expect("read bias");
assert!(bias.is_empty());
}
}
+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"
);
}
}
+428
View File
@@ -0,0 +1,428 @@
//! D-232 deviation/swerve system (T-1003) — the rare per-building wildcard that
//! draws a COHERENT whole template from *outside* the body's closed K-vocabulary.
//!
//! **Three sources, two opposed active drivers (D-232):**
//! - *foreign import* — another corridor's grammar (that corridor's baseline +
//! the shared cross-corridor pool), driven **up** by cosmopolitanism /
//! centrality / transit / Epicenter tier;
//! - *heritage callback* — the body's own corridor **heritage sub-pool**, driven
//! **up** by remoteness / isolation / conservatism;
//! - the passive *past-vogue holdover* is **not** drawn here — it rides the
//! D-217 wear/era condition layer (`era_cause`), no new mechanism (D-232:
//! "the temporal sibling of the spatial swerve").
//!
//! The swerve is **cultural only**: candidate pools are built from the
//! hard-gate-*eligible* catalog (D-233 economic gates still hold — "function
//! still passes the normal economic hard gates; never an axis-scramble").
//!
//! The **sparsity escape hatch is the same mechanism** triggered by necessity
//! rather than dice ([`necessity_swerve`]): when the closed vocabulary genuinely
//! cannot serve a district type, the phase-2 dominant pick reaches the full
//! eligible catalog (see `trait_draw::pick_district_dominant_by_type`).
//!
//! Like `trait_draw`, this module is pure — the driver rates and candidate
//! pools are resolved once per settlement at L3→L4 dispatch time
//! (`atlas::plugin`) and threaded through `CityGenerationContext`; the
//! per-building roll happens in `skeleton_gen::assign_block_tags` off the
//! footprint's own `SeedChain` (never in `FillChunk` — T-987 keeps fill pure).
//!
//! All numbers are integer basis points (D-010). Every constant below is a
//! **T-1003 refinement placeholder** (base 100 bps ≈ 1 %/building, hard cap
//! 300 bps per driver) — needs Nigel/Burnelli calibration once real
//! multi-corridor bodies are authored, same status as
//! `trait_draw::SECTOR_MISMATCH_BPS`.
use crate::atlas::trait_catalog_reader::TraitTemplate;
use crate::seed::AtlasRng;
use crate::simulation::generator::WorldTier;
/// Baseline per-building wildcard chance (bps of 10 000) before driver scaling.
const SWERVE_BASE_BPS: u32 = 100;
/// Hard cap per driver after scaling (refinement: "hard cap 300 bps").
const SWERVE_DRIVER_CAP_BPS: u32 = 300;
// ── Foreign-import driver multipliers (bps, 10 000 = 1.0×) ──────────────────
/// Epicenter tier — the cosmopolitan hub end of the dial.
const FOREIGN_EPICENTER_MULT_BPS: u32 = 20_000;
/// Passage tier — the refinement's "transit" input.
const FOREIGN_PASSAGE_MULT_BPS: u32 = 15_000;
/// `dominant_faction == "mixed"` — the refinement's "cosmopolitanism" input.
const FOREIGN_MIXED_FACTION_MULT_BPS: u32 = 15_000;
/// Per road/rail-graph link (centrality), additive on the multiplier.
const FOREIGN_PER_ROAD_DEGREE_BPS: u32 = 1_000;
/// Degree contribution cap — beyond 5 links a hub is a hub.
const FOREIGN_ROAD_DEGREE_CAP: u32 = 5;
// ── Heritage-callback driver multipliers ─────────────────────────────────────
/// Road/rail degree ≤ 1 — the refinement's "isolation" input.
const HERITAGE_ISOLATED_MULT_BPS: u32 = 20_000;
/// Waypoint/Backwater tier — remoteness proxy. (The refinement floated a
/// `star_systems.dist_ly` percentile; that column is not in the D-199 read-set
/// today, and tier + graph degree are the in-world signals the percentile was
/// approximating. Slot a distance band in here if a reader field ever lands.)
const HERITAGE_REMOTE_TIER_MULT_BPS: u32 = 15_000;
/// `founding_age_years ≥ 300` — the refinement's "conservatism" input.
const HERITAGE_OLD_FOUNDING_MULT_BPS: u32 = 15_000;
/// `founding_age_years ≥ 150` (and < 300).
const HERITAGE_MID_FOUNDING_MULT_BPS: u32 = 12_500;
/// Per-settlement driver inputs, all resolvable at L3→L4 dispatch time from
/// data that exists today (T-1003 refinement: "mappings to REAL fields").
///
/// `world_tier` currently rides the `city_context_reader` Waypoint stub in
/// production (same caveat as the T-994 `max_k` aggregation) — the Epicenter/
/// Passage multipliers activate for real once a `world_tier` derivation lands.
pub struct SwerveDrivers<'a> {
pub world_tier: &'a WorldTier,
/// `dominant_faction == Some("mixed")` (cosmopolitanism).
pub faction_mixed: bool,
/// This settlement's node degree in the T-1038 road/rail graph
/// (centrality high — isolation low).
pub road_degree: u32,
/// D-199 field 5 (conservatism: older settlements reach back harder).
pub founding_age_years: u32,
}
/// Per-building swerve chances (bps of 10 000), one per active driver. A
/// driver whose candidate pool is empty contributes no roll mass — enforced
/// inside [`roll_building_swerve`], not here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SwerveRates {
pub foreign_bps: u32,
pub heritage_bps: u32,
}
/// Scale the base rate by the two opposed driver stacks (D-232). A settlement
/// can plausibly score on both (an old, well-connected regional town keeps a
/// nonzero heritage pull) — the drivers oppose in *what they favour*, not as a
/// zero-sum split.
pub fn compute_swerve_rates(drivers: &SwerveDrivers) -> SwerveRates {
let mut foreign_mult: u64 = 10_000;
match drivers.world_tier {
WorldTier::Epicenter => foreign_mult = FOREIGN_EPICENTER_MULT_BPS as u64,
WorldTier::Passage => foreign_mult = FOREIGN_PASSAGE_MULT_BPS as u64,
_ => {}
}
if drivers.faction_mixed {
foreign_mult = foreign_mult * FOREIGN_MIXED_FACTION_MULT_BPS as u64 / 10_000;
}
foreign_mult +=
(drivers.road_degree.min(FOREIGN_ROAD_DEGREE_CAP) * FOREIGN_PER_ROAD_DEGREE_BPS) as u64;
let mut heritage_mult: u64 = 10_000;
if drivers.road_degree <= 1 {
heritage_mult = HERITAGE_ISOLATED_MULT_BPS as u64;
}
if matches!(
drivers.world_tier,
WorldTier::Waypoint | WorldTier::Backwater
) {
heritage_mult = heritage_mult * HERITAGE_REMOTE_TIER_MULT_BPS as u64 / 10_000;
}
if drivers.founding_age_years >= 300 {
heritage_mult = heritage_mult * HERITAGE_OLD_FOUNDING_MULT_BPS as u64 / 10_000;
} else if drivers.founding_age_years >= 150 {
heritage_mult = heritage_mult * HERITAGE_MID_FOUNDING_MULT_BPS as u64 / 10_000;
}
SwerveRates {
foreign_bps: ((SWERVE_BASE_BPS as u64 * foreign_mult / 10_000) as u32)
.min(SWERVE_DRIVER_CAP_BPS),
heritage_bps: ((SWERVE_BASE_BPS as u64 * heritage_mult / 10_000) as u32)
.min(SWERVE_DRIVER_CAP_BPS),
}
}
/// The two out-of-vocabulary candidate pools, weighted by `base_weight`
/// (hero-body bias intentionally not applied — bias shapes the body's *own*
/// vocabulary, D-232; a swerve is by definition from elsewhere/elsewhen).
/// Tags, not indices: the swerve result is recorded as
/// `ArchitectureFlavorRef::Swerve(tag)`, re-derivable like everything else.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SwervePools {
/// Foreign import: eligible templates outside the vocabulary from *another*
/// corridor's grammar or the shared `cross_corridor` pool.
pub foreign: Vec<(String, u32)>,
/// Heritage callback: eligible `heritage`-pool templates of the body's own
/// corridor (or sector-unpinned heritage entries).
pub heritage: Vec<(String, u32)>,
}
/// Partition the hard-gate-eligible catalog (minus the body's own vocabulary)
/// into the two swerve pools (D-232 corridor = two-part pool).
///
/// `eligible` must already have passed the D-233 hard gates
/// (`trait_draw::hard_gate_eligible`) — the cultural-only rule.
pub fn build_swerve_pools(
eligible: &[&TraitTemplate],
trait_selection: &[String],
body_sector: Option<&str>,
) -> SwervePools {
let mut pools = SwervePools::default();
for t in eligible {
if trait_selection.iter().any(|tag| tag == &t.tag) {
continue; // in-vocabulary — the closed draw already covers it
}
let entry = (t.tag.clone(), t.base_weight.max(1));
match t.corridor_pool.as_str() {
// Another corridor's baseline grammar, or the shared cross-corridor
// pool that D-232 says "feeds the foreign-import swerves".
"cross_corridor" => pools.foreign.push(entry),
"baseline" => match (t.geographic_sector.as_deref(), body_sector) {
(Some(sector), Some(own)) if sector != own => pools.foreign.push(entry),
(Some(_), None) => pools.foreign.push(entry),
_ => {} // own-corridor (or unpinned) baseline — not foreign
},
// The body's own corridor heritage sub-pool ("the remoteness dial
// draws specifically from it"). Other corridors' heritage is *not*
// a foreign-import source — D-232 scopes foreign import to grammar,
// heritage callback to one's own past.
"heritage" => match (t.geographic_sector.as_deref(), body_sector) {
(Some(sector), Some(own)) if sector == own => pools.heritage.push(entry),
(None, _) => pools.heritage.push(entry),
_ => {}
},
other => {
tracing::debug!(tag = %t.tag, corridor_pool = %other, "unknown corridor_pool — excluded from swerve pools");
}
}
}
pools
}
/// Weighted pick from one pool. `None` on an empty pool.
fn pick_weighted(pool: &[(String, u32)], rng: &mut AtlasRng) -> Option<String> {
if pool.is_empty() {
return None;
}
let total: u64 = pool.iter().map(|(_, w)| *w as u64).sum();
let mut roll = (rng.next_u32() as u64) % total.max(1);
for (tag, w) in pool {
if roll < *w as u64 {
return Some(tag.clone());
}
roll -= *w as u64;
}
pool.last().map(|(tag, _)| tag.clone())
}
/// The per-building wildcard roll (D-232 deviation system). One `u32` roll in
/// `[0, 10 000)`: below `foreign_bps` → foreign-import pick; below
/// `foreign_bps + heritage_bps` → heritage-callback pick; otherwise `None`
/// (the overwhelmingly common case — the building takes the district-dominant
/// template as usual). An empty pool's driver contributes no roll mass — a hit
/// would have nothing to draw.
///
/// Takes the pools as slices (the `CityGenerationContext` fields) so per-block
/// callers never construct anything. `rng` must be a footprint-scoped stream
/// (`SeedDomain::TraitSwerve` off the footprint's own chain) so the roll is
/// deterministic per building (D-010) and uncorrelated with the zone/era/
/// extent draws.
pub fn roll_building_swerve(
rates: SwerveRates,
foreign_pool: &[(String, u32)],
heritage_pool: &[(String, u32)],
rng: &mut AtlasRng,
) -> Option<String> {
let foreign_bps = if foreign_pool.is_empty() {
0
} else {
rates.foreign_bps
};
let heritage_bps = if heritage_pool.is_empty() {
0
} else {
rates.heritage_bps
};
let total = foreign_bps + heritage_bps;
if total == 0 {
return None;
}
let roll = rng.next_u32() % 10_000;
if roll < foreign_bps {
pick_weighted(foreign_pool, rng)
} else if roll < total {
pick_weighted(heritage_pool, rng)
} else {
None
}
}
/// The sparsity escape hatch (D-232: "the SAME mechanism triggered by necessity
/// rather than dice"): when zero vocabulary templates serve a district type at
/// phase-2 dominant-pick time, reach the full hard-gate-eligible catalog for
/// the best-weighted template that covers it. Deterministic (max-weight,
/// last-on-tie over the catalog's stable tag-sorted order — D-010), no dice:
/// necessity is not random.
pub fn necessity_swerve(
eligible: &[&TraitTemplate],
covers: impl Fn(&TraitTemplate) -> bool,
) -> Option<String> {
eligible
.iter()
.filter(|t| covers(t))
.max_by_key(|t| t.base_weight)
.map(|t| t.tag.clone())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::{SeedChain, SeedDomain};
use std::collections::BTreeMap;
fn tmpl(tag: &str, pool: &str, sector: Option<&str>, base_weight: u32) -> TraitTemplate {
TraitTemplate {
tag: tag.to_string(),
corridor_pool: pool.to_string(),
geographic_sector: sector.map(str::to_string),
bulk_class_gate: Vec::new(),
production_ubiquity_gate: Vec::new(),
min_prosperity_bps: 0,
base_weight,
weight_mods: BTreeMap::new(),
zone_affinity: BTreeMap::new(),
}
}
fn quiet_drivers() -> SwerveDrivers<'static> {
SwerveDrivers {
world_tier: &WorldTier::Regional,
faction_mixed: false,
road_degree: 2,
founding_age_years: 50,
}
}
#[test]
fn baseline_rates_are_the_base_bps() {
let r = compute_swerve_rates(&quiet_drivers());
assert_eq!(
r.foreign_bps,
SWERVE_BASE_BPS + 2 * FOREIGN_PER_ROAD_DEGREE_BPS / 100
);
assert_eq!(r.heritage_bps, SWERVE_BASE_BPS);
}
#[test]
fn epicenter_hub_boosts_foreign_and_caps() {
let drivers = SwerveDrivers {
world_tier: &WorldTier::Epicenter,
faction_mixed: true,
road_degree: 9,
founding_age_years: 50,
};
let r = compute_swerve_rates(&drivers);
assert_eq!(
r.foreign_bps, SWERVE_DRIVER_CAP_BPS,
"×2.0 ×1.5 + degree hits the cap"
);
assert_eq!(r.heritage_bps, SWERVE_BASE_BPS);
}
#[test]
fn isolated_old_backwater_boosts_heritage_and_caps() {
let drivers = SwerveDrivers {
world_tier: &WorldTier::Backwater,
faction_mixed: false,
road_degree: 1,
founding_age_years: 400,
};
let r = compute_swerve_rates(&drivers);
assert_eq!(
r.heritage_bps, SWERVE_DRIVER_CAP_BPS,
"×2.0 ×1.5 ×1.5 hits the cap"
);
}
#[test]
fn pools_partition_by_corridor_and_exclude_vocabulary() {
let own = tmpl("own_baseline", "baseline", Some("east_reach"), 10_000);
let foreign = tmpl("west_baseline", "baseline", Some("west_reach"), 10_000);
let shared = tmpl("shared", "cross_corridor", None, 10_000);
let own_heritage = tmpl("east_temple", "heritage", Some("east_reach"), 10_000);
let foreign_heritage = tmpl("west_hacienda", "heritage", Some("west_reach"), 10_000);
let in_vocab = tmpl("in_vocab", "cross_corridor", None, 10_000);
let eligible: Vec<&TraitTemplate> = vec![
&own,
&foreign,
&shared,
&own_heritage,
&foreign_heritage,
&in_vocab,
];
let pools = build_swerve_pools(&eligible, &["in_vocab".to_string()], Some("east_reach"));
let foreign_tags: Vec<&str> = pools.foreign.iter().map(|(t, _)| t.as_str()).collect();
let heritage_tags: Vec<&str> = pools.heritage.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(foreign_tags, ["west_baseline", "shared"]);
assert_eq!(
heritage_tags,
["east_temple"],
"other corridors' heritage is not drawn"
);
}
#[test]
fn empty_pools_never_swerve_regardless_of_rates() {
let rates = SwerveRates {
foreign_bps: 10_000,
heritage_bps: 10_000,
};
let mut rng = SeedChain::root(1)
.derive(SeedDomain::TraitSwerve, 0)
.atlas_rng();
for _ in 0..100 {
assert_eq!(roll_building_swerve(rates, &[], &[], &mut rng), None);
}
}
#[test]
fn swerve_is_rare_and_deterministic() {
let shared = tmpl("shared", "cross_corridor", None, 10_000);
let temple = tmpl("temple", "heritage", None, 10_000);
let eligible: Vec<&TraitTemplate> = vec![&shared, &temple];
let pools = build_swerve_pools(&eligible, &[], None);
let rates = SwerveRates {
foreign_bps: 100,
heritage_bps: 100,
}; // 2% total
let count_hits = || {
let mut hits = 0;
for i in 0..10_000u64 {
let mut rng = SeedChain::root(7)
.derive(SeedDomain::TraitSwerve, i)
.atlas_rng();
if roll_building_swerve(rates, &pools.foreign, &pools.heritage, &mut rng).is_some()
{
hits += 1;
}
}
hits
};
let hits = count_hits();
assert_eq!(hits, count_hits(), "same seeds → same swerves (D-010)");
// 2% nominal over 10k rolls — generous band, this is a rarity check
// not a distribution test.
assert!(
(100..=400).contains(&hits),
"expected ~200 swerves in 10k rolls, got {hits}"
);
}
#[test]
fn necessity_swerve_picks_max_weight_covering_template() {
let a = tmpl("light", "baseline", None, 5_000);
let b = tmpl("heavy", "baseline", None, 9_000);
let c = tmpl("heavier_but_not_covering", "baseline", None, 12_000);
let eligible: Vec<&TraitTemplate> = vec![&a, &b, &c];
let picked = necessity_swerve(&eligible, |t| t.tag != "heavier_but_not_covering");
assert_eq!(picked.as_deref(), Some("heavy"));
assert_eq!(necessity_swerve(&eligible, |_| false), None);
}
}
+22
View File
@@ -234,6 +234,28 @@ fn main() {
),
}
// D-232 trait-template catalog reader (T-994): reads `trait_templates` +
// `atlas_body_trait_bias` on a body-analysis completion so the L3→L4 dispatch
// aggregation (atlas::plugin::drain_generation_completions) can run the
// three-phase draw. Absent → trait_selection stays empty for every body
// (the pre-T-994 degenerate behaviour), not a hard failure.
match settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReader::open(
&systems_db_path,
) {
Ok(reader) => {
tracing::info!("Trait catalog reader opened: {:?}", systems_db_path);
app.insert_resource(
settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReaderResource(
reader,
),
);
}
Err(e) => tracing::warn!(
"Trait catalog reader unavailable ({}). Architecture-flavor draw will stay empty.",
e
),
}
// Initialize SQLite settings store (#627).
// Path: alongside save files in the server's working directory.
let settings_path = std::path::PathBuf::from("settings.db");
+19
View File
@@ -120,6 +120,22 @@ pub enum SeedDomain {
/// keyed by a single constant id). Distinct domain so the mosaic-selection lattice
/// can never correlate with the relief, cover, or per-voxel terrain streams.
VoxelMosaic = 12,
/// Architecture-flavor body-vocabulary K-draw (D-232 phase 1, T-994). Keyed by
/// a constant id (one draw per body — `SeedChain::for_body` already isolates
/// bodies). Distinct domain so the vocabulary draw can never correlate with
/// any other body-scoped stream.
TraitVocabulary = 13,
/// Architecture-flavor district-dominant template pick (D-232 phase 2, T-994).
/// Keyed by a packed `(DistrictPos, DistrictType)` id so every settlement whose
/// quarter falls in the same D-243 2 048 m district independently derives the
/// *identical* dominant template for a given district type — same body seed +
/// same key, no cross-quarter coordination required.
TraitDistrict = 14,
/// Per-building deviation/swerve roll (D-232 deviation system, T-1003).
/// Derived off the footprint's own chain, keyed by footprint index. Distinct
/// domain so the rare-wildcard roll can never correlate with the zone/era/
/// extent draws sharing that chain.
TraitSwerve = 15,
}
/// A position in the deterministic seed tree (D-224).
@@ -281,6 +297,9 @@ mod tests {
assert_eq!(SeedDomain::Cover as u64, 10);
assert_eq!(SeedDomain::VoxelRelief as u64, 11);
assert_eq!(SeedDomain::VoxelMosaic as u64, 12);
assert_eq!(SeedDomain::TraitVocabulary as u64, 13);
assert_eq!(SeedDomain::TraitDistrict as u64, 14);
assert_eq!(SeedDomain::TraitSwerve as u64, 15);
}
#[test]
+81 -7
View File
@@ -198,7 +198,13 @@ pub enum SettingType {
}
/// Classification of a district (high-level function).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
///
/// `Ord`/`PartialOrd` (T-994): lets callers dedup a body-wide coverage set into a
/// `BTreeSet`/`BTreeMap` key (D-010 determinism) — the D-232 trait-template draw's
/// `zone_affinity` lookup and the body-level district-type-mix coverage aggregate.
/// Declaration order is not a stability-pinned wire format (unlike `MorphologyZone`
/// or `SeedDomain`), so this is a safe additive derive.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum DistrictType {
LogisticsHub,
Residential,
@@ -817,15 +823,31 @@ impl TileRect {
}
}
/// Architecture flavor index into the body's trait-template draw (D-229, D-232).
/// Architecture flavor reference into the body's trait-template draw (D-229, D-232).
///
/// Records which template the generator selected at skeleton time for Phase-6 to
/// read cold. The selection mechanism is D-232's weighted `allow`/`block` filter;
/// the index is frozen-amber once written.
/// read cold. The selection mechanism is D-232's three-phase draw (T-994): a body
/// vocabulary K-draw, then a per-district dominant-template pick by `zone_affinity`,
/// then within-template seed picks (not represented here — that's the D-235 visual
/// bundle resolution). The reference is frozen-amber once written.
///
/// Shaped as an enum (rather than a bare index) so the closed-vocabulary case and
/// the future out-of-vocabulary swerve (T-1003, D-232's deviation system) share one
/// wire type without a breaking change when the swerve lands — `T-994` introduces
/// the shape only; swerve *logic* (foreign-import / heritage-callback draws) is not
/// implemented here.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct ArchitectureFlavorRef {
/// Index into `CityGenerationContext::trait_selection` (0-based).
pub flavor_index: u8,
pub enum ArchitectureFlavorRef {
/// Index into `CityGenerationContext::trait_selection` (0-based) — the body's
/// closed K-template vocabulary (D-232 phase 1).
InVocabulary(u8),
/// A template tag drawn from *outside* the body's closed vocabulary (T-1003,
/// D-232 deviation system): the rare per-building foreign-import /
/// heritage-callback wildcard (`trait_swerve::roll_building_swerve`), or the
/// sparsity escape hatch at district-dominant time
/// (`trait_swerve::necessity_swerve`). The passive past-vogue holdover is NOT
/// a swerve — it rides the D-217 wear/era condition layer (`era_cause`).
Swerve(String),
}
/// A single building footprint tag — the frozen step-3 output placed on every
@@ -1116,11 +1138,57 @@ pub struct CityGenerationContext {
/// Body vocabulary draw result — K trait-template tags selected at skeleton
/// time (D-232). K is locked to `complexity_tier`: Full=5, Moderate=3,
/// Minimal=1, Empty=0. NOT named `flavor_profile` (that was the round-2 name).
/// Populated by the T-994 phase-1 K-draw; empty when the trait catalog reader
/// is unavailable or `complexity_tier == Empty` (K=0).
pub trait_selection: Vec<String>,
/// Dominant `BulkClass` for this settlement's primary commodity (D-233).
pub dominant_bulk_class: BulkClass,
/// Spatial concentration of dominant production (D-233).
pub dominant_production_ubiquity: ProductionUbiquity,
// ── T-994 additions (D-232 three-phase draw) ───────────────────────────
/// This system's corridor (`star_systems.geographic_sector` —
/// core/north_reach/south_reach/west_reach/east_reach/deep_frontier; `None`
/// if unset). A SOFT weight on the phase-1 body-vocabulary draw only — composes
/// with a template's own `geographic_sector` pool-narrowing column AND its
/// `weight_mods.geographic_sector` map (PR #148 review note: a two-part join,
/// never a hard gate — D-232 "corridors are tendencies, not borders").
pub geographic_sector: Option<String>,
/// Body-level district-type coverage: every `DistrictType` present anywhere
/// among this body's settlements, deduped (T-994). Threaded from the L3→L4
/// dispatch aggregation so the phase-1 K-draw is coverage-aware up front
/// (≥1 eligible template per district type actually present on the body).
pub body_district_type_mix: Vec<DistrictType>,
/// The D-243 2 048 m District cell (`atlas::scale::DistrictPos`) this
/// settlement's quarter falls in (T-994). Two settlements sharing a
/// `settlement_district_pos` independently derive the identical phase-2
/// dominant template for a given `DistrictType` — same body seed + same key,
/// so a coherent 2 048 m area reads as one style with no cross-quarter
/// coordination required.
pub settlement_district_pos: (i32, i32),
/// Phase-2 dominant-template pick (D-232), pre-resolved at L3→L4 dispatch time
/// (T-994) — **not** at `FillChunk` (T-987 keeps fill pure/cache-free) and not
/// even inside the `GenerateSkeleton` Rayon task itself, since the inputs
/// (`trait_selection` + the catalog's `zone_affinity`) are already known at
/// dispatch. One entry per `DistrictType` (all 9, so `assign_block_tags` is a
/// cheap infallible lookup). Usually `InVocabulary` (an index into
/// `trait_selection`); `Swerve` when the sparsity escape hatch fired (T-1003).
/// `BTreeMap` for D-010 determinism.
pub district_dominant_by_type: BTreeMap<DistrictType, ArchitectureFlavorRef>,
// ── T-1003 additions (D-232 deviation/swerve system) ────────────────────
/// Per-building wildcard chances (bps of 10 000): `(foreign_bps,
/// heritage_bps)`, resolved once per settlement at dispatch time from the
/// driver inputs (`trait_swerve::compute_swerve_rates`). Both zero when no
/// catalog reader is wired.
pub swerve_rates_bps: (u32, u32),
/// Foreign-import swerve candidates — hard-gate-eligible templates outside
/// the body vocabulary, from another corridor's grammar or the shared
/// `cross_corridor` pool: `(tag, weight_bps)` (`trait_swerve` module).
pub swerve_foreign_pool: Vec<(String, u32)>,
/// Heritage-callback swerve candidates — the body's own corridor heritage
/// sub-pool: `(tag, weight_bps)`.
pub swerve_heritage_pool: Vec<(String, u32)>,
}
// ---------------------------------------------------------------------------
@@ -1146,6 +1214,12 @@ pub struct BlockSkeleton {
/// Grid position (03, 03).
pub position: (u8, u8),
pub zoning: ZoningType,
/// The D-194 district-mix `DistrictType` this block was assigned (T-994).
/// `zoning` is the *function* derived from it (`zoning_for_district`); this
/// field keeps the source classification around because the D-232 phase-2
/// dominant-template pick keys `zone_affinity` by `DistrictType`, not
/// `ZoningType` — dropping it here would make the pick unrecoverable.
pub district_type: DistrictType,
/// Which multi-block reservation this block belongs to (if any).
pub reservation: Option<ReservationId>,
pub chunk_layout: ChunkLayout,