fix(simulation): prosperity in basis points + shared log10_floor helper (#954)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
pub mod atlas;
|
||||
pub mod bookmark;
|
||||
pub mod bps;
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cause_chain;
|
||||
pub mod knowledge;
|
||||
|
||||
@@ -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<u8>,
|
||||
|
||||
Reference in New Issue
Block a user