pub fn poisson_binomial_tail(probs: &[f64], k: u32) -> f64 {
let k = k as usize;
if k == 0 {
return 1.0;
}
let mut dist = vec![0.0_f64; k];
dist[0] = 1.0;
let mut tail = 0.0_f64;
for &p in probs {
tail += dist[k - 1] * p;
for j in (1..k).rev() {
dist[j] = dist[j] * (1.0 - p) + dist[j - 1] * p;
}
dist[0] *= 1.0 - p;
}
tail
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DetectionModel {
pub m50: f64,
pub steepness: f64,
pub ceiling: f64,
}
impl DetectionModel {
pub fn new(m50: f64, steepness: f64, ceiling: f64) -> Self {
Self {
m50,
steepness,
ceiling,
}
}
pub fn efficiency(&self, mag: f64) -> f64 {
self.efficiency_at_depth(mag, self.m50)
}
pub fn efficiency_at_depth(&self, mag: f64, m50: f64) -> f64 {
self.ceiling / (1.0 + (self.steepness * (mag - m50)).exp())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkingRequirement {
KOfN {
k: u32,
},
}
impl LinkingRequirement {
pub fn probability(&self, epoch_probs: &[f64]) -> f64 {
match self {
LinkingRequirement::KOfN { k } => poisson_binomial_tail(epoch_probs, *k),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn poisson_binomial_tail_matches_binomial() {
let probs = vec![0.5; 10];
let tail = poisson_binomial_tail(&probs, 6);
assert!((tail - 386.0 / 1024.0).abs() < 1e-12, "tail = {tail}");
assert_eq!(poisson_binomial_tail(&probs, 0), 1.0);
assert!(poisson_binomial_tail(&probs, 11) < 1e-12);
assert_eq!(poisson_binomial_tail(&[], 0), 1.0);
assert_eq!(poisson_binomial_tail(&[], 1), 0.0);
}
#[test]
fn poisson_binomial_tail_heterogeneous() {
let probs = [0.1, 0.4, 0.7];
let tail = poisson_binomial_tail(&probs, 1);
let expected = 1.0 - 0.9 * 0.6 * 0.3;
assert!((tail - expected).abs() < 1e-12);
let all = poisson_binomial_tail(&probs, 3);
assert!((all - 0.1 * 0.4 * 0.7).abs() < 1e-12);
let p2 = 0.1 * 0.4 * 0.3 + 0.1 * 0.6 * 0.7 + 0.9 * 0.4 * 0.7 + 0.1 * 0.4 * 0.7;
assert!((poisson_binomial_tail(&probs, 2) - p2).abs() < 1e-12);
}
#[test]
fn poisson_binomial_tail_monotone_in_k() {
let probs = [0.9, 0.2, 0.5, 0.8, 0.3];
let mut prev = 1.0;
for k in 0..=6 {
let t = poisson_binomial_tail(&probs, k);
assert!(t <= prev + 1e-15, "tail not monotone at k = {k}");
assert!((0.0..=1.0).contains(&t));
prev = t;
}
}
#[test]
fn logistic_efficiency_shape() {
let model = DetectionModel::new(24.0, 3.0, 0.95);
assert!((model.efficiency(24.0) - 0.475).abs() < 1e-12);
assert!(model.efficiency(18.0) > 0.94);
assert!(model.efficiency(18.0) <= 0.95);
assert!(model.efficiency(28.0) < 1e-4);
let mut prev = model.efficiency(15.0);
for tenth in 151..=300 {
let p = model.efficiency(tenth as f64 / 10.0);
assert!(p <= prev + 1e-15);
prev = p;
}
}
#[test]
fn per_epoch_depth_override() {
let model = DetectionModel::new(24.0, 3.0, 1.0);
assert!((model.efficiency_at_depth(22.0, 22.0) - 0.5).abs() < 1e-12);
assert_eq!(
model.efficiency_at_depth(20.0, 24.0),
model.efficiency(20.0)
);
}
#[test]
fn linking_k_of_n() {
let probs = [0.5; 10];
let link = LinkingRequirement::KOfN { k: 6 };
assert!((link.probability(&probs) - 386.0 / 1024.0).abs() < 1e-12);
let any = LinkingRequirement::KOfN { k: 1 };
assert!((any.probability(&[0.25, 0.25]) - (1.0 - 0.75 * 0.75)).abs() < 1e-12);
}
}