use std::time::Duration;
#[must_use]
pub fn stable_hash(seed: &str, salt: u64) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in seed.bytes() {
h = (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
}
for b in salt.to_le_bytes() {
h = (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
#[must_use]
pub fn within_window(seed: &str, min_secs: u64, max_secs: u64) -> Duration {
let span = max_secs.saturating_sub(min_secs);
let secs = min_secs + (stable_hash(seed, 0) % (span + 1));
Duration::from_secs(secs)
}
#[must_use]
pub fn plus_minus_pct(base: Duration, seed: &str, salt: u64, pct: u64) -> Duration {
let span = 2 * pct + 1;
let frac = i64::try_from(stable_hash(seed, salt) % span).unwrap_or(0)
- i64::try_from(pct).unwrap_or(0);
let base_ms = i64::try_from(base.as_millis()).unwrap_or(i64::MAX);
let ms = u64::try_from((base_ms + base_ms * frac / 100).max(0)).unwrap_or(0);
Duration::from_millis(ms)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn within_window_is_bounded_and_deterministic() {
for name in ["c1", "conversation-abc", "", "x"] {
let d = within_window(name, 10, 60).as_secs();
assert!((10..=60).contains(&d), "{name}: {d}s out of [10,60]");
assert_eq!(
d,
within_window(name, 10, 60).as_secs(),
"not deterministic"
);
}
}
#[test]
fn within_window_desynchronises_distinct_seeds() {
let a = within_window("conv-a", 10, 60);
let b = within_window("conv-b", 10, 60);
assert_ne!(a, b, "distinct seeds should usually differ");
}
#[test]
fn plus_minus_pct_stays_within_band() {
let base = Duration::from_millis(1000);
for attempt in 0..10u64 {
let d = plus_minus_pct(base, "conv-x", attempt, 20).as_millis();
assert!(
(800..=1200).contains(&d),
"attempt {attempt}: {d}ms out of ±20%"
);
}
}
#[test]
fn plus_minus_pct_handles_zero_base() {
assert_eq!(plus_minus_pct(Duration::ZERO, "c", 1, 20), Duration::ZERO);
}
}