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:
2026-05-31 14:31:27 +02:00
co-authored by Claude Opus 4.8
parent a854b89aa0
commit d6e37c0c0e
7 changed files with 304 additions and 114 deletions
+122
View File
@@ -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);
}
}