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>
123 lines
3.5 KiB
Rust
123 lines
3.5 KiB
Rust
//! 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);
|
||
}
|
||
}
|