1use std::f64::consts::PI;
4
5use prospicio_core::{Error, Result};
6use prospicio_math::special::{beta_inc, ln_gamma};
7
8use crate::distribution::{Distribution, check_probability};
9use crate::severity::Severity;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct Loglogistic {
36 shape: f64,
37 scale: f64,
38}
39
40impl Loglogistic {
41 pub fn new(shape: f64, scale: f64) -> Result<Self> {
43 for (name, v) in [("shape", shape), ("scale", scale)] {
44 if !(v.is_finite() && v > 0.0) {
45 return Err(Error::InvalidParameter {
46 name,
47 value: v,
48 reason: "must be finite and positive",
49 });
50 }
51 }
52 Ok(Self { shape, scale })
53 }
54
55 pub fn shape(&self) -> f64 {
57 self.shape
58 }
59
60 pub fn scale(&self) -> f64 {
62 self.scale
63 }
64
65 fn raw_moment(&self, j: f64) -> f64 {
67 if j >= self.shape {
68 return f64::INFINITY;
69 }
70 let r = PI * j / self.shape;
71 self.scale.powf(j) * r / r.sin()
72 }
73
74 fn probs(&self, u: f64) -> (f64, f64) {
76 let z = (u / self.scale).powf(self.shape);
77 (z / (1.0 + z), 1.0 / (1.0 + z))
78 }
79
80 fn limited_moment(&self, j: i32, u: f64) -> f64 {
82 if u == f64::INFINITY {
83 return self.raw_moment(f64::from(j));
84 }
85 let (v, s) = self.probs(u);
86 let r = f64::from(j) / self.shape;
87 self.scale.powi(j) * beta_lower(1.0 + r, 1.0 - r, v) + u.powi(j) * s
88 }
89
90 fn tail_moment(&self, j: i32, u: f64) -> f64 {
94 if u == f64::INFINITY {
95 return 0.0;
96 }
97 let (_, s) = self.probs(u);
98 let r = f64::from(j) / self.shape;
99 (self.scale.powi(j) * beta_lower(1.0 - r, 1.0 + r, s) - u.powi(j) * s).max(0.0)
100 }
101
102 fn partial(&self, j: i32, a: f64, b: f64) -> f64 {
106 let below = |u: f64| {
107 if u <= 0.0 {
108 0.0
109 } else {
110 self.limited_moment(j, u) / f64::from(j)
111 }
112 };
113 let c = self.scale * 2f64.powf(1.0 / self.shape);
114 if b <= c {
115 return below(b) - below(a);
116 }
117 if a >= c {
118 return self.tail_series(j, a, b);
119 }
120 below(c) - below(a) + self.tail_series(j, c, b)
121 }
122
123 fn tail_series(&self, j: i32, a: f64, b: f64) -> f64 {
128 let ra = (self.scale / a).powf(self.shape);
129 let rb = if b == f64::INFINITY {
130 0.0
131 } else {
132 (self.scale / b).powf(self.shape)
133 };
134 let ln_ratio = (rb / ra).ln();
135 let r = f64::from(j) / self.shape;
136 let mut sum = 0.0;
137 for k in 0..200 {
138 let e = f64::from(k) + 1.0 - r;
140 let term = if e.abs() < 1e-12 {
141 -ln_ratio
142 } else if rb == 0.0 {
143 if e > 0.0 {
144 ra.powf(e) / e
145 } else {
146 f64::INFINITY
147 }
148 } else {
149 -ra.powf(e) * (e * ln_ratio).exp_m1() / e
150 };
151 if !term.is_finite() {
152 return f64::INFINITY;
153 }
154 sum += if k % 2 == 0 { term } else { -term };
155 if k > 0 && term.abs() < 1e-17 * sum.abs() {
156 break;
157 }
158 }
159 self.scale.powi(j) / self.shape * sum
160 }
161}
162
163fn beta_lower(a: f64, b: f64, x: f64) -> f64 {
172 if x <= 0.0 {
173 return 0.0;
174 }
175 if b > 0.0 {
176 let ln_b = ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b);
177 return beta_inc(a, b, x) * ln_b.exp();
178 }
179 let steps = (-b).floor();
181 let c = b + steps;
182 let (mut value, mut c) = if c == 0.0 {
183 (beta_zero(a, x), 0.0)
184 } else {
185 let c = c + 1.0;
186 let ln_b = ln_gamma(a) + ln_gamma(c) - ln_gamma(a + c);
187 (beta_inc(a, c, x) * ln_b.exp(), c)
188 };
189 while c > b {
190 c -= 1.0;
191 value = ((a + c) * value - x.powf(a) * (c * (-x).ln_1p()).exp()) / c;
192 }
193 value
194}
195
196fn beta_zero(a: f64, x: f64) -> f64 {
199 let m = a.round() as i32;
200 if x < 0.5 {
201 let mut sum = 0.0;
202 let mut term = x.powi(m);
203 for n in 0..2000 {
204 let add = term / f64::from(m + n);
205 sum += add;
206 if add < 1e-17 * sum {
207 break;
208 }
209 term *= x;
210 }
211 sum
212 } else {
213 let partial: f64 = (1..m).map(|k| x.powi(k) / f64::from(k)).sum();
214 -(-x).ln_1p() - partial
215 }
216}
217
218impl Distribution for Loglogistic {
219 fn mean(&self) -> f64 {
220 self.raw_moment(1.0)
221 }
222
223 fn variance(&self) -> f64 {
224 if self.shape <= 2.0 {
225 return f64::INFINITY;
226 }
227 let m = self.mean();
228 self.raw_moment(2.0) - m * m
229 }
230
231 fn cdf(&self, x: f64) -> f64 {
232 if x <= 0.0 {
233 return 0.0;
234 }
235 self.probs(x).0
236 }
237
238 fn survival(&self, x: f64) -> f64 {
239 if x <= 0.0 {
240 return 1.0;
241 }
242 self.probs(x).1
243 }
244
245 fn quantile(&self, p: f64) -> Result<f64> {
247 check_probability(p)?;
248 Ok(self.scale * (p / (1.0 - p)).powf(1.0 / self.shape))
249 }
250}
251
252impl Severity for Loglogistic {
253 fn lev(&self, limit: f64) -> f64 {
254 if limit <= 0.0 {
255 return limit;
256 }
257 self.limited_moment(1, limit)
258 }
259
260 fn stop_loss(&self, retention: f64) -> f64 {
263 if retention <= 0.0 {
264 return self.mean() - retention;
265 }
266 if self.shape <= 1.0 {
267 return f64::INFINITY;
268 }
269 self.tail_moment(1, retention)
270 }
271
272 fn layer(&self, limit: f64, attachment: f64) -> f64 {
274 let a = attachment.max(0.0);
275 self.partial(1, a, a + limit)
276 }
277
278 fn layer_second_moment(&self, limit: f64, attachment: f64) -> f64 {
280 let a = attachment.max(0.0);
281 let b = a + limit;
282 let p1 = self.partial(1, a, b);
283 if p1 == 0.0 {
284 return 0.0;
285 }
286 (2.0 * (self.partial(2, a, b) - a * p1)).max(0.0)
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn identities() {
296 for shape in [0.5, 0.8, 1.0, 1.5, 2.0, 4.0] {
297 let d = Loglogistic::new(shape, 100.0).unwrap();
298 for u in [1.0, 100.0, 20_000.0] {
299 if d.cdf(u) < 0.99 {
301 let x = d.quantile(d.cdf(u)).unwrap();
302 assert!((x / u - 1.0).abs() < 1e-12);
303 }
304 if shape > 1.0 {
305 let total = d.lev(u) + d.stop_loss(u);
306 assert!((total / d.mean() - 1.0).abs() < 1e-12, "{shape} {u}");
307 }
308 }
309 let stacked = d.layer(500.0, 0.0) + d.layer(500.0, 500.0);
310 assert!((stacked / d.layer(1000.0, 0.0) - 1.0).abs() < 1e-12);
311 assert!(d.layer_second_moment(200.0, 300.0) > 0.0);
312 }
313 let d = Loglogistic::new(4.0, 3.0).unwrap();
314 let m2 = d.layer_second_moment(f64::INFINITY, 0.0);
315 assert!((m2 / (d.variance() + d.mean().powi(2)) - 1.0).abs() < 1e-12);
316 assert!(Loglogistic::new(0.0, 1.0).is_err());
317 }
318
319 #[test]
321 fn layer_second_moment_branches() {
322 let d = Loglogistic::new(3.0, 50.0).unwrap();
323 let (a, b) = (40.0, 90.0);
324 let direct = d.lev(b) - d.lev(a);
325 assert!((d.layer(b - a, a) / direct - 1.0).abs() < 1e-12);
326 let m = |j: i32, u: f64| d.limited_moment(j, u);
327 let direct = m(2, b) - m(2, a) - 2.0 * a * (m(1, b) - m(1, a));
328 let tails = d.layer_second_moment(b - a, a);
329 assert!((tails / direct - 1.0).abs() < 1e-11);
330 }
331
332 #[test]
333 fn beta_lower_matches_integration() {
334 for (a, b, x) in [
336 (1.5, 0.5, 0.7),
337 (2.0, -1.0, 0.9),
338 (2.25, -0.25, 0.4),
339 (3.0, -1.0, 0.2),
340 ] {
341 let n = 200_000;
342 let h = x / f64::from(n);
343 let num: f64 = (0..n)
344 .map(|i| {
345 let t = (f64::from(i) + 0.5) * h;
346 t.powf(a - 1.0) * (1.0 - t).powf(b - 1.0) * h
347 })
348 .sum();
349 let got = beta_lower(a, b, x);
350 assert!(
351 (got / num - 1.0).abs() < 1e-8,
352 "{a} {b} {x}: {got} vs {num}"
353 );
354 }
355 }
356}