1use prospicio_core::{Error, Result, StreamRng};
4use prospicio_math::roots::bisect;
5use prospicio_math::special::{gamma_inc, ln_gamma, norm_quantile};
6
7use crate::distribution::{Distribution, check_probability};
8use crate::severity::Severity;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct Gamma {
30 shape: f64,
31 scale: f64,
32}
33
34impl Gamma {
35 pub fn new(shape: f64, scale: f64) -> Result<Self> {
37 positive("shape", shape)?;
38 positive("scale", scale)?;
39 Ok(Self { shape, scale })
40 }
41
42 pub fn from_mean_cv(mean: f64, cv: f64) -> Result<Self> {
45 positive("mean", mean)?;
46 positive("cv", cv)?;
47 Self::new(1.0 / (cv * cv), mean * cv * cv)
48 }
49
50 pub fn from_mean_dispersion(mean: f64, dispersion: f64) -> Result<Self> {
53 positive("mean", mean)?;
54 positive("dispersion", dispersion)?;
55 Self::new(1.0 / dispersion, dispersion * mean)
56 }
57
58 pub fn shape(&self) -> f64 {
60 self.shape
61 }
62
63 pub fn scale(&self) -> f64 {
65 self.scale
66 }
67
68 pub fn ln_pdf(&self, x: f64) -> f64 {
79 if x < 0.0 || x.is_nan() {
80 return f64::NEG_INFINITY;
81 }
82 let (a, t) = (self.shape, self.scale);
83 if x == 0.0 {
84 return if a < 1.0 {
85 f64::INFINITY
86 } else if a == 1.0 {
87 -t.ln()
88 } else {
89 f64::NEG_INFINITY
90 };
91 }
92 (a - 1.0) * x.ln() - x / t - ln_gamma(a) - a * t.ln()
93 }
94
95 fn tail_moment(&self, k: i32, u: f64) -> f64 {
99 if u <= 0.0 {
100 return self.raw_moment(k) - u.powi(k);
101 }
102 if u == f64::INFINITY {
103 return 0.0;
104 }
105 let z = u / self.scale;
106 let (_, q_shifted) = gamma_inc(self.shape + f64::from(k), z);
107 let (_, q) = gamma_inc(self.shape, z);
108 (self.raw_moment(k) * q_shifted - u.powi(k) * q).max(0.0)
109 }
110
111 fn raw_moment(&self, k: i32) -> f64 {
113 let (a, t) = (self.shape, self.scale);
114 match k {
115 1 => a * t,
116 _ => a * (a + 1.0) * t * t,
117 }
118 }
119}
120
121impl Distribution for Gamma {
122 fn mean(&self) -> f64 {
123 self.shape * self.scale
124 }
125
126 fn variance(&self) -> f64 {
127 self.shape * self.scale * self.scale
128 }
129
130 fn cdf(&self, x: f64) -> f64 {
131 if x <= 0.0 {
132 return 0.0;
133 }
134 gamma_inc(self.shape, x / self.scale).0
135 }
136
137 fn survival(&self, x: f64) -> f64 {
138 if x <= 0.0 {
139 return 1.0;
140 }
141 gamma_inc(self.shape, x / self.scale).1
142 }
143
144 fn quantile(&self, p: f64) -> Result<f64> {
148 check_probability(p)?;
149 if p == 0.0 {
150 return Ok(0.0);
151 }
152 if p == 1.0 {
153 return Ok(f64::INFINITY);
154 }
155 let below = |x: f64| {
156 if p <= 0.5 {
157 self.cdf(x) < p
158 } else {
159 self.survival(x) > 1.0 - p
160 }
161 };
162 let mut hi = self.mean() + self.std_dev();
163 while below(hi) {
164 hi *= 2.0;
165 }
166 Ok(bisect(0.0, hi, below))
167 }
168
169 fn sample(&self, rng: &mut StreamRng, n: usize) -> Vec<f64> {
177 (0..n)
178 .map(|_| self.scale * standard_gamma(rng, self.shape))
179 .collect()
180 }
181}
182
183pub(crate) fn standard_gamma(rng: &mut StreamRng, shape: f64) -> f64 {
196 if shape < 1.0 {
197 let g = standard_gamma(rng, shape + 1.0);
198 return g * rng.next_open01().powf(1.0 / shape);
199 }
200 let d = shape - 1.0 / 3.0;
201 let c = 1.0 / (9.0 * d).sqrt();
202 loop {
203 let x = norm_quantile(rng.next_open01());
204 let v = 1.0 + c * x;
205 if v <= 0.0 {
206 continue;
207 }
208 let v = v * v * v;
209 let u = rng.next_open01();
210 let x2 = x * x;
212 if u < 1.0 - 0.0331 * x2 * x2 || u.ln() < 0.5 * x2 + d - d * v + d * v.ln() {
213 return d * v;
214 }
215 }
216}
217
218impl Severity for Gamma {
219 fn lev(&self, limit: f64) -> f64 {
221 if limit <= 0.0 {
222 return limit;
223 }
224 if limit == f64::INFINITY {
225 return self.mean();
226 }
227 let z = limit / self.scale;
228 self.mean() * gamma_inc(self.shape + 1.0, z).0 + limit * gamma_inc(self.shape, z).1
229 }
230
231 fn stop_loss(&self, retention: f64) -> f64 {
234 if retention <= 0.0 {
235 return self.mean() - retention;
236 }
237 self.tail_moment(1, retention)
238 }
239
240 fn layer(&self, limit: f64, attachment: f64) -> f64 {
244 let a = attachment.max(0.0);
245 if a <= self.mean() {
246 self.lev(a + limit) - self.lev(a)
247 } else {
248 self.stop_loss(a) - self.stop_loss(a + limit)
249 }
250 }
251
252 fn layer_second_moment(&self, limit: f64, attachment: f64) -> f64 {
256 let a = attachment.max(0.0);
257 let b = a + limit;
258 let t = |k: i32, u: f64| self.tail_moment(k, u);
259 (t(2, a) - t(2, b) - 2.0 * a * (t(1, a) - t(1, b))).max(0.0)
260 }
261}
262
263fn positive(name: &'static str, value: f64) -> Result<()> {
264 if value.is_finite() && value > 0.0 {
265 Ok(())
266 } else {
267 Err(Error::InvalidParameter {
268 name,
269 value,
270 reason: "must be finite and positive",
271 })
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use prospicio_core::StreamRng;
279
280 #[test]
281 fn severity_identities_and_edges() {
282 let d = Gamma::new(2.5, 400.0).unwrap();
283 for limit in [100.0, 1_000.0, 5_000.0] {
284 let sum = d.lev(limit) + d.stop_loss(limit);
285 assert!((sum - d.mean()).abs() < 1e-12 * d.mean());
286 }
287 assert_eq!(d.lev(0.0), 0.0);
288 assert_eq!(d.lev(f64::INFINITY), d.mean());
289 assert_eq!(d.stop_loss(f64::INFINITY), 0.0);
290 assert_eq!(d.stop_loss(0.0), d.mean());
291 assert!((d.layer(f64::INFINITY, 0.0) - d.mean()).abs() < 1e-9);
292 let stacked = d.layer(1_000.0, 0.0) + d.layer(1_000.0, 1_000.0);
293 assert!((stacked - d.layer(2_000.0, 0.0)).abs() < 1e-9);
294 let m2 = d.layer_second_moment(f64::INFINITY, 0.0);
296 assert!((m2 - (d.variance() + d.mean() * d.mean())).abs() < 1e-9 * m2);
297 }
298
299 #[test]
300 fn layer_variance_matches_simulation() {
301 let d = Gamma::new(1.5, 1000.0).unwrap();
302 let x = d.sample(&mut StreamRng::new(4, 0), 100_000);
303 let (l, a) = (2000.0, 1000.0);
304 let y: Vec<f64> = x.iter().map(|v| (v - a).clamp(0.0, l)).collect();
305 let n = y.len() as f64;
306 let m = y.iter().sum::<f64>() / n;
307 let v = y.iter().map(|v| (v - m).powi(2)).sum::<f64>() / n;
308 assert!((m / d.layer(l, a) - 1.0).abs() < 0.02, "{m}");
309 assert!((v / d.layer_variance(l, a) - 1.0).abs() < 0.04, "{v}");
310 }
311
312 #[test]
313 fn quantile_inverts_both_tails() {
314 let d = Gamma::new(0.3, 2.0).unwrap();
315 for p in [1e-12, 0.01, 0.5, 0.99] {
316 let x = d.quantile(p).unwrap();
317 assert!((d.cdf(x) / p - 1.0).abs() < 1e-12, "{p}");
318 }
319 let x = d.quantile(1.0 - 1e-12).unwrap();
320 assert!((d.survival(x) / 1e-12 - 1.0).abs() < 1e-3, "{x}");
321 assert_eq!(d.quantile(0.0), Ok(0.0));
322 assert_eq!(d.quantile(1.0), Ok(f64::INFINITY));
323 }
324
325 fn central_moments(a: f64) -> [f64; 7] {
328 let mut mu = [0.0; 7];
329 mu[2] = a;
330 mu[3] = 2.0 * a;
331 mu[4] = 3.0 * a * a + 6.0 * a;
332 mu[5] = 20.0 * a * a + 24.0 * a;
333 mu[6] = 15.0 * a.powi(3) + 130.0 * a * a + 120.0 * a;
334 mu
335 }
336
337 fn chi_square_upper(df: f64, z: f64) -> f64 {
340 let h = 2.0 / (9.0 * df);
341 df * (1.0 - h + z * h.sqrt()).powi(3)
342 }
343
344 #[test]
353 fn sampler_matches_moments_and_quantiles() {
354 let n = 100_000;
355 let mut failures = Vec::new();
356 let shapes = [
357 1e-3, 0.01, 0.1, 0.5, 0.999, 1.0, 1.5, 3.0, 10.0, 100.0, 1e4, 1e6,
358 ];
359 for (stream, &a) in shapes.iter().enumerate() {
360 let d = Gamma::new(a, 1.0).unwrap();
361 let mut x = d.sample(&mut StreamRng::new(2026, stream as u64), n);
362 let nf = n as f64;
363 let mu = central_moments(a);
364 let average = |f: &dyn Fn(f64) -> f64| x.iter().map(|&v| f(v)).sum::<f64>() / nf;
365 let z = [
366 (average(&|v| v) - a) / (mu[2] / nf).sqrt(),
367 (average(&|v| (v - a).powi(2)) - mu[2]) / ((mu[4] - mu[2] * mu[2]) / nf).sqrt(),
368 (average(&|v| (v - a).powi(3)) - mu[3]) / ((mu[6] - mu[3] * mu[3]) / nf).sqrt(),
369 ];
370 for (name, z) in ["mean", "variance", "third moment"].iter().zip(z) {
371 if z.abs() > 5.0 {
372 failures.push(format!("shape {a}: {name} z = {z:.2}"));
373 }
374 }
375
376 x.sort_by(f64::total_cmp);
377 let mut edges: Vec<f64> = (1..100)
378 .map(|k| d.quantile(f64::from(k) / 100.0).unwrap())
379 .filter(|&q| q >= f64::MIN_POSITIVE)
380 .collect();
381 edges.dedup();
382 let mut chi2 = 0.0;
383 let (mut below, mut cdf_below) = (0, 0.0);
384 for i in 0..=edges.len() {
385 let (count, cdf) = match edges.get(i) {
386 Some(&e) => (x.partition_point(|&v| v <= e), d.cdf(e)),
387 None => (n, 1.0),
388 };
389 let expected = (cdf - cdf_below) * nf;
390 chi2 += ((count - below) as f64 - expected).powi(2) / expected;
391 (below, cdf_below) = (count, cdf);
392 }
393 let critical = chi_square_upper(edges.len() as f64, 4.75);
394 if chi2 > critical {
395 failures.push(format!(
396 "shape {a}: chi-square {chi2:.1} over {} bins, above {critical:.1}",
397 edges.len() + 1
398 ));
399 }
400 }
401 assert!(failures.is_empty(), "{}", failures.join("\n"));
402 }
403
404 #[test]
410 fn sample_is_pinned() {
411 let small = Gamma::new(0.3, 2.0).unwrap();
412 let large = Gamma::new(2.5, 400.0).unwrap();
413 let mut rng = StreamRng::new(42, 3);
414 let draws = [small.sample(&mut rng, 2), large.sample(&mut rng, 2)].concat();
415 assert_eq!(
416 draws,
417 [
418 0.23214754851650782,
419 0.7755693217108093,
420 110.9058975015963,
421 560.609408990688
422 ]
423 );
424 }
425
426 #[test]
427 fn exponential_and_parameterizations() {
428 let d = Gamma::new(1.0, 3.0).unwrap();
429 assert!((d.survival(6.0) - (-2f64).exp()).abs() < 1e-16);
430 let g = Gamma::from_mean_dispersion(200.0, 0.25).unwrap();
431 assert!((g.mean() - 200.0).abs() < 1e-12);
432 assert!((g.variance() - 0.25 * 200.0 * 200.0).abs() < 1e-8);
433 assert!(Gamma::new(0.0, 1.0).is_err());
434 assert!(Gamma::from_mean_cv(1.0, f64::NAN).is_err());
435 }
436}