polyc-controller 2026.9.0

Conversation CRD + kube reconciler for the polychrome control plane.
//! Deterministic, dependency-free jitter for de-correlating retry/poll
//! schedules (anti-thundering-herd).
//!
//! Two call sites need the same primitive — the controller's reconcile error
//! backoff ([`crate::reconcile::error_policy`]) and the control plane's harness
//! retry loop (`polychrome::grpc`) — so the hash lives here once rather than as
//! two hand-rolled copies (one FNV-1a, one [`std::hash::DefaultHasher`]) that
//! could drift apart. Deterministic on purpose: same `(seed, salt)` always
//! yields the same delay, which keeps the schedules reproducible and
//! unit-testable without pulling in a RNG.

use std::time::Duration;

/// FNV-1a over `seed` then `salt`. Stable across versions and platforms (unlike
/// [`std::hash::DefaultHasher`], whose output is unspecified), so values used in
/// tests stay fixed.
#[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
}

/// Pick a delay uniformly within `[min_secs, max_secs]` (inclusive),
/// deterministically from `seed`. Used for the reconcile error backoff window.
///
/// `max_secs` is clamped to be `>= min_secs`.
#[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)
}

/// `base` ± up to `pct`%, deterministically from `(seed, salt)`. Used for the
/// harness retry's exponential backoff so many conversations don't retry in
/// lockstep.
#[must_use]
pub fn plus_minus_pct(base: Duration, seed: &str, salt: u64, pct: u64) -> Duration {
    // frac ∈ [-pct, pct], scaled by 1000 for integer math.
    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);
    }
}