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(
121 "meta_analysis: SEs must be finite and > 0".into(),
122 ));
123 }
124 }
125 Ok(())
126}
127
128fn fixed_effect_stats(studies: &[Study]) -> (f64, Vec<f64>, f64, f64) {
129 let mut weights = Vec::with_capacity(studies.len());
130 let mut sum_w = 0.0_f64;
131 let mut sum_wy = 0.0_f64;
132 for s in studies {
133 let w = 1.0 / (s.se * s.se);
134 weights.push(w);
135 sum_w += w;
136 sum_wy += w * s.estimate;
137 }
138 let pooled = sum_wy / sum_w;
139 let pooled_se = (1.0 / sum_w).sqrt();
140 let mut q = 0.0_f64;
142 for (i, s) in studies.iter().enumerate() {
143 q += weights[i] * (s.estimate - pooled).powi(2);
144 }
145 (q, weights, pooled, pooled_se)
146}
147
148fn ci_95(estimate: f64, se: f64) -> (f64, f64) {
149 (
150 estimate - 1.959963984540054 * se,
151 estimate + 1.959963984540054 * se,
152 )
153}
154
155fn i_squared(q: f64, df: f64) -> f64 {
156 if q <= df || df <= 0.0 {
157 return 0.0;
158 }
159 ((q - df) / q).clamp(0.0, 1.0)
160}
161
162fn chi2_survival(x: f64, df: f64) -> f64 {
163 if x <= 0.0 || df <= 0.0 {
164 return 1.0;
165 }
166 1.0 - lower_regularised_gamma(df / 2.0, x / 2.0)
167}
168
169fn lower_regularised_gamma(s: f64, x: f64) -> f64 {
170 if x < 0.0 || s <= 0.0 {
171 return 0.0;
172 }
173 if x < s + 1.0 {
174 gamma_series(s, x)
175 } else {
176 1.0 - gamma_continued_fraction(s, x)
177 }
178}
179
180fn gamma_series(s: f64, x: f64) -> f64 {
181 let mut sum = 1.0 / s;
182 let mut term = sum;
183 for n in 1..200 {
184 term *= x / (s + n as f64);
185 sum += term;
186 if term.abs() < sum.abs() * 3e-15 {
187 break;
188 }
189 }
190 sum * (-x + s * x.ln() - ln_gamma(s)).exp()
191}
192
193fn gamma_continued_fraction(s: f64, x: f64) -> f64 {
194 let mut b = x + 1.0 - s;
195 let mut c = 1.0 / 1e-300;
196 let mut d = 1.0 / b;
197 let mut h = d;
198 for i in 1..200 {
199 let an = -(i as f64) * (i as f64 - s);
200 b += 2.0;
201 d = an * d + b;
202 if d.abs() < 1e-300 {
203 d = 1e-300;
204 }
205 c = b + an / c;
206 if c.abs() < 1e-300 {
207 c = 1e-300;
208 }
209 d = 1.0 / d;
210 let delta = d * c;
211 h *= delta;
212 if (delta - 1.0).abs() < 3e-15 {
213 break;
214 }
215 }
216 (-x + s * x.ln() - ln_gamma(s)).exp() * h
217}
218
219fn ln_gamma(x: f64) -> f64 {
220 let g = 7.0;
221 let cof = [
222 0.999_999_999_999_809_93,
223 676.520_368_121_885_1,
224 -1_259.139_216_722_402_8,
225 771.323_428_777_653_13,
226 -176.615_029_162_140_59,
227 12.507_343_278_686_905,
228 -0.138_571_095_265_720_12,
229 9.984_369_578_019_571_5e-6,
230 1.505_632_735_149_311_6e-7,
231 ];
232 if x < 0.5 {
233 std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
234 } else {
235 let x = x - 1.0;
236 let mut a = cof[0];
237 let t = x + g + 0.5;
238 for (i, &c) in cof.iter().enumerate().skip(1) {
239 a += c / (x + i as f64);
240 }
241 0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn fixed_effect_returns_the_weighted_average() {
251 let studies = vec![
252 Study {
253 estimate: 0.5,
254 se: 0.1,
255 },
256 Study {
257 estimate: 0.6,
258 se: 0.1,
259 },
260 Study {
261 estimate: 0.55,
262 se: 0.15,
263 },
264 ];
265 let r = meta_fixed_effect(&studies).unwrap();
266 assert!(r.estimate > 0.5 && r.estimate < 0.6);
267 assert!(r.se > 0.0);
268 assert!(r.i_squared >= 0.0 && r.i_squared <= 1.0);
269 }
270
271 #[test]
272 fn random_effects_widens_ci_versus_fixed_effect_under_heterogeneity() {
273 let studies = vec![
274 Study {
275 estimate: 0.5,
276 se: 0.05,
277 },
278 Study {
279 estimate: 1.5,
280 se: 0.05,
281 },
282 Study {
283 estimate: -0.2,
284 se: 0.05,
285 },
286 ];
287 let fe = meta_fixed_effect(&studies).unwrap();
288 let re = meta_random_effects(&studies).unwrap();
289 assert!(re.se >= fe.se);
290 assert!(re.tau_squared > 0.0);
291 }
292}