use serde::{Deserialize, Serialize};
use super::join::{Ratio, RatioMethod};
pub const MIN_REPLICATES: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArmOrder {
SubjectFirst,
ComparatorFirst,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplicatePair {
pub subject: f64,
pub comparator: f64,
pub order: ArmOrder,
}
#[must_use]
pub fn t_lower_one_sided_95(df: usize) -> f64 {
const TABLE: [f64; 30] = [
6.314, 2.920, 2.353, 2.132, 2.015, 1.943, 1.895, 1.860, 1.833, 1.812, 1.796, 1.782, 1.771,
1.761, 1.753, 1.746, 1.740, 1.734, 1.729, 1.725, 1.721, 1.717, 1.714, 1.711, 1.708, 1.706,
1.703, 1.701, 1.699, 1.697,
];
match df {
0 => f64::INFINITY,
d if d <= TABLE.len() => TABLE[d - 1],
_ => 1.645,
}
}
#[must_use]
pub fn log_ratio_lcb(pairs: &[ReplicatePair]) -> Option<Ratio> {
if pairs.len() < MIN_REPLICATES || !is_strictly_alternating(pairs) {
return None;
}
let logs = log_ratios(pairs)?;
let n = logs.len();
let mean = logs.iter().sum::<f64>() / n as f64;
let var = logs.iter().map(|l| (l - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
let se = (var / n as f64).sqrt();
let t = t_lower_one_sided_95(n - 1);
Some(Ratio {
point: mean.exp(),
lcb95: Some(t.mul_add(-se, mean).exp()),
method: RatioMethod::ReplicateTLower,
n,
})
}
#[must_use]
pub fn log_ratio_point(pairs: &[ReplicatePair]) -> Option<Ratio> {
let logs = log_ratios(pairs)?;
let n = logs.len();
let mean = logs.iter().sum::<f64>() / n as f64;
Some(Ratio::reporting_only(
mean.exp(),
RatioMethod::ReplicateTLower,
n,
))
}
#[must_use]
pub fn log_ratio_bound_or_point(pairs: &[ReplicatePair]) -> Option<Ratio> {
log_ratio_lcb(pairs).or_else(|| log_ratio_point(pairs))
}
fn log_ratios(pairs: &[ReplicatePair]) -> Option<Vec<f64>> {
if pairs.len() < 2 {
return None;
}
pairs
.iter()
.map(|p| {
if p.subject > 0.0 && p.comparator > 0.0 {
Some((p.subject / p.comparator).ln())
} else {
None
}
})
.collect()
}
fn is_strictly_alternating(pairs: &[ReplicatePair]) -> bool {
pairs.windows(2).all(|w| w[0].order != w[1].order)
}
#[cfg(test)]
mod tests {
use super::*;
fn alternating(values: &[(f64, f64)]) -> Vec<ReplicatePair> {
values
.iter()
.enumerate()
.map(|(i, &(subject, comparator))| ReplicatePair {
subject,
comparator,
order: if i % 2 == 0 {
ArmOrder::SubjectFirst
} else {
ArmOrder::ComparatorFirst
},
})
.collect()
}
#[test]
fn one_sided_t_table_matches_published_values() {
for (df, want) in [
(1_usize, 6.314_f64),
(2, 2.920),
(3, 2.353),
(4, 2.132),
(5, 2.015),
(6, 1.943),
(7, 1.895),
(8, 1.860),
(9, 1.833),
(10, 1.812),
(11, 1.796),
(12, 1.782),
(13, 1.771),
(14, 1.761),
(15, 1.753),
(16, 1.746),
(17, 1.740),
(18, 1.734),
(19, 1.729),
(20, 1.725),
(21, 1.721),
(22, 1.717),
(23, 1.714),
(24, 1.711),
(25, 1.708),
(26, 1.706),
(27, 1.703),
(28, 1.701),
(29, 1.699),
(30, 1.697),
] {
assert_eq!(t_lower_one_sided_95(df), want, "df={df}");
}
assert_eq!(t_lower_one_sided_95(31), 1.645, "beyond 30, the normal");
assert_eq!(t_lower_one_sided_95(1_000), 1.645);
assert!(
t_lower_one_sided_95(0).is_infinite(),
"df=0 supports no bound"
);
}
#[test]
fn the_table_is_one_sided_not_two_tailed() {
assert_eq!(t_lower_one_sided_95(4), 2.132);
assert_ne!(t_lower_one_sided_95(4), 2.776);
}
#[test]
fn fewer_than_five_replicates_give_no_bound() {
let three = alternating(&[(100.0, 90.0), (101.0, 91.0), (99.0, 89.0)]);
assert!(log_ratio_lcb(&three).is_none());
assert_eq!(MIN_REPLICATES, 5);
let reporting = log_ratio_point(&three).expect("point estimate exists");
assert!(reporting.lcb95.is_none());
assert_eq!(reporting.n, 3);
assert!(!reporting.passes(0.0), "no bound is not a pass");
let five = alternating(&[
(100.0, 90.0),
(101.0, 91.0),
(99.0, 89.0),
(100.5, 90.5),
(100.2, 90.1),
]);
assert!(log_ratio_lcb(&five).is_some());
}
#[test]
fn non_alternating_order_is_refused() {
let mut pairs = alternating(&[
(100.0, 90.0),
(101.0, 91.0),
(99.0, 89.0),
(100.5, 90.5),
(100.2, 90.1),
]);
assert!(log_ratio_lcb(&pairs).is_some(), "control: alternating");
pairs[3].order = pairs[2].order;
assert!(
log_ratio_lcb(&pairs).is_none(),
"two consecutive replicates led with the same arm"
);
assert!(log_ratio_point(&pairs).is_some());
}
#[test]
fn log_ratio_bound_is_exponentiated() {
let flat = alternating(&[
(110.0, 100.0),
(220.0, 200.0),
(55.0, 50.0),
(11.0, 10.0),
(1100.0, 1000.0),
]);
let r = log_ratio_lcb(&flat).expect("n = 5, alternating");
assert!((r.point - 1.10).abs() < 1e-12, "{r:?}");
assert!(
(r.lcb95.expect("bounded") - 1.10).abs() < 1e-12,
"zero variance leaves the bound at the point: {r:?}"
);
assert_eq!(r.method, RatioMethod::ReplicateTLower);
assert_eq!(r.n, 5);
let skewed = alternating(&[
(50.0, 100.0),
(200.0, 100.0),
(50.0, 100.0),
(200.0, 100.0),
(100.0, 100.0),
]);
let g = log_ratio_lcb(&skewed).expect("n = 5");
assert!((g.point - 1.0).abs() < 1e-12, "geometric mean: {g:?}");
assert!(g.lcb95.expect("bounded") < g.point, "{g:?}");
}
#[test]
fn more_dispersion_lowers_the_bound() {
let tight = alternating(&[
(110.0, 100.0),
(109.0, 100.0),
(111.0, 100.0),
(110.5, 100.0),
(109.5, 100.0),
]);
let loose = alternating(&[
(60.0, 100.0),
(160.0, 100.0),
(70.0, 100.0),
(150.0, 100.0),
(110.0, 100.0),
]);
let a = log_ratio_lcb(&tight).expect("n = 5");
let b = log_ratio_lcb(&loose).expect("n = 5");
assert!(
b.lcb95.expect("bounded") < a.lcb95.expect("bounded"),
"dispersed {b:?} must bound lower than tight {a:?}"
);
}
#[test]
fn a_single_replicate_has_no_log_ratio() {
let one = alternating(&[(110.0, 100.0)]);
assert!(log_ratio_point(&one).is_none());
assert!(log_ratio_lcb(&one).is_none());
assert!(log_ratio_bound_or_point(&one).is_none());
assert!(log_ratio_point(&[]).is_none());
let two = alternating(&[(110.0, 100.0), (90.0, 100.0)]);
let r = log_ratio_point(&two).expect("two pairs give a point");
assert_eq!(r.n, 2);
assert!(r.lcb95.is_none());
}
#[test]
fn a_zero_lane_has_no_log_ratio() {
let zeroed = alternating(&[
(110.0, 100.0),
(0.0, 100.0),
(111.0, 100.0),
(110.5, 100.0),
(109.5, 100.0),
]);
assert!(log_ratio_lcb(&zeroed).is_none());
assert!(log_ratio_point(&zeroed).is_none());
}
#[test]
fn the_wrapper_falls_back_to_reporting_only() {
let three = alternating(&[(100.0, 90.0), (101.0, 91.0), (99.0, 89.0)]);
let r = log_ratio_bound_or_point(&three).expect("point estimate");
assert!(r.lcb95.is_none());
let five = alternating(&[
(100.0, 90.0),
(101.0, 91.0),
(99.0, 89.0),
(100.5, 90.5),
(100.2, 90.1),
]);
assert!(log_ratio_bound_or_point(&five)
.expect("bounded")
.lcb95
.is_some());
}
}