use glmm::formula::{lower, Column, Table};
use glmm::{
fit_cold, BinomialLink, Family, Fit, GammaLink, InverseGaussianLink, NegBinomialLink,
PoissonLink, WaldSe,
};
use serde::Deserialize;
use serde_json::Value;
pub mod tol {
pub const BETA_REL: f64 = 1e-3;
pub const STDDEV_REL: f64 = 1e-3;
pub const SE_REL: f64 = 1e-3;
pub const SE_HESSIAN_REL: f64 = 1e-3;
pub const AGQ_BETA_REL: f64 = 3e-3;
pub const AGQ_STDDEV_REL: f64 = 4e-3;
pub const AGQ_CORR_ABS: f64 = 4e-3;
pub const AGQ_SE_HESSIAN_REL: f64 = 2e-2;
}
#[derive(Deserialize)]
pub struct Golden {
pub name: String,
pub engine: String,
pub kind: String,
pub data: String,
#[serde(skip)]
pub source: String,
#[serde(skip)]
pub csv: Option<String>,
#[serde(skip)]
pub weights_col: Option<String>,
#[serde(skip)]
pub weights_suite: bool,
pub family: String,
#[serde(default)]
pub link: Value,
#[serde(default = "one")]
pub nagq: u8,
pub r_formula: String,
pub converged: bool,
pub singular: bool,
pub coef_names: Vec<String>,
pub estimates: Est,
}
fn one() -> u8 {
1
}
#[derive(Deserialize)]
pub struct Est {
pub beta: Vec<Option<f64>>,
#[serde(default)]
pub se: Option<Vec<Option<f64>>>,
#[serde(default)]
pub se_hessian: Option<Vec<Option<f64>>>,
#[serde(default)]
pub se_rx: Option<Vec<Option<f64>>>,
#[serde(default)]
pub sigma: Option<f64>,
#[serde(default)]
pub loglik: Option<f64>,
#[serde(default)]
pub dispersion: Option<f64>,
#[serde(default)]
pub theta: Option<f64>,
#[serde(default)]
pub varcomp: Option<Vec<Vc>>,
}
#[derive(Deserialize)]
pub struct Vc {
pub group: String,
pub terms: Vec<String>,
pub stddev: Vec<f64>,
#[serde(default)]
pub corr: Option<Vec<Vec<f64>>>,
}
impl Est {
pub fn block(&self, name: &str) -> &Vc {
self.varcomp
.as_ref()
.expect("golden has no varcomp")
.iter()
.find(|b| b.group == name)
.unwrap_or_else(|| panic!("golden has no varcomp block named {name}"))
}
}
pub fn load_golden(name: &str) -> Golden {
let path = format!(
"{}/validation/goldens/{name}.json",
env!("CARGO_MANIFEST_DIR")
);
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
let mut golden: Golden = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{name}: {e}"));
golden.source = path;
golden
}
const NEAR_ZERO_ABS: f64 = 1e-3;
fn rel(got: f64, want: f64) -> f64 {
let scale = got.abs().max(want.abs()).max(1e-12);
if scale <= NEAR_ZERO_ABS {
return 0.0;
}
(got - want).abs() / scale
}
fn adjudicate(observed: f64, band: f64, ctx: &str, detail: &dyn Fn() -> String) {
if observed <= band {
return;
}
match divergence::registry().covers(ctx, observed) {
divergence::Coverage::Documented => {}
divergence::Coverage::Exceeded { id, max_rel } => panic!(
"{}\n documented divergence `{id}` covers only {max_rel:.1e} — this one grew",
detail()
),
divergence::Coverage::NotDocumented => panic!("{}", detail()),
}
}
pub fn assert_rel(got: f64, want: f64, band: f64, ctx: &str) {
let r = rel(got, want);
adjudicate(r, band, ctx, &|| {
format!("{ctx}: glmm={got} oracle={want} (rel {r:.3e} > {band:.0e})")
});
}
pub fn assert_abs(got: f64, want: f64, band: f64, ctx: &str) {
let d = (got - want).abs();
adjudicate(d, band, ctx, &|| {
format!("{ctx}: glmm={got} oracle={want} (abs {d:.3e} > {band:.0e})")
});
}
pub mod divergence {
use serde::Deserialize;
use std::collections::BTreeSet;
use std::sync::{Mutex, OnceLock};
#[derive(Deserialize)]
pub struct Entry {
pub id: String,
pub dataset: String,
pub rung: u32,
pub comparison: Vec<String>,
pub quantities: Vec<String>,
pub max_rel: f64,
pub direction: String,
pub summary: String,
pub review: String,
}
#[derive(Deserialize)]
struct File {
entries: Vec<Entry>,
}
pub enum Coverage {
Documented,
Exceeded { id: String, max_rel: f64 },
NotDocumented,
}
pub struct Registry {
entries: Vec<Entry>,
fired: Mutex<BTreeSet<String>>,
}
const SCOPE: &str = "oracle-tier";
impl Registry {
pub fn covers(&self, ctx: &str, observed: f64) -> Coverage {
let Some((dataset, rest)) = ctx.split_once(": ") else {
return Coverage::NotDocumented;
};
let quantity = rest.split('[').next().unwrap_or(rest).trim();
let Some(e) = self.entries.iter().find(|e| {
e.dataset == dataset
&& e.quantities.iter().any(|q| q == quantity)
&& e.comparison.iter().any(|c| c == SCOPE)
}) else {
return Coverage::NotDocumented;
};
if observed > e.max_rel {
return Coverage::Exceeded {
id: e.id.clone(),
max_rel: e.max_rel,
};
}
self.fired
.lock()
.expect("divergence registry mutex")
.insert(e.id.clone());
Coverage::Documented
}
pub fn fired(&self) -> BTreeSet<String> {
self.fired
.lock()
.expect("divergence registry mutex")
.clone()
}
pub fn scoped(&self) -> impl Iterator<Item = &Entry> {
self.entries
.iter()
.filter(|e| e.comparison.iter().any(|c| c == SCOPE))
}
}
pub fn registry() -> &'static Registry {
static REG: OnceLock<Registry> = OnceLock::new();
REG.get_or_init(|| {
let path = format!("{}/validation/divergences.json", env!("CARGO_MANIFEST_DIR"));
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
let f: File = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path}: {e}"));
Registry {
entries: f.entries,
fired: Mutex::new(BTreeSet::new()),
}
})
}
}
pub const DEV_EPS: f64 = 2e-4;
pub const DEV_BIG: f64 = 0.5;
pub mod dev_align {
use super::{col_index, csv_for, parse_formula, split_line, Golden};
#[allow(clippy::excessive_precision)]
fn ln_gamma(x: f64) -> f64 {
const C: [f64; 9] = [
0.999_999_999_999_809_93,
676.520_368_121_885_1,
-1_259.139_216_722_402_8,
771.323_428_777_653_13,
-176.615_029_162_140_59,
12.507_343_278_686_905,
-0.138_571_095_265_720_12,
9.984_369_578_019_571_6e-6,
1.505_632_735_149_311_6e-7,
];
const G: f64 = 7.0;
const LN_SQRT_2PI: f64 = 0.918_938_533_204_672_74; let x = x - 1.0;
let mut a = C[0];
let t = x + G + 0.5;
for (i, &c) in C.iter().enumerate().skip(1) {
a += c / (x + i as f64);
}
LN_SQRT_2PI + (x + 0.5) * t.ln() - t + a.ln()
}
fn lchoose(n: f64, k: f64) -> f64 {
ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0)
}
fn is_lme4(engine: &str) -> bool {
engine.starts_with("lme4")
}
fn saturated_loglik_deficit(g: &Golden) -> f64 {
let spec = parse_formula(&g.r_formula);
let raw =
std::fs::read_to_string(csv_for(g)).unwrap_or_else(|e| panic!("{}: {e}", csv_for(g)));
let mut lines = raw.lines().filter(|l| !l.trim().is_empty());
let header = split_line(lines.next().expect("CSV has a header"));
let rows: Vec<Vec<String>> = lines.map(split_line).collect();
match g.family.as_str() {
"binomial" => {
let (yi, ni) = match &spec.aggregated {
Some((succ, total)) => {
(col_index(&header, succ), Some(col_index(&header, total)))
}
None => (col_index(&header, &spec.response), None),
};
let mut s = 0.0;
for r in &rows {
let y: f64 = r[yi].parse().expect("successes parse");
let n: f64 = match ni {
Some(ti) => r[ti].parse().expect("total parse"),
None => 1.0,
};
let p = y / n;
s += lchoose(n, y);
if y > 0.0 {
s += y * p.ln();
}
if y < n {
s += (n - y) * (1.0 - p).ln();
}
}
s
}
"poisson" => {
let ri = col_index(&header, &spec.response);
let mut s = 0.0;
for r in &rows {
let y: f64 = r[ri].parse().expect("response parse");
let t = if y > 0.0 { y * y.ln() } else { 0.0 };
s += t - y - ln_gamma(y + 1.0);
}
s
}
f => panic!("no verified saturated correction for family {f}"),
}
}
pub fn aligned_dev(g: &Golden) -> Option<f64> {
let ll = g.estimates.loglik?;
let ll = if is_lme4(&g.engine) && g.nagq > 1 {
ll + saturated_loglik_deficit(g)
} else {
ll
};
Some(-2.0 * ll)
}
}
pub fn assert_coefs(
got: &[f64],
aliased: &[bool],
align: &[usize],
want: &[Option<f64>],
band: f64,
ctx: &str,
) {
assert_eq!(align.len(), want.len(), "{ctx}: coefficient count");
for (o, (&j, w)) in align.iter().zip(want).enumerate() {
match w {
Some(w) => {
assert!(
!aliased[j],
"{ctx}[{o}]: glmm flags this column aliased, the reference reports {w}"
);
assert_rel(got[j], *w, band, &format!("{ctx}[{o}]"));
}
None => assert!(
aliased[j],
"{ctx}[{o}]: the reference dropped this column as aliased, glmm reports {}",
got[j]
),
}
}
}
pub fn align_coefs(
glmm_names: &[String],
oracle_names: &[String],
aliased: &[bool],
ctx: &str,
) -> Vec<usize> {
let align: Vec<usize> = oracle_names
.iter()
.map(|n| {
glmm_names
.iter()
.position(|m| m == n)
.unwrap_or_else(|| panic!("{ctx}: oracle names column `{n}`, glmm does not"))
})
.collect();
for (j, name) in glmm_names.iter().enumerate() {
assert!(
align.contains(&j) || aliased[j],
"{ctx}: the oracle omits column `{name}` but glmm does not flag it aliased"
);
}
align
}
fn csv_for(g: &Golden) -> String {
if let Some(path) = &g.csv {
return path.clone();
}
let dir = if g.data.starts_with("sim_") {
"simulated"
} else {
"empirical"
};
format!(
"{}/validation/data/{dir}/{}.csv",
env!("CARGO_MANIFEST_DIR"),
g.data
)
}
fn split_line(line: &str) -> Vec<String> {
line.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.collect()
}
fn mentions(s: &str, name: &str) -> bool {
let bytes = s.as_bytes();
let mut from = 0;
while let Some(hit) = s[from..].find(name) {
let a = from + hit;
let b = a + name.len();
let left_ok = a == 0 || !is_ident(bytes[a - 1]);
let right_ok = b == bytes.len() || !is_ident(bytes[b]);
if left_ok && right_ok {
return true;
}
from = a + 1;
}
false
}
fn is_ident(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'.' || b == b'_'
}
struct Spec {
aggregated: Option<(String, String)>,
response: String,
rhs: String,
}
fn parse_formula(r_formula: &str) -> Spec {
let (lhs, rhs) = r_formula
.split_once('~')
.expect("golden r_formula has no `~`");
let lhs = lhs.trim();
let rhs = rhs.trim();
let rhs = rhs.strip_prefix("1 +").unwrap_or(rhs).trim().to_string();
let Some(args) = lhs.strip_prefix("cbind(").and_then(|s| s.strip_suffix(')')) else {
return Spec {
aggregated: None,
response: lhs.to_string(),
rhs,
};
};
let (succ, fail) = args.split_once(',').expect("cbind needs two arguments");
let total = fail
.trim()
.split(|c: char| !c.is_ascii() || !is_ident(c as u8))
.find(|t| !t.is_empty())
.expect("cbind failure term names no column");
Spec {
aggregated: Some((succ.trim().to_string(), total.to_string())),
response: "y".to_string(),
rhs,
}
}
pub fn refit(g: &Golden, factors: &[&str]) -> (Fit, Vec<String>, Vec<String>) {
refit_with(g, factors, WaldSe::Hessian)
}
pub fn refit_with(
g: &Golden,
factors: &[&str],
wald_se: WaldSe,
) -> (Fit, Vec<String>, Vec<String>) {
let spec = parse_formula(&g.r_formula);
let raw = std::fs::read_to_string(csv_for(g)).unwrap_or_else(|e| panic!("{}: {e}", csv_for(g)));
let mut lines = raw.lines().filter(|l| !l.trim().is_empty());
let header = split_line(lines.next().expect("CSV has a header"));
let rows: Vec<Vec<String>> = lines.map(split_line).collect();
let (y_agg, weights) = match &spec.aggregated {
None => (None, None),
Some((succ, total)) => {
let si = col_index(&header, succ);
let ti = col_index(&header, total);
let mut y = Vec::with_capacity(rows.len());
let mut w = Vec::with_capacity(rows.len());
for r in &rows {
let s: f64 = r[si].parse().expect("successes parse");
let n: f64 = r[ti].parse().expect("total parse");
y.push(s / n);
w.push(n);
}
(Some(y), Some(w))
}
};
let weights = match &g.weights_col {
None => weights,
Some(col) => {
assert!(
weights.is_none(),
"{}: weights_col and an aggregated response both claim the weight slot",
g.name
);
let wi = col_index(&header, col);
Some(
rows.iter()
.map(|r| r[wi].parse().expect("weight parse"))
.collect(),
)
}
};
let mut columns: Vec<(String, Column)> = Vec::new();
if let Some(y) = y_agg {
columns.push((spec.response.clone(), Column::Numeric(y)));
}
for (j, name) in header.iter().enumerate() {
if !mentions(&spec.rhs, name) && *name != spec.response {
continue;
}
if columns.iter().any(|(n, _)| n == name) {
continue;
}
let cells: Vec<String> = rows.iter().map(|r| r[j].clone()).collect();
let col = if factors.contains(&name.as_str()) {
Column::factor_from_labels(&cells)
} else {
Column::Numeric(
cells
.iter()
.map(|c| c.parse().expect("numeric parse"))
.collect(),
)
};
columns.push((name.clone(), col));
}
let table = Table {
n: rows.len(),
columns,
};
let formula = format!("{} ~ {}", spec.response, spec.rhs);
let lo = lower(&formula, &table, family_of(g)).unwrap_or_else(|e| panic!("{formula}: {e:?}"));
let opts = glmm::FitOptions {
nagq: g.nagq,
wald_se,
weights,
..lo.opts
};
let fit = fit_cold(&lo.x, &lo.y, lo.n, lo.p, &lo.model, &lo.ids, &opts);
let group_names = lo.re_groups.iter().map(|r| r.name.clone()).collect();
(fit, lo.col_names, group_names)
}
fn col_index(header: &[String], name: &str) -> usize {
header
.iter()
.position(|h| h == name)
.unwrap_or_else(|| panic!("CSV has no column {name}"))
}
fn family_of(g: &Golden) -> Family {
let link = g.link.as_str().unwrap_or_else(|| match g.family.as_str() {
"gaussian" => "identity",
"binomial" => "logit",
"poisson" | "gamma" | "negbin" => "log",
f => panic!("golden {}: no default link known for family {f}", g.name),
});
match (g.family.as_str(), link) {
("gaussian", _) => Family::Gaussian,
("binomial", "logit") => Family::Binomial {
link: BinomialLink::Logit,
},
("binomial", "probit") => Family::Binomial {
link: BinomialLink::Probit,
},
("binomial", "cloglog") => Family::Binomial {
link: BinomialLink::Cloglog,
},
("poisson", "log") => Family::Poisson {
link: PoissonLink::Log,
},
("gamma", "log") => Family::Gamma {
link: GammaLink::Log,
},
("gamma", "inverse") => Family::Gamma {
link: GammaLink::Inverse,
},
("inversegaussian", "log") => Family::InverseGaussian {
link: InverseGaussianLink::Log,
},
("inversegaussian", "inverse_squared") => Family::InverseGaussian {
link: InverseGaussianLink::InverseSquared,
},
("negbin", "log") => Family::NegativeBinomial {
link: NegBinomialLink::Log,
},
(f, l) => panic!("golden {}: unsupported family/link {f}/{l}", g.name),
}
}