diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 51e30b2f6..85c9ae8e4 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -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)`. **Q-104 resolution (the D-110 ↔ D-227 bridge):** two pure functions — `floor_at_voxel_z(z) -> Option` 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:** ~25–35 templates at floor, 40–45 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` 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. diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index be7358e6d..088196f27 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -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, } // --------------------------------------------------------------------------- @@ -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, // acn.economic_role i64, // acn.population Option, // acn.settlement_class Option, // b.founding_age_years Option, // sf.dominant_faction + Option, // 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] diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index ef58c3382..78479da6d 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -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, diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 8bec7dcf2..220c05628 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -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; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index bc7a39c4f..65e13ec9a 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -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, mut cache: ResMut, city_reader: Option>, + trait_catalog: Option>, rng: Option>, ) { 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 = + 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 = + 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, Vec) = + 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, 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"); diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs index bd61bbfd9..4d91d80fa 100644 --- a/server/src/atlas/shell.rs +++ b/server/src/atlas/shell.rs @@ -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, diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 4742df2d8..4040cb89a 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -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; diff --git a/server/src/atlas/trait_catalog_reader.rs b/server/src/atlas/trait_catalog_reader.rs new file mode 100644 index 000000000..9b44affe0 --- /dev/null +++ b/server/src/atlas/trait_catalog_reader.rs @@ -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, + /// Hard gate: eligible `BulkClass`es. Empty = eligible for all. + pub bulk_class_gate: Vec, + /// Hard gate: eligible `ProductionUbiquity` values. Empty = eligible for all. + pub production_ubiquity_gate: Vec, + /// 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>, + /// `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, +} + +/// 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, ~30–40 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, +} + +// --------------------------------------------------------------------------- +// 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>, +} + +impl TraitCatalogReader { + /// Open a read-only connection to `systems_db`. + pub fn open(systems_db: &Path) -> Result { + 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, 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>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(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, 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>(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 { + match s { + "pin" => Some(BiasKind::Pin), + "boost" => Some(BiasKind::Boost), + "suppress" => Some(BiasKind::Suppress), + _ => None, + } +} + +fn parse_bulk_class(s: &str) -> Option { + 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 { + 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 { + 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 { + let Some(json) = json else { return Vec::new() }; + let raw: Vec = 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 { + let Some(json) = json else { return Vec::new() }; + let raw: Vec = 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> { + let Some(json) = json else { + return Default::default(); + }; + match serde_json::from_str::< + std::collections::BTreeMap>, + >(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 { + let Some(json) = json else { + return Default::default(); + }; + let raw: std::collections::BTreeMap = 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` 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()); + } +} diff --git a/server/src/atlas/trait_draw.rs b/server/src/atlas/trait_draw.rs new file mode 100644 index 000000000..8f4ca9cb6 --- /dev/null +++ b/server/src/atlas/trait_draw.rs @@ -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 { + 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 { + 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 = (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 = (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 = (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 = (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" + ); + } +} diff --git a/server/src/atlas/trait_swerve.rs b/server/src/atlas/trait_swerve.rs new file mode 100644 index 000000000..c6240d9bf --- /dev/null +++ b/server/src/atlas/trait_swerve.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 853bd182a..0e36326b7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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"); diff --git a/server/src/seed.rs b/server/src/seed.rs index 252956880..67b86adc1 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -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] diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index f23ce179f..71c3df7a3 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -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, /// 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, + /// 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, + /// 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, + + // ── 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 (0–3, 0–3). 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, pub chunk_layout: ChunkLayout,