use super::*;
use crate::likelihood::{LogLikelihood, MleFit, fit_mle};
fn model() -> BinomialLikelihood {
BinomialLikelihood {
number_of_trials: 10,
..Default::default()
}
}
const DATA: [f64; 5] = [3.0, 5.0, 2.0, 4.0, 6.0];
fn is_neg_inf(x: f64) -> bool {
x.is_infinite() && x.is_sign_negative()
}
fn first_param(fit: &MleFit) -> f64 {
fit.params().first().copied().unwrap_or(f64::NAN)
}
#[test]
fn fit_empty_data_is_insufficient() {
assert!(
matches!(model().fit(&[]), Err(Error::InsufficientData)),
"empty data should be InsufficientData"
);
}
#[test]
fn fit_observation_above_n_trials_is_invalid() {
assert!(
matches!(model().fit(&[3.0, 11.0]), Err(Error::InvalidInput(_))),
"x > n should be InvalidInput"
);
}
#[test]
fn fit_non_integer_observation_is_invalid() {
assert!(
matches!(model().fit(&[3.0, 2.5]), Err(Error::InvalidInput(_))),
"non-integer x should be InvalidInput"
);
}
#[test]
fn fit_all_zero_observations_is_degenerate() {
assert!(
matches!(
model().fit(&[0.0, 0.0, 0.0]),
Err(Error::DegenerateInput(_))
),
"all-zero data (p_hat = 0) should be DegenerateInput"
);
}
#[test]
fn fit_all_at_n_trials_is_degenerate() {
assert!(
matches!(model().fit(&[10.0, 10.0]), Err(Error::DegenerateInput(_))),
"all-at-n data (p_hat = 1) should be DegenerateInput"
);
}
#[test]
fn fit_non_positive_n_trials_is_invalid() {
let bad = BinomialLikelihood {
number_of_trials: 0,
..Default::default()
};
assert!(
matches!(bad.fit(&DATA), Err(Error::InvalidInput(_))),
"n_trials = 0 should be InvalidInput"
);
}
#[test]
fn log_likelihood_matches_scipy_golden() {
let expected = -9.961_906_023_181_967_f64;
let got = model().log_likelihood(&[0.3], &DATA);
let rel = (got - expected).abs() / expected.abs();
assert!(
rel < 1e-10,
"logL was {got}, expected {expected} (rel {rel})"
);
}
#[test]
fn log_likelihood_outside_unit_interval_is_neg_infinity() {
let m = model();
assert!(
is_neg_inf(m.log_likelihood(&[0.0], &DATA)),
"p = 0 must be -inf"
);
assert!(
is_neg_inf(m.log_likelihood(&[1.0], &DATA)),
"p = 1 must be -inf"
);
assert!(
is_neg_inf(m.log_likelihood(&[1.5], &DATA)),
"p = 1.5 must be -inf"
);
assert!(
is_neg_inf(m.log_likelihood(&[-0.1], &DATA)),
"p = -0.1 must be -inf"
);
}
#[test]
fn log_likelihood_rejects_invalid_observations() {
let m = model();
assert!(
is_neg_inf(m.log_likelihood(&[0.4], &[3.0, 11.0])),
"x > n must be -inf"
);
assert!(
is_neg_inf(m.log_likelihood(&[0.4], &[3.0, 2.5])),
"non-integer x must be -inf"
);
assert!(
is_neg_inf(m.log_likelihood(&[0.4], &[-1.0])),
"negative x must be -inf"
);
}
#[test]
fn log_likelihood_non_finite_observation_is_neg_inf() {
let m = model();
assert!(
is_neg_inf(m.log_likelihood(&[0.4], &[3.0, f64::NAN])),
"NaN observation must be -inf"
);
assert!(
is_neg_inf(m.log_likelihood(&[0.4], &[3.0, f64::INFINITY])),
"+inf observation must be -inf"
);
}
#[test]
fn n_params_is_one() {
assert_eq!(model().n_params(), 1, "binomial has one free parameter");
}
#[test]
fn fit_recovers_closed_form_p_hat() -> Result<()> {
let fit = model().fit(&DATA)?;
let p_hat = first_param(&fit);
assert!((p_hat - 0.4).abs() < 1e-12, "p_hat was {p_hat}");
Ok(())
}
#[test]
fn fit_reports_converged_zero_iterations() -> Result<()> {
let fit = model().fit(&DATA)?;
assert!(fit.converged(), "closed-form fit must report converged");
assert_eq!(fit.iterations(), 0, "closed-form fit does 0 iterations");
Ok(())
}
#[test]
fn fit_log_likelihood_matches_scipy_at_p_hat() -> Result<()> {
let expected = -8.832_784_968_964_091_f64;
let fit = model().fit(&DATA)?;
let got = fit.log_likelihood();
let rel = (got - expected).abs() / expected.abs();
assert!(
rel < 1e-10,
"fit logL was {got}, expected {expected} (rel {rel})"
);
Ok(())
}
#[test]
fn fit_aic_bic_identities() -> Result<()> {
let fit = model().fit(&DATA)?;
let ll = fit.log_likelihood();
let neg_two_ll = -2.0 * ll;
let aic = fit.aic();
let bic = fit.bic();
assert!(
(aic - (2.0 + neg_two_ll)).abs() < 1e-12,
"aic identity failed: {aic}"
);
assert!(
(bic - (5.0_f64.ln() + neg_two_ll)).abs() < 1e-12,
"bic identity failed: {bic}"
);
assert!(
(aic - 19.665_569_937_928_183).abs() < 1e-9,
"aic golden mismatch: {aic}"
);
assert!(
(bic - 19.275_007_850_362_282).abs() < 1e-9,
"bic golden mismatch: {bic}"
);
Ok(())
}
#[test]
fn fit_mle_from_perturbed_init_recovers_p_hat() -> Result<()> {
let m = model();
let fit = fit_mle(&m, &DATA, &[0.25], 1e-10)?;
let p_hat = first_param(&fit);
assert!((p_hat - 0.4).abs() < 1e-5, "optimizer p_hat was {p_hat}");
Ok(())
}
#[test]
fn boundary_observations_are_valid_when_interior_p_hat() -> Result<()> {
let m = model();
let data = [0.0, 10.0, 5.0];
let fit = m.fit(&data)?;
let p_hat = first_param(&fit);
assert!((p_hat - 0.5).abs() < 1e-12, "p_hat was {p_hat}");
assert!(
m.log_likelihood(&[0.5], &data).is_finite(),
"logL must be finite"
);
Ok(())
}