poolsim_core/
distribution.rs1use rand::Rng;
21use rand_distr::{
22 Distribution as RandDistribution, Exp, Gamma as RandGamma, LogNormal as RandLogNormal,
23};
24use statrs::distribution::{ContinuousCDF, Gamma as StatGamma, LogNormal as StatLogNormal, Normal};
25
26use crate::{
27 error::PoolsimError,
28 types::{DistributionModel, WorkloadConfig},
29};
30
31#[derive(Debug, Clone)]
33pub struct EmpiricalCdf {
34 samples: Vec<f64>,
35}
36
37impl EmpiricalCdf {
38 fn new(mut samples: Vec<f64>) -> Result<Self, PoolsimError> {
39 if samples.is_empty() {
40 return Err(PoolsimError::Distribution(
41 "empirical distribution requires at least one sample".to_string(),
42 ));
43 }
44 samples.sort_by(|a, b| a.total_cmp(b));
45 Ok(Self { samples })
46 }
47
48 fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> f64 {
49 let idx = rng.gen_range(0..self.samples.len());
50 self.samples[idx]
51 }
52
53 fn percentile(&self, p: f64) -> f64 {
54 if self.samples.is_empty() {
55 return 0.0;
56 }
57 let p = p.clamp(0.0, 1.0);
58 let idx = ((self.samples.len() - 1) as f64 * p).round() as usize;
59 self.samples[idx]
60 }
61
62 fn mean(&self) -> f64 {
63 self.samples.iter().sum::<f64>() / self.samples.len() as f64
64 }
65}
66
67#[derive(Debug, Clone)]
69pub enum LatencyDistribution {
70 LogNormal {
72 mu: f64,
74 sigma: f64,
76 },
77 Exponential {
79 mean_ms: f64,
81 },
82 Empirical(EmpiricalCdf),
84 Gamma {
86 shape: f64,
88 scale: f64,
90 },
91}
92
93impl LatencyDistribution {
94 pub fn fit(workload: &WorkloadConfig, model: DistributionModel) -> Result<Self, PoolsimError> {
104 if let Some(raw_samples) = &workload.raw_samples_ms {
105 return EmpiricalCdf::new(raw_samples.clone()).map(Self::Empirical);
106 }
107
108 match model {
109 DistributionModel::LogNormal | DistributionModel::Empirical => {
110 let (mu, sigma) = fit_lognormal(workload)?;
111 Ok(Self::LogNormal { mu, sigma })
112 }
113 DistributionModel::Exponential => {
114 let mean_ms = workload.latency_p50_ms / std::f64::consts::LN_2;
115 Ok(Self::Exponential { mean_ms })
116 }
117 DistributionModel::Gamma => {
118 let (shape, scale) = fit_gamma(workload)?;
119 Ok(Self::Gamma { shape, scale })
120 }
121 }
122 }
123
124 pub fn sample_ms<R: Rng + ?Sized>(&self, rng: &mut R) -> f64 {
126 match self {
127 Self::LogNormal { mu, sigma } => {
128 let dist = RandLogNormal::new(*mu, *sigma).expect("valid lognormal params");
129 dist.sample(rng)
130 }
131 Self::Exponential { mean_ms } => {
132 let dist = Exp::new(1.0 / mean_ms).expect("valid exponential rate");
133 dist.sample(rng)
134 }
135 Self::Empirical(empirical) => empirical.sample(rng),
136 Self::Gamma { shape, scale } => {
137 let dist = RandGamma::new(*shape, *scale).expect("valid gamma params");
138 dist.sample(rng)
139 }
140 }
141 }
142
143 pub fn percentile_ms(&self, p: f64) -> Result<f64, PoolsimError> {
152 let p = p.clamp(0.0, 1.0);
153 match self {
154 Self::LogNormal { mu, sigma } => {
155 let dist = StatLogNormal::new(*mu, *sigma)
156 .map_err(|e| PoolsimError::Distribution(e.to_string()))?;
157 Ok(dist.inverse_cdf(p))
158 }
159 Self::Exponential { mean_ms } => Ok(-mean_ms * (1.0 - p).ln()),
160 Self::Empirical(empirical) => Ok(empirical.percentile(p)),
161 Self::Gamma { shape, scale } => {
162 let dist = StatGamma::new(*shape, *scale)
163 .map_err(|e| PoolsimError::Distribution(e.to_string()))?;
164 Ok(dist.inverse_cdf(p))
165 }
166 }
167 }
168
169 pub fn mean_ms(&self) -> f64 {
171 match self {
172 Self::LogNormal { mu, sigma } => (mu + 0.5 * sigma * sigma).exp(),
173 Self::Exponential { mean_ms } => *mean_ms,
174 Self::Empirical(empirical) => empirical.mean(),
175 Self::Gamma { shape, scale } => shape * scale,
176 }
177 }
178}
179
180fn fit_lognormal(workload: &WorkloadConfig) -> Result<(f64, f64), PoolsimError> {
181 let mu = workload.latency_p50_ms.ln();
182 let normal = Normal::new(0.0, 1.0).map_err(|e| PoolsimError::Distribution(e.to_string()))?;
183
184 let mut sigmas = Vec::new();
185 if workload.latency_p95_ms > workload.latency_p50_ms {
186 let z95 = normal.inverse_cdf(0.95);
187 sigmas.push((workload.latency_p95_ms / workload.latency_p50_ms).ln() / z95);
188 }
189 if workload.latency_p99_ms > workload.latency_p50_ms {
190 let z99 = normal.inverse_cdf(0.99);
191 sigmas.push((workload.latency_p99_ms / workload.latency_p50_ms).ln() / z99);
192 }
193
194 let sigma = sigmas
195 .into_iter()
196 .filter(|s| s.is_finite() && *s > 0.0)
197 .sum::<f64>();
198
199 if sigma <= 0.0 {
200 return Err(PoolsimError::Distribution(
201 "unable to derive positive lognormal sigma from percentiles".to_string(),
202 ));
203 }
204
205 let count = if workload.latency_p99_ms > workload.latency_p50_ms {
206 2.0
207 } else {
208 1.0
209 };
210 Ok((mu, sigma / count))
211}
212
213fn fit_gamma(workload: &WorkloadConfig) -> Result<(f64, f64), PoolsimError> {
214 let mean = (workload.latency_p50_ms + workload.latency_p95_ms + workload.latency_p99_ms) / 3.0;
215 let std_est = ((workload.latency_p99_ms - workload.latency_p50_ms) / 2.326_347_874).max(1e-6);
216 let var = std_est * std_est;
217 let shape = (mean * mean / var).max(1e-6);
218 let scale = (var / mean).max(1e-6);
219
220 Ok((shape, scale))
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn empirical_percentile_returns_zero_for_empty_internal_samples() {
229 let empirical = EmpiricalCdf {
230 samples: Vec::new(),
231 };
232 assert_eq!(empirical.percentile(0.5), 0.0);
233 }
234
235 #[test]
236 fn fit_gamma_clamps_non_finite_derived_parameters_to_safe_minimum() {
237 let workload = WorkloadConfig {
238 requests_per_second: 100.0,
239 latency_p50_ms: 10.0,
240 latency_p95_ms: 20.0,
241 latency_p99_ms: f64::INFINITY,
242 raw_samples_ms: None,
243 step_load_profile: None,
244 };
245
246 let (shape, scale) =
247 fit_gamma(&workload).expect("non-finite intermediates should clamp to safe values");
248 assert_eq!(shape, 1e-6);
249 assert_eq!(scale, 1e-6);
250 }
251
252 #[test]
253 fn fit_lognormal_uses_single_tail_when_p99_is_not_above_p50() {
254 let workload = WorkloadConfig {
255 requests_per_second: 100.0,
256 latency_p50_ms: 10.0,
257 latency_p95_ms: 20.0,
258 latency_p99_ms: 10.0,
259 raw_samples_ms: None,
260 step_load_profile: None,
261 };
262
263 let (mu, sigma) =
264 fit_lognormal(&workload).expect("positive p95 spread should fit lognormal");
265 assert!(mu.is_finite());
266 assert!(sigma > 0.0);
267 }
268}