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