//! Distractor-tool / tool-overload scoring (MCPAgentBench, MCP-Atlas).
//!
//! Why this module exists: a real MCP deployment rarely presents the agent with
//! exactly the tools a task needs. The candidate list is padded with irrelevant
//! or near-duplicate tools, and a capable agent must still pick the correct one
//! amid the noise. MCPAgentBench (arXiv:2512.24565) and MCP-Atlas
//! (arXiv:2602.00933) measure exactly this: inject N distractor tools and report
//! selection accuracy as a function of distractor count. Nothing in mcptest
//! injected distractors before this module.
//!
//! This is the pure engine half: a [`DistractorSpec`] declares how many
//! distractors to inject and where they come from (a bundled catalog of
//! plausible-but-irrelevant tools, or near-duplicate variants synthesized from
//! the real tool names), and a deterministic scorer ([`score_selection`]) folds
//! a recorded chosen-tool set plus the known correct and distractor id sets into
//! a selection accuracy with an accuracy-vs-distractor-count datapoint.
//! Everything is deterministic and free of model calls, so a fixed observation
//! always yields the same score and the result is byte-stable. Pairs with the
//! equal-function-set F1 matcher ([`crate::eval::tool_selection_f1`]): both score
//! exact membership with no model in the loop.
//!
//! # Accuracy rule
//!
//! Given the agent's chosen tool ids, the set of correct ids, and the set of
//! injected distractor ids, the scorer counts, per chosen id:
//!
//! - a *correct* hit when the id is in the correct set;
//! - a *distractor* hit when the id is in the distractor set;
//! - everything else is ignored (an id that is neither correct nor an injected
//!   distractor is out of scope for this metric).
//!
//! Accuracy is the integer percent `chose_correct * 100 / chose_in_scope`, where
//! `chose_in_scope = chose_correct + chose_distractor`. A run that chose nothing
//! in scope scores 0 (it never selected the correct tool), except the vacuous
//! case of zero correct ids declared, which scores 100. `chose_distractor` is
//! the count of distractor hits, the headline failure signal.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::suite::{Assertion, Matcher};

/// Default selection-accuracy floor a `distractors:` gate applies when it
/// declares no `expect:`. Half is the smallest useful bar: an agent that picks
/// the correct tool at least as often as a distractor clears it.
const DEFAULT_MIN_ACCURACY_PERCENT: i64 = 50;

/// Invocation complexity tag for a distractor scenario (the MCPAgentBench
/// serial-vs-parallel axis).
///
/// A scenario can stratify its accuracy reporting by whether the task needs the
/// correct tools called one after another (`Serial`) or independently in one
/// turn (`Parallel`). The tag is descriptive metadata, it does not change the
/// accuracy math; it lets a report group degradation curves by complexity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Complexity {
    /// The correct tools must be invoked in sequence (one depends on another).
    Serial,
    /// The correct tools are independent and can be invoked in parallel.
    Parallel,
}

/// Where a scenario's injected distractor tools come from.
///
/// A scenario draws plausible-but-irrelevant tools from the bundled
/// [`catalog`], or synthesizes near-duplicate variants of its real tools.
/// Modeled as an enum so the parser and the schema present one tagged choice
/// rather than two flags.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DistractorSource {
    /// Draw distractors from the bundled catalog of plausible-but-irrelevant
    /// tools (see [`catalog`]).
    Catalog,
    /// Synthesize near-duplicate / name-collision variants of the named real
    /// tools (see [`near_duplicates`]).
    NearDuplicate {
        /// Real tool names to derive variants from. Each name yields several
        /// deterministic variants (casing, pluralization, suffix).
        of: Vec<String>,
    },
}

/// How a scenario injects distractor tools into the candidate list.
///
/// A spec is the authoring affordance: it says how many distractors to inject
/// and where they come from, with an optional complexity tag. The runner uses it
/// to pad the presented tool list (preview, see the schema note); the scorer
/// uses the resolved distractor id set, which [`resolve_distractor_ids`] derives
/// from the spec.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DistractorSpec {
    /// Number of distractor tools to inject. Zero is the baseline (no noise).
    pub count: u32,
    /// Where the distractors come from.
    pub source: DistractorSource,
    /// Optional invocation-complexity tag for stratified reporting.
    pub complexity: Option<Complexity>,
}

impl DistractorSpec {
    /// Build a spec from its count and source, with no complexity tag. Kept so
    /// call sites and tests share one construction path.
    pub fn new(count: u32, source: DistractorSource) -> Self {
        Self {
            count,
            source,
            complexity: None,
        }
    }

    /// The distractor ids this spec resolves to, capped at `count`.
    ///
    /// Catalog specs take the first `count` bundled names; near-duplicate specs
    /// take the first `count` synthesized variants across the named tools in
    /// declaration order. Deterministic: the same spec always yields the same
    /// ordered ids.
    pub fn resolve_ids(&self) -> Vec<String> {
        resolve_distractor_ids(self)
    }
}

/// One bundled distractor tool: a plausible-but-irrelevant name and a one-line
/// description.
///
/// Const-friendly (string slices, not owned) so the catalog is a static table
/// the binary carries with no allocation until a spec resolves it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogEntry {
    /// The distractor tool name presented in the candidate list.
    pub name: &'static str,
    /// A plausible one-line description, so the distractor reads like a real
    /// tool rather than obvious filler.
    pub description: &'static str,
}

/// The bundled distractor catalog: plausible-but-irrelevant tools.
///
/// These names read like real MCP tools from unrelated domains (weather,
/// calendars, currency, and such) so they are credible noise in a candidate
/// list without overlapping a typical task's correct tools. The list is fixed
/// and ordered, so a catalog spec of `count: N` always injects the same first N.
pub const BUNDLED_CATALOG: &[CatalogEntry] = &[
    CatalogEntry {
        name: "weather_forecast",
        description: "Return the multi-day weather forecast for a city.",
    },
    CatalogEntry {
        name: "currency_convert",
        description: "Convert an amount between two fiat currencies.",
    },
    CatalogEntry {
        name: "calendar_create_event",
        description: "Create a calendar event with a title and time.",
    },
    CatalogEntry {
        name: "translate_text",
        description: "Translate a block of text into a target language.",
    },
    CatalogEntry {
        name: "stock_quote",
        description: "Look up the latest price for a stock ticker.",
    },
    CatalogEntry {
        name: "send_sms",
        description: "Send a short text message to a phone number.",
    },
    CatalogEntry {
        name: "unit_convert",
        description: "Convert a measurement between units of the same kind.",
    },
    CatalogEntry {
        name: "qr_encode",
        description: "Encode a string as a QR code image.",
    },
    CatalogEntry {
        name: "dice_roll",
        description: "Roll one or more dice and return the total.",
    },
    CatalogEntry {
        name: "horoscope_today",
        description: "Return today's horoscope for a star sign.",
    },
];

/// Return the bundled distractor catalog. Thin accessor so call sites read
/// `catalog()` rather than the const, keeping the const an implementation
/// detail that can grow without touching callers.
pub fn catalog() -> &'static [CatalogEntry] {
    BUNDLED_CATALOG
}

/// Synthesize deterministic near-duplicate / name-collision variants of a real
/// tool name.
///
/// The variants are the classic collision traps an agent must not fall for:
///
/// - an all-uppercase spelling (`search` -> `SEARCH`);
/// - a naive plural (`search` -> `searches`, `fetch` -> `fetches`);
/// - a `_v2` suffix (`search` -> `search_v2`);
/// - an `_internal` suffix (`search` -> `search_internal`).
///
/// Returned in this fixed order and de-duplicated against the original name, so
/// the output is byte-stable. A name that already collides with one of its own
/// variants (rare) simply yields fewer variants.
pub fn near_duplicates(tool: &str) -> Vec<String> {
    let candidates = [
        tool.to_uppercase(),
        pluralize(tool),
        format!("{tool}_v2"),
        format!("{tool}_internal"),
    ];
    let mut out: Vec<String> = Vec::with_capacity(candidates.len());
    for candidate in candidates {
        if candidate != tool && !out.contains(&candidate) {
            out.push(candidate);
        }
    }
    out
}

/// Naive English pluralization for the near-duplicate synthesizer.
///
/// Words ending in s, x, z, ch, or sh take `es`; everything else takes `s`.
/// Deliberately simple: the goal is a plausible collision, not grammatical
/// correctness.
fn pluralize(word: &str) -> String {
    let takes_es = word.ends_with('s')
        || word.ends_with('x')
        || word.ends_with('z')
        || word.ends_with("ch")
        || word.ends_with("sh");
    if takes_es {
        format!("{word}es")
    } else {
        format!("{word}s")
    }
}

/// Resolve a spec into its ordered, capped distractor id list.
///
/// Free function backing [`DistractorSpec::resolve_ids`]. Catalog specs map to
/// the first `count` bundled names; near-duplicate specs flatten the variants of
/// each named tool in declaration order and take the first `count`.
pub fn resolve_distractor_ids(spec: &DistractorSpec) -> Vec<String> {
    let limit = spec.count as usize;
    match &spec.source {
        DistractorSource::Catalog => catalog()
            .iter()
            .take(limit)
            .map(|entry| entry.name.to_string())
            .collect(),
        DistractorSource::NearDuplicate { of } => {
            let mut ids: Vec<String> = Vec::new();
            for tool in of {
                for variant in near_duplicates(tool) {
                    if ids.len() >= limit {
                        return ids;
                    }
                    ids.push(variant);
                }
            }
            ids
        }
    }
}

/// Deterministic outcome of scoring one recorded selection against the known
/// correct and injected-distractor id sets.
///
/// Serializable so the report can carry the full picture: how many distractors
/// were in play, how many correct vs distractor tools the agent chose, and the
/// resulting accuracy datapoint on the degradation curve.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DistractorScore {
    /// Number of distractors injected into the candidate list for this run.
    pub distractor_count: u32,
    /// Chosen ids that were in the correct set (correct hits).
    pub chose_correct: u32,
    /// Chosen ids that were an injected distractor (the failure signal).
    pub chose_distractor: u32,
    /// Selection accuracy as an integer percent in `0..=100`.
    pub accuracy_percent: u32,
}

impl DistractorScore {
    /// One-line human-readable summary the CLI prints next to the gate verdict.
    /// Mirrors [`crate::eval::tool_selection_f1::F1Aggregate::line`].
    pub fn line(&self) -> String {
        format!(
            "accuracy {}% ({} correct, {} distractor) over {} injected",
            self.accuracy_percent, self.chose_correct, self.chose_distractor, self.distractor_count
        )
    }
}

/// Score one recorded selection against the correct and distractor id sets.
///
/// `chosen` is the ordered list of tool ids the agent selected; `correct` and
/// `distractors` are the known id sets for the task. Deterministic and free of
/// model calls. See the module doc for the exact accuracy rule. An id present in
/// both `correct` and `distractors` is treated as correct (the task wins ties),
/// so a malformed scenario never penalizes a correct choice.
pub fn score_selection(
    chosen: &[String],
    correct: &[String],
    distractors: &[String],
) -> DistractorScore {
    let mut chose_correct = 0u32;
    let mut chose_distractor = 0u32;
    for id in chosen {
        if correct.iter().any(|c| c == id) {
            chose_correct += 1;
        } else if distractors.iter().any(|d| d == id) {
            chose_distractor += 1;
        }
    }
    let in_scope = chose_correct + chose_distractor;
    let accuracy_percent = accuracy(chose_correct, in_scope, correct.is_empty());
    DistractorScore {
        distractor_count: distractors.len() as u32,
        chose_correct,
        chose_distractor,
        accuracy_percent,
    }
}

/// Compute the integer-percent accuracy from the in-scope counts.
///
/// `correct * 100 / in_scope`, with two pinned edge cases so the output is never
/// NaN and is byte-stable: nothing in scope scores 0 (the agent never picked the
/// correct tool), unless no correct ids were declared at all, which is vacuously
/// 100. Free function so the rule lives in one place.
fn accuracy(chose_correct: u32, in_scope: u32, no_correct_declared: bool) -> u32 {
    match (chose_correct * 100).checked_div(in_scope) {
        Some(percent) => percent,
        None if no_correct_declared => 100,
        None => 0,
    }
}

/// Build the targets object the distractor assertions resolve against.
///
/// Shape: `{ "distractors": { "accuracy": <num>, "chose_distractor": <num>,
/// "chose_correct": <num>, "distractor_count": <num> } }`, so the existing
/// dot-path resolver walks `distractors.accuracy` and friends directly, the same
/// way `tool_selection.*` resolves against its metrics object. Reusing the
/// resolver means no new matcher and no new reporter.
pub fn metrics_object(score: &DistractorScore) -> Value {
    json!({
        "distractors": {
            "accuracy": score.accuracy_percent,
            "chose_distractor": score.chose_distractor,
            "chose_correct": score.chose_correct,
            "distractor_count": score.distractor_count,
        }
    })
}

/// The default assertion set applied when a `distractors:` block declares no
/// `expect:`: `distractors.accuracy >= 50`.
///
/// The smallest useful form of the gate floors selection accuracy at half; a
/// user who wants to forbid distractor hits outright (`distractors.chose_distractor <= 0`)
/// or raise the floor writes an explicit `expect:`. Mirrors
/// [`crate::eval::tool_selection_f1::default_expect`].
pub fn default_expect() -> Vec<Assertion> {
    vec![Assertion {
        target: "distractors.accuracy".to_string(),
        matcher: Matcher::Schema(json!({ "minimum": DEFAULT_MIN_ACCURACY_PERCENT })),
        weight: 1.0,
        name: None,
        transform: None,
    }]
}

/// One point on the accuracy-vs-distractor-count degradation curve.
///
/// Serializable so the report can plot accuracy as the candidate list grows.
/// Each point pairs a distractor count with the accuracy observed at that count.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CurvePoint {
    /// Number of injected distractors at this point.
    pub distractor_count: u32,
    /// Selection accuracy percent observed at this distractor count.
    pub accuracy_percent: u32,
}

/// Fold a set of per-count scores into the accuracy-vs-distractor-count curve.
///
/// Each input score contributes one [`CurvePoint`]; points are sorted by
/// distractor count so the report renders a monotone x-axis (the degradation
/// curve). Pure and order-independent: the same scores always yield the same
/// curve.
pub fn degradation_curve(scores: &[DistractorScore]) -> Vec<CurvePoint> {
    let mut points: Vec<CurvePoint> = scores
        .iter()
        .map(|score| CurvePoint {
            distractor_count: score.distractor_count,
            accuracy_percent: score.accuracy_percent,
        })
        .collect();
    points.sort_by_key(|point| point.distractor_count);
    points
}

/// Upper binomial tail `P(X >= k)` for `X ~ Binomial(n, p)` (WOR-1367).
///
/// Summed from the probability mass at `i = 0` upward via the stable
/// recurrence `pmf(i+1) = pmf(i) * (n - i) / (i + 1) * p / (1 - p)`, so no
/// factorial or binomial coefficient is ever materialized and large `n` does
/// not overflow. Pinned edges: `k == 0` is the whole mass (`1.0`); `k > n` is
/// impossible (`0.0`); `p <= 0` gives `0.0` for `k >= 1`; `p >= 1` gives `1.0`
/// for `k <= n`. Pure, no special functions.
fn binomial_upper_tail(k: u32, n: u32, p: f64) -> f64 {
    if k == 0 {
        return 1.0;
    }
    if k > n {
        return 0.0;
    }
    if p <= 0.0 {
        return 0.0;
    }
    if p >= 1.0 {
        return 1.0;
    }
    let q = 1.0 - p;
    let mut pmf = q.powi(n as i32); // mass at i = 0
    let mut tail = 0.0;
    for i in 0..=n {
        if i >= k {
            tail += pmf;
        }
        if i < n {
            // Advance the mass to i + 1 without forming C(n, i).
            pmf = pmf * (f64::from(n - i) / f64::from(i + 1)) * (p / q);
        }
    }
    tail.min(1.0)
}

/// Clopper-Pearson exact lower confidence bound on a binomial success rate
/// (WOR-1367), returned as a fraction in `0.0..=1.0`.
///
/// Why exact (not the Wald / normal approximation in
/// [`crate::eval::reliability`]): distractor saturation collapses selection
/// accuracy toward the extremes, where Wald is badly miscalibrated (it can
/// report a bound below 0 or a zero-width interval at a perfect run). The
/// Clopper-Pearson bound has guaranteed coverage at every rate, which is the
/// whole point of a *certified* bound (LLMCert-T).
///
/// For `successes` `k` of `trials` `n` at `confidence_pct` (e.g. 95), the lower
/// bound is the `p` solving `P(X >= k; n, p) = alpha/2` with
/// `alpha = 1 - confidence`. Found by bisection on the monotone upper
/// binomial tail; `k == 0` pins the bound at `0.0` (no evidence of any
/// success). A zero-trial sample also returns `0.0`.
pub fn clopper_pearson_lower(successes: u32, trials: u32, confidence_pct: u32) -> f64 {
    if trials == 0 || successes == 0 {
        return 0.0;
    }
    let successes = successes.min(trials);
    let alpha = f64::from(100u32.saturating_sub(confidence_pct)) / 100.0;
    let target = alpha / 2.0;
    // `binomial_upper_tail(k, n, p)` rises monotonically from 0 (at p = 0) to
    // ~1 (at p = 1), so bisect for the p where it equals the target tail mass.
    let mut lo = 0.0f64;
    let mut hi = f64::from(successes) / f64::from(trials);
    for _ in 0..100 {
        let mid = 0.5 * (lo + hi);
        if binomial_upper_tail(successes, trials, mid) < target {
            lo = mid;
        } else {
            hi = mid;
        }
    }
    0.5 * (lo + hi)
}

/// Certified lower bound on selection accuracy across the per-run distractor
/// scores, as an integer percent (WOR-1367).
///
/// A run counts as a success when the agent selected only correct tools
/// (`accuracy_percent == 100`); `k` such successes over `n` runs feed
/// [`clopper_pearson_lower`] at `confidence_pct`. The result is the
/// guaranteed-at-confidence floor on the true accuracy, rounded to a percent so
/// it gates with a plain `minimum:` like the other distractor targets. An empty
/// sample returns 0 (no evidence).
pub fn certified_accuracy_percent(scores: &[DistractorScore], confidence_pct: u32) -> u32 {
    let trials = scores.len() as u32;
    let successes = scores.iter().filter(|s| s.accuracy_percent == 100).count() as u32;
    (clopper_pearson_lower(successes, trials, confidence_pct) * 100.0).round() as u32
}

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

    fn ids(items: &[&str]) -> Vec<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn bundled_catalog_is_non_empty_and_named() {
        assert!(!catalog().is_empty());
        assert!(catalog().iter().all(|e| !e.name.is_empty()));
        assert!(catalog().iter().all(|e| !e.description.is_empty()));
    }

    #[test]
    fn near_duplicates_are_deterministic_collision_variants() {
        assert_eq!(
            near_duplicates("search"),
            ids(&["SEARCH", "searches", "search_v2", "search_internal"])
        );
    }

    #[test]
    fn near_duplicates_pluralize_ch_and_sh_with_es() {
        assert_eq!(
            near_duplicates("fetch"),
            ids(&["FETCH", "fetches", "fetch_v2", "fetch_internal"])
        );
    }

    #[test]
    fn near_duplicates_drop_a_variant_equal_to_the_original() {
        // An all-uppercase original yields no uppercase variant.
        let variants = near_duplicates("GET");
        assert!(!variants.contains(&"GET".to_string()));
        assert!(variants.contains(&"GETs".to_string()));
    }

    #[test]
    fn catalog_spec_takes_the_first_count_entries() {
        let spec = DistractorSpec::new(3, DistractorSource::Catalog);
        assert_eq!(
            spec.resolve_ids(),
            ids(&[
                "weather_forecast",
                "currency_convert",
                "calendar_create_event"
            ])
        );
    }

    #[test]
    fn catalog_spec_caps_at_catalog_size() {
        let spec = DistractorSpec::new(9999, DistractorSource::Catalog);
        assert_eq!(spec.resolve_ids().len(), catalog().len());
    }

    #[test]
    fn near_duplicate_spec_flattens_variants_in_declaration_order() {
        let spec = DistractorSpec::new(
            3,
            DistractorSource::NearDuplicate {
                of: ids(&["search"]),
            },
        );
        assert_eq!(
            spec.resolve_ids(),
            ids(&["SEARCH", "searches", "search_v2"])
        );
    }

    #[test]
    fn score_chose_correct_is_full_accuracy() {
        let score = score_selection(&ids(&["search"]), &ids(&["search"]), &ids(&["SEARCH"]));
        assert_eq!(score.chose_correct, 1);
        assert_eq!(score.chose_distractor, 0);
        assert_eq!(score.accuracy_percent, 100);
        assert_eq!(score.distractor_count, 1);
    }

    #[test]
    fn score_chose_distractor_is_zero_accuracy() {
        let score = score_selection(&ids(&["SEARCH"]), &ids(&["search"]), &ids(&["SEARCH"]));
        assert_eq!(score.chose_correct, 0);
        assert_eq!(score.chose_distractor, 1);
        assert_eq!(score.accuracy_percent, 0);
    }

    #[test]
    fn score_mixed_choice_is_half_accuracy() {
        let score = score_selection(
            &ids(&["search", "SEARCH"]),
            &ids(&["search"]),
            &ids(&["SEARCH", "searches"]),
        );
        // One correct, one distractor: 1/2 in scope = 50.
        assert_eq!(score.chose_correct, 1);
        assert_eq!(score.chose_distractor, 1);
        assert_eq!(score.accuracy_percent, 50);
    }

    #[test]
    fn zero_distractor_baseline_scores_on_correctness_alone() {
        let score = score_selection(&ids(&["search"]), &ids(&["search"]), &[]);
        assert_eq!(score.distractor_count, 0);
        assert_eq!(score.accuracy_percent, 100);
    }

    #[test]
    fn out_of_scope_choice_is_ignored() {
        // "fetch" is neither correct nor an injected distractor here.
        let score = score_selection(
            &ids(&["search", "fetch"]),
            &ids(&["search"]),
            &ids(&["SEARCH"]),
        );
        assert_eq!(score.chose_correct, 1);
        assert_eq!(score.chose_distractor, 0);
        assert_eq!(score.accuracy_percent, 100);
    }

    #[test]
    fn nothing_in_scope_scores_zero() {
        let score = score_selection(&ids(&["other"]), &ids(&["search"]), &ids(&["SEARCH"]));
        assert_eq!(score.accuracy_percent, 0);
    }

    #[test]
    fn no_correct_declared_is_vacuously_perfect() {
        let score = score_selection(&[], &[], &ids(&["SEARCH"]));
        assert_eq!(score.accuracy_percent, 100);
    }

    #[test]
    fn metrics_object_exposes_the_distractor_targets() {
        use crate::executor::resolve_target;
        let score = score_selection(&ids(&["search"]), &ids(&["search"]), &ids(&["SEARCH"]));
        let obj = metrics_object(&score);
        assert!(resolve_target(&obj, "distractors.accuracy").is_number());
        assert!(resolve_target(&obj, "distractors.chose_distractor").is_number());
        assert!(resolve_target(&obj, "distractors.distractor_count").is_number());
    }

    #[test]
    fn default_expect_floors_accuracy_at_half() {
        let expect = default_expect();
        assert_eq!(expect.len(), 1);
        assert_eq!(expect[0].target, "distractors.accuracy");
        // The default gate must pass a perfect run and fail an all-distractor run.
        let perfect = metrics_object(&score_selection(
            &ids(&["search"]),
            &ids(&["search"]),
            &ids(&["SEARCH"]),
        ));
        let all_wrong = metrics_object(&score_selection(
            &ids(&["SEARCH"]),
            &ids(&["search"]),
            &ids(&["SEARCH"]),
        ));
        assert!(crate::executor::eval_metric_assertion(&perfect, &expect[0]).is_ok());
        assert!(crate::executor::eval_metric_assertion(&all_wrong, &expect[0]).is_err());
    }

    #[test]
    fn degradation_curve_sorts_points_by_distractor_count() {
        let scores = vec![
            DistractorScore {
                distractor_count: 8,
                chose_correct: 0,
                chose_distractor: 1,
                accuracy_percent: 0,
            },
            DistractorScore {
                distractor_count: 0,
                chose_correct: 1,
                chose_distractor: 0,
                accuracy_percent: 100,
            },
            DistractorScore {
                distractor_count: 4,
                chose_correct: 1,
                chose_distractor: 1,
                accuracy_percent: 50,
            },
        ];
        let curve = degradation_curve(&scores);
        let counts: Vec<u32> = curve.iter().map(|p| p.distractor_count).collect();
        assert_eq!(counts, vec![0, 4, 8]);
        // The curve degrades as distractors grow: 100, 50, 0.
        let acc: Vec<u32> = curve.iter().map(|p| p.accuracy_percent).collect();
        assert_eq!(acc, vec![100, 50, 0]);
    }

    #[test]
    fn clopper_pearson_pins_known_bounds() {
        // 10/10 successes at 95%: the lower bound is 0.025^(1/10) because
        // P(X >= 10; 10, p) = p^10, so p_L solves p^10 = 0.025.
        let expected = 0.025_f64.powf(0.1);
        let lower = clopper_pearson_lower(10, 10, 95);
        assert!(
            (lower - expected).abs() < 1e-3,
            "10/10 lower {lower} vs {expected}"
        );
        // 8/10 at 95%: the textbook Clopper-Pearson lower bound is ~0.4439.
        let lower_8 = clopper_pearson_lower(8, 10, 95);
        assert!((lower_8 - 0.4439).abs() < 2e-3, "8/10 lower {lower_8}");
    }

    #[test]
    fn clopper_pearson_zero_successes_is_zero() {
        assert_eq!(clopper_pearson_lower(0, 10, 95), 0.0);
        // A zero-trial sample is no evidence, so the floor is zero too.
        assert_eq!(clopper_pearson_lower(0, 0, 95), 0.0);
    }

    #[test]
    fn clopper_pearson_lower_is_below_the_point_estimate() {
        // The certified floor must never exceed the observed rate.
        let lower = clopper_pearson_lower(7, 10, 95);
        assert!(lower < 0.7, "lower bound {lower} should be below 0.7");
        assert!(lower > 0.0);
    }

    #[test]
    fn certified_accuracy_counts_perfect_runs_only() {
        // Three perfect runs (accuracy 100) and one failure: 3/4 successes.
        let scores = vec![
            DistractorScore {
                distractor_count: 4,
                chose_correct: 1,
                chose_distractor: 0,
                accuracy_percent: 100,
            },
            DistractorScore {
                distractor_count: 4,
                chose_correct: 1,
                chose_distractor: 0,
                accuracy_percent: 100,
            },
            DistractorScore {
                distractor_count: 4,
                chose_correct: 1,
                chose_distractor: 0,
                accuracy_percent: 100,
            },
            DistractorScore {
                distractor_count: 4,
                chose_correct: 0,
                chose_distractor: 1,
                accuracy_percent: 0,
            },
        ];
        let certified = certified_accuracy_percent(&scores, 95);
        // The 95% certified floor on 3/4 is well below the 75% point estimate.
        assert!(certified < 75, "certified {certified} should be below 75");
        // An empty sample is no evidence, so the floor is zero.
        assert_eq!(certified_accuracy_percent(&[], 95), 0);
    }
}
