@@ -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 )
}
// ─── p rosperity_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.04 1) = 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 .. 20 u64 {
let n = prosperity_noise ( city_id , 99 ) ;
let n = prosperity_noise_bps ( city_id , 99 ) ;
assert! (
( - 0.0 5 .. 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 = ( 0 u64 .. 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.4 9 ..= 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 "
) ;
}
}