//! Cross-run aggregation of within-session stability sub-scores.
//!
//! Why this module exists: [`crate::eval::stability::agent_stability`] folds
//! ONE multi-turn trace into four deterministic sub-scores, but a multi-run
//! agent test (`runs: N`) produces N traces. A single run can look stable by
//! luck, so this module aggregates the per-run weakest sub-scores across the N
//! runs and exposes three assertable numbers: the mean stability across runs,
//! the worst run's weakest dimension, and how much the runs varied. The CLI
//! gates these with the same matcher grammar the tool-selection floor and the
//! `jury.*` / `calibration:` targets use, so a suite asserts within-session
//! stability without a new matcher or reporter.
//!
//! Kept separate from `stability.rs` (the single-trace folding) so each module
//! stays under the size guard and the per-run math stays decoupled from the
//! across-run math.

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

use crate::eval::stability::agent_stability;
use crate::suite::{Assertion, Matcher};

/// Aggregated within-session stability across the N runs of one (agent, model)
/// pair.
///
/// Each field summarizes the per-run [`crate::eval::stability::StabilityReport::weakest_score`]
/// (the weakest of one run's four sub-scores) over all N runs. Serializable so
/// the enterprise audit chain can persist the across-run picture next to the
/// per-run rows.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StabilitySummary {
    /// Number of runs aggregated (the agent test's `runs:` value).
    pub runs: usize,
    /// Mean of the per-run weakest sub-scores, in `0.0..=1.0`. The headline
    /// "how stable were the runs on average" number. `1.0` with no runs.
    pub score: f64,
    /// The single worst run's weakest sub-score, in `0.0..=1.0`. This is the
    /// floor across every run and every dimension, so it is the strictest
    /// gate: one bad run drives it down. `1.0` with no runs.
    pub weakest_score: f64,
    /// Population variance of the per-run weakest sub-scores. Near `0.0` when
    /// every run was equally stable; larger when the runs swung between stable
    /// and degraded. `0.0` with fewer than two runs (nothing to vary).
    pub variance: f64,
    /// Tool-sequence similarity across the runs, in `0.0..=1.0` (WOR-1369): the
    /// mean pairwise longest-common-subsequence ratio of the per-run tool-name
    /// sequences. `1.0` when every run took the same path; lower as the orders
    /// diverge. `1.0` with fewer than two runs.
    pub tool_sequence_similarity: f64,
    /// Argument consistency across the runs, in `0.0..=1.0` (WOR-1369): among
    /// tool calls that line up by position and name, the mean pairwise fraction
    /// called with identical arguments. `1.0` when aligned calls always reused
    /// the same args, or with fewer than two runs.
    pub argument_consistency: f64,
    /// Whether the runs tend to split apart in the first two steps (WOR-1369),
    /// the band where agent divergence concentrates. `false` when the runs are
    /// reproducible (no pair diverges) or diverge mostly late.
    pub early_divergence: bool,
}

impl StabilitySummary {
    /// One-line human-readable summary the CLI prints so the operator sees the
    /// headline numbers together without re-scanning the struct.
    pub fn line(&self) -> String {
        let early = if self.early_divergence {
            ", early-divergence"
        } else {
            ""
        };
        format!(
            "score {:.2}, weakest {:.2}, variance {:.4}, tss {:.2}, arg-consistency {:.2}{} over {} runs",
            self.score,
            self.weakest_score,
            self.variance,
            self.tool_sequence_similarity,
            self.argument_consistency,
            early,
            self.runs
        )
    }
}

/// Fold the N trace envelopes of a multi-run agent test into a
/// [`StabilitySummary`].
///
/// Computes the per-run weakest sub-score with
/// [`crate::eval::stability::agent_stability`], then reduces that sample to a
/// mean (`score`), a minimum (`weakest_score`), and a population variance
/// (`variance`). An empty slice is trivially stable: `score` and
/// `weakest_score` are `1.0` and `variance` is `0.0`, so a gate never fires on
/// "no runs."
pub fn agent_stability_across(trace_envelopes: &[Value]) -> StabilitySummary {
    let weakest_per_run: Vec<f64> = trace_envelopes
        .iter()
        .map(|envelope| agent_stability(envelope).weakest_score())
        .collect();
    let runs = weakest_per_run.len();
    if runs == 0 {
        return StabilitySummary {
            runs: 0,
            score: 1.0,
            weakest_score: 1.0,
            variance: 0.0,
            tool_sequence_similarity: 1.0,
            argument_consistency: 1.0,
            early_divergence: false,
        };
    }
    let n = runs as f64;
    let mean = weakest_per_run.iter().sum::<f64>() / n;
    let weakest = weakest_per_run
        .iter()
        .copied()
        .fold(f64::INFINITY, f64::min);
    let variance = weakest_per_run
        .iter()
        .map(|&w| {
            let d = w - mean;
            d * d
        })
        .sum::<f64>()
        / n;
    StabilitySummary {
        runs,
        score: mean,
        weakest_score: weakest,
        variance,
        tool_sequence_similarity: super::consistency::tool_sequence_similarity(trace_envelopes),
        argument_consistency: super::consistency::argument_consistency(trace_envelopes),
        early_divergence: super::consistency::early_divergence(trace_envelopes),
    }
}

/// Default weakest-score floor when a `stability:` block declares no `expect:`
///. Mirrors the per-sub-score `0.5` floor in
/// [`crate::eval::stability::StabilityFloors`]: a weakest run under half is
/// more degraded than stable on its worst dimension.
const DEFAULT_WEAKEST_SCORE: f64 = 0.5;

/// Build the targets object the `stability:` assertions resolve against.
///
/// Shape: `{ "stability": { "score": <num>, "weakest_score": <num>,
/// "variance": <num> } }`, so the existing dot-path resolver walks
/// `stability.weakest_score` and friends directly, the same way the `jury.*`
/// targets resolve against their metrics object.
pub fn metrics_object(summary: &StabilitySummary) -> Value {
    json!({
        "stability": {
            "score": summary.score,
            "weakest_score": summary.weakest_score,
            "variance": summary.variance,
            "tool_sequence_similarity": summary.tool_sequence_similarity,
            "argument_consistency": summary.argument_consistency,
            // Exposed as 0/1 so the same numeric matcher grammar gates it
            // (`stability.early_divergence <= 0` forbids an early split).
            "early_divergence": if summary.early_divergence { 1 } else { 0 },
        }
    })
}

/// The default assertion set applied when a `stability:` block declares no
/// `expect:`: `stability.weakest_score >= 0.5`. The smallest useful
/// form of the block gates the worst run's weakest dimension, leaving `score`
/// and `variance` for a user who wants to tighten the gate explicitly.
pub fn default_expect() -> Vec<Assertion> {
    vec![Assertion {
        target: "stability.weakest_score".to_string(),
        matcher: Matcher::Schema(json!({ "minimum": DEFAULT_WEAKEST_SCORE })),
        weight: 1.0,
        name: None,
        transform: None,
    }]
}

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

    /// Build an envelope with the given tool calls (each a distinct name),
    /// assistant-turn lengths, and token total, reusing the single-trace
    /// folding the per-run weakest score is computed from.
    fn envelope(distinct_tools: usize, assistant_lengths: &[usize], total_tokens: u64) -> Value {
        let calls: Vec<Value> = (0..distinct_tools)
            .map(|i| json!({ "name": format!("tool_{i}"), "server": "s", "args": {} }))
            .collect();
        let turns: Vec<Value> = assistant_lengths
            .iter()
            .map(|&len| json!({ "role": "assistant", "content": "x".repeat(len) }))
            .collect();
        json!({
            "tool_calls": calls,
            "conversation": {
                "tokens": { "total": total_tokens },
                "turns": turns,
            }
        })
    }

    /// A clean, focused run: one tool, similar-length answers, modest spend.
    fn clean_run() -> Value {
        envelope(1, &[100, 105, 95], 1000)
    }

    /// A thrashing run: a fresh tool on every call drives tool-usage low.
    fn thrashing_run() -> Value {
        envelope(5, &[100, 100], 1000)
    }

    #[test]
    fn empty_sample_is_trivially_stable() {
        let summary = agent_stability_across(&[]);
        assert_eq!(summary.runs, 0);
        assert!((summary.score - 1.0).abs() < f64::EPSILON);
        assert!((summary.weakest_score - 1.0).abs() < f64::EPSILON);
        assert!((summary.variance - 0.0).abs() < f64::EPSILON);
    }

    #[test]
    fn uniformly_stable_runs_have_high_score_and_zero_variance() {
        let runs = vec![clean_run(), clean_run(), clean_run()];
        let summary = agent_stability_across(&runs);
        assert_eq!(summary.runs, 3);
        assert!(summary.score >= 0.5, "{summary:?}");
        assert!(summary.weakest_score >= 0.5, "{summary:?}");
        // Identical runs vary not at all.
        assert!(summary.variance.abs() < 1e-9, "{summary:?}");
    }

    #[test]
    fn one_bad_run_drives_weakest_below_the_mean_and_lifts_variance() {
        let runs = vec![clean_run(), clean_run(), thrashing_run()];
        let summary = agent_stability_across(&runs);
        // The thrashing run's weakest dimension is below the two clean runs,
        // so the minimum is strictly under the mean.
        assert!(summary.weakest_score < summary.score, "{summary:?}");
        // A mix of stable and degraded runs has nonzero variance.
        assert!(summary.variance > 0.0, "{summary:?}");
    }

    #[test]
    fn metrics_object_exposes_the_three_targets() {
        let summary = agent_stability_across(&[clean_run(), thrashing_run()]);
        let obj = metrics_object(&summary);
        assert!(resolve_target(&obj, "stability.score").is_number());
        assert!(resolve_target(&obj, "stability.weakest_score").is_number());
        assert!(resolve_target(&obj, "stability.variance").is_number());
    }

    #[test]
    fn default_expect_gates_only_the_weakest_score() {
        let defaults = default_expect();
        let targets: Vec<&str> = defaults.iter().map(|a| a.target.as_str()).collect();
        assert_eq!(targets, vec!["stability.weakest_score"]);
    }

    #[test]
    fn line_pairs_all_the_numbers() {
        let summary = StabilitySummary {
            runs: 4,
            score: 0.8,
            weakest_score: 0.6,
            variance: 0.0125,
            tool_sequence_similarity: 0.9,
            argument_consistency: 1.0,
            early_divergence: false,
        };
        assert_eq!(
            summary.line(),
            "score 0.80, weakest 0.60, variance 0.0125, tss 0.90, arg-consistency 1.00 over 4 runs"
        );
    }

    #[test]
    fn consistency_targets_resolve_and_flag_an_early_split() {
        use crate::executor::resolve_target;
        // Two runs that diverge at the first tool call: TSS below 1, and the
        // early-divergence flag set, both reachable as stability.* targets.
        let runs = vec![
            json!({ "tool_calls": [{ "name": "a", "args": {} }, { "name": "b", "args": {} }] }),
            json!({ "tool_calls": [{ "name": "z", "args": {} }, { "name": "b", "args": {} }] }),
        ];
        let obj = metrics_object(&agent_stability_across(&runs));
        assert_eq!(resolve_target(&obj, "stability.early_divergence"), json!(1));
        let tss = resolve_target(&obj, "stability.tool_sequence_similarity");
        assert!(tss.as_f64().unwrap() < 1.0, "tss should be < 1: {tss}");
        // Aligned second call (b) shares args, so argument consistency holds.
        assert_eq!(
            resolve_target(&obj, "stability.argument_consistency"),
            json!(1.0)
        );
    }
}
