//! Compliance scoring algorithm.
//!
//! Pure function that maps per-rule [`CheckResult`]s plus a [`Rubric`] (the
//! registry plus negotiated capabilities) onto a [`ComplianceScore`]. The
//! score carries a single user-facing letter grade, a 0 to 100 numeric
//! score, and the per-section breakdown the renderers consume.
//!
//! Why split scoring from rendering: the grade is a function of the run,
//! not of the output format. Pretty, JSON, Markdown, and HTML all consume
//! the same [`ComplianceScore`], so we lock the arithmetic here and let
//! the reporters argue about presentation.
//!
//! ## Grade table (ADR-0012, sharpened)
//!
//! | Grade | Condition |
//! | ----- | --------- |
//! | F     | any MUST fails                                                          |
//! | D     | all MUST pass, <50% SHOULD pass                                         |
//! | C     | all MUST pass, 50% to 79% SHOULD pass                                   |
//! | B     | all MUST pass, 80% to 99% SHOULD pass                                   |
//! | A     | all MUST + all SHOULD pass                                              |
//! | A+    | A, plus at least one MAY passes in every applicable section             |
//!
//! The bonus rule is one-way (Mozilla pattern): MAY items only promote a
//! run that has already earned A. A run at B with three MAY passes stays B.

use std::collections::{BTreeMap, HashMap};

use super::official::CheckResult;
use super::registry::{RuleMetadata, RuleSeverity};

// Re-export the data model from the sibling types module so the historical
// path `compliance::scoring::Grade` (and the `compliance::*` re-exports that
// build on it) keep resolving after the  split.
pub use super::score_types::{
    ApplicabilitySummary, CheckId, ComplianceScore, Grade, Rubric, Section, SectionScore,
};

/// Score a run.
///
/// The function is pure: identical inputs produce identical outputs and
/// no I/O happens here. The runner is responsible for assembling
/// [`CheckResult`]s and the rubric before calling.
pub fn score_run(results: &[CheckResult], rubric: &Rubric<'_>) -> ComplianceScore {
    let by_rule: HashMap<&str, &CheckResult> =
        results.iter().map(|r| (r.rule_id.as_str(), r)).collect();

    let mut section_data = init_section_data(rubric);
    let (failed_must, may_pool) = tally_rules(rubric, &by_rule, &mut section_data);
    let totals = SeverityTotals::from_sections(&section_data);
    finalize_section_grades(&mut section_data, &may_pool);

    let base_grade = base_grade(
        totals.must_total,
        totals.must_passed,
        totals.should_total,
        totals.should_passed,
    );
    let (grade, missing_for_a_plus) = promote_with_may(base_grade, &section_data, &may_pool);
    let numeric_score = numeric_score(
        totals.must_passed,
        totals.must_total,
        totals.should_passed,
        totals.should_total,
        totals.may_passed,
        totals.may_total,
    );

    let applicability_summary = build_applicability_summary(rubric, &section_data);
    let completeness_score = applicability_summary.applicable_fraction;

    ComplianceScore {
        grade,
        numeric_score,
        sections: section_data,
        completeness_score,
        applicability_summary,
        rubric_version: rubric.version.clone(),
        failed_must_checks: failed_must,
        missing_for_a_plus,
    }
}

/// Seed one empty [`SectionScore`] per [`Section`], pre-marking each with its
/// applicability. Pulled out of [`score_run`] to keep that function under the
/// 60-line AGENTS cap.
fn init_section_data(rubric: &Rubric<'_>) -> HashMap<Section, SectionScore> {
    let mut section_data: HashMap<Section, SectionScore> = HashMap::new();
    for section in Section::ALL {
        section_data.insert(
            *section,
            SectionScore {
                section: *section,
                applicable: rubric.section_applicable(*section),
                must_passed: 0,
                must_total: 0,
                should_passed: 0,
                should_total: 0,
                may_passed: 0,
                may_total: 0,
                grade: Grade::A,
                missing_may_ids: Vec::new(),
            },
        );
    }
    section_data
}

/// Walk the registry, bucketing each applicable rule into `section_data` and
/// collecting the failed-MUST list plus the MAY pool. A rule with no result is
/// treated as a fail; runners that legitimately skip a rule should signal
/// pass-with-skip separately in a future revision.
fn tally_rules(
    rubric: &Rubric<'_>,
    by_rule: &HashMap<&str, &CheckResult>,
    section_data: &mut HashMap<Section, SectionScore>,
) -> (Vec<CheckId>, BTreeMap<Section, Vec<(CheckId, bool)>>) {
    let mut failed_must: Vec<CheckId> = Vec::new();
    let mut may_pool: BTreeMap<Section, Vec<(CheckId, bool)>> = BTreeMap::new();
    for rule in &rubric.registry.rules {
        let Some(section) = Section::from_registry_str(&rule.section) else {
            continue;
        };
        let Some(score) = section_data.get_mut(&section) else {
            continue;
        };
        if !score.applicable {
            continue;
        }
        let passed = by_rule
            .get(rule.id.as_str())
            .map(|r| r.passed)
            .unwrap_or(false);
        bucket_rule(rule, passed, score, &mut failed_must, &mut may_pool);
    }
    (failed_must, may_pool)
}

/// Aggregate per-severity totals across every section. Carried as a struct so
/// [`score_run`] can pass named fields to [`base_grade`] and [`numeric_score`]
/// without juggling six positional locals.
struct SeverityTotals {
    must_total: usize,
    must_passed: usize,
    should_total: usize,
    should_passed: usize,
    may_total: usize,
    may_passed: usize,
}

impl SeverityTotals {
    fn from_sections(section_data: &HashMap<Section, SectionScore>) -> Self {
        SeverityTotals {
            must_total: section_data.values().map(|s| s.must_total).sum(),
            must_passed: section_data.values().map(|s| s.must_passed).sum(),
            should_total: section_data.values().map(|s| s.should_total).sum(),
            should_passed: section_data.values().map(|s| s.should_passed).sum(),
            may_total: section_data.values().map(|s| s.may_total).sum(),
            may_passed: section_data.values().map(|s| s.may_passed).sum(),
        }
    }
}

/// Assign each section its own letter grade and record the MAY IDs a section
/// is missing so renderers can surface a concrete next-step suggestion. The
/// missing list is empty for sections that already have a MAY pass or no MAY
/// rules.
fn finalize_section_grades(
    section_data: &mut HashMap<Section, SectionScore>,
    may_pool: &BTreeMap<Section, Vec<(CheckId, bool)>>,
) {
    for score in section_data.values_mut() {
        score.grade = grade_for_section(score, may_pool);
        if score.applicable && score.may_total > 0 && score.may_passed == 0 {
            if let Some(pool) = may_pool.get(&score.section) {
                score.missing_may_ids = pool.iter().map(|(id, _)| id.clone()).collect();
            }
        }
    }
}

/// Push one rule's tally into the correct severity slot.
///
/// Pulled out of [`score_run`] to keep that function under the 60-line
/// AGENTS.md cap. The MAY pool tracks rule IDs and outcomes so the
/// promotion hint can list specific MAY rules to implement.
fn bucket_rule(
    rule: &RuleMetadata,
    passed: bool,
    score: &mut SectionScore,
    failed_must: &mut Vec<CheckId>,
    may_pool: &mut BTreeMap<Section, Vec<(CheckId, bool)>>,
) {
    match rule.severity {
        RuleSeverity::Must => {
            score.must_total += 1;
            if passed {
                score.must_passed += 1;
            } else {
                failed_must.push(rule.id.clone());
            }
        }
        RuleSeverity::Should => {
            score.should_total += 1;
            if passed {
                score.should_passed += 1;
            }
        }
        RuleSeverity::May => {
            score.may_total += 1;
            if passed {
                score.may_passed += 1;
            }
            may_pool
                .entry(score.section)
                .or_default()
                .push((rule.id.clone(), passed));
        }
    }
}

/// Compute the base grade (A through F) before MAY promotion is applied.
fn base_grade(
    must_total: usize,
    must_passed: usize,
    should_total: usize,
    should_passed: usize,
) -> Grade {
    if must_passed < must_total {
        return Grade::F;
    }
    if should_total == 0 {
        return Grade::A;
    }
    let ratio = should_passed as f64 / should_total as f64;
    if ratio >= 1.0 {
        Grade::A
    } else if ratio >= 0.80 {
        Grade::B
    } else if ratio >= 0.50 {
        Grade::C
    } else {
        Grade::D
    }
}

/// If the base grade is A and every applicable section that has at
/// least one MAY rule sees a MAY pass, promote to A+. Otherwise return
/// the base grade plus the flat list of MAY rule IDs whose passing
/// would close the gap.
fn promote_with_may(
    base: Grade,
    sections: &HashMap<Section, SectionScore>,
    may_pool: &BTreeMap<Section, Vec<(CheckId, bool)>>,
) -> (Grade, Vec<CheckId>) {
    let mut missing: Vec<CheckId> = Vec::new();
    if base != Grade::A {
        return (base, missing);
    }

    let mut any_section_missing = false;
    for section_kind in Section::ALL {
        let Some(score) = sections.get(section_kind) else {
            continue;
        };
        if !score.applicable || score.may_total == 0 {
            continue;
        }
        if score.may_passed == 0 {
            any_section_missing = true;
            if let Some(pool) = may_pool.get(section_kind) {
                for (id, _) in pool {
                    missing.push(id.clone());
                }
            }
        }
    }

    if any_section_missing {
        (Grade::A, missing)
    } else {
        (Grade::APlus, missing)
    }
}

/// Per-section grade reuses [`base_grade`] then applies the same MAY
/// promotion rule scoped to the section.
fn grade_for_section(
    score: &SectionScore,
    may_pool: &BTreeMap<Section, Vec<(CheckId, bool)>>,
) -> Grade {
    if !score.applicable {
        return Grade::A;
    }
    let base = base_grade(
        score.must_total,
        score.must_passed,
        score.should_total,
        score.should_passed,
    );
    if base != Grade::A {
        return base;
    }
    if score.may_total == 0 {
        return Grade::APlus;
    }
    let any_may_pass = may_pool
        .get(&score.section)
        .map(|pool| pool.iter().any(|(_, ok)| *ok))
        .unwrap_or(false);
    if any_may_pass {
        Grade::APlus
    } else {
        Grade::A
    }
}

/// Weighted numeric score: MUST counts 1, SHOULD counts 0.5, MAY counts
/// 0.1. Returns a `[0, 100]` value or 100 when nothing applies.
fn numeric_score(
    must_passed: usize,
    must_total: usize,
    should_passed: usize,
    should_total: usize,
    may_passed: usize,
    may_total: usize,
) -> f64 {
    let max = must_total as f64 + 0.5 * should_total as f64 + 0.1 * may_total as f64;
    if max <= 0.0 {
        return 100.0;
    }
    let earned = must_passed as f64 + 0.5 * should_passed as f64 + 0.1 * may_passed as f64;
    (earned / max) * 100.0
}

/// Build the [`ApplicabilitySummary`] snapshot from the populated
/// section map. Keeps the field assembly out of [`score_run`].
fn build_applicability_summary(
    rubric: &Rubric<'_>,
    section_data: &HashMap<Section, SectionScore>,
) -> ApplicabilitySummary {
    let mut applicable_sections: Vec<Section> = Vec::new();
    let mut not_applicable_sections: Vec<Section> = Vec::new();
    for section in Section::ALL {
        let applicable = section_data
            .get(section)
            .map(|s| s.applicable)
            .unwrap_or(false);
        if applicable {
            applicable_sections.push(*section);
        } else {
            not_applicable_sections.push(*section);
        }
    }
    let applicable_fraction = applicable_sections.len() as f64 / Section::ALL.len() as f64;
    ApplicabilitySummary {
        declared_capabilities: rubric.declared_capabilities.clone(),
        applicable_sections,
        not_applicable_sections,
        applicable_fraction,
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeSet;

    use super::*;
    use crate::compliance::official::CheckSource;
    use crate::compliance::registry::RuleRegistry;
    use proptest::prelude::*;

    fn rule(id: &str, section: &str, severity: RuleSeverity) -> RuleMetadata {
        RuleMetadata {
            id: id.into(),
            name: format!("rule {id}"),
            category: id.split('-').next().unwrap_or("PROTO").into(),
            severity,
            applies_to: vec!["stdio".into(), "http".into()],
            spec_versions: vec!["v2024-11-05".into()],
            description: "test rule".into(),
            references: vec![],
            section: section.into(),
            applicability: "true".into(),
            spec_citation: "spec".into(),
        }
    }

    fn registry(rules: Vec<RuleMetadata>) -> RuleRegistry {
        RuleRegistry { version: 1, rules }
    }

    fn check(id: &str, passed: bool) -> CheckResult {
        CheckResult {
            rule_id: id.into(),
            passed,
            source: CheckSource::Local,
            message: None,
            duration_ms: 1,
        }
    }

    fn all_caps() -> BTreeSet<String> {
        BTreeSet::from(["tools".into(), "resources".into(), "prompts".into()])
    }

    #[test]
    fn empty_run_grades_a_plus_when_no_rules_apply() {
        // No rules means every condition is vacuously satisfied. A+ is
        // the most generous reading and matches "no spec to violate".
        let reg = registry(vec![]);
        let rubric = Rubric::new(&reg, all_caps());
        let score = score_run(&[], &rubric);
        assert_eq!(score.grade, Grade::APlus);
        assert!((score.numeric_score - 100.0).abs() < f64::EPSILON);
    }

    #[test]
    fn any_must_failure_grades_f() {
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-002", "lifecycle", RuleSeverity::Must),
        ]);
        let rubric = Rubric::new(&reg, all_caps());
        let results = vec![check("PROTO-001", true), check("PROTO-002", false)];
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::F);
        assert_eq!(score.failed_must_checks, vec!["PROTO-002"]);
    }

    #[test]
    fn all_must_and_should_pass_earns_a_plus_when_no_may_rules_exist() {
        // No MAY rules anywhere: every applicable section is vacuously
        // satisfied for the A+ check, so the run promotes from A to A+.
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("TOOL-001", "tools", RuleSeverity::Should),
        ]);
        let rubric = Rubric::new(&reg, all_caps());
        let score = score_run(
            &[check("PROTO-001", true), check("TOOL-001", true)],
            &rubric,
        );
        assert_eq!(score.grade, Grade::APlus);
    }

    #[test]
    fn all_must_and_should_pass_earns_a_when_a_section_has_unpassed_may() {
        // A single MAY rule in transport-auth that does not pass blocks
        // the A+ promotion: that section now has a non-zero may_total
        // and zero may_passed.
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("TOOL-001", "tools", RuleSeverity::Should),
            rule("TRANSPORT-010", "transport-auth", RuleSeverity::May),
        ]);
        let rubric = Rubric::new(&reg, all_caps());
        let score = score_run(
            &[
                check("PROTO-001", true),
                check("TOOL-001", true),
                check("TRANSPORT-010", false),
            ],
            &rubric,
        );
        assert_eq!(score.grade, Grade::A);
    }

    #[test]
    fn a_promotes_to_a_plus_when_every_section_has_one_may_pass() {
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-010", "lifecycle", RuleSeverity::May),
            rule("TRANSPORT-010", "transport-auth", RuleSeverity::May),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(
            &[
                check("PROTO-001", true),
                check("PROTO-010", true),
                check("TRANSPORT-010", true),
            ],
            &rubric,
        );
        assert_eq!(score.grade, Grade::APlus);
        assert!(score.missing_for_a_plus.is_empty());
    }

    #[test]
    fn a_stays_a_when_a_section_is_missing_its_may() {
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-010", "lifecycle", RuleSeverity::May),
            rule("TRANSPORT-010", "transport-auth", RuleSeverity::May),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(
            &[
                check("PROTO-001", true),
                check("PROTO-010", true),
                check("TRANSPORT-010", false),
            ],
            &rubric,
        );
        assert_eq!(score.grade, Grade::A);
        assert!(score
            .missing_for_a_plus
            .contains(&"TRANSPORT-010".to_string()));
    }

    #[test]
    fn a_plus_requires_may_in_every_applicable_section_not_just_one() {
        // Lifecycle has a passing MAY but transport-auth has a MAY rule
        // that does not pass. A+ promotion requires a pass in EVERY
        // applicable section with at least one MAY rule.
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-010", "lifecycle", RuleSeverity::May),
            rule("TRANSPORT-010", "transport-auth", RuleSeverity::May),
            rule("TRANSPORT-020", "transport-auth", RuleSeverity::May),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(
            &[
                check("PROTO-001", true),
                check("PROTO-010", true),
                check("TRANSPORT-010", false),
                check("TRANSPORT-020", false),
            ],
            &rubric,
        );
        assert_eq!(score.grade, Grade::A);
        // Both transport-auth MAYs surface as candidates for promotion.
        assert!(score
            .missing_for_a_plus
            .contains(&"TRANSPORT-010".to_string()));
        assert!(score
            .missing_for_a_plus
            .contains(&"TRANSPORT-020".to_string()));
        // Lifecycle is satisfied; its MAY is not in the missing list.
        assert!(!score.missing_for_a_plus.contains(&"PROTO-010".to_string()));
    }

    #[test]
    fn b_when_should_pass_rate_is_at_least_eighty_percent() {
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = registry(rules);
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![check("PROTO-001", true)];
        // 8 of 10 SHOULDs pass = 80%.
        for i in 0..10 {
            results.push(check(&format!("TOOL-{i:03}"), i < 8));
        }
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::B);
    }

    #[test]
    fn c_when_should_pass_rate_is_between_fifty_and_seventy_nine() {
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = registry(rules);
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![check("PROTO-001", true)];
        // 5 of 10 SHOULDs pass = 50%.
        for i in 0..10 {
            results.push(check(&format!("TOOL-{i:03}"), i < 5));
        }
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::C);
    }

    #[test]
    fn d_when_should_pass_rate_is_below_fifty() {
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = registry(rules);
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            // Only 4 of 10 SHOULDs pass = 40%, below the 50% C cutoff.
            results.push(check(&format!("TOOL-{i:03}"), i < 4));
        }
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::D);
    }

    #[test]
    fn bonuses_above_baseline_b_never_promotes_to_a_plus() {
        // Mozilla pattern: MAY passes only count when the base grade is
        // A. A run at B with every MAY passing stays B.
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        rules.push(rule("PROTO-010", "lifecycle", RuleSeverity::May));
        rules.push(rule("TRANSPORT-010", "transport-auth", RuleSeverity::May));
        let reg = registry(rules);
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![
            check("PROTO-001", true),
            check("PROTO-010", true),
            check("TRANSPORT-010", true),
        ];
        for i in 0..10 {
            results.push(check(&format!("TOOL-{i:03}"), i < 8));
        }
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::B);
        // The flat missing list is empty because the base grade is not
        // A; the promotion path is gated.
        assert!(score.missing_for_a_plus.is_empty());
    }

    #[test]
    fn applicability_excludes_non_declared_capabilities() {
        // A server that did not declare `tools` is not penalized for
        // failing tools rules; they are skipped entirely. With no MAY
        // rules in the rubric every applicable section is vacuously
        // satisfied and the run reaches A+.
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("TOOL-001", "tools", RuleSeverity::Must),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(&[check("PROTO-001", true)], &rubric);
        assert_eq!(score.grade, Grade::APlus);
        let tools = score.sections.get(&Section::Tools).unwrap();
        assert!(!tools.applicable);
        assert_eq!(tools.must_total, 0);
        assert!(score
            .applicability_summary
            .not_applicable_sections
            .contains(&Section::Tools));
    }

    #[test]
    fn marking_a_check_n_a_matches_removing_it_from_the_corpus() {
        // N/A invariant: a TOOL-* rule is excluded when `tools` is not
        // declared. The grade should match the run where that rule is
        // simply absent from the registry.
        let base_rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        let with_tool = {
            let mut r = base_rules.clone();
            r.push(rule("TOOL-001", "tools", RuleSeverity::Should));
            r
        };
        let reg_with = registry(with_tool);
        let reg_without = registry(base_rules);
        // Capabilities exclude `tools`, so the tools rule is N/A.
        let rubric_with = Rubric::new(&reg_with, BTreeSet::new());
        let rubric_without = Rubric::new(&reg_without, BTreeSet::new());
        let s_with = score_run(&[check("PROTO-001", true)], &rubric_with);
        let s_without = score_run(&[check("PROTO-001", true)], &rubric_without);
        assert_eq!(s_with.grade, s_without.grade);
        assert!((s_with.numeric_score - s_without.numeric_score).abs() < f64::EPSILON);
    }

    #[test]
    fn completeness_reflects_applicable_section_fraction() {
        let reg = registry(vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)]);
        // Declare only `tools`. Lifecycle + transport-auth are always
        // applicable; tools is applicable; resources + prompts are not.
        // Three of five sections applicable = 0.6.
        let rubric = Rubric::new(&reg, BTreeSet::from(["tools".into()]));
        let score = score_run(&[check("PROTO-001", true)], &rubric);
        assert!((score.completeness_score - 0.6).abs() < 1e-9);
        assert!(
            (score.applicability_summary.applicable_fraction - score.completeness_score).abs()
                < f64::EPSILON
        );
    }

    #[test]
    fn missing_result_treated_as_failure() {
        // The runner is expected to emit a CheckResult for every applicable
        // rule. If one is missing, that rule fails, which matters most for
        // MUSTs because the grade flips to F.
        let reg = registry(vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(&[], &rubric);
        assert_eq!(score.grade, Grade::F);
    }

    #[test]
    fn numeric_score_reflects_weighted_pass_rate() {
        // 1 MUST passed (weight 1), 1 SHOULD passed (weight 0.5), 1 MAY
        // passed (weight 0.1). All weights earned, no rules missed:
        // 1.6 / 1.6 = 100.
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-002", "lifecycle", RuleSeverity::Should),
            rule("PROTO-003", "lifecycle", RuleSeverity::May),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(
            &[
                check("PROTO-001", true),
                check("PROTO-002", true),
                check("PROTO-003", true),
            ],
            &rubric,
        );
        assert!((score.numeric_score - 100.0).abs() < 1e-9);
    }

    #[test]
    fn numeric_score_drops_with_should_failures() {
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-002", "lifecycle", RuleSeverity::Should),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(
            &[check("PROTO-001", true), check("PROTO-002", false)],
            &rubric,
        );
        // 1.0 / 1.5 = 66.6...
        assert!((score.numeric_score - (1.0 / 1.5) * 100.0).abs() < 1e-9);
    }

    #[test]
    fn boundary_at_eighty_percent_should_lands_in_b() {
        // 4 of 5 SHOULDs pass = exactly 80%; grade is B per the table.
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..5 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = registry(rules);
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..5 {
            results.push(check(&format!("TOOL-{i:03}"), i < 4));
        }
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::B);
    }

    #[test]
    fn boundary_at_fifty_percent_should_lands_in_c() {
        // 5 of 10 SHOULDs pass = exactly 50%; grade is C.
        let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
        for i in 0..10 {
            rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
        }
        let reg = registry(rules);
        let rubric = Rubric::new(&reg, all_caps());
        let mut results = vec![check("PROTO-001", true)];
        for i in 0..10 {
            results.push(check(&format!("TOOL-{i:03}"), i < 5));
        }
        let score = score_run(&results, &rubric);
        assert_eq!(score.grade, Grade::C);
    }

    #[test]
    fn section_score_grade_reflects_section_outcomes() {
        // Two sections, one perfect (A) one with MUST failure (F).
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("TOOL-001", "tools", RuleSeverity::Must),
        ]);
        let rubric = Rubric::new(&reg, all_caps());
        let score = score_run(
            &[check("PROTO-001", true), check("TOOL-001", false)],
            &rubric,
        );
        assert_eq!(score.sections[&Section::Lifecycle].grade, Grade::APlus);
        assert_eq!(score.sections[&Section::Tools].grade, Grade::F);
        assert_eq!(score.grade, Grade::F);
    }

    #[test]
    fn grade_ord_orders_letters_worst_to_best() {
        assert!(Grade::F < Grade::D);
        assert!(Grade::D < Grade::C);
        assert!(Grade::C < Grade::B);
        assert!(Grade::B < Grade::A);
        assert!(Grade::A < Grade::APlus);
    }

    #[test]
    fn rubric_version_is_recorded_on_the_score() {
        let reg = registry(vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)]);
        let mut rubric = Rubric::new(&reg, BTreeSet::new());
        rubric.version = "v1.3-test".into();
        let score = score_run(&[check("PROTO-001", true)], &rubric);
        assert_eq!(score.rubric_version, "v1.3-test");
    }

    #[test]
    fn applicability_summary_records_declared_capabilities() {
        let reg = registry(vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)]);
        let caps = BTreeSet::from(["tools".to_string(), "resources".to_string()]);
        let rubric = Rubric::new(&reg, caps.clone());
        let score = score_run(&[check("PROTO-001", true)], &rubric);
        assert_eq!(score.applicability_summary.declared_capabilities, caps);
        assert!(score
            .applicability_summary
            .applicable_sections
            .contains(&Section::Tools));
        assert!(score
            .applicability_summary
            .not_applicable_sections
            .contains(&Section::Prompts));
    }

    #[test]
    fn section_score_records_missing_may_ids() {
        // A run at A surfaces the per-section MAY candidates needed to
        // close the A+ gap.
        let reg = registry(vec![
            rule("PROTO-001", "lifecycle", RuleSeverity::Must),
            rule("PROTO-010", "lifecycle", RuleSeverity::May),
            rule("TRANSPORT-010", "transport-auth", RuleSeverity::May),
        ]);
        let rubric = Rubric::new(&reg, BTreeSet::new());
        let score = score_run(
            &[
                check("PROTO-001", true),
                check("PROTO-010", true),
                check("TRANSPORT-010", false),
            ],
            &rubric,
        );
        let transport = score.sections.get(&Section::TransportAuth).unwrap();
        assert_eq!(transport.missing_may_ids, vec!["TRANSPORT-010".to_string()]);
        let lifecycle = score.sections.get(&Section::Lifecycle).unwrap();
        assert!(lifecycle.missing_may_ids.is_empty());
    }

    // ----- proptest invariants -------------------------------------------

    /// Build a tiny rubric and matching results from a vector of
    /// (severity, passed) pairs. The helper keeps proptest cases small
    /// so a shrunk failure points at the offending tuple.
    fn build(cases: &[(RuleSeverity, bool)]) -> (RuleRegistry, Vec<CheckResult>) {
        let mut rules = Vec::new();
        let mut results = Vec::new();
        for (i, (sev, ok)) in cases.iter().enumerate() {
            let id = format!("PROTO-{i:03}");
            rules.push(rule(&id, "lifecycle", *sev));
            results.push(check(&id, *ok));
        }
        (registry(rules), results)
    }

    fn arb_severity() -> impl Strategy<Value = RuleSeverity> {
        prop_oneof![
            Just(RuleSeverity::Must),
            Just(RuleSeverity::Should),
            Just(RuleSeverity::May),
        ]
    }

    proptest! {
        #[test]
        fn numeric_score_is_always_in_range(cases in proptest::collection::vec((arb_severity(), any::<bool>()), 0..32)) {
            let (reg, results) = build(&cases);
            let rubric = Rubric::new(&reg, BTreeSet::new());
            let score = score_run(&results, &rubric);
            prop_assert!(score.numeric_score >= 0.0);
            prop_assert!(score.numeric_score <= 100.0);
        }

        #[test]
        fn flipping_a_fail_to_pass_never_lowers_numeric_score(cases in proptest::collection::vec((arb_severity(), any::<bool>()), 1..16)) {
            let (reg, results) = build(&cases);
            let rubric = Rubric::new(&reg, BTreeSet::new());
            let base_score = score_run(&results, &rubric).numeric_score;
            for idx in 0..results.len() {
                if !results[idx].passed {
                    let mut bumped = results.clone();
                    bumped[idx].passed = true;
                    let new_score = score_run(&bumped, &rubric).numeric_score;
                    prop_assert!(new_score + 1e-9 >= base_score, "score regressed from {} to {} after flipping idx {}", base_score, new_score, idx);
                }
            }
        }

        #[test]
        fn flipping_a_fail_to_pass_never_lowers_grade(cases in proptest::collection::vec((arb_severity(), any::<bool>()), 1..16)) {
            let (reg, results) = build(&cases);
            let rubric = Rubric::new(&reg, BTreeSet::new());
            let base_grade = score_run(&results, &rubric).grade;
            for idx in 0..results.len() {
                if !results[idx].passed {
                    let mut bumped = results.clone();
                    bumped[idx].passed = true;
                    let new_grade = score_run(&bumped, &rubric).grade;
                    prop_assert!(new_grade >= base_grade, "grade regressed from {:?} to {:?} after flipping idx {}", base_grade, new_grade, idx);
                }
            }
        }

        #[test]
        fn n_a_rule_does_not_affect_grade(cases in proptest::collection::vec((arb_severity(), any::<bool>()), 1..12)) {
            // Adding a rule under a non-declared capability (tools) and
            // marking it N/A must produce the same grade as omitting it.
            let (reg_without, results) = build(&cases);
            let rubric_without = Rubric::new(&reg_without, BTreeSet::new());
            let baseline = score_run(&results, &rubric_without).grade;

            let mut rules_with = reg_without.rules.clone();
            // A rule under a section that is not declared in capabilities
            // is N/A for the run. Whether the runner emitted a result
            // for it does not matter; it stays excluded.
            rules_with.push(rule("TOOL-999", "tools", RuleSeverity::Must));
            let reg_with = RuleRegistry { version: 1, rules: rules_with };
            let rubric_with = Rubric::new(&reg_with, BTreeSet::new());
            let with_grade = score_run(&results, &rubric_with).grade;
            prop_assert_eq!(with_grade, baseline);
        }

        #[test]
        fn b_grade_never_promotes_to_a_plus_however_many_mays_pass(may_count in 0usize..16) {
            // Lock the SHOULD pass rate into the B band, then sprinkle
            // an arbitrary number of passing MAYs in every applicable
            // section. The grade must remain B.
            let mut rules = vec![rule("PROTO-001", "lifecycle", RuleSeverity::Must)];
            for i in 0..10 {
                rules.push(rule(&format!("TOOL-{i:03}"), "tools", RuleSeverity::Should));
            }
            for i in 0..may_count {
                let section = match i % 5 {
                    0 => "lifecycle",
                    1 => "tools",
                    2 => "resources",
                    3 => "prompts",
                    _ => "transport-auth",
                };
                rules.push(rule(&format!("MAY-{i:03}"), section, RuleSeverity::May));
            }
            let reg = registry(rules);
            let rubric = Rubric::new(&reg, all_caps());
            let mut results = vec![check("PROTO-001", true)];
            for i in 0..10 {
                results.push(check(&format!("TOOL-{i:03}"), i < 8));
            }
            for i in 0..may_count {
                results.push(check(&format!("MAY-{i:03}"), true));
            }
            let score = score_run(&results, &rubric);
            prop_assert_eq!(score.grade, Grade::B);
        }
    }
}
