//! Context-rot knee detection: a quality-versus-context-size curve and the
//! point where quality falls off.
//!
//! Long-context models do not degrade uniformly; they hold quality up to a
//! context size and then drop sharply. A single accuracy number at one context
//! size hides that cliff. This module folds a set of (context size, quality)
//! observations into a [`ContextRotSummary`] that names the knee (the context
//! size at the steepest quality drop), the worst degradation seen, and the
//! baseline, and exposes them as assertable `context_rot.*` targets through the
//! same dot-path resolver the [`reliability`](crate::eval::reliability) and
//! tool-selection reports use, so a suite gates on "no more than N percent
//! degradation" or "the knee is past M tokens" with no new matcher.
//!
//! The observations are supplied by the caller (a context-size sweep, or a
//! suite that records measured points). The math here is deterministic: the
//! same points always yield the same knee.

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

/// One measured point on the quality-versus-context curve.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextPoint {
    /// Input context size for this observation, in tokens.
    pub context_tokens: u32,
    /// Observed quality at that context size, an integer percent in `0..=100`
    /// (an F1, an accuracy, or any other 0..=100 quality score).
    pub quality_percent: u32,
}

/// The smallest consecutive quality drop, in percentage points, that counts as
/// a knee. Below this the curve is treated as flat (no rot), so ordinary noise
/// does not invent a cliff.
const MIN_KNEE_DROP: u32 = 5;

/// Summary of a context-rot curve.
///
/// Serializable so reporters and the audit chain can persist the curve next to
/// the headline quality number. Percent fields are integers in `0..=100` to
/// match the eval-module convention and stay byte-stable across platforms.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextRotSummary {
    /// The observations, sorted by ascending context size.
    pub points: Vec<ContextPoint>,
    /// Quality at the smallest context size: the reference the rest is measured
    /// against. 100 when there are no points (trivially robust).
    pub baseline_percent: u32,
    /// The lowest quality observed across the curve.
    pub min_percent: u32,
    /// Worst degradation from baseline, in percentage points (`baseline - min`,
    /// floored at 0). The headline rot number.
    pub max_degradation_percent: u32,
    /// True when a knee was detected (a consecutive drop of at least
    /// `MIN_KNEE_DROP`).
    pub knee_detected: bool,
    /// Context size, in tokens, at the bottom of the steepest quality drop, or
    /// 0 when no knee was detected. Read `knee_detected` to disambiguate a real
    /// knee at a low token count from "no knee".
    pub knee_tokens: u32,
}

impl ContextRotSummary {
    /// One-line human-readable summary the CLI prints.
    pub fn line(&self) -> String {
        if self.knee_detected {
            format!(
                "context rot: {}% baseline, {}% worst ({}pt drop), knee at {} tokens",
                self.baseline_percent,
                self.min_percent,
                self.max_degradation_percent,
                self.knee_tokens
            )
        } else {
            format!(
                "context rot: {}% baseline, {}% worst ({}pt drop), no knee",
                self.baseline_percent, self.min_percent, self.max_degradation_percent
            )
        }
    }

    /// Resolve a `context_rot.<field>` dot-path target to a number, mirroring
    /// the resolver the other eval reports expose. Booleans surface as `0`/`100`
    /// so a numeric matcher can gate them.
    pub fn resolve_target(&self, target: &str) -> Option<f64> {
        match target {
            "context_rot.points" => Some(self.points.len() as f64),
            "context_rot.baseline_quality" => Some(f64::from(self.baseline_percent)),
            "context_rot.min_quality" => Some(f64::from(self.min_percent)),
            "context_rot.max_degradation" => Some(f64::from(self.max_degradation_percent)),
            "context_rot.knee_detected" => Some(if self.knee_detected { 100.0 } else { 0.0 }),
            "context_rot.knee_tokens" => Some(f64::from(self.knee_tokens)),
            _ => None,
        }
    }

    /// Build the targets object the assertions resolve against, the same shape
    /// the `reliability.*` and `tool_selection.*` targets use.
    pub fn metrics_object(&self) -> Value {
        json!({
            "context_rot": {
                "points": self.points.len(),
                "baseline_quality": self.baseline_percent,
                "min_quality": self.min_percent,
                "max_degradation": self.max_degradation_percent,
                "knee_detected": self.knee_detected,
                "knee_tokens": self.knee_tokens,
            }
        })
    }
}

/// Fold a set of quality-versus-context observations into a
/// [`ContextRotSummary`].
///
/// An empty slice is trivially robust: baseline and min are 100, degradation is
/// 0, and no knee, so a gate never fires on "no observations", matching the
/// empty-sample behavior of [`reliability::summarize`](crate::eval::reliability::summarize).
/// The knee is the context size at the bottom of the steepest consecutive
/// quality drop, reported only when that drop is at least `MIN_KNEE_DROP`.
pub fn summarize(points: &[ContextPoint]) -> ContextRotSummary {
    if points.is_empty() {
        return ContextRotSummary {
            points: Vec::new(),
            baseline_percent: 100,
            min_percent: 100,
            max_degradation_percent: 0,
            knee_detected: false,
            knee_tokens: 0,
        };
    }

    let mut sorted = points.to_vec();
    sorted.sort_by_key(|p| p.context_tokens);

    let baseline = sorted[0].quality_percent;
    let min = sorted
        .iter()
        .map(|p| p.quality_percent)
        .min()
        .unwrap_or(baseline);
    let max_degradation = baseline.saturating_sub(min);

    // The knee is the point with the largest drop from the previous point.
    let mut best_drop = 0u32;
    let mut knee_tokens = 0u32;
    for pair in sorted.windows(2) {
        let drop = pair[0]
            .quality_percent
            .saturating_sub(pair[1].quality_percent);
        if drop > best_drop {
            best_drop = drop;
            knee_tokens = pair[1].context_tokens;
        }
    }
    let knee_detected = best_drop >= MIN_KNEE_DROP;

    ContextRotSummary {
        points: sorted,
        baseline_percent: baseline,
        min_percent: min,
        max_degradation_percent: max_degradation,
        knee_detected,
        knee_tokens: if knee_detected { knee_tokens } else { 0 },
    }
}

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

    fn pt(context_tokens: u32, quality_percent: u32) -> ContextPoint {
        ContextPoint {
            context_tokens,
            quality_percent,
        }
    }

    #[test]
    fn empty_curve_is_trivially_robust() {
        let s = summarize(&[]);
        assert_eq!(s.baseline_percent, 100);
        assert_eq!(s.max_degradation_percent, 0);
        assert!(!s.knee_detected);
        // A "no more than 10pt degradation" gate passes on no data.
        assert_eq!(s.resolve_target("context_rot.max_degradation"), Some(0.0));
    }

    #[test]
    fn a_flat_curve_has_no_knee() {
        let s = summarize(&[pt(1000, 90), pt(4000, 89), pt(16000, 88)]);
        assert!(!s.knee_detected);
        assert_eq!(s.knee_tokens, 0);
        assert_eq!(s.max_degradation_percent, 2);
    }

    #[test]
    fn a_cliff_is_detected_as_the_knee_at_the_drop() {
        // Quality holds near 90 until 16k tokens, then falls off to 55.
        let s = summarize(&[pt(1000, 92), pt(4000, 90), pt(16000, 88), pt(64000, 55)]);
        assert!(s.knee_detected);
        assert_eq!(
            s.knee_tokens, 64000,
            "the knee is the context size at the bottom of the drop"
        );
        assert_eq!(s.baseline_percent, 92);
        assert_eq!(s.min_percent, 55);
        assert_eq!(s.max_degradation_percent, 37);
    }

    #[test]
    fn the_knee_picks_the_steepest_drop_not_the_first() {
        // A small dip at 8k, then a bigger cliff at 32k.
        let s = summarize(&[pt(2000, 95), pt(8000, 88), pt(32000, 60)]);
        assert!(s.knee_detected);
        assert_eq!(s.knee_tokens, 32000);
    }

    #[test]
    fn summarize_sorts_unordered_points_by_context_size() {
        let s = summarize(&[pt(64000, 55), pt(1000, 92), pt(16000, 88)]);
        assert_eq!(s.points[0].context_tokens, 1000);
        assert_eq!(s.points[2].context_tokens, 64000);
        // Baseline is the smallest-context point regardless of input order.
        assert_eq!(s.baseline_percent, 92);
    }

    #[test]
    fn targets_resolve_for_assertions() {
        let s = summarize(&[pt(1000, 90), pt(32000, 60)]);
        assert_eq!(s.resolve_target("context_rot.knee_detected"), Some(100.0));
        assert_eq!(s.resolve_target("context_rot.knee_tokens"), Some(32000.0));
        assert_eq!(s.resolve_target("context_rot.baseline_quality"), Some(90.0));
        assert_eq!(s.resolve_target("context_rot.min_quality"), Some(60.0));
        assert_eq!(s.resolve_target("context_rot.points"), Some(2.0));
        assert_eq!(s.resolve_target("context_rot.unknown"), None);
    }

    #[test]
    fn metrics_object_has_the_dot_path_shape() {
        let s = summarize(&[pt(1000, 90), pt(32000, 60)]);
        let m = s.metrics_object();
        assert_eq!(m["context_rot"]["max_degradation"], json!(30));
        assert_eq!(m["context_rot"]["knee_tokens"], json!(32000));
        assert_eq!(m["context_rot"]["knee_detected"], json!(true));
    }

    #[test]
    fn a_drop_below_the_threshold_is_not_a_knee() {
        // A 4-point drop is below MIN_KNEE_DROP, so it is noise, not a cliff.
        let s = summarize(&[pt(1000, 90), pt(8000, 86)]);
        assert!(!s.knee_detected);
        assert_eq!(s.max_degradation_percent, 4);
    }
}
