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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user