use std::collections::HashMap;
use std::hash::BuildHasher;
pub trait SamplingPolicy: Send + Sync + std::fmt::Debug {
fn should_sample(&self, kind: &str, correlator: &str) -> bool;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct AlwaysSample;
impl SamplingPolicy for AlwaysSample {
fn should_sample(&self, _kind: &str, _correlator: &str) -> bool {
true
}
}
#[derive(Debug, Clone)]
pub struct RatePolicy {
rates: HashMap<String, f64>,
default_rate: f64,
}
impl Default for RatePolicy {
fn default() -> Self {
Self::new()
}
}
impl RatePolicy {
pub fn new() -> Self {
Self {
rates: HashMap::new(),
default_rate: 1.0,
}
}
#[must_use]
pub fn with_default_rate(mut self, rate: f64) -> Self {
self.default_rate = clamp_unit(rate);
self
}
#[must_use]
pub fn with_rate(mut self, kind: impl Into<String>, rate: f64) -> Self {
self.rates.insert(kind.into(), clamp_unit(rate));
self
}
fn rate_for(&self, kind: &str) -> f64 {
self.rates.get(kind).copied().unwrap_or(self.default_rate)
}
}
impl SamplingPolicy for RatePolicy {
fn should_sample(&self, kind: &str, correlator: &str) -> bool {
let rate = self.rate_for(kind);
if rate >= 1.0 {
return true;
}
if rate <= 0.0 {
return false;
}
let bucket = (FixedHasher.hash_one(correlator) as u32) as f64 / (u32::MAX as f64 + 1.0);
bucket < rate
}
}
fn clamp_unit(rate: f64) -> f64 {
if rate.is_nan() {
return 0.0;
}
rate.clamp(0.0, 1.0)
}
#[derive(Debug, Default, Clone, Copy)]
struct FixedHasher;
impl BuildHasher for FixedHasher {
type Hasher = std::collections::hash_map::DefaultHasher;
fn build_hasher(&self) -> Self::Hasher {
std::collections::hash_map::DefaultHasher::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn always_sample_keeps_every_event() {
let policy = AlwaysSample;
assert!(policy.should_sample("tool.invoked", "call-1"));
assert!(policy.should_sample("prompt.completed", "conv-1"));
assert!(policy.should_sample("anything", ""));
}
#[test]
fn rate_zero_drops_everything_rate_one_keeps_everything() {
let policy = RatePolicy::new()
.with_rate("tool.invoked", 0.0)
.with_rate("tool.completed", 1.0);
for i in 0..100 {
let id = format!("call-{i}");
assert!(!policy.should_sample("tool.invoked", &id));
assert!(policy.should_sample("tool.completed", &id));
}
}
#[test]
fn rate_decisions_are_deterministic_and_pair_coherent() {
let policy = RatePolicy::new()
.with_rate("tool.invoked", 0.5)
.with_rate("tool.completed", 0.5);
for i in 0..50 {
let id = format!("call-{i}");
let invoked = policy.should_sample("tool.invoked", &id);
assert_eq!(invoked, policy.should_sample("tool.invoked", &id));
let completed = policy.should_sample("tool.completed", &id);
assert_eq!(
invoked, completed,
"tool.invoked/tool.completed must share the same bucket for {id}"
);
}
}
#[test]
fn rate_default_rate_is_used_for_unspecified_kinds() {
let policy = RatePolicy::new().with_default_rate(0.0);
assert!(!policy.should_sample("memory.frame_written", "conv-1"));
let policy = policy.with_rate("memory.frame_written", 1.0);
assert!(policy.should_sample("memory.frame_written", "conv-1"));
}
#[test]
fn rate_clamps_out_of_range_inputs() {
let policy = RatePolicy::new()
.with_rate("a", -0.5)
.with_rate("b", 1.5)
.with_rate("c", f64::NAN);
assert!(!policy.should_sample("a", "x"));
assert!(policy.should_sample("b", "x"));
assert!(!policy.should_sample("c", "x"));
}
#[test]
fn rate_approximates_configured_rate_over_a_population() {
let policy = RatePolicy::new().with_rate("tool.invoked", 0.30);
let mut kept = 0;
let total = 5_000;
for i in 0..total {
let id = format!("call-{i}");
if policy.should_sample("tool.invoked", &id) {
kept += 1;
}
}
let observed = kept as f64 / total as f64;
assert!(
(observed - 0.30).abs() < 0.05,
"observed rate {observed} drifted from configured 0.30"
);
}
}