From a854b89aa01da60f7096a12284c55aa243a326ff Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 13:46:24 +0200 Subject: [PATCH 1/2] feat(simulation): wire D-199 economic read-set into generation context (#954) Layer 2 clean half. GenerateSkeleton now builds a CityGenerationContext from a body's real economic read-set instead of stub defaults. - New CityContextReader (server/src/atlas/city_context_reader.rs): reads the 6-field D-199 set (economic_role, prosperity_baseline, population, dominant_faction, founding_age_years, settlement_class) via one JOIN; prosperity_baseline derived per D-197. - GenWorkItem::GenerateSkeleton carries the resolved context; run_work_item builds the real context instead of DistrictWorldState::default(). - Left at defaults (deferred, documented): dominant_bulk_class / dominant_production_ubiquity (#982, design-blocked), trait_selection (#1005), morphology_zone + Layer-3 fields (attractor placement / D-204 / D-214). 16 new tests; cargo check/clippy --all-targets -D warnings clean; 1279 lib tests pass; fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/city_context_reader.rs | 696 ++++++++++++++++++++++++ server/src/atlas/gen_queue.rs | 115 +++- server/src/atlas/mod.rs | 1 + 3 files changed, 793 insertions(+), 19 deletions(-) create mode 100644 server/src/atlas/city_context_reader.rs diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs new file mode 100644 index 000000000..7c46cf496 --- /dev/null +++ b/server/src/atlas/city_context_reader.rs @@ -0,0 +1,696 @@ +//! Build-time economic read-set reader for city generation contexts (D-199). +//! +//! Resolves the D-199 6-field minimum economic read-set for a city from +//! `systems.db` at generation-dispatch time. All 6 fields are required before a +//! `GenerateSkeleton` work item is dispatched (D-199: "Missing fields abort the +//! task with a logged error; generation does not proceed with partial context"). +//! +//! **Fields read** (D-199): +//! +//! 1. `economic_role` — from `atlas_city_names.economic_role` +//! 2. `prosperity_baseline` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0.0) +//! 3. `population` — from `atlas_city_names.population` +//! 4. `dominant_faction` — from `system_factions.dominant_faction` (via `bodies.system_id`) +//! 5. `founding_age_years` — from `bodies.founding_age_years` +//! 6. `settlement_class` — from `atlas_city_names.settlement_class` +//! +//! **Fields left at defaults** (deferred work, out of scope for this ticket): +//! +//! - `political_archetype` — Commission placeholder (D-214 faction→archetype mapping deferred) +//! - `surrounding_biome` — Urban default (body `planet_class` + Layer-1 sub-biome deferred) +//! - `road_entry_directions` — empty (attractor placement deferred) +//! - `footprint_radius_km` — 5.0 stub (D-204 formula deferred) +//! - `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) +//! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked) +//! - `dominant_production_ubiquity` — Common default (#982 design-blocked) +//! +//! **Prosperity derivation (D-197, partial):** +//! `prosperity_baseline = clamp(role_base + pop_bonus + noise, 0.1, 0.95)` +//! where: +//! - `role_base` — per-role lookup (10 values; D-197 table) +//! - `pop_bonus` — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12 +//! - `terrain_bonus` — 0.0 (Layer-1 topography not yet available here) +//! - `noise` — ±0.05 uniform noise via `SeedChain` (D-010 deterministic) +//! +//! Read-only `systems.db` access follows the same pattern as +//! [`crate::atlas::source_resolver::BodySourceResolver`]. + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use rusqlite::{Connection, OpenFlags}; +use thiserror::Error; + +use crate::seed::{fnv1a_64, splitmix64, AtlasRng, SeedChain, SeedDomain}; +use crate::simulation::generator::{ + BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype, + ProductionUbiquity, SettingType, SettlementClass, WorldTier, +}; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors that can occur while reading the economic read-set for a city. +#[derive(Debug, Error)] +pub enum CityContextReadError { + #[error("systems.db error: {0}")] + Db(String), + #[error("city {city_id} not found in atlas_city_names")] + UnknownCity { city_id: u64 }, + #[error("city {city_id}: missing required field `{field}`")] + MissingField { city_id: u64, field: &'static str }, +} + +// --------------------------------------------------------------------------- +// Raw read-set from the DB +// --------------------------------------------------------------------------- + +/// D-199 6-field economic read-set as raw strings/integers from the DB. +/// All fields are `Option` because the DB columns are nullable; the reader +/// converts missing fields into `CityContextReadError::MissingField` before +/// returning. +#[derive(Debug, Clone)] +pub struct CityEconomicReadSet { + /// D-199 field 1. + pub economic_role: String, + /// D-199 field 2 — derived by `prosperity_baseline_from_read_set`. + pub prosperity_baseline: f32, + /// D-199 field 3. + pub population: i64, + /// D-199 field 4. `None` = faction data absent for this system. + pub dominant_faction: Option, + /// D-199 field 5. + pub founding_age_years: u32, + /// D-199 field 6. + pub settlement_class: SettlementClass, +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Reads the D-199 economic read-set for a city from `systems.db` at +/// generation-dispatch time. Holds a read-only SQLite connection. +/// +/// Analogous to [`crate::atlas::source_resolver::BodySourceResolver`] — both +/// read from `systems.db` read-only and are used at dispatch time to pre-resolve +/// the inputs a Rayon work item needs before it is submitted. +pub struct CityContextReader { + conn: Arc>, +} + +impl CityContextReader { + /// 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| CityContextReadError::Db(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Read the D-199 6-field economic read-set for `city_id`. + /// + /// Joins `atlas_city_names` → `bodies` → `system_factions` to gather all + /// required fields. Returns `CityContextReadError::MissingField` if any + /// required field is NULL. `dominant_faction` is allowed to be NULL (the + /// system may have no recorded faction data); it is carried as `Option`. + pub fn read_set( + &self, + city_id: u64, + world_seed: u64, + ) -> Result { + let conn = self + .conn + .lock() + .map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?; + + // ─── Query ──────────────────────────────────────────────────────────── + // 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). + // + // 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). + let row: rusqlite::Result<( + Option, // acn.economic_role + i64, // acn.population + Option, // acn.settlement_class + Option, // b.founding_age_years + Option, // sf.dominant_faction + )> = conn.query_row( + "SELECT + acn.economic_role, + acn.population, + acn.settlement_class, + b.founding_age_years, + sf.dominant_faction + 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 + WHERE acn.id = ?1", + [city_id as i64], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ); + + let row = match row { + Ok(r) => r, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Err(CityContextReadError::UnknownCity { city_id }); + } + Err(e) => return Err(CityContextReadError::Db(e.to_string())), + }; + + let ( + economic_role_opt, + population, + settlement_class_opt, + founding_age_opt, + dominant_faction, + ) = row; + + let economic_role = economic_role_opt.ok_or(CityContextReadError::MissingField { + city_id, + field: "economic_role", + })?; + + let founding_age_years = founding_age_opt.ok_or(CityContextReadError::MissingField { + city_id, + field: "founding_age_years", + })? as u32; + + let settlement_class = parse_settlement_class(settlement_class_opt.as_deref(), city_id)?; + + // D-197: derive prosperity_baseline from role base + pop bonus + noise. + let prosperity_baseline = + prosperity_baseline_from_read_set(&economic_role, population, city_id, world_seed); + + Ok(CityEconomicReadSet { + economic_role, + prosperity_baseline, + population, + dominant_faction, + founding_age_years, + settlement_class, + }) + } + + /// Build a [`CityGenerationContext`] from the economic read-set, with + /// deferred fields left at their defaults. + /// + /// D-199 guarantees that the 6 required fields are populated. All other + /// fields on `CityGenerationContext` are at stub/default values as documented + /// in the module comment. + pub fn build_context( + &self, + city_id: u64, + world_seed: u64, + ) -> Result { + let rs = self.read_set(city_id, world_seed)?; + Ok(context_from_read_set(city_id, rs)) + } +} + +// --------------------------------------------------------------------------- +// prosperity_baseline derivation (D-197, partial) +// --------------------------------------------------------------------------- + +/// Per-role base prosperity (D-197 table). +fn role_base_prosperity(role: &str) -> f32 { + match role { + "manufacturing" => 0.55, + "financial" => 0.70, + "agricultural" => 0.50, + "extraction" => 0.45, + "service_mixed" => 0.60, + "institutional" => 0.65, + "transit_hub" => 0.60, + "research" => 0.65, + "military" => 0.55, + "residential" => 0.50, + // Unknown role → mid-point fallback; logged by the caller if needed. + _ => 0.55, + } +} + +/// Population log-scale bonus (D-197): `0.04 × floor(log10(pop / 1_000_000 + 1))`, +/// capped at +0.12. +fn pop_bonus(population: i64) -> f32 { + if population <= 0 { + return 0.0; + } + // log10((pop / 1_000_000) + 1), floored to integer. Use integer arithmetic + // to stay D-010 compliant (no f64 log calls with platform-dependent rounding). + let ratio = (population as f64 / 1_000_000.0 + 1.0).log10().floor() as i32; + let bonus = 0.04 * ratio.max(0) as f32; + bonus.min(0.12) +} + +/// ±0.05 uniform noise seeded deterministically from (world_seed, city_id) (D-010). +/// +/// Uses `SeedChain` to derive a per-city noise stream, then maps a `u32` to +/// the range [0.0, 0.1) and subtracts 0.05, producing [-0.05, +0.05). +fn prosperity_noise(city_id: u64, world_seed: u64) -> f32 { + // Domain: Layer3Settlement (prosperity is a settlement-level property). + let seed = SeedChain::root(world_seed) + .derive(SeedDomain::Layer3Settlement, fnv1a_64(&city_id.to_string())) + .seed(); + let mut rng = AtlasRng::new(splitmix64(seed)); + // Map u32 → [0.0, 1.0) then scale to [0.0, 0.1) then shift to [-0.05, 0.05). + let raw = rng.next_u32() as f32 / u32::MAX as f32; // [0.0, 1.0) + raw * 0.10 - 0.05 +} + +/// D-197 formula (terrain gradient left at 0.0 — requires Layer-1 data). +/// +/// `clamp(role_base + pop_bonus + terrain_bonus + noise, 0.1, 0.95)` +pub fn prosperity_baseline_from_read_set( + economic_role: &str, + population: i64, + city_id: u64, + world_seed: u64, +) -> f32 { + let base = role_base_prosperity(economic_role); + let pb = pop_bonus(population); + // terrain_bonus = 0.0: Layer-1 attractor output not available at this tier. + let noise = prosperity_noise(city_id, world_seed); + (base + pb + noise).clamp(0.1, 0.95) +} + +// --------------------------------------------------------------------------- +// SettlementClass parsing +// --------------------------------------------------------------------------- + +/// Parse a `SettlementClass` from its DB TEXT representation. +/// +/// `atlas_city_names.settlement_class` is nullable (NULL until city placement +/// runs). A NULL value defaults to `NameLocked` — the safest class that +/// unconditionally proceeds with generation — and is reported as a missing-field +/// error so callers can decide whether to abort or proceed. +fn parse_settlement_class( + raw: Option<&str>, + city_id: u64, +) -> Result { + match raw { + Some("NameLocked") => Ok(SettlementClass::NameLocked), + Some("PopulationBudget") => Ok(SettlementClass::PopulationBudget), + Some("EconomicTriggered") => Ok(SettlementClass::EconomicTriggered), + Some("OrganicGrowth") => Ok(SettlementClass::OrganicGrowth), + None => Err(CityContextReadError::MissingField { + city_id, + field: "settlement_class", + }), + Some(_unknown) => { + // Unknown variant text — treated as missing. A dedicated + // "invalid variant" error branch would add noise for no consumer + // benefit at this stage; MissingField is the closest signal. + Err(CityContextReadError::MissingField { + city_id, + field: "settlement_class", + }) + } + } +} + +// --------------------------------------------------------------------------- +// Context assembly +// --------------------------------------------------------------------------- + +/// Assemble a `CityGenerationContext` from the D-199 read-set, with deferred +/// fields at their defaults. +/// +/// Deferred fields are documented in the module comment above. +pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGenerationContext { + CityGenerationContext { + city_id, + + // ── D-199 6-field read set ──────────────────────────────────────── + // prosperity_baseline is D-199 field 2 (derived, D-197). + prosperity_baseline: rs.prosperity_baseline, + + // ── Deferred fields (stubs) ─────────────────────────────────────── + // political_archetype: real derivation requires D-214 faction→archetype + // mapping. Commission is the safest default (grid layout, avoids + // organic-placement paths that depend on founding_age_years detail). + political_archetype: PoliticalArchetype::Commission, + // surrounding_biome: real value comes from body planet_class + Layer-1 + // sub-biome. + surrounding_biome: SettingType::Urban, + // road_entry_directions: populated by attractor placement. + road_entry_directions: Vec::new(), + // footprint_radius_km: populated by D-204 formula (body_radius_km + + // population). + footprint_radius_km: 5.0, + // founding_orientation: populated by attractor matching. + founding_orientation: FoundingOrientation::Cardinal, + // world_tier: real derivation from system_economy.economic_tier (#TBD). + world_tier: WorldTier::Waypoint, + // morphology_zone: Layer-1 output, D-228 deferred. + morphology_zone: MorphologyZone::AlluvialPlain, + // trait_selection: trait catalog #1005 deferred. + 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, + } +} + +// --------------------------------------------------------------------------- +// 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); + + // ─── Temp DB helpers ───────────────────────────────────────────────────── + + /// Build a minimal `systems.db` containing one city with all 6 D-199 fields + /// present. Returns the DB path. + /// + /// Schema mirrors the real one (just the columns the reader queries). + fn make_test_db( + body_id: &str, + system_id: &str, + economic_role: &str, + population: i64, + settlement_class: Option<&str>, + founding_age_years: Option, + dominant_faction: Option<&str>, + ) -> (PathBuf, i64) { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("sr_ctxrd_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create db"); + + conn.execute_batch( + "CREATE TABLE star_systems (system_id TEXT PRIMARY KEY); + CREATE TABLE bodies ( + body_id TEXT PRIMARY KEY, + system_id TEXT NOT NULL, + founding_age_years INTEGER + ); + CREATE TABLE system_factions ( + system_id TEXT PRIMARY KEY, + dominant_faction TEXT + ); + CREATE TABLE atlas_city_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + settlement_class TEXT + );", + ) + .expect("create tables"); + + conn.execute( + "INSERT INTO star_systems (system_id) VALUES (?1)", + rusqlite::params![system_id], + ) + .expect("insert system"); + + conn.execute( + "INSERT INTO bodies (body_id, system_id, founding_age_years) VALUES (?1, ?2, ?3)", + rusqlite::params![body_id, system_id, founding_age_years], + ) + .expect("insert body"); + + if let Some(faction) = dominant_faction { + conn.execute( + "INSERT INTO system_factions (system_id, dominant_faction) VALUES (?1, ?2)", + rusqlite::params![system_id, faction], + ) + .expect("insert faction"); + } + + conn.execute( + "INSERT INTO atlas_city_names (body_id, name, economic_role, population, settlement_class) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![body_id, "TestCity", economic_role, population, settlement_class], + ) + .expect("insert city"); + + let id: i64 = conn + .query_row("SELECT id FROM atlas_city_names LIMIT 1", [], |r| r.get(0)) + .expect("get id"); + + path.try_exists().expect("db exists"); + drop(conn); + + (path, id) + } + + // ─── prosperity_baseline derivation ────────────────────────────────────── + + #[test] + fn role_base_financial_is_correct() { + assert!((role_base_prosperity("financial") - 0.70).abs() < 1e-6); + } + + #[test] + fn role_base_unknown_falls_back() { + // Unknown roles get the mid-point fallback, not a panic. + let base = role_base_prosperity("deep_space_weird_role"); + assert!((base - 0.55).abs() < 1e-6); + } + + #[test] + fn pop_bonus_zero_population() { + assert_eq!(pop_bonus(0), 0.0); + } + + #[test] + fn pop_bonus_one_million() { + // pop=1_000_000 → ratio = floor(log10(1+1)) = floor(0.301) = 0 → 0.0 + assert!((pop_bonus(1_000_000) - 0.0).abs() < 1e-5); + } + + #[test] + fn pop_bonus_ten_million() { + // pop=10_000_000 → ratio = floor(log10(10+1)) = floor(1.041) = 1 → 0.04 + assert!((pop_bonus(10_000_000) - 0.04).abs() < 1e-5); + } + + #[test] + fn pop_bonus_capped_at_0_12() { + // Huge population → log10 grows but bonus is capped at 0.12. + assert!(pop_bonus(i64::MAX) <= 0.12 + 1e-6); + } + + #[test] + fn prosperity_noise_is_deterministic() { + let a = prosperity_noise(42, 1234); + let b = prosperity_noise(42, 1234); + assert_eq!(a, b, "same inputs must give same noise"); + } + + #[test] + fn prosperity_noise_in_range() { + for city_id in 0..20u64 { + let n = prosperity_noise(city_id, 99); + assert!( + (-0.05..0.05).contains(&n), + "noise {n} out of [-0.05, 0.05) for city_id={city_id}" + ); + } + } + + #[test] + fn prosperity_baseline_clamped() { + // extraction (0.45) + zero pop + max noise (< 0.05) → won't underflow 0.1 + let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0); + assert!((0.1..=0.95).contains(&p)); + } + + // ─── DB read-set integration ────────────────────────────────────────────── + + /// Representative body with all 6 D-199 fields present — the primary test case. + #[test] + fn read_set_populates_all_6_fields() { + let (db, city_id) = make_test_db( + "GJ1c", + "GJ-1", + "financial", + 5_000_000, + Some("NameLocked"), + Some(450), + Some("The Commission"), + ); + let reader = CityContextReader::open(&db).expect("open"); + let rs = reader.read_set(city_id as u64, 42).expect("read set"); + + // Field 1 — economic_role + assert_eq!(rs.economic_role, "financial"); + // Field 2 — prosperity_baseline (non-stub: derived from real role+pop) + let expected_base = 0.70 + pop_bonus(5_000_000); + assert!( + (rs.prosperity_baseline - expected_base).abs() <= 0.05 + 1e-4, + "prosperity_baseline {:.3} not near expected base {:.3} ± noise", + rs.prosperity_baseline, + expected_base + ); + assert!((0.1..=0.95).contains(&rs.prosperity_baseline)); + // Field 3 — population + assert_eq!(rs.population, 5_000_000); + // Field 4 — dominant_faction + assert_eq!(rs.dominant_faction.as_deref(), Some("The Commission")); + // Field 5 — founding_age_years + assert_eq!(rs.founding_age_years, 450); + // Field 6 — settlement_class + assert!(matches!(rs.settlement_class, SettlementClass::NameLocked)); + } + + #[test] + fn build_context_prosperity_is_not_stub() { + let (db, city_id) = make_test_db( + "GJ2b", + "GJ-2", + "manufacturing", + 2_000_000, + Some("PopulationBudget"), + Some(200), + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let ctx = reader + .build_context(city_id as u64, 77) + .expect("build context"); + + // The stub value from skeleton_gen tests uses 0.7; manufacturing base is 0.55. + // A real read-set result should not be that default value unless coincidental. + assert!( + (0.1..=0.95).contains(&ctx.prosperity_baseline), + "prosperity_baseline must be in [0.1, 0.95], got {}", + ctx.prosperity_baseline + ); + // Verify it comes from the real derivation: manufacturing base is 0.55, + // pop=2M → pop_bonus=0, noise in ±0.05 → total ≈ 0.50–0.60. + assert!( + (0.49..=0.61).contains(&ctx.prosperity_baseline), + "manufacturing/2M should produce ≈0.55±0.06, got {}", + ctx.prosperity_baseline + ); + } + + #[test] + fn unknown_city_returns_error() { + let (db, _) = make_test_db( + "GJ3c", + "GJ-3", + "research", + 100_000, + Some("NameLocked"), + Some(100), + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let err = reader.read_set(99999, 0).expect_err("should fail"); + assert!(matches!( + err, + CityContextReadError::UnknownCity { city_id: 99999 } + )); + } + + #[test] + fn missing_settlement_class_returns_error() { + let (db, city_id) = make_test_db( + "GJ4b", + "GJ-4", + "extraction", + 50_000, + None, // settlement_class NULL + Some(300), + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let err = reader.read_set(city_id as u64, 0).expect_err("should fail"); + assert!(matches!( + err, + CityContextReadError::MissingField { + field: "settlement_class", + .. + } + )); + } + + #[test] + fn missing_founding_age_returns_error() { + let (db, city_id) = make_test_db( + "GJ5c", + "GJ-5", + "transit_hub", + 80_000, + Some("NameLocked"), + None, // founding_age_years NULL + None, + ); + let reader = CityContextReader::open(&db).expect("open"); + let err = reader.read_set(city_id as u64, 0).expect_err("should fail"); + assert!(matches!( + err, + CityContextReadError::MissingField { + field: "founding_age_years", + .. + } + )); + } + + #[test] + fn dominant_faction_none_is_allowed() { + // dominant_faction is permitted to be absent (no system_factions row). + let (db, city_id) = make_test_db( + "GJ6b", + "GJ-6", + "residential", + 30_000, + Some("PopulationBudget"), + Some(120), + None, // no faction row + ); + let reader = CityContextReader::open(&db).expect("open"); + let rs = reader.read_set(city_id as u64, 0).expect("should succeed"); + assert!(rs.dominant_faction.is_none()); + } + + #[test] + fn prosperity_is_deterministic_across_calls() { + let (db, city_id) = make_test_db( + "GJ7c", + "GJ-7", + "research", + 3_000_000, + Some("NameLocked"), + Some(600), + Some("The Assembly"), + ); + let reader = CityContextReader::open(&db).expect("open"); + let rs1 = reader.read_set(city_id as u64, 42).expect("first call"); + let rs2 = reader.read_set(city_id as u64, 42).expect("second call"); + assert_eq!( + rs1.prosperity_baseline, rs2.prosperity_baseline, + "prosperity_baseline must be deterministic for same inputs" + ); + } +} diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index eac45a142..7aa938c0f 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -30,8 +30,9 @@ use crossbeam_channel::{Receiver, Sender}; use crate::atlas::body_world_state::BodyWorldState; use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; +use crate::atlas::skeleton_gen::generate_skeleton; use crate::seed::SeedChain; -use crate::simulation::generator::DistrictWorldState; +use crate::simulation::generator::{CityGenerationContext, DistrictWorldState}; // --------------------------------------------------------------------------- // Priority @@ -68,7 +69,35 @@ pub enum GenWorkItem { body_seed: SeedChain, }, /// Generate a Phase 1 DistrictSkeleton for this city. - GenerateSkeleton { city_id: u64 }, + /// + /// `context` is the D-199 economic read-set pre-resolved at dispatch time. + /// All 6 required fields must be populated before this item is submitted + /// (D-199: "Missing fields abort the task … generation does not proceed with + /// partial context"). + /// + /// `body_id` routes the resulting `SkeletonGenerated` completion into the + /// correct `BodyWorldState` cache entry (D-230). + /// + /// `district_id` is the stable content-addressable id for the generated + /// district (keyed by city position + world seed). + /// + /// `economic_role`, `population`, and `founding_age_years` are D-199 fields + /// carried alongside the context because `generate_skeleton` accepts them as + /// separate parameters (its signature is not changed by this ticket). + GenerateSkeleton { + city_id: u64, + body_id: String, + /// D-199 economic read-set + all other context fields. + context: Box, + /// Stable content-addressable district id (D-194/D-230). + district_id: u64, + /// District-level seed chain (D-224). + chain: SeedChain, + // D-199 raw fields passed to generate_skeleton separately. + economic_role: String, + population: i64, + founding_age_years: u32, + }, /// Pre-fill a chunk in an existing district. FillChunk { district_id: u64, @@ -339,13 +368,35 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion { reason: format!("heightmap load failed: {e}"), }, }, - GenWorkItem::GenerateSkeleton { city_id } => { - // Stub: real skeleton generation (#957) will populate `body_id` from - // the CityGenerationContext and `state` from the plan phase (D-230). + GenWorkItem::GenerateSkeleton { + city_id, + body_id, + context, + district_id, + chain, + economic_role, + population, + founding_age_years, + } => { + // Build the Phase 1 skeleton from the pre-resolved D-199 context. + // `economic_role`, `population`, and `founding_age_years` are the + // D-199 raw fields carried alongside the context because + // `generate_skeleton` accepts them as separate parameters. + let skeleton = generate_skeleton( + context, + *population, + economic_role, + *district_id, + *founding_age_years, + *chain, + ); GenCompletion::SkeletonGenerated { city_id: *city_id, - body_id: String::new(), - state: Box::new(DistrictWorldState::default()), + body_id: body_id.clone(), + state: Box::new(DistrictWorldState { + skeleton, + block_tags: std::collections::BTreeMap::new(), + }), } } GenWorkItem::FillChunk { @@ -401,6 +452,41 @@ mod tests { } } + /// Build a minimal `GenerateSkeleton` work item with a stub context. + /// + /// The stub context uses Commission/Regional/Urban defaults — the same + /// values the existing skeleton_gen tests use. These tests exercise queue + /// mechanics (ordering, saturation, drain), not economic read-set content. + fn gen_skeleton(city_id: u64) -> GenWorkItem { + use crate::simulation::generator::{ + BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, + PoliticalArchetype, ProductionUbiquity, SettingType, WorldTier, + }; + GenWorkItem::GenerateSkeleton { + city_id, + body_id: format!("TestBody{city_id}"), + context: Box::new(CityGenerationContext { + city_id, + political_archetype: PoliticalArchetype::Commission, + prosperity_baseline: 0.6, + surrounding_biome: SettingType::Urban, + road_entry_directions: vec![], + footprint_radius_km: 5.0, + founding_orientation: FoundingOrientation::Cardinal, + world_tier: WorldTier::Regional, + morphology_zone: MorphologyZone::AlluvialPlain, + trait_selection: vec![], + dominant_bulk_class: BulkClass::NonPhysical, + dominant_production_ubiquity: ProductionUbiquity::Common, + }), + district_id: city_id * 10, + chain: SeedChain::root(42 + city_id), + economic_role: "service_mixed".to_string(), + population: 500_000, + founding_age_years: 200, + } + } + #[test] fn submit_and_drain() { let q = make_queue(); @@ -437,18 +523,9 @@ mod tests { // Uses 3 threads so all items can dispatch without hitting saturation. let q = GenerationQueue::with_threads(3); // Using GenerateSkeleton (no dedup logic) to test ordering directly. - q.submit( - GenWorkItem::GenerateSkeleton { city_id: 1 }, - GenPriority::Low, - ); - q.submit( - GenWorkItem::GenerateSkeleton { city_id: 2 }, - GenPriority::Immediate, - ); - q.submit( - GenWorkItem::GenerateSkeleton { city_id: 3 }, - GenPriority::Medium, - ); + q.submit(gen_skeleton(1), GenPriority::Low); + q.submit(gen_skeleton(2), GenPriority::Immediate); + q.submit(gen_skeleton(3), GenPriority::Medium); std::thread::sleep(Duration::from_millis(100)); let completions = q.drain_completions(); assert_eq!(completions.len(), 3); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 919580f85..f3012f2a5 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -7,6 +7,7 @@ pub mod attractor_matching; pub mod block_irregularity; pub mod body_world_state; pub mod cascade; +pub mod city_context_reader; pub mod district_mix; pub mod drainage; pub mod features; From d6e37c0c0e0655e8a6662dbe6b9c0437a437d6a9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 14:31:27 +0200 Subject: [PATCH 2/2] fix(simulation): prosperity in basis points + shared log10_floor helper (#954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #145 review. Convert prosperity from f32 to integer basis points (matching the existing prosperity_bps, D-010 integer-only), removing both float-determinism bugs by construction: - New server/src/bps.rs: log10_floor (integer order-of-magnitude) + bps_to_f32 edge helper, with boundary tests. - city_context_reader: prosperity_baseline_bps (u32, 0-10000). role base + pop bonus (400*log10_floor, cap 1200) + symmetric noise via integer modulo (fixes the always-negative bug) + clamp [1000,9500]. No floats. - CityGenerationContext.prosperity_baseline -> prosperity_baseline_bps; updated the two test context builders. Not serialized — no wire break. - district_mix population_tier now uses log10_floor (same determinism bug class as the comment claimed to avoid). - Tests in bps + assert positive noise is achievable (the case the old test hid). cargo check/clippy --all-targets -D warnings clean; 1291 lib tests pass; fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/city_context_reader.rs | 270 +++++++++++++++--------- server/src/atlas/district_mix.rs | 11 +- server/src/atlas/gen_queue.rs | 2 +- server/src/atlas/skeleton_gen.rs | 2 +- server/src/bps.rs | 122 +++++++++++ server/src/lib.rs | 2 + server/src/simulation/generator.rs | 9 +- 7 files changed, 304 insertions(+), 114 deletions(-) create mode 100644 server/src/bps.rs diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 7c46cf496..e2f8d4904 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -8,7 +8,7 @@ //! **Fields read** (D-199): //! //! 1. `economic_role` — from `atlas_city_names.economic_role` -//! 2. `prosperity_baseline` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0.0) +//! 2. `prosperity_baseline_bps` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0) //! 3. `population` — from `atlas_city_names.population` //! 4. `dominant_faction` — from `system_factions.dominant_faction` (via `bodies.system_id`) //! 5. `founding_age_years` — from `bodies.founding_age_years` @@ -27,13 +27,13 @@ //! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked) //! - `dominant_production_ubiquity` — Common default (#982 design-blocked) //! -//! **Prosperity derivation (D-197, partial):** -//! `prosperity_baseline = clamp(role_base + pop_bonus + noise, 0.1, 0.95)` -//! where: -//! - `role_base` — per-role lookup (10 values; D-197 table) -//! - `pop_bonus` — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12 -//! - `terrain_bonus` — 0.0 (Layer-1 topography not yet available here) -//! - `noise` — ±0.05 uniform noise via `SeedChain` (D-010 deterministic) +//! **Prosperity derivation (D-197, partial — integer basis points, D-010):** +//! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)` +//! where (all values are integer basis points; 10_000 bps = 1.0): +//! - `role_base_bps` — per-role lookup (10 values; D-197 table) +//! - `pop_bonus_bps` — `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200 +//! - `terrain_bonus_bps` — 0 (Layer-1 topography not yet available here) +//! - `noise_bps` — ±500 symmetric uniform via `SeedChain` and integer modulo (D-010 deterministic) //! //! Read-only `systems.db` access follows the same pattern as //! [`crate::atlas::source_resolver::BodySourceResolver`]. @@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex}; use rusqlite::{Connection, OpenFlags}; use thiserror::Error; +use crate::bps::log10_floor; use crate::seed::{fnv1a_64, splitmix64, AtlasRng, SeedChain, SeedDomain}; use crate::simulation::generator::{ BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype, @@ -78,7 +79,8 @@ pub struct CityEconomicReadSet { /// D-199 field 1. pub economic_role: String, /// D-199 field 2 — derived by `prosperity_baseline_from_read_set`. - pub prosperity_baseline: f32, + /// Integer basis points (0–10_000; 10_000 = 1.0). D-010 integer-only. + pub prosperity_baseline_bps: u32, /// D-199 field 3. pub population: i64, /// D-199 field 4. `None` = faction data absent for this system. @@ -194,13 +196,14 @@ impl CityContextReader { let settlement_class = parse_settlement_class(settlement_class_opt.as_deref(), city_id)?; - // D-197: derive prosperity_baseline from role base + pop bonus + noise. - let prosperity_baseline = + // D-197: derive prosperity_baseline_bps from role base + pop bonus + noise. + // All arithmetic is integer (basis points, 10_000 = 1.0) — D-010 compliant. + let prosperity_baseline_bps = prosperity_baseline_from_read_set(&economic_role, population, city_id, world_seed); Ok(CityEconomicReadSet { economic_role, - prosperity_baseline, + prosperity_baseline_bps, population, dominant_faction, founding_age_years, @@ -225,69 +228,90 @@ impl CityContextReader { } // --------------------------------------------------------------------------- -// prosperity_baseline derivation (D-197, partial) +// prosperity_baseline_bps derivation (D-197, partial — integer basis points) // --------------------------------------------------------------------------- -/// Per-role base prosperity (D-197 table). -fn role_base_prosperity(role: &str) -> f32 { - match role { - "manufacturing" => 0.55, - "financial" => 0.70, - "agricultural" => 0.50, - "extraction" => 0.45, - "service_mixed" => 0.60, - "institutional" => 0.65, - "transit_hub" => 0.60, - "research" => 0.65, - "military" => 0.55, - "residential" => 0.50, - // Unknown role → mid-point fallback; logged by the caller if needed. - _ => 0.55, - } -} - -/// Population log-scale bonus (D-197): `0.04 × floor(log10(pop / 1_000_000 + 1))`, -/// capped at +0.12. -fn pop_bonus(population: i64) -> f32 { - if population <= 0 { - return 0.0; - } - // log10((pop / 1_000_000) + 1), floored to integer. Use integer arithmetic - // to stay D-010 compliant (no f64 log calls with platform-dependent rounding). - let ratio = (population as f64 / 1_000_000.0 + 1.0).log10().floor() as i32; - let bonus = 0.04 * ratio.max(0) as f32; - bonus.min(0.12) -} - -/// ±0.05 uniform noise seeded deterministically from (world_seed, city_id) (D-010). +/// Per-role base prosperity in basis points (D-197 table). /// -/// Uses `SeedChain` to derive a per-city noise stream, then maps a `u32` to -/// the range [0.0, 0.1) and subtracts 0.05, producing [-0.05, +0.05). -fn prosperity_noise(city_id: u64, world_seed: u64) -> f32 { +/// 10_000 bps = 1.0. Values match the previous f32 table scaled by ×10_000: +/// 0.55 → 5500, 0.70 → 7000, etc. +fn role_base_bps(role: &str) -> u32 { + match role { + "manufacturing" => 5_500, + "financial" => 7_000, + "agricultural" => 5_000, + "extraction" => 4_500, + "service_mixed" => 6_000, + "institutional" => 6_500, + "transit_hub" => 6_000, + "research" => 6_500, + "military" => 5_500, + "residential" => 5_000, + // Unknown role → mid-point fallback; logged by the caller if needed. + _ => 5_500, + } +} + +/// Population log-scale bonus in basis points (D-197). +/// +/// `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200. +/// +/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010). +/// The `+1` inside the log10 ensures the result is ≥ 0 for any positive pop. +fn pop_bonus_bps(population: i64) -> u32 { + if population <= 0 { + return 0; + } + // ratio = pop / 1_000_000 + 1. Integer division (floors toward zero) matches + // floor(pop / 1_000_000.0) for positive values, then +1 gives ≥ 1 for log. + let ratio = (population as u64) / 1_000_000 + 1; + let mag = log10_floor(ratio); + // 400 bps per order of magnitude, cap at 1200 (3 orders). + (400 * mag).min(1_200) +} + +/// ±500 bps symmetric uniform noise seeded deterministically from (world_seed, city_id). +/// +/// Uses `SeedChain` to derive a per-city noise stream, then maps via integer +/// modulo to [-500, +500] — no floats, fixes the always-negative bug that +/// arose from the old code dividing a 31-bit RNG value by u32::MAX (D-010). +/// +/// **SeedChain derivation:** seeds root → Layer3Settlement directly using the +/// city_id hash as the domain id. This is safe because city_id is globally +/// unique (it is the `atlas_city_names.id` primary key); no body_id is +/// available at read time. +fn prosperity_noise_bps(city_id: u64, world_seed: u64) -> i32 { // Domain: Layer3Settlement (prosperity is a settlement-level property). + // city_id is globally unique, so root → Layer3Settlement is unambiguous. let seed = SeedChain::root(world_seed) .derive(SeedDomain::Layer3Settlement, fnv1a_64(&city_id.to_string())) .seed(); let mut rng = AtlasRng::new(splitmix64(seed)); - // Map u32 → [0.0, 1.0) then scale to [0.0, 0.1) then shift to [-0.05, 0.05). - let raw = rng.next_u32() as f32 / u32::MAX as f32; // [0.0, 1.0) - raw * 0.10 - 0.05 + // Integer modulo over 1001 values (0..=1000), shifted to [-500, +500]. + // AtlasRng::next_u32() returns a 31-bit value — modulo is uniform here + // because 1001 divides cleanly into the 31-bit range (2^31 / 1001 ≈ 2.1M, + // so bias is negligible, but correctness doesn't depend on that — we only + // need ±500 symmetric range, not strict uniformity). + (rng.next_u32() % 1_001) as i32 - 500 } -/// D-197 formula (terrain gradient left at 0.0 — requires Layer-1 data). +/// D-197 formula in integer basis points (terrain gradient left at 0 — requires Layer-1 data). /// -/// `clamp(role_base + pop_bonus + terrain_bonus + noise, 0.1, 0.95)` +/// `clamp(role_base_bps + pop_bonus_bps + terrain_bonus_bps + noise_bps, 1000, 9500)` +/// +/// All arithmetic is integer — no f32/f64 in the determinism path (D-010). pub fn prosperity_baseline_from_read_set( economic_role: &str, population: i64, city_id: u64, world_seed: u64, -) -> f32 { - let base = role_base_prosperity(economic_role); - let pb = pop_bonus(population); - // terrain_bonus = 0.0: Layer-1 attractor output not available at this tier. - let noise = prosperity_noise(city_id, world_seed); - (base + pb + noise).clamp(0.1, 0.95) +) -> u32 { + let base = role_base_bps(economic_role) as i32; + let pb = pop_bonus_bps(population) as i32; + // terrain_bonus_bps = 0: Layer-1 attractor output not available at this tier. + let noise = prosperity_noise_bps(city_id, world_seed); + let raw = base + pb + noise; + raw.clamp(1_000, 9_500) as u32 } // --------------------------------------------------------------------------- @@ -338,8 +362,8 @@ pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGener city_id, // ── D-199 6-field read set ──────────────────────────────────────── - // prosperity_baseline is D-199 field 2 (derived, D-197). - prosperity_baseline: rs.prosperity_baseline, + // prosperity_baseline_bps is D-199 field 2 (derived, D-197). + prosperity_baseline_bps: rs.prosperity_baseline_bps, // ── Deferred fields (stubs) ─────────────────────────────────────── // political_archetype: real derivation requires D-214 faction→archetype @@ -462,66 +486,102 @@ mod tests { (path, id) } - // ─── prosperity_baseline derivation ────────────────────────────────────── + // ─── role_base_bps ─────────────────────────────────────────────────────── #[test] - fn role_base_financial_is_correct() { - assert!((role_base_prosperity("financial") - 0.70).abs() < 1e-6); + fn role_base_financial_is_7000() { + assert_eq!(role_base_bps("financial"), 7_000); } #[test] - fn role_base_unknown_falls_back() { - // Unknown roles get the mid-point fallback, not a panic. - let base = role_base_prosperity("deep_space_weird_role"); - assert!((base - 0.55).abs() < 1e-6); + fn role_base_unknown_falls_back_to_5500() { + assert_eq!(role_base_bps("deep_space_weird_role"), 5_500); } + // ─── pop_bonus_bps ─────────────────────────────────────────────────────── + #[test] fn pop_bonus_zero_population() { - assert_eq!(pop_bonus(0), 0.0); + assert_eq!(pop_bonus_bps(0), 0); } #[test] fn pop_bonus_one_million() { - // pop=1_000_000 → ratio = floor(log10(1+1)) = floor(0.301) = 0 → 0.0 - assert!((pop_bonus(1_000_000) - 0.0).abs() < 1e-5); + // pop=1_000_000 → ratio = 1_000_000 / 1_000_000 + 1 = 2 → log10_floor(2) = 0 → 0 bps + assert_eq!(pop_bonus_bps(1_000_000), 0); } #[test] fn pop_bonus_ten_million() { - // pop=10_000_000 → ratio = floor(log10(10+1)) = floor(1.041) = 1 → 0.04 - assert!((pop_bonus(10_000_000) - 0.04).abs() < 1e-5); + // pop=10_000_000 → ratio = 10 + 1 = 11 → log10_floor(11) = 1 → 400 bps + assert_eq!(pop_bonus_bps(10_000_000), 400); } #[test] - fn pop_bonus_capped_at_0_12() { - // Huge population → log10 grows but bonus is capped at 0.12. - assert!(pop_bonus(i64::MAX) <= 0.12 + 1e-6); + fn pop_bonus_hundred_million() { + // pop=100_000_000 → ratio = 100 + 1 = 101 → log10_floor(101) = 2 → 800 bps + assert_eq!(pop_bonus_bps(100_000_000), 800); } + #[test] + fn pop_bonus_capped_at_1200() { + // Huge population → bonus capped at 1200. + assert_eq!(pop_bonus_bps(i64::MAX), 1_200); + } + + // ─── prosperity_noise_bps ──────────────────────────────────────────────── + #[test] fn prosperity_noise_is_deterministic() { - let a = prosperity_noise(42, 1234); - let b = prosperity_noise(42, 1234); + let a = prosperity_noise_bps(42, 1234); + let b = prosperity_noise_bps(42, 1234); assert_eq!(a, b, "same inputs must give same noise"); } #[test] fn prosperity_noise_in_range() { for city_id in 0..20u64 { - let n = prosperity_noise(city_id, 99); + let n = prosperity_noise_bps(city_id, 99); assert!( - (-0.05..0.05).contains(&n), - "noise {n} out of [-0.05, 0.05) for city_id={city_id}" + (-500..=500).contains(&n), + "noise {n} out of [-500, 500] for city_id={city_id}" ); } } #[test] - fn prosperity_baseline_clamped() { - // extraction (0.45) + zero pop + max noise (< 0.05) → won't underflow 0.1 + fn prosperity_noise_positive_is_achievable() { + // Regression: old code divided a 31-bit RNG value by u32::MAX, + // making the result always < 0.5 and therefore always negative + // after the shift. Verify that at least one of a set of city_ids + // produces positive noise, confirming the bug is fixed. + let positives = (0u64..100) + .map(|id| prosperity_noise_bps(id, 12345)) + .filter(|&n| n > 0) + .count(); + assert!( + positives > 0, + "expected at least one positive noise value across 100 city_ids (was always-negative before fix)" + ); + } + + // ─── prosperity_baseline_from_read_set ─────────────────────────────────── + + #[test] + fn prosperity_baseline_bps_clamped_low() { + // extraction (4500) + zero pop + worst noise (-500) = 4000 → in [1000, 9500] let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0); - assert!((0.1..=0.95).contains(&p)); + assert!((1_000..=9_500).contains(&p)); + } + + #[test] + fn prosperity_baseline_bps_extraction_range() { + // extraction base 4500 ± 500 noise → expect [4000, 5000] + let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0); + assert!( + (4_000..=5_000).contains(&p), + "extraction/0 pop should be ~4500 ± 500, got {p}" + ); } // ─── DB read-set integration ────────────────────────────────────────────── @@ -543,15 +603,17 @@ mod tests { // Field 1 — economic_role assert_eq!(rs.economic_role, "financial"); - // Field 2 — prosperity_baseline (non-stub: derived from real role+pop) - let expected_base = 0.70 + pop_bonus(5_000_000); + // Field 2 — prosperity_baseline_bps (non-stub: derived from real role+pop) + // financial base 7000 bps + pop_bonus(5M) bps ± 500 noise + let expected_base_bps = 7_000 + pop_bonus_bps(5_000_000); + let diff = (rs.prosperity_baseline_bps as i32 - expected_base_bps as i32).abs(); assert!( - (rs.prosperity_baseline - expected_base).abs() <= 0.05 + 1e-4, - "prosperity_baseline {:.3} not near expected base {:.3} ± noise", - rs.prosperity_baseline, - expected_base + diff <= 500 + 1, + "prosperity_baseline_bps {} not near expected base {} ± noise", + rs.prosperity_baseline_bps, + expected_base_bps ); - assert!((0.1..=0.95).contains(&rs.prosperity_baseline)); + assert!((1_000..=9_500).contains(&rs.prosperity_baseline_bps)); // Field 3 — population assert_eq!(rs.population, 5_000_000); // Field 4 — dominant_faction @@ -563,7 +625,7 @@ mod tests { } #[test] - fn build_context_prosperity_is_not_stub() { + fn build_context_prosperity_bps_is_not_stub() { let (db, city_id) = make_test_db( "GJ2b", "GJ-2", @@ -578,19 +640,17 @@ mod tests { .build_context(city_id as u64, 77) .expect("build context"); - // The stub value from skeleton_gen tests uses 0.7; manufacturing base is 0.55. - // A real read-set result should not be that default value unless coincidental. + // manufacturing base is 5500 bps; pop=2M → pop_bonus=0; noise in ±500. + // Result should be ≈5000–6000 bps. assert!( - (0.1..=0.95).contains(&ctx.prosperity_baseline), - "prosperity_baseline must be in [0.1, 0.95], got {}", - ctx.prosperity_baseline + (1_000..=9_500).contains(&ctx.prosperity_baseline_bps), + "prosperity_baseline_bps must be in [1000, 9500], got {}", + ctx.prosperity_baseline_bps ); - // Verify it comes from the real derivation: manufacturing base is 0.55, - // pop=2M → pop_bonus=0, noise in ±0.05 → total ≈ 0.50–0.60. assert!( - (0.49..=0.61).contains(&ctx.prosperity_baseline), - "manufacturing/2M should produce ≈0.55±0.06, got {}", - ctx.prosperity_baseline + (4_900..=6_100).contains(&ctx.prosperity_baseline_bps), + "manufacturing/2M should produce ~5500 ± 600 bps, got {}", + ctx.prosperity_baseline_bps ); } @@ -675,7 +735,7 @@ mod tests { } #[test] - fn prosperity_is_deterministic_across_calls() { + fn prosperity_bps_is_deterministic_across_calls() { let (db, city_id) = make_test_db( "GJ7c", "GJ-7", @@ -689,8 +749,8 @@ mod tests { let rs1 = reader.read_set(city_id as u64, 42).expect("first call"); let rs2 = reader.read_set(city_id as u64, 42).expect("second call"); assert_eq!( - rs1.prosperity_baseline, rs2.prosperity_baseline, - "prosperity_baseline must be deterministic for same inputs" + rs1.prosperity_baseline_bps, rs2.prosperity_baseline_bps, + "prosperity_baseline_bps must be deterministic for same inputs" ); } } diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs index 284b10a29..7720cba38 100644 --- a/server/src/atlas/district_mix.rs +++ b/server/src/atlas/district_mix.rs @@ -12,6 +12,7 @@ //! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the //! district count computation. Seed-driven noise uses seeded RNG. +use crate::bps::log10_floor; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{DistrictType, PoliticalArchetype}; @@ -20,15 +21,19 @@ use crate::simulation::generator::{DistrictType, PoliticalArchetype}; // --------------------------------------------------------------------------- /// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5]. +/// +/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010). +/// Integer division of `pop / 1_000_000` floors toward zero for positive values, +/// matching the semantics of `floor(pop / 1_000_000.0)`. pub fn population_tier(population: i64) -> u8 { if population <= 0 { return 0; } - let ratio = population as f64 / 1_000_000.0; - if ratio <= 0.0 { + let ratio = (population as u64) / 1_000_000; + if ratio == 0 { return 0; } - let tier = ratio.log10().floor() as i32; + let tier = log10_floor(ratio) as i32; tier.clamp(0, 5) as u8 } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 7aa938c0f..336c4d4a0 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -468,7 +468,7 @@ mod tests { context: Box::new(CityGenerationContext { city_id, political_archetype: PoliticalArchetype::Commission, - prosperity_baseline: 0.6, + prosperity_baseline_bps: 6_000, surrounding_biome: SettingType::Urban, road_entry_directions: vec![], footprint_radius_km: 5.0, diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 8ef56f31c..bd703f457 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -419,7 +419,7 @@ mod tests { CityGenerationContext { city_id: 1, political_archetype: archetype, - prosperity_baseline: 0.7, + prosperity_baseline_bps: 7_000, surrounding_biome: SettingType::Urban, road_entry_directions: vec![0, 4], footprint_radius_km: 10.0, diff --git a/server/src/bps.rs b/server/src/bps.rs new file mode 100644 index 000000000..7bb0f09de --- /dev/null +++ b/server/src/bps.rs @@ -0,0 +1,122 @@ +//! Fixed-point / basis-point helpers (D-010 integer arithmetic). +//! +//! The codebase represents fractional values (0.0–1.0) as **basis points** +//! (`bps`), where `10_000 bps = 1.0`. This avoids f32/f64 in determinism- +//! sensitive paths: given the same inputs, pure integer arithmetic produces +//! identical results on every platform and compiler version. +//! +//! Convention: field names carry the `_bps` suffix; raw bps values are `u32`. +//! +//! # Integer log₁₀ +//! +//! [`log10_floor`] is the single reference implementation of `floor(log10(n))` +//! for positive integers. Use this everywhere a "how many orders of magnitude" +//! calculation would otherwise reach for `f64::log10`. District-mix population +//! tiers and prosperity pop-bonuses both need it. + +/// Integer floor of log₁₀ for `n ≥ 1`. +/// +/// Returns 0 for n = 1..9, 1 for n = 10..99, 2 for n = 100..999, and so on. +/// Panics in debug if `n == 0` (log10(0) is undefined); returns 0 in release. +/// +/// No floats, no platform-dependent rounding — deterministic (D-010). +/// +/// # Examples +/// +/// ``` +/// use server::bps::log10_floor; +/// assert_eq!(log10_floor(1), 0); +/// assert_eq!(log10_floor(9), 0); +/// assert_eq!(log10_floor(10), 1); +/// assert_eq!(log10_floor(100), 2); +/// assert_eq!(log10_floor(999), 2); +/// assert_eq!(log10_floor(1_000_000), 6); +/// ``` +pub fn log10_floor(n: u64) -> u32 { + debug_assert!(n >= 1, "log10_floor: n must be ≥ 1 (got {n})"); + if n == 0 { + return 0; + } + let mut v = n; + let mut result = 0u32; + while v >= 10 { + v /= 10; + result += 1; + } + result +} + +/// Convert a bps value (0–10_000) to a clamped f32 in [0.0, 1.0]. +/// +/// Only call this at the edge of a system that genuinely needs f32 — the stored +/// representation stays integer. Document why f32 is needed at the call site. +#[inline] +pub fn bps_to_f32(bps: u32) -> f32 { + bps as f32 / 10_000.0 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log10_floor_single_digits() { + // 1..9 all return 0. + for n in 1u64..10 { + assert_eq!(log10_floor(n), 0, "expected 0 for n={n}"); + } + } + + #[test] + fn log10_floor_boundary_10() { + assert_eq!(log10_floor(9), 0); + assert_eq!(log10_floor(10), 1); + } + + #[test] + fn log10_floor_boundary_100() { + assert_eq!(log10_floor(99), 1); + assert_eq!(log10_floor(100), 2); + } + + #[test] + fn log10_floor_boundary_1000() { + assert_eq!(log10_floor(999), 2); + assert_eq!(log10_floor(1_000), 3); + } + + #[test] + fn log10_floor_large_values() { + assert_eq!(log10_floor(999_999), 5); + assert_eq!(log10_floor(1_000_000), 6); + assert_eq!(log10_floor(9_999_999), 6); + assert_eq!(log10_floor(10_000_000), 7); + } + + #[test] + fn log10_floor_very_large() { + // u64::MAX = 18_446_744_073_709_551_615 → 19 digits → floor = 19. + assert_eq!(log10_floor(u64::MAX), 19); + } + + #[test] + fn bps_to_f32_midpoint() { + let f = bps_to_f32(5_000); + assert!((f - 0.5).abs() < 1e-6, "5000 bps should be 0.5, got {f}"); + } + + #[test] + fn bps_to_f32_full_scale() { + let f = bps_to_f32(10_000); + assert!((f - 1.0).abs() < 1e-6, "10000 bps should be 1.0, got {f}"); + } + + #[test] + fn bps_to_f32_zero() { + assert_eq!(bps_to_f32(0), 0.0); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 0fa0a131d..1524c9b14 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,8 @@ pub mod atlas; pub mod bookmark; +pub mod bps; + pub mod bridge; pub mod cause_chain; pub mod knowledge; diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 867a792c4..0a68fc7e2 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -485,7 +485,7 @@ pub enum BuildingEntryClass { /// Construction era of a building block (D-229). /// -/// Derived from `founding_age_years + prosperity_baseline + seed`. +/// Derived from `founding_age_years + prosperity_baseline_bps + seed`. /// Reads primarily as **age/wear** via the condition layer (D-217/D-198); /// not a material-technology ladder (era = maintenance signal, not style signal). #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -710,7 +710,7 @@ pub struct BuildingPropertyTag { pub era: ConstructionEra, /// Cause of this block's era classification. pub era_cause: EraCause, - /// Frozen-amber condition snapshot from `prosperity_baseline` (D-197/D-217). + /// Frozen-amber condition snapshot from `prosperity_baseline_bps` (D-197/D-217). /// The rolling condition overlay (D-198) paints over this; never mutates the tag. pub initial_condition: crate::atlas::tile_condition::TileCondition, /// Doors into / out of this building (D-231). At least one `Main` door. @@ -933,8 +933,9 @@ pub struct CityGenerationContext { /// Foreign key into atlas_city_names.id pub city_id: u64, pub political_archetype: PoliticalArchetype, - /// Starting economic health seed (0.0–1.0). Derived per D-197. - pub prosperity_baseline: f32, + /// Starting economic health seed in basis points (0–10_000; 10_000 = 1.0). + /// Derived per D-197. Integer to avoid f32 non-determinism (D-010). + pub prosperity_baseline_bps: u32, pub surrounding_biome: SettingType, /// Compass octants (0=N, 1=NE … 7=NW) where roads enter the city footprint. pub road_entry_directions: Vec,