//! Item Response Theory (Rasch / 1-parameter) primitives for eval calibration.
//!
//! WOR-1581 spike. An eval over a fixed corpus spends equal effort on every
//! item, including ones far from a server's capability boundary that carry
//! little information. The Rasch model assigns each item a difficulty so an eval
//! can rank items by how informative they are near the estimated ability and
//! converge on the boundary with fewer items.
//!
//! These are the pure primitives: difficulty from a historical pass rate, the
//! response probability, Fisher information, an ability estimate, and
//! most-informative selection. See `docs/irt-spike.md` for the determinism
//! tradeoff: live next-item adaptation makes the item order depend on live
//! outcomes, which conflicts with mcptest's deterministic cassette replay, so
//! the recommended use is advisory ranking of a fixed corpus, not live
//! adaptation.

/// One calibrated item: a stable id and its Rasch difficulty (higher = harder).
#[derive(Debug, Clone, PartialEq)]
pub struct RaschItem {
    /// Stable item id.
    pub id: String,
    /// Rasch difficulty parameter, on the logit scale.
    pub difficulty: f64,
}

/// Difficulty from a historical pass rate at a neutral ability (theta = 0).
///
/// Inverts the Rasch model `P = 1 / (1 + e^(b - theta))` at `theta = 0`, giving
/// `b = ln((1 - p) / p)`. A pass rate near 1 yields a low (easy) difficulty;
/// near 0 a high (hard) one. The rate is clamped away from 0 and 1 so the logit
/// stays finite.
pub fn difficulty_from_pass_rate(pass_rate: f64) -> f64 {
    let p = pass_rate.clamp(0.001, 0.999);
    ((1.0 - p) / p).ln()
}

/// Probability a respondent of the given `ability` answers an item of the given
/// `difficulty` correctly, under the Rasch model.
pub fn probability(ability: f64, difficulty: f64) -> f64 {
    1.0 / (1.0 + (difficulty - ability).exp())
}

/// Fisher information an item carries for estimating an ability. Maximal when
/// the item's difficulty matches the ability (the capability boundary), where
/// the answer is most discriminating.
pub fn information(ability: f64, difficulty: f64) -> f64 {
    let p = probability(ability, difficulty);
    p * (1.0 - p)
}

/// The item carrying the most information at `ability`, or `None` for an empty
/// set. The core of adaptive selection: probe nearest the boundary first.
pub fn most_informative(items: &[RaschItem], ability: f64) -> Option<&RaschItem> {
    items.iter().max_by(|a, b| {
        information(ability, a.difficulty)
            .partial_cmp(&information(ability, b.difficulty))
            .unwrap_or(std::cmp::Ordering::Equal)
    })
}

/// Estimate a respondent's ability from graded responses (each an item
/// difficulty plus whether it was answered correctly) by Newton-Raphson on the
/// Rasch log-likelihood.
///
/// Deterministic: a fixed start, bounded iterations, and a perfect or zero score
/// (which has no finite maximum-likelihood estimate) clamped to a wide finite
/// bound rather than diverging.
pub fn estimate_ability(responses: &[(f64, bool)]) -> f64 {
    if responses.is_empty() {
        return 0.0;
    }
    let correct = responses.iter().filter(|(_, c)| *c).count();
    if correct == 0 {
        return -4.0;
    }
    if correct == responses.len() {
        return 4.0;
    }
    let mut theta = 0.0;
    for _ in 0..50 {
        let mut grad = 0.0;
        let mut hess = 0.0;
        for (difficulty, c) in responses {
            let p = probability(theta, *difficulty);
            grad += f64::from(u8::from(*c)) - p;
            hess -= p * (1.0 - p);
        }
        if hess.abs() < 1e-9 {
            break;
        }
        let step = grad / hess;
        theta -= step;
        if step.abs() < 1e-6 {
            break;
        }
    }
    theta.clamp(-4.0, 4.0)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn item(id: &str, difficulty: f64) -> RaschItem {
        RaschItem {
            id: id.to_string(),
            difficulty,
        }
    }

    #[test]
    fn difficulty_decreases_with_pass_rate() {
        assert!(difficulty_from_pass_rate(0.1) > difficulty_from_pass_rate(0.9));
        assert!((difficulty_from_pass_rate(0.5)).abs() < 1e-9);
    }

    #[test]
    fn probability_is_half_at_the_boundary_and_rises_with_ability() {
        assert!((probability(1.0, 1.0) - 0.5).abs() < 1e-9);
        assert!(probability(2.0, 1.0) > probability(0.0, 1.0));
    }

    #[test]
    fn information_peaks_when_difficulty_matches_ability() {
        let at_boundary = information(1.0, 1.0);
        assert!(at_boundary > information(1.0, 3.0));
        assert!(at_boundary > information(1.0, -3.0));
        assert!((at_boundary - 0.25).abs() < 1e-9);
    }

    #[test]
    fn most_informative_picks_the_item_nearest_the_ability() {
        let items = vec![item("easy", -2.0), item("near", 0.2), item("hard", 3.0)];
        assert_eq!(most_informative(&items, 0.0).unwrap().id, "near");
        assert_eq!(most_informative(&items, 3.0).unwrap().id, "hard");
        assert!(most_informative(&[], 0.0).is_none());
    }

    #[test]
    fn ability_estimate_reflects_the_score() {
        // All correct on hard items -> high ability; all wrong -> low.
        assert_eq!(estimate_ability(&[(1.0, true), (2.0, true)]), 4.0);
        assert_eq!(estimate_ability(&[(0.0, false), (-1.0, false)]), -4.0);
        // A symmetric mixed record lands near the middle.
        let mixed = estimate_ability(&[(-1.0, true), (1.0, false)]);
        assert!(mixed.abs() < 0.5, "expected near-zero ability, got {mixed}");
    }
}
