1use solow_core::{Error, Result};
6
7#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Study {
10 pub estimate: f64,
12 pub se: f64,
14}
15
16#[derive(Clone, Debug, PartialEq)]
18pub struct MetaResult {
19 pub estimate: f64,
21 pub se: f64,
23 pub ci_95: (f64, f64),
25 pub q: f64,
27 pub df: usize,
29 pub q_pvalue: f64,
31 pub i_squared: f64,
33 pub tau_squared: f64,
35 pub weights: Vec<f64>,
37 pub model: MetaModel,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq)]
43pub enum MetaModel {
44 FixedEffect,
46 RandomEffects,
48}
49
50pub fn meta_fixed_effect(studies: &[Study]) -> Result<MetaResult> {
52 validate(studies)?;
53 let (q, weights, pooled, pooled_se) = fixed_effect_stats(studies);
54 let ci = ci_95(pooled, pooled_se);
55 let df = studies.len().saturating_sub(1);
56 let q_pvalue = chi2_survival(q, df as f64);
57 let i2 = i_squared(q, df as f64);
58 Ok(MetaResult {
59 estimate: pooled,
60 se: pooled_se,
61 ci_95: ci,
62 q,
63 df,
64 q_pvalue,
65 i_squared: i2,
66 tau_squared: 0.0,
67 weights,
68 model: MetaModel::FixedEffect,
69 })
70}
71
72pub fn meta_random_effects(studies: &[Study]) -> Result<MetaResult> {
74 validate(studies)?;
75 let (q, weights_fe, pooled_fe, _) = fixed_effect_stats(studies);
76 let df = studies.len().saturating_sub(1) as f64;
77 let sum_w: f64 = weights_fe.iter().sum();
79 let sum_w2: f64 = weights_fe.iter().map(|w| w * w).sum();
80 let c = sum_w - sum_w2 / sum_w.max(1e-30);
81 let tau2 = if c > 0.0 {
82 ((q - df) / c).max(0.0)
83 } else {
84 0.0
85 };
86 let mut weights_re = Vec::with_capacity(studies.len());
87 for s in studies {
88 weights_re.push(1.0 / (s.se * s.se + tau2));
89 }
90 let sum_w_re: f64 = weights_re.iter().sum();
91 let mut pooled = 0.0_f64;
92 for (i, s) in studies.iter().enumerate() {
93 pooled += weights_re[i] * s.estimate;
94 }
95 pooled /= sum_w_re.max(1e-30);
96 let pooled_se = (1.0 / sum_w_re.max(1e-30)).sqrt();
97 let ci = ci_95(pooled, pooled_se);
98 let q_pvalue = chi2_survival(q, df);
99 let i2 = i_squared(q, df);
100 Ok(MetaResult {
101 estimate: pooled,
102 se: pooled_se,
103 ci_95: ci,
104 q,
105 df: df as usize,
106 q_pvalue,
107 i_squared: i2,
108 tau_squared: tau2,
109 weights: weights_re,
110 model: MetaModel::RandomEffects,
111 })
112}
113
114fn validate(studies: &[Study]) -> Result<()> {
115 if studies.len() < 2 {
116 return Err(Error::Value("meta_analysis: need ≥ 2 studies".into()));
117 }
118 for s in studies {
119 if !(s.se > 0.0 && s.se.is_finite()) {
120 return Err(Error::Value("meta_analysis: SEs must be finite and > 0".into()));
121 }
122 }
123 Ok(())
124}
125
126fn fixed_effect_stats(studies: &[Study]) -> (f64, Vec<f64>, f64, f64) {
127 let mut weights = Vec::with_capacity(studies.len());
128 let mut sum_w = 0.0_f64;
129 let mut sum_wy = 0.0_f64;
130 for s in studies {
131 let w = 1.0 / (s.se * s.se);
132 weights.push(w);
133 sum_w += w;
134 sum_wy += w * s.estimate;
135 }
136 let pooled = sum_wy / sum_w;
137 let pooled_se = (1.0 / sum_w).sqrt();
138 let mut q = 0.0_f64;
140 for (i, s) in studies.iter().enumerate() {
141 q += weights[i] * (s.estimate - pooled).powi(2);
142 }
143 (q, weights, pooled, pooled_se)
144}
145
146fn ci_95(estimate: f64, se: f64) -> (f64, f64) {
147 (estimate - 1.959963984540054 * se, estimate + 1.959963984540054 * se)
148}
149
150fn i_squared(q: f64, df: f64) -> f64 {
151 if q <= df || df <= 0.0 {
152 return 0.0;
153 }
154 ((q - df) / q).clamp(0.0, 1.0)
155}
156
157fn chi2_survival(x: f64, df: f64) -> f64 {
158 if x <= 0.0 || df <= 0.0 {
159 return 1.0;
160 }
161 1.0 - lower_regularised_gamma(df / 2.0, x / 2.0)
162}
163
164fn lower_regularised_gamma(s: f64, x: f64) -> f64 {
165 if x < 0.0 || s <= 0.0 {
166 return 0.0;
167 }
168 if x < s + 1.0 {
169 gamma_series(s, x)
170 } else {
171 1.0 - gamma_continued_fraction(s, x)
172 }
173}
174
175fn gamma_series(s: f64, x: f64) -> f64 {
176 let mut sum = 1.0 / s;
177 let mut term = sum;
178 for n in 1..200 {
179 term *= x / (s + n as f64);
180 sum += term;
181 if term.abs() < sum.abs() * 3e-15 {
182 break;
183 }
184 }
185 sum * (-x + s * x.ln() - ln_gamma(s)).exp()
186}
187
188fn gamma_continued_fraction(s: f64, x: f64) -> f64 {
189 let mut b = x + 1.0 - s;
190 let mut c = 1.0 / 1e-300;
191 let mut d = 1.0 / b;
192 let mut h = d;
193 for i in 1..200 {
194 let an = -(i as f64) * (i as f64 - s);
195 b += 2.0;
196 d = an * d + b;
197 if d.abs() < 1e-300 {
198 d = 1e-300;
199 }
200 c = b + an / c;
201 if c.abs() < 1e-300 {
202 c = 1e-300;
203 }
204 d = 1.0 / d;
205 let delta = d * c;
206 h *= delta;
207 if (delta - 1.0).abs() < 3e-15 {
208 break;
209 }
210 }
211 (-x + s * x.ln() - ln_gamma(s)).exp() * h
212}
213
214fn ln_gamma(x: f64) -> f64 {
215 let g = 7.0;
216 let cof = [
217 0.999_999_999_999_809_93,
218 676.520_368_121_885_1,
219 -1_259.139_216_722_402_8,
220 771.323_428_777_653_13,
221 -176.615_029_162_140_59,
222 12.507_343_278_686_905,
223 -0.138_571_095_265_720_12,
224 9.984_369_578_019_571_5e-6,
225 1.505_632_735_149_311_6e-7,
226 ];
227 if x < 0.5 {
228 std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
229 } else {
230 let x = x - 1.0;
231 let mut a = cof[0];
232 let t = x + g + 0.5;
233 for (i, &c) in cof.iter().enumerate().skip(1) {
234 a += c / (x + i as f64);
235 }
236 0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn fixed_effect_returns_the_weighted_average() {
246 let studies = vec![
247 Study { estimate: 0.5, se: 0.1 },
248 Study { estimate: 0.6, se: 0.1 },
249 Study { estimate: 0.55, se: 0.15 },
250 ];
251 let r = meta_fixed_effect(&studies).unwrap();
252 assert!(r.estimate > 0.5 && r.estimate < 0.6);
253 assert!(r.se > 0.0);
254 assert!(r.i_squared >= 0.0 && r.i_squared <= 1.0);
255 }
256
257 #[test]
258 fn random_effects_widens_ci_versus_fixed_effect_under_heterogeneity() {
259 let studies = vec![
260 Study { estimate: 0.5, se: 0.05 },
261 Study { estimate: 1.5, se: 0.05 },
262 Study { estimate: -0.2, se: 0.05 },
263 ];
264 let fe = meta_fixed_effect(&studies).unwrap();
265 let re = meta_random_effects(&studies).unwrap();
266 assert!(re.se >= fe.se);
267 assert!(re.tau_squared > 0.0);
268 }
269}