Skip to main content

lc_evaluation/
pairwise.rs

1//! Pairwise-comparison evaluator: an LLM judge picks the better of two answers (arena mode).
2//!
3//! Comes with position-bias mitigation: runs twice with A/B swapped, and only a consistent winner counts; otherwise it is a tie.
4
5use async_trait::async_trait;
6use futures_util::future;
7use serde::Deserialize;
8
9use lc_core::judge::{structured_call, truncate, StructuredJudgeError};
10use lc_core::tools::ToolDefinition;
11use lc_core::BaseChatModel;
12use lc_schema::Message;
13
14use super::{EvalError, PairwiseEvaluator, Score};
15
16/// Pairwise comparison result
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Verdict {
19    /// Answer A is better
20    AWins,
21    /// Answer B is better
22    BWins,
23    /// Tie
24    Tie,
25}
26
27/// Which position the judge picked
28#[derive(Debug, Clone, PartialEq, Eq)]
29enum Pick {
30    First,
31    Second,
32    Tie,
33}
34
35/// Pairwise-comparison evaluator (an LLM as the judge, picks one of two).
36///
37/// P1-1: implements the `PairwiseEvaluator` trait and can join the pointwise `Evaluator`s in `EvalRunner`
38/// unified report; calling `compare` directly still yields the fine-grained `Verdict` (A wins / B wins / tie).
39pub struct PairwiseJudge<M: BaseChatModel> {
40    judge: M,
41    rubric: String,
42}
43
44const DEFAULT_PAIRWISE_RUBRIC: &str = "\
45正确性:回答是否事实准确、是否切题。
46完整性:是否完整回答了问题。
47清晰性:表达是否清晰、简洁。";
48
49impl<M: BaseChatModel> PairwiseJudge<M> {
50    /// Creates a pairwise-comparison evaluator using the default rubric.
51    pub fn new(judge: M) -> Self {
52        Self {
53            judge,
54            rubric: DEFAULT_PAIRWISE_RUBRIC.to_string(),
55        }
56    }
57
58    /// Sets a custom rubric (builder style).
59    pub fn with_rubric(mut self, rubric: impl Into<String>) -> Self {
60        self.rubric = rubric.into();
61        self
62    }
63
64    /// Compares answers A and B, returning which is better.
65    ///
66    /// Runs twice with A/B swapped to eliminate position bias: a consistent winner in both counts,
67    /// otherwise a tie. P2-4: the two asks are independent and fire concurrently via `future::join`
68    /// (eliminating the N+1 serial round-trips).
69    pub async fn compare(&self, input: &str, a: &str, b: &str) -> Result<Verdict, EvalError> {
70        let (v1, v2) = future::join(self.ask(input, a, b), self.ask(input, b, a)).await;
71        let v1 = v1?; // A first
72        let v2 = v2?; // swapped, B first
73
74        Ok(match (v1, v2) {
75            (Pick::Tie, _) | (_, Pick::Tie) => Verdict::Tie,
76            (Pick::First, Pick::Second) => Verdict::AWins, // v1 picks A (first), v2 picks A (second)
77            (Pick::Second, Pick::First) => Verdict::BWins, // v1 picks B (second), v2 picks B (first)
78            _ => Verdict::Tie, // position bias: both rounds picked the same position but it maps to different answers
79        })
80    }
81
82    async fn ask(&self, input: &str, first: &str, second: &str) -> Result<Pick, EvalError> {
83        let system = format!(
84            "你是裁判。根据评分标准,判断两个回答哪个更好。调用 pick_better 工具提交判定。\n\n\
85             评分标准:\n{rubric}\n\n\
86             判定的 verdict 取三者之一:\"a\"(第一个更好) / \"b\"(第二个更好) / \"tie\"(平局)",
87            rubric = self.rubric
88        );
89        let user =
90            format!("题目:\n{input}\n\n第一个回答:\n{first}\n\n第二个回答:\n{second}\n\n哪个更好?");
91        let messages = vec![Message::system(system), Message::human(user)];
92
93        // P0-1: prefer structured output (verdict: a/b/tie); models without tool binding fall back to text parsing.
94        let args: PickArgs = structured_call(&self.judge, pick_tool(), messages, |raw| {
95            let pick = parse_pick(raw).ok_or_else(|| {
96                StructuredJudgeError::Parse(format!(
97                    "failed to parse winner from judge reply: {}",
98                    truncate(raw, 200)
99                ))
100            })?;
101            Ok(PickArgs {
102                verdict: pick_to_str(pick).to_string(),
103                reason: String::new(),
104            })
105        })
106        .await?;
107        str_to_pick(&args.verdict)
108    }
109}
110
111/// P1-1: enters `EvalRunner` as a `PairwiseEvaluator`, judging the two candidates
112/// as (a=prediction, b=reference). Score mapping: 1.0 = A wins,
113/// 0.5 = tie, 0.0 = B wins, and the label keeps the verdict meaning (a_wins / tie / b_wins).
114#[async_trait]
115impl<M: BaseChatModel> PairwiseEvaluator for PairwiseJudge<M> {
116    async fn eval_pair(&self, input: &str, a: &str, b: &str) -> Result<Score, EvalError> {
117        let (value, label) = match self.compare(input, a, b).await? {
118            Verdict::AWins => (1.0, "a_wins"),
119            Verdict::Tie => (0.5, "tie"),
120            Verdict::BWins => (0.0, "b_wins"),
121        };
122        Ok(Score::new(value).with_label(label))
123    }
124
125    fn name(&self) -> &str {
126        "pairwise"
127    }
128}
129
130/// Structured verdict arguments (returned via tool_calls).
131#[derive(Debug, Deserialize)]
132struct PickArgs {
133    verdict: String, // "a" | "b" | "tie"
134    /// Asks the LLM to attach a brief reason (improves judgment quality); currently unused.
135    #[serde(default)]
136    #[allow(dead_code)]
137    reason: String,
138}
139
140/// Builds the pick-one-of-two tool: lets the LLM submit a verdict as `{"verdict": "a"|"b"|"tie", "reason": "..."}`.
141fn pick_tool() -> ToolDefinition {
142    ToolDefinition::new(
143        "pick_better",
144        "判断两个回答哪个更好。verdict 取 \"a\"(第一个更好)、\"b\"(第二个更好)、\"tie\"(平局)。",
145    )
146    .with_parameters(serde_json::json!({
147        "type": "object",
148        "properties": {
149            "verdict": {
150                "type": "string",
151                "enum": ["a", "b", "tie"],
152                "description": "a=第一个更好, b=第二个更好, tie=平局"
153            },
154            "reason": { "type": "string", "description": "简短理由" }
155        },
156        "required": ["verdict", "reason"]
157    }))
158}
159
160fn pick_to_str(pick: Pick) -> &'static str {
161    match pick {
162        Pick::First => "a",
163        Pick::Second => "b",
164        Pick::Tie => "tie",
165    }
166}
167
168/// Maps a structured verdict string back to `Pick`; an invalid value reports a parse error.
169fn str_to_pick(verdict: &str) -> Result<Pick, EvalError> {
170    match verdict {
171        "a" => Ok(Pick::First),
172        "b" => Ok(Pick::Second),
173        "tie" => Ok(Pick::Tie),
174        other => Err(EvalError::ParseError(format!(
175            "judge returned invalid verdict: {}",
176            other
177        ))),
178    }
179}
180
181/// Parses a judge reply into a Pick. With no valid marker returns `None` (parse failure, reported by the caller),
182/// rather than silently defaulting to a tie — so an off-topic LLM reply is not read as "no preference".
183fn parse_pick(raw: &str) -> Option<Pick> {
184    let lower = raw.to_lowercase();
185    if lower.contains("平局") || lower.contains("tie") || lower.contains("一样") {
186        return Some(Pick::Tie);
187    }
188    // "first"/"former" wordings: any phrasing, take the earliest occurrence position
189    let first_pos = ["第一个", "first", "前者", "former"]
190        .into_iter()
191        .filter_map(|kw| lower.find(kw))
192        .min();
193    // "second"/"latter" wordings
194    let second_pos = ["第二个", "second", "后者", "latter"]
195        .into_iter()
196        .filter_map(|kw| lower.find(kw))
197        .min();
198    match (first_pos, second_pos) {
199        (Some(f), Some(s)) if f < s => Some(Pick::First),
200        (Some(_), Some(_)) => Some(Pick::Second),
201        (Some(_), None) => Some(Pick::First),
202        (None, Some(_)) => Some(Pick::Second),
203        (None, None) => None,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use async_trait::async_trait;
211    use futures_util::Stream;
212    use lc_core::language_models::{LLMResult, StreamChunk};
213    use lc_core::{BaseLanguageModel, Runnable, RunnableConfig};
214    use std::pin::Pin;
215    use std::sync::atomic::{AtomicUsize, Ordering};
216    use std::sync::Arc;
217
218    #[derive(Debug)]
219    struct JudgeError(String);
220    impl std::fmt::Display for JudgeError {
221        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222            write!(f, "{}", self.0)
223        }
224    }
225    impl std::error::Error for JudgeError {}
226
227    /// Mock judge returning preset replies in order
228    struct SeqMockJudge {
229        replies: Vec<String>,
230        call: Arc<AtomicUsize>,
231    }
232    impl SeqMockJudge {
233        fn new(replies: Vec<String>) -> Self {
234            Self {
235                replies,
236                call: Arc::new(AtomicUsize::new(0)),
237            }
238        }
239    }
240
241    #[async_trait]
242    impl Runnable<Vec<Message>, LLMResult> for SeqMockJudge {
243        type Error = JudgeError;
244        async fn invoke(
245            &self,
246            _input: Vec<Message>,
247            _config: Option<RunnableConfig>,
248        ) -> Result<LLMResult, Self::Error> {
249            Err(JudgeError("use chat".into()))
250        }
251    }
252
253    #[async_trait]
254    impl BaseLanguageModel<Vec<Message>, LLMResult> for SeqMockJudge {
255        fn model_name(&self) -> &str {
256            "seq-mock"
257        }
258        fn get_num_tokens(&self, t: &str) -> usize {
259            t.len()
260        }
261        fn with_temperature(self, _: f32) -> Self {
262            self
263        }
264        fn with_max_tokens(self, _: usize) -> Self {
265            self
266        }
267    }
268
269    #[async_trait]
270    impl BaseChatModel for SeqMockJudge {
271        async fn chat(
272            &self,
273            _messages: Vec<Message>,
274            _config: Option<RunnableConfig>,
275        ) -> Result<LLMResult, Self::Error> {
276            let idx = self.call.fetch_add(1, Ordering::SeqCst);
277            let reply = self.replies.get(idx).cloned().unwrap_or_default();
278            Ok(LLMResult {
279                content: reply,
280                model: "seq-mock".to_string(),
281                token_usage: None,
282                tool_calls: None,
283                thinking_content: None,
284            })
285        }
286        async fn stream_chat(
287            &self,
288            _messages: Vec<Message>,
289            _config: Option<RunnableConfig>,
290        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
291        {
292            Err(JudgeError("not supported".into()))
293        }
294    }
295
296    #[tokio::test]
297    async fn test_pairwise_a_wins() {
298        // first round (A first) picks the first = A; second round (B first) picks the second = A => A wins
299        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
300            "第一个更好".into(),
301            "第二个更好".into(),
302        ]));
303        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::AWins);
304    }
305
306    #[tokio::test]
307    async fn test_pairwise_b_wins() {
308        // first round (A first) picks the second = B; second round (B first) picks the first = B => B wins
309        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
310            "第二个更好".into(),
311            "第一个更好".into(),
312        ]));
313        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::BWins);
314    }
315
316    #[tokio::test]
317    async fn test_pairwise_position_bias_tie() {
318        // judge always picks the first (position bias): both rounds pick first => maps to different answers => tie
319        let judge = PairwiseJudge::new(SeqMockJudge::new(vec![
320            "第一个更好".into(),
321            "第一个更好".into(),
322        ]));
323        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
324    }
325
326    #[tokio::test]
327    async fn test_pairwise_explicit_tie() {
328        let judge = PairwiseJudge::new(SeqMockJudge::new(vec!["平局".into(), "平局".into()]));
329        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
330    }
331
332    #[test]
333    fn test_parse_pick() {
334        assert_eq!(parse_pick("第一个更好"), Some(Pick::First));
335        assert_eq!(parse_pick("第二个更好"), Some(Pick::Second));
336        assert_eq!(parse_pick("平局"), Some(Pick::Tie));
337        assert_eq!(parse_pick("两个一样好"), Some(Pick::Tie));
338        assert_eq!(parse_pick("第二个比第一个好"), Some(Pick::Second));
339        // "former"/"latter" wordings: the LLM may not reply in the "first"/"second" format
340        assert_eq!(parse_pick("前者更好"), Some(Pick::First));
341        assert_eq!(parse_pick("后者更准确"), Some(Pick::Second));
342        assert_eq!(parse_pick("the former is better"), Some(Pick::First));
343        assert_eq!(parse_pick("the latter wins"), Some(Pick::Second));
344        // no valid marker = parse failure, must not silently default to a tie
345        assert_eq!(parse_pick("我无法判断"), None);
346    }
347
348    /// P0-1: models supporting bind_tools use structured output (verdict: a/b/tie).
349    #[tokio::test]
350    async fn test_pairwise_structured_verdict() {
351        use crate::test_support::ToolJudge;
352        // A wins: round 1 (A first) picks "a" (first = A), round 2 (B first) picks "b" (second = A)
353        let judge = PairwiseJudge::new(ToolJudge::sequence(vec![
354            r#"{"verdict": "a", "reason": "第一个更完整"}"#.into(),
355            r#"{"verdict": "b", "reason": "第二个更完整"}"#.into(),
356        ]));
357        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::AWins);
358    }
359
360    #[tokio::test]
361    async fn test_pairwise_structured_verdict_b() {
362        use crate::test_support::ToolJudge;
363        // B wins: round 1 (A first) picks "b" (second = B), round 2 (B first) picks "a" (first = B)
364        let judge = PairwiseJudge::new(ToolJudge::sequence(vec![
365            r#"{"verdict": "b", "reason": "第二个更准确"}"#.into(),
366            r#"{"verdict": "a", "reason": "第一个更准确"}"#.into(),
367        ]));
368        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::BWins);
369    }
370
371    #[tokio::test]
372    async fn test_pairwise_structured_verdict_tie() {
373        use crate::test_support::ToolJudge;
374        let judge = PairwiseJudge::new(ToolJudge::new(
375            r#"{"verdict": "tie", "reason": "难分高下"}"#,
376        ));
377        assert_eq!(judge.compare("q", "A", "B").await.unwrap(), Verdict::Tie);
378    }
379}