//! `llm-jury` matcher.
//!
//! A jury matcher runs N independent judges over the same candidate and
//! folds their verdicts using a quorum threshold. Inter-juror agreement
//! (ADR-0030) is computed alongside the verdict so reporters can flag
//! split decisions.
//!
//! Why a separate matcher from `llm-judge`: the single-judge path stays
//! cheap and obvious. The jury path adds a fan-out cost, an aggregation
//! step, and a per-juror verdict list the reporter renders.
//!
//! The per-juror fan-out itself goes through the jury engine's shared
//! dispatch loop ([`crate::eval::jury::engine::dispatch_votes`], the
//! WOR-1308 convergence), which supplies cost-budget enforcement and
//! per-juror error isolation. Each juror's vote is still acquired through
//! the judge contract ([`super::llm_judge`]) so prompts, parse retries,
//! per-juror thresholds, and provenance notes are unchanged, and the
//! aggregation (weighted quorum, `min_agreement`, bias mitigations) stays
//! here.

use std::sync::Mutex;
use std::time::Instant;

use serde::Deserialize;
use serde_json::Value;

use super::jury_bias::{self, BiasConfig};
use super::llm_judge::{self, JudgeConfig};
use super::result::{AssertionDiff, AssertionResult, MatcherKind};
use crate::eval::cost::CostBudget;
use crate::eval::jury::agreement::{compute_agreement, Agreement, ConfidenceBand};
use crate::eval::jury::bias::{self, combined_bias_warning, detect_same_family};
use crate::eval::jury::engine::{self, JurorCall, JurorSeat, JurorVote, Vote};
use crate::eval::provider::LlmProvider;
use crate::eval::ProviderError;
use crate::report::jury::{JuryConsensus, Verdict};
use crate::Error;

/// One juror configuration inside an `llm-jury:` block.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JuryJuror {
    /// Provider model identifier used for this juror.
    pub model: String,
    /// Optional per-juror threshold override. Defaults to the jury
    /// threshold.
    #[serde(default)]
    pub threshold: Option<f64>,
    /// Reliability weight applied during the quorum fold (ADR 0042).
    ///
    /// Defaults to `1.0`, which is equal weight and reproduces the
    /// head-count quorum exactly. A higher weight makes this juror pull
    /// the verdict harder; a config with no `weight` on any juror folds
    /// identically to before ADR 0042 landed.
    #[serde(default = "default_weight")]
    pub weight: f64,
}

/// Parsed `llm-jury:` YAML block.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JuryConfig {
    /// Rubric shared across every juror.
    pub rubric: String,
    /// Per-juror specs. At least one entry is required.
    pub jurors: Vec<JuryJuror>,
    /// Default threshold applied when a juror does not override.
    /// Defaults to 0.7.
    #[serde(default = "default_threshold")]
    pub threshold: f64,
    /// Fraction of passing jurors required for the jury to pass.
    /// Defaults to 0.5 (simple majority).
    #[serde(default = "default_quorum")]
    pub quorum: f64,
    /// Minimum Krippendorff's alpha required for the verdict to count
    /// (WOR-1301). A quorum can pass on a deeply split jury; this gate
    /// lets a test demand the jurors actually agree before the verdict
    /// stands. When set and alpha falls below it, the jury fails
    /// regardless of quorum. `None` (the default) keeps the old behavior:
    /// agreement is reported but never gates the verdict.
    #[serde(default)]
    pub min_agreement: Option<f64>,
    /// Opt-in bias mitigations (WOR-1296): chain-of-thought prompting, the
    /// verbosity guard, and anchor calibration. `None` (the default) keeps
    /// pre-WOR-1296 behavior exactly. Anchors are real provider calls, one
    /// per juror per anchor, billed before the candidate is graded.
    #[serde(default)]
    pub bias: Option<BiasConfig>,
    /// Optional cap on total jury spend in USD (WOR-1387). When set, the
    /// shared dispatch loop stops calling jurors once the cumulative cost
    /// would exceed it (the engine already enforces a `CostBudget`); a
    /// partial jury then decides on the votes gathered. `None` (the default)
    /// leaves the jury unbounded, the pre-WOR-1387 behavior.
    #[serde(default)]
    pub max_cost: Option<f64>,
}

fn default_threshold() -> f64 {
    0.7
}

fn default_quorum() -> f64 {
    0.5
}

fn default_weight() -> f64 {
    1.0
}

/// Parse a raw matcher value into a typed [`JuryConfig`].
///
/// Returns [`Error::InvalidMatcherInput`] for a malformed block, an empty
/// juror list, an out-of-range quorum or min_agreement, a bad weight, or an
/// out-of-range `bias:` knob (a verbosity ratio at or below 1.0, an anchor
/// expected score outside [0, 1]).
/// Uses the crate's
/// typed error like every sibling matcher rather than a bare `String`, so the
/// executor folds it the same way.
pub fn parse_config(value: &Value) -> crate::Result<JuryConfig> {
    let cfg: JuryConfig = serde_json::from_value(value.clone())
        .map_err(|e| Error::InvalidMatcherInput(format!("invalid llm-jury block: {e}")))?;
    if cfg.jurors.is_empty() {
        return Err(Error::InvalidMatcherInput(
            "llm-jury requires at least one juror".to_string(),
        ));
    }
    if !(0.0..=1.0).contains(&cfg.quorum) {
        return Err(Error::InvalidMatcherInput(format!(
            "llm-jury quorum {} must be in [0, 1]",
            cfg.quorum
        )));
    }
    if let Some(min) = cfg.min_agreement {
        if !min.is_finite() || !(0.0..=1.0).contains(&min) {
            return Err(Error::InvalidMatcherInput(format!(
                "llm-jury min_agreement {min} must be in [0, 1]"
            )));
        }
    }
    for juror in &cfg.jurors {
        if !juror.weight.is_finite() || juror.weight < 0.0 {
            return Err(Error::InvalidMatcherInput(format!(
                "llm-jury juror `{}` weight {} must be finite and non-negative",
                juror.model, juror.weight
            )));
        }
    }
    if let Some(cap) = cfg.max_cost {
        if !cap.is_finite() || cap < 0.0 {
            return Err(Error::InvalidMatcherInput(format!(
                "llm-jury max_cost {cap} must be finite and non-negative"
            )));
        }
    }
    if let Some(bias) = &cfg.bias {
        jury_bias::validate(bias)?;
    }
    Ok(cfg)
}

/// Engine-computed jury analytics, exposed so a test can assert on the
/// jury that runs inside it.
///
/// The matcher already computes inter-juror agreement and the confidence
/// band on every jury run; before  those numbers were only printed
/// in the verdict reason. This struct surfaces the same three values so the
/// runner can resolve `jury.agreement`, `jury.confidence`, and
/// `jury.escalate` targets against them. It carries no new statistic: it is
/// the agreement result the matcher already folds, lifted into a typed
/// shape the target resolver can read.
#[derive(Debug, Clone, PartialEq)]
pub struct JuryMetrics {
    /// Krippendorff's alpha for the juror scores, or `None` when alpha is
    /// undefined (a single-juror jury). The runner maps `None` onto a null
    /// `jury.agreement` target and skips its assertion with a note.
    pub agreement: Option<f64>,
    /// Confidence band derived from inter-juror agreement (`high`, `medium`,
    /// or `low`). Resolves the `jury.confidence` target.
    pub confidence: ConfidenceBand,
    /// True when the jury landed in low agreement and the verdict should go
    /// to a human. Resolves the `jury.escalate` target; mirrors the existing
    /// `escalate_for_review` flag.
    pub escalate: bool,
    /// True when at least one juror shares a vendor family with the generator
    /// model under test. Resolves the `jury.same_family` target. A
    /// self-preferring juror that shares the generator's family is a bias
    /// signal. `false` when no generator model is known (a tool-result jury
    /// has no generator), since we cannot detect a shared family without one.
    pub same_family: bool,
    /// A folded bias warning, present only when the jury hit low
    /// agreement AND a same-family juror was detected. Resolves the
    /// `jury.bias_warning` target; `None` maps onto a null target.
    pub bias_warning: Option<String>,
    /// Style-bias magnitude from the `bias.style_swap` probe (WOR-1370): the
    /// largest verdict drift any juror showed when only the candidate's
    /// formatting changed. Resolves the `jury.style_drift` target; `None` when
    /// style swap was off, which maps onto a null target.
    pub style_drift: Option<f64>,
}

/// Run the jury against the supplied candidate and return an
/// [`AssertionResult`] folding the verdicts and the inter-juror
/// agreement metric.
///
/// `providers` is a parallel slice to [`JuryConfig::jurors`]; the caller
/// builds one trait object per juror so a mixed-provider jury (Anthropic
/// with OpenAI, say) is a one-line slice. The fan-out runs through the
/// jury engine's shared dispatch loop (sequential, because the cost budget
/// consults the running spend before each call), and jurors that fail with
/// a provider error are recorded as abstaining for the quorum math rather
/// than aborting the run.
pub async fn evaluate(
    config: &JuryConfig,
    candidate: &Value,
    providers: &[&dyn LlmProvider],
) -> Result<AssertionResult, ProviderError> {
    Ok(evaluate_with_metrics(config, candidate, providers, None)
        .await?
        .0)
}

/// Like [`evaluate`], but also returns the engine-computed [`JuryMetrics`]
/// so the runner can resolve `jury.*` assertion targets against the same
/// jury that produced the verdict.
///
/// Kept as the single dispatch path so the verdict and the analytics can
/// never come from two different runs of the jury. [`evaluate`] is a thin
/// wrapper that drops the metrics for callers that only want the verdict.
///
/// `generator_model` is the model that produced the candidate under test (an
/// agent test's own model). When given, it feeds the `same_family` bias signal:
/// a juror that shares its vendor family with the generator is a
/// self-preference risk. Pass `None` for a tool-result jury, which has no
/// generator model; `same_family` is then `false`.
pub async fn evaluate_with_metrics(
    config: &JuryConfig,
    candidate: &Value,
    providers: &[&dyn LlmProvider],
    generator_model: Option<&str>,
) -> Result<(AssertionResult, JuryMetrics, JuryConsensus), ProviderError> {
    if providers.len() != config.jurors.len() {
        return Err(ProviderError::InvalidRequest(format!(
            "llm-jury provider count ({}) does not match juror count ({})",
            providers.len(),
            config.jurors.len()
        )));
    }
    let bias = config.bias.as_ref();
    // Chain-of-thought rides on the rubric (WOR-1296) so the judge prompt
    // template, and with it PROMPT_TEMPLATE_VERSION, stays untouched.
    let rubric = jury_bias::effective_rubric(&config.rubric, bias);
    // Anchor calibration grades the known-answer set before the candidate;
    // each anchor is one real provider call per juror.
    let calibration = jury_bias::run_anchor_calibration(config, providers, &rubric).await;
    let (engine_votes, style_drifts) =
        dispatch_through_engine(config, providers, &rubric, candidate).await;
    let votes: Vec<JurorVerdict> = config
        .jurors
        .iter()
        .zip(engine_votes.iter())
        .map(|(juror, vote)| juror_verdict(juror, vote))
        .collect();
    let replies: Vec<(&str, Option<&str>)> = votes
        .iter()
        .map(|v| (v.model.as_str(), v.note.as_deref()))
        .collect();
    let bias_report = jury_bias::BiasReport {
        active: jury_bias::active_tags(bias),
        verbosity_warning: jury_bias::verbosity_signal(bias, candidate, &replies),
        style_warning: jury_bias::style_signal(bias, candidate),
        calibration,
    };
    let scores: Vec<f64> = votes.iter().filter_map(|v| v.score).collect();
    let agreement = compute_agreement(&scores);
    // The most conservative style-bias signal: the largest drift any juror
    // showed when only the candidate's formatting changed (WOR-1370). `None`
    // when style swap was off (no juror produced a drift).
    let style_drift = aggregate_style_drift(&style_drifts);
    let metrics = jury_metrics(config, &agreement, generator_model, style_drift);
    // Build the reporter-facing consensus from the same engine votes the
    // verdict was folded from, so `TestResult.consensus` is populated on a
    // real run (WOR-1384). This is a report artifact, not the authoritative
    // verdict: the pass/fail still comes from `fold_verdicts`' weighted
    // fractional quorum, so `Majority` here only labels the consensus view.
    let consensus = engine::evaluate_with_jury(&engine_votes, engine::ConsensusMethod::Majority);
    Ok((
        fold_verdicts(&votes, config, &bias_report),
        metrics,
        consensus,
    ))
}

/// Fan the jury out through the engine's shared dispatch loop (WOR-1308).
///
/// One judge call per juror flows through
/// [`crate::eval::jury::engine::dispatch_votes`], so budget enforcement
/// and per-juror error isolation come from the engine instead of a second
/// hand-rolled loop. Consensus short-circuiting stays off (`None`): the
/// matcher's weighted fractional quorum is not the engine's head-count
/// [`engine::ConsensusMethod`], and a cancelled juror would silently
/// shrink the quorum's denominator.
async fn dispatch_through_engine(
    config: &JuryConfig,
    providers: &[&dyn LlmProvider],
    rubric: &str,
    candidate: &Value,
) -> (Vec<JurorVote>, Vec<Option<f64>>) {
    let seats: Vec<JurorSeat> = config
        .jurors
        .iter()
        .zip(providers.iter())
        .map(|(juror, provider)| JurorSeat {
            provider: provider.name(),
            model: juror.model.clone(),
            weight: juror.weight,
        })
        .collect();
    // Thread the jury's `max_cost` into the engine's budget (WOR-1387). The
    // shared dispatch loop already stops calling jurors once the running
    // spend would exceed the cap, so a capped jury decides on the votes it
    // could afford. An absent cap stays unbounded.
    let mut budget = match config.max_cost {
        Some(cap) => CostBudget::with_cap(cap),
        None => CostBudget::unbounded(),
    };
    // Per-juror style-swap drift, keyed by seat index (WOR-1370). The
    // style-swap probe is matcher knowledge that the engine's shared
    // `JurorCall` vocabulary does not carry, so it rides on a side channel next
    // to the dispatch closure rather than widening that type for every
    // front-end. A `Mutex` (not a `RefCell`) so the dispatched future stays
    // `Send`, which the runner requires; the lock is taken and released within
    // one statement, never held across an await.
    let style_drifts: Mutex<Vec<Option<f64>>> = Mutex::new(vec![None; seats.len()]);
    let drifts_ref = &style_drifts;
    let votes = engine::dispatch_votes(&seats, None, &mut budget, |index| async move {
        let (call, drift) = juror_call(
            config,
            &config.jurors[index],
            providers[index],
            rubric,
            candidate,
        )
        .await;
        if let Ok(mut drifts) = drifts_ref.lock() {
            drifts[index] = drift;
        }
        call
    })
    .await
    .votes;
    // Recover the values even if a juror panicked and poisoned the lock; a lost
    // drift just means that juror contributes no style signal.
    (
        votes,
        style_drifts
            .into_inner()
            .unwrap_or_else(std::sync::PoisonError::into_inner),
    )
}

/// Run one juror through the judge path and adapt the outcome into the
/// engine's [`JurorCall`] vocabulary, returning the style-swap drift
/// alongside (WOR-1370).
///
/// The judge contract (prompt template, parse retry, per-juror threshold,
/// provenance note) is the matcher's own; only the fan-out machinery
/// converged on the engine (WOR-1308). The judge path does not expose
/// provider usage, so `cost` is `None` and the engine records nothing into
/// its budget. The second tuple element is the per-juror style-bias drift:
/// `Some(d)` when `bias.style_swap` is on and both the plain and restyled
/// scores were available, `None` otherwise.
async fn juror_call(
    config: &JuryConfig,
    juror: &JuryJuror,
    provider: &dyn LlmProvider,
    rubric: &str,
    candidate: &Value,
) -> (JurorCall, Option<f64>) {
    let per_juror_cfg = JudgeConfig {
        rubric: rubric.to_string(),
        threshold: juror.threshold.unwrap_or(config.threshold),
        model: Some(juror.model.clone()),
    };
    let start = Instant::now();
    let result = llm_judge::evaluate(&per_juror_cfg, candidate, provider, &juror.model).await;
    // When style swap is on, re-grade the same answer rendered in a
    // formatting-heavy style and report how far this juror's verdict moved on
    // formatting alone (WOR-1370). The drift is `None` when the probe is off or
    // either grade was unavailable.
    let style_drift = juror_style_drift(
        config,
        &per_juror_cfg,
        provider,
        &juror.model,
        candidate,
        &result,
    )
    .await;
    let latency_ms = start.elapsed().as_millis() as u64;
    let vote = result.map(|judged| Vote {
        verdict: if judged.pass {
            Verdict::Pass
        } else {
            Verdict::Fail
        },
        score: extract_score(&judged),
        reasoning: extract_note(&judged),
    });
    (
        JurorCall {
            result: vote,
            cost: None,
            tokens: None,
            latency_ms,
        },
        style_drift,
    )
}

/// Re-score the candidate in a formatting-heavy rendering and return this
/// juror's style-bias drift (WOR-1370).
///
/// Off unless `bias.style_swap` is set. Both grades come from `await`s done
/// before the sync [`bias::apply_style_swap`] math, so no future is held across
/// the borrow. `plain_result` is the juror's already-fetched plain grade, so the
/// probe costs one extra provider call (the styled grade), not two. Returns
/// `None` when either grade is unavailable, since a drift then has no meaning.
async fn juror_style_drift(
    config: &JuryConfig,
    judge_cfg: &JudgeConfig,
    provider: &dyn LlmProvider,
    model: &str,
    candidate: &Value,
    plain_result: &Result<AssertionResult, ProviderError>,
) -> Option<f64> {
    if !config.bias.as_ref().map(|b| b.style_swap).unwrap_or(false) {
        return None;
    }
    let plain_score = plain_result.as_ref().ok().and_then(extract_score)?;
    let plain = llm_judge::candidate_text(candidate);
    let styled = bias::restyle_dense(&plain);
    // Do the second (styled) grade's await first, then feed both pre-fetched
    // scores into the sync closure so apply_style_swap holds no future.
    let styled_result =
        llm_judge::evaluate(judge_cfg, &Value::String(styled.clone()), provider, model).await;
    let styled_score = styled_result.ok().and_then(|r| extract_score(&r))?;
    let score = bias::apply_style_swap(&plain, &styled, |t| {
        if t == plain.as_str() {
            plain_score
        } else {
            styled_score
        }
    });
    Some(score.drift)
}

/// Fold the per-juror style-swap drifts into one [`JuryMetrics::style_drift`].
///
/// Returns the maximum drift across jurors, the most conservative style-bias
/// signal (WOR-1370): if any juror's verdict moved a lot on formatting alone,
/// surface that. `None` when no juror produced a drift (style swap off).
fn aggregate_style_drift(drifts: &[Option<f64>]) -> Option<f64> {
    drifts
        .iter()
        .filter_map(|d| *d)
        .max_by(|a, b| a.total_cmp(b))
}

/// Fold one engine-dispatched vote back into the matcher's [`JurorVerdict`].
/// An errored juror (provider failure, or a dispatch cancellation) becomes
/// an abstaining (errored) verdict rather than aborting the jury, so one
/// rate-limited juror cannot poison the run.
fn juror_verdict(juror: &JuryJuror, vote: &JurorVote) -> JurorVerdict {
    match &vote.result {
        Ok(v) => JurorVerdict {
            model: juror.model.clone(),
            // The judge path only casts Pass or Fail, so Abstain cannot
            // reach here; folding it as a fail vote is the conservative
            // read if that ever changes.
            pass: v.verdict == Verdict::Pass,
            score: v.score,
            note: v.reasoning.clone(),
            errored: false,
            weight: juror.weight,
        },
        Err(e) => JurorVerdict {
            model: juror.model.clone(),
            pass: false,
            score: None,
            note: Some(format!("juror errored: {e}")),
            errored: true,
            weight: juror.weight,
        },
    }
}

/// Build the [`JuryMetrics`] for one jury run from its agreement result and the
/// optional generator model.
///
/// Folds the already-computed agreement (alpha, band, escalation) with the
/// same-family bias signal so the `jury.*` targets all come from one place.
/// `same_family` is true when any juror's model shares a vendor family with
/// `generator_model`; `bias_warning` fires only when that signal lands on a
/// low-agreement run (the [`combined_bias_warning`] fold).
fn jury_metrics(
    config: &JuryConfig,
    agreement: &Agreement,
    generator_model: Option<&str>,
    style_drift: Option<f64>,
) -> JuryMetrics {
    let same_family = generator_model
        .map(|gen| {
            config
                .jurors
                .iter()
                .any(|j| detect_same_family(&j.model, gen))
        })
        .unwrap_or(false);
    JuryMetrics {
        agreement: agreement.krippendorff_alpha,
        confidence: agreement.confidence_band(),
        escalate: agreement.needs_escalation(),
        same_family,
        bias_warning: combined_bias_warning(same_family, agreement),
        style_drift,
    }
}

/// Apply the optional `min_agreement` gate (WOR-1301) to one jury run.
///
/// Returns `None` when the gate is unset or satisfied, and a human-readable
/// failure reason when alpha falls below the configured floor. An undefined
/// alpha (fewer than two scored jurors) also trips the gate: the config asked
/// for demonstrated agreement and the jury cannot demonstrate any.
fn agreement_gate(config: &JuryConfig, agreement: &Agreement) -> Option<String> {
    let min = config.min_agreement?;
    match agreement.krippendorff_alpha {
        Some(alpha) if alpha >= min => None,
        Some(alpha) => Some(format!(
            "jury agreement alpha {alpha:.2} below min_agreement {min:.2} (verdict inconclusive)"
        )),
        None => Some(format!(
            "jury agreement alpha undefined with fewer than two scored jurors, \
             min_agreement {min:.2} cannot be met (verdict inconclusive)"
        )),
    }
}

#[derive(Debug, Clone)]
struct JurorVerdict {
    model: String,
    pass: bool,
    score: Option<f64>,
    note: Option<String>,
    errored: bool,
    weight: f64,
}

/// Read the numeric score off a judge result's first diff row. Shared with
/// [`super::jury_bias`] so anchor grading reads scores exactly the way the
/// candidate path does.
pub(crate) fn extract_score(result: &AssertionResult) -> Option<f64> {
    result
        .diffs
        .first()
        .and_then(|d| d.actual.as_ref())
        .and_then(|v| v.as_f64())
}

fn extract_note(result: &AssertionResult) -> Option<String> {
    result.diffs.first().and_then(|d| d.note.clone())
}

fn fold_verdicts(
    votes: &[JurorVerdict],
    config: &JuryConfig,
    bias: &jury_bias::BiasReport,
) -> AssertionResult {
    let eligible: Vec<&JurorVerdict> = votes.iter().filter(|v| !v.errored).collect();
    let pass_count = eligible.iter().filter(|v| v.pass).count();
    let total = eligible.len().max(1);
    // The quorum compares summed passing weight against summed eligible
    // weight (ADR 0042). With every juror at the default weight of 1.0
    // this reduces to the head-count pass fraction. The head counts above
    // still drive the human-readable summary line.
    let pass_weight: f64 = eligible.iter().filter(|v| v.pass).map(|v| v.weight).sum();
    let eligible_weight: f64 = eligible.iter().map(|v| v.weight).sum();
    let pass_fraction = if eligible_weight > 0.0 {
        pass_weight / eligible_weight
    } else {
        0.0
    };
    let quorum_pass =
        pass_fraction >= config.quorum && !eligible.is_empty() && eligible_weight > 0.0;
    let scores: Vec<f64> = votes.iter().filter_map(|v| v.score).collect();
    let agreement = compute_agreement(&scores);
    let gate_failure = agreement_gate(config, &agreement);
    let pass = quorum_pass && gate_failure.is_none();
    let mut summary = format!(
        "jury: {pass_count}/{total} passing (quorum {:.2}, alpha {})",
        config.quorum,
        agreement
            .krippendorff_alpha
            .map(|a| format!("{a:.3}"))
            .unwrap_or_else(|| "n/a".to_string()),
    );
    // Active bias mitigations and their signals (WOR-1296). Rendered before
    // the gate reason so the `bias=[...]` tag sits next to the verdict line.
    if let Some(bias_suffix) = bias.render() {
        summary.push_str(&bias_suffix);
    }
    if let Some(reason) = &gate_failure {
        summary = format!("{summary}; {reason}");
    }
    let mut diffs = vec![AssertionDiff {
        path: String::new(),
        expected: Some(Value::from(config.quorum)),
        actual: Some(Value::from(pass_fraction)),
        note: Some(summary),
    }];
    diffs.extend(juror_diffs(votes));
    AssertionResult {
        kind: MatcherKind::LlmJury,
        pass,
        diffs,
        captures: Default::default(),
    }
}

/// Render each juror's verdict as one diff row so the reporter can show
/// the per-juror breakdown under the jury summary line. Split out of
/// [`fold_verdicts`] to keep that function under the 60-line ceiling.
fn juror_diffs(votes: &[JurorVerdict]) -> Vec<AssertionDiff> {
    votes
        .iter()
        .map(|v| {
            let verdict_label = if v.errored {
                "errored"
            } else if v.pass {
                "pass"
            } else {
                "fail"
            };
            let detail = v
                .note
                .as_ref()
                .map(|n| format!(": {n}"))
                .unwrap_or_default();
            AssertionDiff {
                path: format!("/jurors/{}", v.model),
                expected: None,
                actual: v.score.map(Value::from),
                note: Some(format!("{verdict_label}{detail}")),
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::eval::provider::{
        Capabilities, CompletionOptions, Cost, FinishReason, LlmProvider, Message, ProviderId,
        Response, ResponseContent, Usage,
    };
    use async_trait::async_trait;
    use serde_json::json;
    use std::sync::Mutex;

    struct CannedProvider {
        /// Replies served in order, one per `chat_completion` call.
        replies: Mutex<Vec<String>>,
        /// Message history of every call, for asserting on the prompt.
        calls: Mutex<Vec<Vec<Message>>>,
    }

    impl CannedProvider {
        fn new(reply: &str) -> Self {
            Self::with_replies(&[reply])
        }

        fn with_replies(replies: &[&str]) -> Self {
            Self {
                // Stored reversed so `pop` serves them in the given order.
                replies: Mutex::new(replies.iter().rev().map(|s| s.to_string()).collect()),
                calls: Mutex::new(Vec::new()),
            }
        }

        fn call_count(&self) -> usize {
            self.calls.lock().unwrap().len()
        }

        fn user_prompts(&self) -> Vec<String> {
            self.calls
                .lock()
                .unwrap()
                .iter()
                .flat_map(|messages| {
                    messages
                        .iter()
                        .filter(|m| m.role == crate::eval::provider::Role::User)
                        .map(|m| m.content.clone())
                        .collect::<Vec<_>>()
                })
                .collect()
        }
    }

    #[async_trait]
    impl LlmProvider for CannedProvider {
        fn name(&self) -> ProviderId {
            ProviderId::Anthropic
        }

        fn supported_capabilities(&self) -> Capabilities {
            Capabilities::none()
        }

        async fn chat_completion(
            &self,
            messages: &[Message],
            _options: &CompletionOptions,
        ) -> Result<Response, ProviderError> {
            self.calls.lock().unwrap().push(messages.to_vec());
            let text = self.replies.lock().unwrap().pop().unwrap_or_default();
            Ok(Response {
                content: ResponseContent::Text { text },
                finish_reason: FinishReason::Stop,
                usage: Usage::default(),
                model: "stub".into(),
            })
        }

        async fn count_tokens(&self, _text: &str, _model: &str) -> Result<u32, ProviderError> {
            Ok(0)
        }

        async fn embed(&self, _text: &str, _model: &str) -> Result<Vec<f32>, ProviderError> {
            Ok(vec![1.0])
        }

        fn estimate_cost(&self, _usage: &Usage, _model: &str) -> Cost {
            Cost::default()
        }
    }

    #[test]
    fn parse_rejects_empty_jurors_list() {
        let v = json!({"rubric": "x", "jurors": []});
        let err = parse_config(&v).unwrap_err();
        match err {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("at least one")),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }

    #[test]
    fn parse_accepts_minimal_jury() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}, {"model": "gpt"}],
        });
        let cfg = parse_config(&v).unwrap();
        assert_eq!(cfg.jurors.len(), 2);
        assert!((cfg.threshold - 0.7).abs() < 1e-9);
        assert!((cfg.quorum - 0.5).abs() < 1e-9);
        // max_cost defaults to None (unbounded), the pre-WOR-1387 behavior.
        assert_eq!(cfg.max_cost, None);
    }

    #[test]
    fn parse_accepts_a_max_cost_cap() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}],
            "max_cost": 0.5,
        });
        let cfg = parse_config(&v).unwrap();
        assert_eq!(cfg.max_cost, Some(0.5));
    }

    #[test]
    fn parse_rejects_a_negative_max_cost() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}],
            "max_cost": -1.0,
        });
        match parse_config(&v).unwrap_err() {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("max_cost")),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn unanimous_pass_is_a_pass() {
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(
                r#"{"pass": true, "score": 0.9, "reason": "ok"}"#,
            )),
            Box::new(CannedProvider::new(
                r#"{"pass": true, "score": 0.95, "reason": "ok"}"#,
            )),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 1.0,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(result.pass);
    }

    #[tokio::test]
    async fn evaluate_with_metrics_returns_a_populated_consensus() {
        // WOR-1384: the third tuple element is the reporter-facing consensus
        // built from the same votes, so the runner can fold it onto
        // TestResult.consensus instead of leaving the field silently None.
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(
                r#"{"pass": true, "score": 0.9, "reason": "ok"}"#,
            )),
            Box::new(CannedProvider::new(
                r#"{"pass": true, "score": 0.95, "reason": "ok"}"#,
            )),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 1.0,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let (result, _metrics, consensus) = evaluate_with_metrics(&cfg, &json!("hi"), &refs, None)
            .await
            .unwrap();
        assert!(result.pass);
        // One report Juror per declared juror, and a decided pass verdict.
        assert_eq!(consensus.jurors.len(), 2);
        assert_eq!(consensus.verdict, Verdict::Pass);
    }

    #[tokio::test]
    async fn split_decision_below_quorum_is_a_fail() {
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(
                r#"{"pass": true, "score": 0.9, "reason": "ok"}"#,
            )),
            Box::new(CannedProvider::new(
                r#"{"pass": false, "score": 0.3, "reason": "no"}"#,
            )),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 1.0,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(!result.pass);
    }

    #[tokio::test]
    async fn majority_quorum_passes_with_split_decision() {
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.9}"#)),
            Box::new(CannedProvider::new(r#"{"pass": false, "score": 0.3}"#)),
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.85}"#)),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "c".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(result.pass);
        // Diff first slot is the summary
        assert!(result.diffs[0].note.as_deref().unwrap().contains("2/3"));
    }

    #[test]
    fn parse_rejects_negative_weight() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude", "weight": -1.0}],
        });
        let err = parse_config(&v).unwrap_err();
        match err {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("non-negative")),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }

    #[test]
    fn parse_defaults_weight_to_one() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}, {"model": "gpt"}],
        });
        let cfg = parse_config(&v).unwrap();
        assert!((cfg.jurors[0].weight - 1.0).abs() < 1e-9);
        assert!((cfg.jurors[1].weight - 1.0).abs() < 1e-9);
    }

    #[tokio::test]
    async fn high_weight_dissenter_drags_jury_below_quorum() {
        // Two head-count passes and one fail would clear a 0.5 quorum, but
        // the failing juror carries weight 3.0, so the passing weight (2.0)
        // is below half the eligible weight (5.0) and the jury fails.
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.9}"#)),
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.85}"#)),
            Box::new(CannedProvider::new(r#"{"pass": false, "score": 0.2}"#)),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "c".into(),
                    threshold: None,
                    weight: 3.0,
                },
            ],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(!result.pass);
    }

    #[test]
    fn jury_metrics_flags_same_family_when_a_juror_shares_the_generator_family() {
        // A claude juror against a claude generator shares the anthropic
        // family, so same_family is true.
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "gpt-5".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "claude-sonnet-4-5".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let agreement = compute_agreement(&[0.9, 0.85]);
        let m = jury_metrics(&cfg, &agreement, Some("claude-opus-4-7"), None);
        assert!(m.same_family);
    }

    #[test]
    fn jury_metrics_same_family_is_false_without_a_generator_model() {
        // A tool-result jury has no generator model, so the signal is off.
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![JuryJuror {
                model: "claude-sonnet-4-5".into(),
                threshold: None,
                weight: 1.0,
            }],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let agreement = compute_agreement(&[0.9]);
        let m = jury_metrics(&cfg, &agreement, None, None);
        assert!(!m.same_family);
        assert!(m.bias_warning.is_none());
    }

    #[tokio::test]
    async fn high_weight_juror_pulls_jury_over_quorum() {
        // The mirror case: one heavy passing juror (weight 4.0) clears the
        // quorum against two light failing jurors.
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.95}"#)),
            Box::new(CannedProvider::new(r#"{"pass": false, "score": 0.3}"#)),
            Box::new(CannedProvider::new(r#"{"pass": false, "score": 0.25}"#)),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 4.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "c".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(result.pass);
    }

    fn three_split_jurors() -> (Vec<Box<dyn LlmProvider>>, Vec<JuryJuror>) {
        // Two passes and one hard dissent: clears a 0.5 quorum by head
        // count but lands well below alpha 0.66.
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.9}"#)),
            Box::new(CannedProvider::new(r#"{"pass": false, "score": 0.3}"#)),
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.85}"#)),
        ];
        let jurors = ["a", "b", "c"]
            .iter()
            .map(|m| JuryJuror {
                model: (*m).into(),
                threshold: None,
                weight: 1.0,
            })
            .collect();
        (providers, jurors)
    }

    #[tokio::test]
    async fn split_jury_below_min_agreement_fails_despite_passing_quorum() {
        let (providers, jurors) = three_split_jurors();
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors,
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: Some(0.66),
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(!result.pass);
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("below min_agreement"), "note was: {note}");
        assert!(note.contains("verdict inconclusive"), "note was: {note}");
    }

    #[tokio::test]
    async fn unset_min_agreement_preserves_quorum_verdict_on_split_jury() {
        // The same split jury as the gated test above, but with the gate
        // unset, the pre-WOR-1301 quorum verdict stands.
        let (providers, jurors) = three_split_jurors();
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors,
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(result.pass);
    }

    #[tokio::test]
    async fn unanimous_jury_clears_min_agreement_gate() {
        let providers: Vec<Box<dyn LlmProvider>> = vec![
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.9}"#)),
            Box::new(CannedProvider::new(r#"{"pass": true, "score": 0.95}"#)),
        ];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![
                JuryJuror {
                    model: "a".into(),
                    threshold: None,
                    weight: 1.0,
                },
                JuryJuror {
                    model: "b".into(),
                    threshold: None,
                    weight: 1.0,
                },
            ],
            threshold: 0.7,
            quorum: 1.0,
            min_agreement: Some(0.66),
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(result.pass);
    }

    #[tokio::test]
    async fn min_agreement_on_single_juror_fails_for_undefined_alpha() {
        // Alpha is undefined with one scored juror, so a configured gate
        // cannot be satisfied and the verdict is inconclusive.
        let providers: Vec<Box<dyn LlmProvider>> = vec![Box::new(CannedProvider::new(
            r#"{"pass": true, "score": 0.9}"#,
        ))];
        let refs: Vec<&dyn LlmProvider> = providers.iter().map(|p| p.as_ref()).collect();
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![JuryJuror {
                model: "a".into(),
                threshold: None,
                weight: 1.0,
            }],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: Some(0.5),
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(!result.pass);
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("alpha undefined"), "note was: {note}");
    }

    #[test]
    fn parse_rejects_min_agreement_above_one() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}],
            "min_agreement": 1.5,
        });
        let err = parse_config(&v).unwrap_err();
        match err {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("min_agreement")),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_negative_min_agreement() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}],
            "min_agreement": -0.1,
        });
        let err = parse_config(&v).unwrap_err();
        match err {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("must be in [0, 1]")),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }

    #[test]
    fn parse_defaults_min_agreement_to_none_and_accepts_in_range_value() {
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}, {"model": "gpt"}],
        });
        assert!(parse_config(&v).unwrap().min_agreement.is_none());
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}, {"model": "gpt"}],
            "min_agreement": 0.66,
        });
        let cfg = parse_config(&v).unwrap();
        assert!((cfg.min_agreement.unwrap() - 0.66).abs() < 1e-9);
    }

    // -- WOR-1296: bias mitigations ------------------------------------------

    use crate::matchers::jury_bias::BiasAnchor;

    fn juror(model: &str) -> JuryJuror {
        JuryJuror {
            model: model.into(),
            threshold: None,
            weight: 1.0,
        }
    }

    fn jury_with_bias(bias: Option<BiasConfig>, jurors: Vec<JuryJuror>) -> JuryConfig {
        JuryConfig {
            rubric: "Is the answer helpful?".into(),
            jurors,
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias,
            max_cost: None,
        }
    }

    #[test]
    fn parse_defaults_bias_block_to_all_mitigations_off() {
        let v = json!({"rubric": "x", "jurors": [{"model": "claude"}]});
        assert!(parse_config(&v).unwrap().bias.is_none());
        let v = json!({"rubric": "x", "jurors": [{"model": "claude"}], "bias": {}});
        let bias = parse_config(&v).unwrap().bias.unwrap();
        assert!(!bias.chain_of_thought);
        assert!(bias.verbosity_guard.is_none());
        assert!(bias.anchors.is_empty());
        assert!(!bias.style_swap);
    }

    #[test]
    fn parse_rejects_bias_verbosity_guard_at_or_below_one() {
        for bad in [json!(1.0), json!(0.5), json!(-2.0)] {
            let v = json!({
                "rubric": "x",
                "jurors": [{"model": "claude"}],
                "bias": {"verbosity_guard": bad},
            });
            let err = parse_config(&v).unwrap_err();
            match err {
                Error::InvalidMatcherInput(msg) => {
                    assert!(msg.contains("verbosity_guard"), "{msg}")
                }
                other => panic!("expected InvalidMatcherInput, got {other:?}"),
            }
        }
    }

    #[test]
    fn parse_rejects_bias_anchor_expected_outside_unit_interval() {
        for bad in [json!(-0.1), json!(1.5)] {
            let v = json!({
                "rubric": "x",
                "jurors": [{"model": "claude"}],
                "bias": {"anchors": [{"candidate": "good", "expected": bad}]},
            });
            let err = parse_config(&v).unwrap_err();
            match err {
                Error::InvalidMatcherInput(msg) => {
                    assert!(msg.contains("anchor expected score"), "{msg}")
                }
                other => panic!("expected InvalidMatcherInput, got {other:?}"),
            }
        }
    }

    #[test]
    fn parse_rejects_unknown_bias_field() {
        // Position swap rides the engine's pairwise dispatch; its YAML knob
        // is follow-up noted on WOR-1296, so the bias block rejects it.
        let v = json!({
            "rubric": "x",
            "jurors": [{"model": "claude"}],
            "bias": {"position_swap": true},
        });
        let err = parse_config(&v).unwrap_err();
        match err {
            Error::InvalidMatcherInput(msg) => assert!(msg.contains("position_swap"), "{msg}"),
            other => panic!("expected InvalidMatcherInput, got {other:?}"),
        }
    }

    fn cot_bias() -> BiasConfig {
        BiasConfig {
            chain_of_thought: true,
            verbosity_guard: None,
            style_guard: None,
            anchors: Vec::new(),
            style_swap: false,
        }
    }

    #[tokio::test]
    async fn chain_of_thought_flag_adds_reasoning_instruction_to_juror_prompts() {
        let provider = CannedProvider::new(r#"{"pass": true, "score": 0.9}"#);
        let cfg = jury_with_bias(Some(cot_bias()), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        let prompts = provider.user_prompts();
        assert_eq!(prompts.len(), 1);
        assert!(
            prompts[0].contains("Think step by step before deciding"),
            "prompt: {}",
            prompts[0]
        );
    }

    #[tokio::test]
    async fn default_jury_prompt_carries_no_chain_of_thought_instruction() {
        let provider = CannedProvider::new(r#"{"pass": true, "score": 0.9}"#);
        let cfg = jury_with_bias(None, vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        let prompts = provider.user_prompts();
        assert!(
            !prompts[0].contains("Think step by step"),
            "prompt: {}",
            prompts[0]
        );
    }

    #[tokio::test]
    async fn verbosity_guard_warning_lands_in_the_jury_note() {
        let long_reason = "word ".repeat(40);
        let reply = format!(
            r#"{{"pass": true, "score": 0.9, "reason": "{}"}}"#,
            long_reason.trim()
        );
        let provider = CannedProvider::new(&reply);
        let bias = BiasConfig {
            chain_of_thought: false,
            verbosity_guard: Some(2.0),
            style_guard: None,
            anchors: Vec::new(),
            style_swap: false,
        };
        let cfg = jury_with_bias(Some(bias), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let result = evaluate(&cfg, &json!("short candidate"), &refs)
            .await
            .unwrap();
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("bias=[verbosity_guard]"), "note: {note}");
        assert!(note.contains("verbosity: juror `a`"), "note: {note}");
        assert!(note.contains("longer response is"), "note: {note}");
    }

    #[tokio::test]
    async fn verbosity_guard_stays_silent_for_comparable_lengths() {
        let provider =
            CannedProvider::new(r#"{"pass": true, "score": 0.9, "reason": "fine answer overall"}"#);
        let bias = BiasConfig {
            chain_of_thought: false,
            verbosity_guard: Some(2.0),
            style_guard: None,
            anchors: Vec::new(),
            style_swap: false,
        };
        let cfg = jury_with_bias(Some(bias), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let result = evaluate(&cfg, &json!("hi there friend ok"), &refs)
            .await
            .unwrap();
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("bias=[verbosity_guard]"), "note: {note}");
        assert!(!note.contains("verbosity:"), "note: {note}");
    }

    fn anchor_bias(expected: f64) -> BiasConfig {
        BiasConfig {
            chain_of_thought: false,
            verbosity_guard: None,
            style_guard: None,
            anchors: vec![BiasAnchor {
                candidate: json!("a known good answer"),
                expected,
            }],
            style_swap: false,
        }
    }

    #[tokio::test]
    async fn anchors_flag_a_juror_that_deviates_from_the_expected_score() {
        // Anchor expects 0.9. Juror `good` scores it 0.85 (within the 0.2
        // threshold); juror `drifty` scores it 0.2 (deviation 0.7, flagged).
        let good = CannedProvider::with_replies(&[
            r#"{"pass": true, "score": 0.85}"#, // anchor call
            r#"{"pass": true, "score": 0.9}"#,  // candidate call
        ]);
        let drifty = CannedProvider::with_replies(&[
            r#"{"pass": false, "score": 0.2}"#, // anchor call
            r#"{"pass": true, "score": 0.88}"#, // candidate call
        ]);
        let cfg = jury_with_bias(Some(anchor_bias(0.9)), vec![juror("good"), juror("drifty")]);
        let refs: Vec<&dyn LlmProvider> = vec![&good, &drifty];
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert_eq!(good.call_count(), 2, "one anchor call plus the candidate");
        assert_eq!(drifty.call_count(), 2, "one anchor call plus the candidate");
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("bias=[anchors]"), "note: {note}");
        assert!(
            note.contains("miscalibrated jurors: drifty"),
            "note: {note}"
        );
        assert!(!note.contains("miscalibrated jurors: good"), "note: {note}");
    }

    #[tokio::test]
    async fn anchors_stay_quiet_when_every_juror_is_calibrated() {
        let provider = CannedProvider::with_replies(&[
            r#"{"pass": true, "score": 0.85}"#, // anchor call
            r#"{"pass": true, "score": 0.9}"#,  // candidate call
        ]);
        let cfg = jury_with_bias(Some(anchor_bias(0.9)), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("bias=[anchors]"), "note: {note}");
        assert!(!note.contains("miscalibrated"), "note: {note}");
    }

    #[tokio::test]
    async fn jury_note_lists_every_active_mitigation() {
        let provider =
            CannedProvider::new(r#"{"pass": true, "score": 0.9, "reason": "short note"}"#);
        let bias = BiasConfig {
            chain_of_thought: true,
            verbosity_guard: Some(3.0),
            style_guard: None,
            anchors: Vec::new(),
            style_swap: false,
        };
        let cfg = jury_with_bias(Some(bias), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let result = evaluate(&cfg, &json!("hello there friendly assistant"), &refs)
            .await
            .unwrap();
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("bias=[cot,verbosity_guard]"), "note: {note}");
    }

    #[tokio::test]
    async fn jury_note_omits_the_bias_tag_when_no_mitigation_is_configured() {
        let provider = CannedProvider::new(r#"{"pass": true, "score": 0.9}"#);
        let cfg = jury_with_bias(None, vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(!note.contains("bias="), "note: {note}");
    }

    // -- WOR-1370: style-bias swap -------------------------------------------

    fn style_swap_bias() -> BiasConfig {
        BiasConfig {
            chain_of_thought: false,
            verbosity_guard: None,
            style_guard: None,
            anchors: Vec::new(),
            style_swap: true,
        }
    }

    #[tokio::test]
    async fn style_swap_reports_the_verdict_drift_as_jury_style_drift() {
        // The juror scores the plain candidate 0.6 and the formatting-heavy
        // restyling 0.9, so style swap measures a 0.30 drift: the verdict
        // moved on formatting alone. The plain grade is the first call, the
        // styled grade the second.
        let provider = CannedProvider::with_replies(&[
            r#"{"pass": false, "score": 0.6}"#, // plain candidate grade
            r#"{"pass": true, "score": 0.9}"#,  // restyled candidate grade
        ]);
        let cfg = jury_with_bias(Some(style_swap_bias()), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let (_result, metrics, _consensus) =
            evaluate_with_metrics(&cfg, &json!("a plain answer"), &refs, None)
                .await
                .unwrap();
        assert_eq!(
            provider.call_count(),
            2,
            "one plain grade plus one restyled grade"
        );
        let drift = metrics.style_drift.expect("style swap reports a drift");
        assert!((drift - 0.3).abs() < 1e-9, "drift was {drift}");
    }

    #[tokio::test]
    async fn style_drift_stays_none_when_style_swap_is_off() {
        let provider = CannedProvider::new(r#"{"pass": true, "score": 0.9}"#);
        let cfg = jury_with_bias(None, vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let (_result, metrics, _consensus) =
            evaluate_with_metrics(&cfg, &json!("a plain answer"), &refs, None)
                .await
                .unwrap();
        assert_eq!(provider.call_count(), 1, "no extra grade without the probe");
        assert!(metrics.style_drift.is_none());
    }

    #[tokio::test]
    async fn style_swap_folds_the_max_drift_across_jurors() {
        // Juror `calm` drifts 0.05, juror `swingy` drifts 0.40. The folded
        // jury.style_drift is the larger (most conservative) signal.
        let calm = CannedProvider::with_replies(&[
            r#"{"pass": true, "score": 0.80}"#, // plain
            r#"{"pass": true, "score": 0.85}"#, // restyled (drift 0.05)
        ]);
        let swingy = CannedProvider::with_replies(&[
            r#"{"pass": false, "score": 0.50}"#, // plain
            r#"{"pass": true, "score": 0.90}"#,  // restyled (drift 0.40)
        ]);
        let cfg = jury_with_bias(
            Some(style_swap_bias()),
            vec![juror("calm"), juror("swingy")],
        );
        let refs: Vec<&dyn LlmProvider> = vec![&calm, &swingy];
        let (_result, metrics, _consensus) =
            evaluate_with_metrics(&cfg, &json!("a plain answer"), &refs, None)
                .await
                .unwrap();
        let drift = metrics.style_drift.expect("style swap reports a drift");
        assert!((drift - 0.4).abs() < 1e-9, "max drift was {drift}");
    }

    #[tokio::test]
    async fn style_swap_tag_lands_in_the_jury_note() {
        let provider = CannedProvider::with_replies(&[
            r#"{"pass": true, "score": 0.9}"#,
            r#"{"pass": true, "score": 0.9}"#,
        ]);
        let cfg = jury_with_bias(Some(style_swap_bias()), vec![juror("a")]);
        let refs: Vec<&dyn LlmProvider> = vec![&provider];
        let result = evaluate(&cfg, &json!("a plain answer"), &refs)
            .await
            .unwrap();
        let note = result.diffs[0].note.as_deref().unwrap();
        assert!(note.contains("bias=[style_swap]"), "note: {note}");
    }

    // -- WOR-1308: fan-out through the engine dispatch loop ------------------

    /// Provider whose every call fails with a rate limit, so a test can
    /// prove one broken juror does not abort the jury.
    struct RateLimitedProvider;

    #[async_trait]
    impl LlmProvider for RateLimitedProvider {
        fn name(&self) -> ProviderId {
            ProviderId::Openai
        }

        fn supported_capabilities(&self) -> Capabilities {
            Capabilities::none()
        }

        async fn chat_completion(
            &self,
            _messages: &[Message],
            _options: &CompletionOptions,
        ) -> Result<Response, ProviderError> {
            Err(ProviderError::RateLimit { retry_after: None })
        }

        async fn count_tokens(&self, _text: &str, _model: &str) -> Result<u32, ProviderError> {
            Ok(0)
        }

        async fn embed(&self, _text: &str, _model: &str) -> Result<Vec<f32>, ProviderError> {
            Ok(vec![1.0])
        }

        fn estimate_cost(&self, _usage: &Usage, _model: &str) -> Cost {
            Cost::default()
        }
    }

    #[tokio::test]
    async fn matcher_dispatch_goes_through_the_engine() {
        // The fan-out converged on the engine's dispatch loop (WOR-1308):
        // every juror seat is dispatched exactly once, in order, and the
        // fold sees one vote per juror. A double dispatch (the old matcher
        // loop running next to the engine loop) would double each provider's
        // call count; a skipped seat would zero one of them.
        let a = CannedProvider::new(r#"{"pass": true, "score": 0.9}"#);
        let b = CannedProvider::new(r#"{"pass": true, "score": 0.85}"#);
        let c = CannedProvider::new(r#"{"pass": false, "score": 0.2}"#);
        let refs: Vec<&dyn LlmProvider> = vec![&a, &b, &c];
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![juror("a"), juror("b"), juror("c")],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs).await.unwrap();
        assert!(result.pass);
        assert_eq!(a.call_count(), 1, "juror a dispatched exactly once");
        assert_eq!(b.call_count(), 1, "juror b dispatched exactly once");
        assert_eq!(c.call_count(), 1, "juror c dispatched exactly once");
        // The summary plus one row per juror proves every seat reached the
        // fold.
        assert_eq!(result.diffs.len(), 4);
    }

    #[tokio::test]
    async fn juror_error_is_isolated_not_fatal() {
        // One rate-limited juror abstains; the two healthy jurors still
        // clear the 0.5 quorum and the jury reports a verdict instead of a
        // provider error.
        let good = CannedProvider::new(r#"{"pass": true, "score": 0.9}"#);
        let flaky = RateLimitedProvider;
        let also_good = CannedProvider::new(r#"{"pass": true, "score": 0.92}"#);
        let refs: Vec<&dyn LlmProvider> = vec![&good, &flaky, &also_good];
        let cfg = JuryConfig {
            rubric: "x".into(),
            jurors: vec![juror("a"), juror("b"), juror("c")],
            threshold: 0.7,
            quorum: 0.5,
            min_agreement: None,
            bias: None,
            max_cost: None,
        };
        let result = evaluate(&cfg, &json!("hi"), &refs)
            .await
            .expect("a failed juror must not surface as a provider error");
        assert!(result.pass, "two healthy jurors clear the quorum");
        let flaky_row = result
            .diffs
            .iter()
            .find(|d| d.path == "/jurors/b")
            .expect("errored juror stays in the report");
        let note = flaky_row.note.as_deref().unwrap();
        assert!(note.starts_with("errored"), "note: {note}");
        assert!(note.contains("juror errored:"), "note: {note}");
        // The summary counts only eligible jurors.
        let summary = result.diffs[0].note.as_deref().unwrap();
        assert!(summary.contains("2/2"), "summary: {summary}");
    }
}
