//! Jury bias mitigations.
//!
//! Five mitigations from the published literature, implemented as pure
//! functions so the W7 jury runner can compose them without an LLM
//! provider in the loop. The mitigations are:
//!
//! 1. Position swap (Zheng et al., 2024 MT-Bench).
//! 2. Rubric calibration against an anchor set (Kim et al., 2024).
//! 3. Chain-of-thought prompting (Wei et al., 2022). The flag rides on
//!    [`BiasMitigations`]; the prompt rendering itself lives next to the
//!    provider trait, not here.
//! 4. Self-preference detection across model families (Panickssery et
//!    al., 2024).
//! 5. Verbosity guard (Saito et al., 2023; Wu and Aji, 2024).
//!
//! The systematic study at arXiv:2604.23178 motivates picking all five
//! together. See ADR 0029.

use serde::{Deserialize, Serialize};

use super::agreement::Agreement;

/// Configuration knobs for the five  mitigations.
///
/// Each field is independently toggle-able so a user can opt out of one
/// mitigation without losing the others. Defaults follow the published
/// recommendations: position swap on, drift logging on, CoT on,
/// same-family warn on (fail off), verbosity ratio 2.0.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BiasMitigations {
    /// When true, pairwise evaluations run twice with swapped response
    /// order and the two scores are averaged.
    pub position_swap: bool,
    /// When true, the runner records per-juror score drift between the
    /// two orderings so a high-drift juror is visible in reports.
    pub log_position_drift: bool,
    /// Optional anchor set for rubric calibration. `None` skips the
    /// calibration step entirely.
    pub rubric_calibration: Option<RubricCalibration>,
    /// When true, the prompt template asks the juror to reason
    /// step-by-step before scoring. Off saves output tokens.
    pub chain_of_thought: bool,
    /// When true, the runner emits a warning string when the juror and
    /// the response generator are in the same model family.
    pub same_family_warn: bool,
    /// When true, same-family detection escalates to a hard failure
    /// instead of a warning. Opt-in strict mode for regulated callers.
    pub same_family_fail: bool,
    /// Threshold for the verbosity guard. Warn when the longer response
    /// is at least this many times the shorter response in token count.
    pub verbosity_warn_ratio: f64,
}

impl Default for BiasMitigations {
    fn default() -> Self {
        Self {
            position_swap: true,
            log_position_drift: true,
            rubric_calibration: None,
            chain_of_thought: true,
            same_family_warn: true,
            same_family_fail: false,
            verbosity_warn_ratio: 2.0,
        }
    }
}

/// Anchor set for the rubric-calibration mitigation.
///
/// The runner asks each juror to score every anchor first. Jurors whose
/// score deviates from the expected score by more than `threshold` on
/// any anchor are flagged as miscalibrated.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RubricCalibration {
    /// Anchor responses with their expected scores.
    pub anchors: Vec<Anchor>,
    /// Maximum absolute deviation a juror may exhibit on any anchor
    /// before it is flagged. Defaults to 0.2 at construction sites.
    pub threshold: f64,
}

/// One anchor response with the score we expect a calibrated juror to
/// assign.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Anchor {
    /// The response text shown to the juror.
    pub response: String,
    /// The score a calibrated juror should give the response, on the
    /// usual 0.0 to 1.0 scale.
    pub expected_score: f64,
}

/// Structured output the runner attaches to each jury evaluation.
///
/// The field shape mirrors the JSON block in the  ticket so a
/// reporter can serialize it directly. `Default` produces an empty block
/// (no mitigations applied, no warnings raised).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BiasSignals {
    /// True when the runner ran the evaluation with response order
    /// swapped and averaged.
    pub position_swap_applied: bool,
    /// True when at least one juror produced different scores for the
    /// two orderings beyond a small epsilon.
    pub position_drift_detected: bool,
    /// Calibration result, present when calibration was performed.
    pub rubric_calibration: Option<CalibrationReport>,
    /// Warning text when any juror is in the same family as the response
    /// generator. None otherwise.
    pub same_family_warning: Option<String>,
    /// Warning text when the verbosity guard tripped. None otherwise.
    pub verbosity_warning: Option<String>,
    /// True when the runner re-scored the same answer in two formattings to
    /// probe style bias (see [`apply_style_swap`]).
    pub style_swap_applied: bool,
    /// The style-bias magnitude: how far a juror's verdict moved when only the
    /// formatting changed. `None` when style swap was not run. A value above
    /// [`STYLE_DRIFT_EPSILON`] is a warning (style bias outweighs position bias,
    /// arXiv:2604.23178).
    pub style_drift: Option<f64>,
}

impl BiasSignals {
    /// Returns true when at least one warning-bearing field is set.
    ///
    /// Used by the combined warning helper in the parent module to
    /// decide whether to escalate a low-agreement run.
    pub fn any_warning(&self) -> bool {
        self.position_drift_detected
            || self.style_drift_detected()
            || self.same_family_warning.is_some()
            || self.verbosity_warning.is_some()
            || self
                .rubric_calibration
                .as_ref()
                .map(|r| !r.miscalibrated_jurors.is_empty())
                .unwrap_or(false)
    }

    /// True when the style-swap probe ran and the verdict moved more than
    /// [`STYLE_DRIFT_EPSILON`] on formatting alone.
    pub fn style_drift_detected(&self) -> bool {
        self.style_drift
            .map(|d| d > STYLE_DRIFT_EPSILON)
            .unwrap_or(false)
    }

    /// Short human-readable summary used by the combined warning.
    pub fn summary(&self) -> String {
        let mut parts: Vec<String> = Vec::new();
        if self.position_drift_detected {
            parts.push("position drift".to_string());
        }
        if self.style_drift_detected() {
            parts.push(format!(
                "style drift {:.2}",
                self.style_drift.unwrap_or_default()
            ));
        }
        if let Some(w) = &self.same_family_warning {
            parts.push(format!("same-family: {w}"));
        }
        if let Some(w) = &self.verbosity_warning {
            parts.push(format!("verbosity: {w}"));
        }
        if let Some(report) = &self.rubric_calibration {
            if !report.miscalibrated_jurors.is_empty() {
                parts.push(format!(
                    "miscalibrated: {}",
                    report.miscalibrated_jurors.join(", ")
                ));
            }
        }
        if parts.is_empty() {
            "no signals".to_string()
        } else {
            parts.join("; ")
        }
    }
}

/// Result of running the rubric-calibration step.
///
/// `performed` records that calibration ran at all. `miscalibrated_jurors`
/// lists the juror ids whose anchor scores deviated beyond the configured
/// threshold. An empty vec is the healthy case.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibrationReport {
    /// True when the runner executed the calibration step.
    pub performed: bool,
    /// Ids of jurors that deviated from at least one anchor expected
    /// score by more than the threshold.
    pub miscalibrated_jurors: Vec<String>,
}

/// Per-juror calibration result the runner aggregates into a
/// [`CalibrationReport`].
#[derive(Debug, Clone)]
pub struct CalibrationResult {
    /// The juror id this result describes.
    pub juror_id: String,
    /// True when at least one anchor deviated beyond the threshold.
    pub miscalibrated: bool,
    /// Per-anchor absolute deviation values, parallel to the input
    /// anchor list.
    pub deviations: Vec<f64>,
}

/// Averaged score from running a juror in both response orders.
#[derive(Debug, Clone, Copy)]
pub struct Score {
    /// Mean of the two ordering scores.
    pub mean: f64,
    /// Absolute difference between the two ordering scores; a large
    /// drift flags the juror as position-sensitive.
    pub drift: f64,
}

/// Run an evaluator twice with response order swapped and average the
/// two scores.
///
/// `evaluate` is called once for `(response_a, response_b)` and once for
/// `(response_b, response_a)`. The mean is the position-swapped score
/// and the absolute difference is the drift signal. Why pass the
/// evaluator as a closure: it keeps the math here transport-free, so
/// real LLM calls and synthetic test doubles share the path.
///
/// The engine's pairwise dispatch
/// (`crate::eval::jury::engine::dispatch_jury` in pairwise mode, WOR-1308)
/// applies the same averaging inline, where it also threads costs and
/// tokens through each call; this helper stays as the transport-free
/// reference the bias corpus pins the math against. Exposing position swap
/// as a YAML knob remains the follow-up noted on WOR-1296.
pub fn apply_position_swap<F>(response_a: &str, response_b: &str, mut evaluate: F) -> Score
where
    F: FnMut(&str, &str) -> f64,
{
    let forward = evaluate(response_a, response_b);
    let reverse = evaluate(response_b, response_a);
    Score {
        mean: (forward + reverse) / 2.0,
        drift: (forward - reverse).abs(),
    }
}

/// Score each anchor with the supplied evaluator and flag the juror as
/// miscalibrated when any anchor deviates beyond the threshold.
///
/// `evaluate` returns the juror's score for an anchor response. The
/// caller is responsible for matching juror identity to the closure;
/// this function only does the math.
///
/// Wired into the `llm-jury` matcher's `bias.anchors` block (WOR-1296);
/// `crate::matchers::jury_bias` runs the anchor provider calls and feeds
/// the scores through here.
pub fn calibrate_rubric<F>(
    juror_id: &str,
    anchors: &[Anchor],
    threshold: f64,
    mut evaluate: F,
) -> CalibrationResult
where
    F: FnMut(&str) -> f64,
{
    if anchors.is_empty() {
        return CalibrationResult {
            juror_id: juror_id.to_string(),
            miscalibrated: false,
            deviations: Vec::new(),
        };
    }
    let mut deviations = Vec::with_capacity(anchors.len());
    let mut miscalibrated = false;
    for anchor in anchors {
        let observed = evaluate(&anchor.response);
        let deviation = (observed - anchor.expected_score).abs();
        if deviation > threshold {
            miscalibrated = true;
        }
        deviations.push(deviation);
    }
    CalibrationResult {
        juror_id: juror_id.to_string(),
        miscalibrated,
        deviations,
    }
}

/// Return true when two model identifiers share a family.
///
/// The family lookup is a small static table covering the four families
/// we care about: anthropic, openai, gemini, mistral. Unknown models return
/// `false` because we cannot tell whether two unknowns share a family. Wired
/// into the `jury.same_family` target: a self-preferring juror that
/// shares the generator's family is a bias signal worth gating on.
pub fn detect_same_family(juror_model: &str, generator_model: &str) -> bool {
    match (family_of(juror_model), family_of(generator_model)) {
        (Some(a), Some(b)) => a == b,
        _ => false,
    }
}

/// Fold the same-family bias signal and the inter-juror agreement into one
/// optional warning string for the `jury.bias_warning` target.
///
/// Builds a [`BiasSignals`] carrying only the same-family signal (the one
/// signal the deterministic jury path can detect without an LLM in the loop)
/// and runs it through [`super::combined_warning`], so the warning fires only
/// when the jury hit low agreement AND a juror shares a family with the
/// generator. Returns `None` otherwise, which the runner maps onto a null
/// `jury.bias_warning`. Keeping the fold here means the in-test target and the
/// reporter line can never compute the warning two different ways.
pub fn combined_bias_warning(same_family: bool, agreement: &Agreement) -> Option<String> {
    let signals = BiasSignals {
        same_family_warning: same_family
            .then(|| "a juror shares a vendor family with the generator model".to_string()),
        ..BiasSignals::default()
    };
    super::combined_warning(&signals, agreement)
}

fn family_of(model: &str) -> Option<&'static str> {
    let lower = model.to_lowercase();
    if lower.starts_with("claude") || lower.contains("anthropic") {
        return Some("anthropic");
    }
    if lower.starts_with("gpt")
        || lower.starts_with("o1")
        || lower.starts_with("o3")
        || lower.starts_with("o4")
        || lower.contains("openai")
    {
        return Some("openai");
    }
    if lower.starts_with("gemini") || lower.contains("google") {
        return Some("gemini");
    }
    if lower.starts_with("mistral") || lower.starts_with("mixtral") {
        return Some("mistral");
    }
    None
}

/// Emit a warning string when the longer response exceeds the shorter
/// response by `threshold` times in token count.
///
/// Returns `None` when either count is zero (no signal available) or
/// when the ratio is below the threshold.
///
/// Wired into the `llm-jury` matcher's `bias.verbosity_guard` knob
/// (WOR-1296); `crate::matchers::jury_bias` supplies the candidate and
/// juror-reply token counts.
pub fn verbosity_warning(
    response_a_tokens: usize,
    response_b_tokens: usize,
    threshold: f64,
) -> Option<String> {
    if response_a_tokens == 0 || response_b_tokens == 0 {
        return None;
    }
    let (shorter, longer) = if response_a_tokens <= response_b_tokens {
        (response_a_tokens, response_b_tokens)
    } else {
        (response_b_tokens, response_a_tokens)
    };
    let ratio = longer as f64 / shorter as f64;
    if ratio + f64::EPSILON < threshold {
        return None;
    }
    Some(format!(
        "longer response is {ratio:.2}x the shorter (threshold {threshold:.2}x)"
    ))
}

/// Formatting density of a response: the count of markdown and formatting
/// markers per 100 characters.
///
/// A deterministic proxy for how heavily a response leans on presentation
/// (headers, bullets, emphasis, code, links, tables, blockquotes) rather than
/// content. Counts the markers a reader scans for structure; it is not a full
/// markdown parse, just a stable signal. Returns `0.0` for empty text.
pub fn formatting_density(text: &str) -> f64 {
    if text.is_empty() {
        return 0.0;
    }
    let mut markers = 0usize;
    for line in text.lines() {
        let trimmed = line.trim_start();
        // Block-level markers: a heading, a bullet, a numbered item, a
        // blockquote, or a table/code row count once for the line they open.
        if trimmed.starts_with('#')
            || trimmed.starts_with("- ")
            || trimmed.starts_with("* ")
            || trimmed.starts_with("> ")
            || trimmed.starts_with("```")
            || trimmed.starts_with('|')
            || numbered_item(trimmed)
        {
            markers += 1;
        }
    }
    // Inline markers: emphasis and code spans (paired `**`, `*`, `_`, `` ` ``)
    // and links. Counted across the whole text, not per line.
    markers += text.matches("**").count();
    markers += text.matches('`').count();
    markers += text.matches("](").count();
    (markers as f64) * 100.0 / (text.chars().count() as f64)
}

/// Whether a trimmed line opens a numbered list item (`1.`, `2.`, ...).
fn numbered_item(line: &str) -> bool {
    let digits: String = line.chars().take_while(|c| c.is_ascii_digit()).collect();
    !digits.is_empty() && line[digits.len()..].starts_with(". ")
}

/// Emit a warning when a candidate's formatting density reaches `threshold`
/// markers per 100 characters.
///
/// The style-bias guard (arXiv:2604.23178): style bias, a judge preferring a
/// response for its formatting rather than its content, now outweighs position
/// bias, so a heavily formatted candidate risks an inflated verdict. This is a
/// separate axis from [`verbosity_warning`]; a short, dense-formatted response
/// trips it where the length guard would not. Returns `None` for empty text or
/// when the density is below the threshold.
pub fn style_warning(text: &str, threshold: f64) -> Option<String> {
    let density = formatting_density(text);
    if density + f64::EPSILON < threshold {
        return None;
    }
    Some(format!(
        "candidate formatting density is {density:.1} markers/100 chars (threshold {threshold:.1})"
    ))
}

/// Style drift above this counts as a style-bias signal, the style analogue of
/// the position-swap epsilon. A judge whose score moves by more than this when
/// only the formatting changed is preferring presentation over content.
pub const STYLE_DRIFT_EPSILON: f64 = 0.05;

/// Style-bias probe: score the same answer rendered two ways and report how far
/// the verdict moved.
///
/// `evaluate` scores one response; it is called on a `plain` rendering and a
/// `styled` (formatting-heavy) rendering of the *same content*. The returned
/// [`Score::drift`] is the **style-bias magnitude**: a juror that judged content
/// rather than presentation scores both the same and drifts toward zero. Where
/// [`style_warning`] flags one heavily-formatted candidate by a static density
/// threshold, this measures how much a judge's verdict actually moves with
/// formatting, the direct analogue of [`apply_position_swap`] for style. Style
/// bias now outweighs position bias (arXiv:2604.23178), so this is the probe
/// that bias matters most.
///
/// The caller supplies both renderings (the runner reformats one answer; see
/// [`restyle_dense`] for a model-free styling). Kept transport-free so the bias
/// corpus pins the math and real judge calls and test doubles share the path.
pub fn apply_style_swap<F>(plain: &str, styled: &str, mut evaluate: F) -> Score
where
    F: FnMut(&str) -> f64,
{
    let plain_score = evaluate(plain);
    let styled_score = evaluate(styled);
    Score {
        mean: (plain_score + styled_score) / 2.0,
        drift: (styled_score - plain_score).abs(),
    }
}

/// Render `plain` text in a formatting-heavy markdown style **without changing
/// its words**, to drive [`apply_style_swap`] with no model in the loop.
///
/// Each non-empty line becomes a bullet and its first word is bolded; the body
/// is prefixed with a heading. The transformation touches only presentation
/// markers (`#`, `-`, `**`), never the tokens a content-judging juror should
/// score, so any verdict drift between `plain` and the result is style bias, not
/// a content change. Empty input returns empty.
pub fn restyle_dense(plain: &str) -> String {
    if plain.trim().is_empty() {
        return String::new();
    }
    let mut out = String::from("## Summary\n\n");
    for line in plain.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        match trimmed.split_once(char::is_whitespace) {
            Some((first, rest)) => out.push_str(&format!("- **{first}** {rest}\n")),
            None => out.push_str(&format!("- **{trimmed}**\n")),
        }
    }
    out
}

/// Aggregate per-juror calibration results into a [`CalibrationReport`].
///
/// Provided as a convenience so the runner has one place to fold the
/// results. The W7 runner can also build the report directly.
///
/// Wired into the `llm-jury` matcher's anchor calibration (WOR-1296);
/// `crate::matchers::jury_bias` folds its per-juror results through here.
pub fn fold_calibration_results(results: &[CalibrationResult]) -> CalibrationReport {
    let mut miscalibrated_jurors: Vec<String> = results
        .iter()
        .filter(|r| r.miscalibrated)
        .map(|r| r.juror_id.clone())
        .collect();
    miscalibrated_jurors.sort();
    CalibrationReport {
        performed: true,
        miscalibrated_jurors,
    }
}

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

    #[test]
    fn position_swap_averages_two_orderings() {
        let score = apply_position_swap(
            "alpha",
            "beta",
            |a, _b| if a == "alpha" { 0.9 } else { 0.5 },
        );
        assert!((score.mean - 0.7).abs() < 1e-9);
        assert!((score.drift - 0.4).abs() < 1e-9);
    }

    #[test]
    fn position_swap_zero_drift_when_juror_is_position_invariant() {
        let score = apply_position_swap("alpha", "beta", |_a, _b| 0.6);
        assert!((score.mean - 0.6).abs() < 1e-9);
        assert!(score.drift < 1e-9);
    }

    #[test]
    fn calibrate_rubric_flags_jurors_that_exceed_threshold() {
        let anchors = vec![
            Anchor {
                response: "good".into(),
                expected_score: 0.9,
            },
            Anchor {
                response: "bad".into(),
                expected_score: 0.1,
            },
        ];
        let result = calibrate_rubric("juror-1", &anchors, 0.2, |r| {
            if r == "good" {
                0.5 // wide deviation
            } else {
                0.1
            }
        });
        assert!(result.miscalibrated);
        assert_eq!(result.deviations.len(), 2);
    }

    #[test]
    fn calibrate_rubric_passes_when_within_threshold() {
        let anchors = vec![Anchor {
            response: "ok".into(),
            expected_score: 0.5,
        }];
        let result = calibrate_rubric("juror-1", &anchors, 0.2, |_r| 0.55);
        assert!(!result.miscalibrated);
    }

    #[test]
    fn calibrate_rubric_handles_empty_anchor_set() {
        let result = calibrate_rubric("juror-1", &[], 0.2, |_r| 0.0);
        assert!(!result.miscalibrated);
        assert!(result.deviations.is_empty());
    }

    #[test]
    fn detect_same_family_covers_all_four_families() {
        assert!(detect_same_family("claude-opus-4-7", "claude-haiku-4"));
        assert!(detect_same_family("gpt-5", "gpt-4o"));
        assert!(detect_same_family("gemini-2.0-pro", "gemini-1.5-flash"));
        assert!(detect_same_family("mistral-large", "mixtral-8x22b"));
        assert!(!detect_same_family("claude-opus-4-7", "gpt-5"));
        assert!(!detect_same_family("gemini-2.0-pro", "mistral-large"));
    }

    #[test]
    fn detect_same_family_returns_false_for_unknown_models() {
        assert!(!detect_same_family("random-model-7b", "another-7b"));
    }

    fn agreement_with(alpha: f64) -> Agreement {
        use super::super::agreement::interpret_alpha;
        Agreement {
            krippendorff_alpha: Some(alpha),
            score_variance: 0.0,
            score_range: 0.0,
            interpretation: interpret_alpha(alpha),
            low_agreement_threshold: 0.667,
        }
    }

    #[test]
    fn combined_bias_warning_fires_on_same_family_and_low_agreement() {
        // Same-family juror plus low agreement is the warning case.
        let warning = combined_bias_warning(true, &agreement_with(0.40));
        assert!(warning.is_some());
        assert!(warning.unwrap().contains("same-family"));
    }

    #[test]
    fn combined_bias_warning_silent_when_agreement_is_high() {
        // The same-family signal alone does not warn: it must land on low
        // agreement, matching the existing combined_warning gate.
        assert!(combined_bias_warning(true, &agreement_with(0.95)).is_none());
    }

    #[test]
    fn combined_bias_warning_silent_without_same_family() {
        assert!(combined_bias_warning(false, &agreement_with(0.40)).is_none());
    }

    #[test]
    fn verbosity_warning_fires_above_threshold() {
        let warning = verbosity_warning(100, 250, 2.0);
        assert!(warning.is_some());
    }

    #[test]
    fn verbosity_warning_skips_below_threshold() {
        let warning = verbosity_warning(100, 150, 2.0);
        assert!(warning.is_none());
    }

    #[test]
    fn verbosity_warning_passes_exactly_at_threshold() {
        let warning = verbosity_warning(100, 200, 2.0);
        assert!(warning.is_some());
    }

    #[test]
    fn verbosity_warning_skips_zero_token_counts() {
        assert!(verbosity_warning(0, 200, 2.0).is_none());
        assert!(verbosity_warning(200, 0, 2.0).is_none());
    }

    #[test]
    fn formatting_density_counts_markers_per_hundred_chars() {
        // Plain prose has no markers.
        assert_eq!(formatting_density("a clear, plain sentence."), 0.0);
        // A heavily formatted block: heading + two bullets + bold + code span.
        let formatted = "# Title\n- one\n- two\nsee `code` and **bold**";
        let density = formatting_density(formatted);
        assert!(
            density > 0.0,
            "formatted text should have density: {density}"
        );
        // The dense block scores well above plain prose of similar length.
        assert!(density > formatting_density("Title one two see code and bold"));
    }

    #[test]
    fn formatting_density_is_zero_for_empty_text() {
        assert_eq!(formatting_density(""), 0.0);
    }

    #[test]
    fn style_warning_fires_above_the_density_threshold() {
        // Three block markers in a short string clears a low threshold.
        let warning = style_warning("# H\n- a\n- b", 5.0);
        assert!(warning.is_some(), "dense formatting should warn");
        assert!(warning.unwrap().contains("formatting density"));
    }

    #[test]
    fn style_warning_skips_plain_prose() {
        assert!(style_warning("just a normal answer with no markup", 5.0).is_none());
    }

    #[test]
    fn bias_signals_summary_reports_no_signals_when_clean() {
        let signals = BiasSignals::default();
        assert!(!signals.any_warning());
        assert_eq!(signals.summary(), "no signals");
    }

    #[test]
    fn fold_calibration_results_collects_miscalibrated_ids() {
        let results = vec![
            CalibrationResult {
                juror_id: "j2".into(),
                miscalibrated: true,
                deviations: vec![0.3],
            },
            CalibrationResult {
                juror_id: "j1".into(),
                miscalibrated: false,
                deviations: vec![0.05],
            },
            CalibrationResult {
                juror_id: "j3".into(),
                miscalibrated: true,
                deviations: vec![0.4],
            },
        ];
        let report = fold_calibration_results(&results);
        assert!(report.performed);
        assert_eq!(report.miscalibrated_jurors, vec!["j2", "j3"]);
    }

    #[test]
    fn default_mitigations_match_published_recommendations() {
        let m = BiasMitigations::default();
        assert!(m.position_swap);
        assert!(m.log_position_drift);
        assert!(m.chain_of_thought);
        assert!(m.same_family_warn);
        assert!(!m.same_family_fail);
        assert!((m.verbosity_warn_ratio - 2.0).abs() < 1e-9);
    }

    #[test]
    fn style_swap_drift_is_the_verdict_delta_between_formattings() {
        // A style-sensitive juror scores the formatting-heavy rendering higher;
        // drift is the gap, mean is the average.
        let score = apply_style_swap("plain answer", "## styled answer", |text| {
            if text.starts_with('#') {
                0.9
            } else {
                0.6
            }
        });
        assert!((score.drift - 0.3).abs() < 1e-9);
        assert!((score.mean - 0.75).abs() < 1e-9);
    }

    #[test]
    fn style_swap_drift_is_zero_for_a_content_judging_juror() {
        // A juror that scores content, not presentation, gives both the same.
        let score = apply_style_swap("plain", "## plain", |_| 0.8);
        assert_eq!(score.drift, 0.0);
    }

    #[test]
    fn restyle_dense_adds_formatting_without_changing_the_words() {
        let plain = "alpha beta\ngamma delta";
        let styled = restyle_dense(plain);
        // Every original word survives the restyling.
        for word in ["alpha", "beta", "gamma", "delta"] {
            assert!(styled.contains(word), "{word} missing from {styled:?}");
        }
        // The restyled text is markedly more formatting-dense than the plain.
        assert!(formatting_density(&styled) > formatting_density(plain));
    }

    #[test]
    fn restyle_dense_on_blank_input_is_empty() {
        assert_eq!(restyle_dense("   \n  "), "");
    }

    #[test]
    fn style_drift_above_epsilon_is_a_warning_and_appears_in_the_summary() {
        let signals = BiasSignals {
            style_swap_applied: true,
            style_drift: Some(0.3),
            ..BiasSignals::default()
        };
        assert!(signals.style_drift_detected());
        assert!(signals.any_warning());
        assert!(signals.summary().contains("style drift 0.30"));
    }

    #[test]
    fn style_drift_below_epsilon_is_not_a_warning() {
        let signals = BiasSignals {
            style_swap_applied: true,
            style_drift: Some(0.01),
            ..BiasSignals::default()
        };
        assert!(!signals.style_drift_detected());
        assert!(!signals.any_warning());
    }
}
