Skip to main content

poolsim_core/
sensitivity.rs

1//! Sensitivity analysis across a configured pool-size range.
2//!
3//! These helpers generate one [`crate::types::SensitivityRow`] per candidate
4//! pool size so callers can inspect how risk and queue-wait behavior change as
5//! the pool grows.
6//!
7//! Use this module when you need the full tradeoff surface instead of only the
8//! single recommended result returned by [`crate::simulate`].
9
10use crate::{
11    distribution::LatencyDistribution,
12    erlang,
13    error::PoolsimError,
14    monte_carlo,
15    types::{
16        DistributionModel, PoolConfig, QueueModel, RiskLevel, SensitivityRow, SimulationOptions, WorkloadConfig,
17    },
18};
19
20/// Generates sensitivity rows using default simulation options.
21///
22/// # Errors
23///
24/// Returns distribution/simulation errors for invalid inputs or unstable queue states.
25pub fn sweep(workload: &WorkloadConfig, pool: &PoolConfig) -> Result<Vec<SensitivityRow>, PoolsimError> {
26    sweep_with_options(workload, pool, &SimulationOptions::default())
27}
28
29/// Generates sensitivity rows with a custom target p99 wait threshold.
30///
31/// # Errors
32///
33/// Returns distribution/simulation errors for invalid inputs or unstable queue states.
34pub fn sweep_with_target(
35    workload: &WorkloadConfig,
36    pool: &PoolConfig,
37    target_wait_p99_ms: f64,
38) -> Result<Vec<SensitivityRow>, PoolsimError> {
39    let opts = SimulationOptions {
40        target_wait_p99_ms,
41        ..SimulationOptions::default()
42    };
43    sweep_with_options(workload, pool, &opts)
44}
45
46/// Generates sensitivity rows with a custom target and queue model.
47///
48/// # Errors
49///
50/// Returns distribution/simulation errors for invalid inputs or unstable queue states.
51pub fn sweep_with_target_and_model(
52    workload: &WorkloadConfig,
53    pool: &PoolConfig,
54    target_wait_p99_ms: f64,
55    queue_model: QueueModel,
56) -> Result<Vec<SensitivityRow>, PoolsimError> {
57    let opts = SimulationOptions {
58        queue_model,
59        target_wait_p99_ms,
60        distribution: DistributionModel::LogNormal,
61        ..SimulationOptions::default()
62    };
63    sweep_with_options(workload, pool, &opts)
64}
65
66/// Generates sensitivity rows across all candidate pool sizes.
67///
68/// # Errors
69///
70/// Returns distribution/simulation errors for invalid inputs or unstable queue states.
71pub fn sweep_with_options(
72    workload: &WorkloadConfig,
73    pool: &PoolConfig,
74    opts: &SimulationOptions,
75) -> Result<Vec<SensitivityRow>, PoolsimError> {
76    let dist = LatencyDistribution::fit(workload, opts.distribution)?;
77    let mu = 1_000.0 / (dist.mean_ms() + pool.connection_overhead_ms);
78    let lambda = workload.requests_per_second;
79    let target_wait_p99_ms = opts.target_wait_p99_ms;
80
81    let mut rows = Vec::with_capacity((pool.max_pool_size - pool.min_pool_size + 1) as usize);
82
83    for size in pool.min_pool_size..=pool.max_pool_size {
84        let rho = erlang::utilisation(lambda, mu, size);
85
86        let (mean_wait, p99_wait, risk) = if rho >= 1.0 {
87            (f64::MAX, f64::MAX, RiskLevel::Critical)
88        } else {
89            let (mean, p99) = match opts.queue_model {
90                QueueModel::MMC => (
91                    erlang::mean_queue_wait_ms(lambda, mu, size)?,
92                    erlang::queue_wait_percentile_ms(lambda, mu, size, 0.99)?,
93                ),
94                QueueModel::MDC => {
95                    let probe_opts = mdc_probe_options(opts, size);
96                    let probe = monte_carlo::run_with_overhead(
97                        workload,
98                        size,
99                        pool.connection_overhead_ms,
100                        &dist,
101                        &probe_opts,
102                    )?;
103                    (probe.mean, probe.p99)
104                }
105            };
106            let risk = classify_risk(rho, p99, target_wait_p99_ms);
107            (mean, p99, risk)
108        };
109
110        rows.push(SensitivityRow {
111            pool_size: size,
112            utilisation_rho: rho,
113            mean_queue_wait_ms: mean_wait,
114            p99_queue_wait_ms: p99_wait,
115            risk,
116        });
117    }
118
119    Ok(rows)
120}
121
122fn mdc_probe_options(opts: &SimulationOptions, size: u32) -> SimulationOptions {
123    let mut probe_opts = opts.clone();
124    probe_opts.iterations = (opts.iterations / 4).clamp(400, 2_000);
125    if let Some(seed) = opts.seed {
126        probe_opts.seed = Some(seed ^ ((size as u64 + 1).wrapping_mul(0x517C_C1B7_2722_0A95)));
127    }
128    probe_opts
129}
130
131fn classify_risk(rho: f64, p99_wait_ms: f64, target_wait_p99_ms: f64) -> RiskLevel {
132    if rho >= 0.90 {
133        return RiskLevel::Critical;
134    }
135    if rho >= 0.80 {
136        return RiskLevel::High;
137    }
138    if rho < 0.70 && p99_wait_ms < target_wait_p99_ms / 2.0 {
139        return RiskLevel::Low;
140    }
141    if rho < 0.80 || p99_wait_ms < target_wait_p99_ms {
142        return RiskLevel::Medium;
143    }
144    RiskLevel::High
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn classify_risk_falls_back_to_high_for_nan_inputs() {
153        let risk = classify_risk(f64::NAN, f64::NAN, 50.0);
154        assert_eq!(risk, RiskLevel::High);
155    }
156}