Skip to main content

pictor_eval/
boolq.rs

1//! BoolQ yes/no question answering evaluation harness.
2//!
3//! BoolQ (Clark et al., 2019) tests reading comprehension via binary yes/no
4//! questions. Each item has a passage, a question, and a boolean answer.
5//!
6//! # Example
7//!
8//! ```rust
9//! use pictor_eval::boolq::{BoolQDataset, BoolQEvaluator, BoolQItem};
10//!
11//! let evaluator = BoolQEvaluator::new();
12//! let answer = BoolQEvaluator::extract_answer("Yes, that is correct.");
13//! assert_eq!(answer, Some(true));
14//! ```
15
16// ──────────────────────────────────────────────────────────────────────────────
17// BoolQItem
18// ──────────────────────────────────────────────────────────────────────────────
19
20/// A single BoolQ dataset item.
21#[derive(Debug, Clone, PartialEq)]
22pub struct BoolQItem {
23    /// The context passage from which the answer can be inferred.
24    pub passage: String,
25    /// The yes/no question about the passage.
26    pub question: String,
27    /// Gold answer: `true` = yes, `false` = no.
28    pub answer: bool,
29}
30
31// ──────────────────────────────────────────────────────────────────────────────
32// BoolQDataset
33// ──────────────────────────────────────────────────────────────────────────────
34
35/// A collection of [`BoolQItem`] instances.
36pub struct BoolQDataset {
37    /// All items in insertion order.
38    pub items: Vec<BoolQItem>,
39}
40
41impl BoolQDataset {
42    /// Create a dataset from a vector of items.
43    pub fn from_items(items: Vec<BoolQItem>) -> Self {
44        Self { items }
45    }
46
47    /// Return the number of items in the dataset.
48    pub fn len(&self) -> usize {
49        self.items.len()
50    }
51
52    /// Return `true` if the dataset contains no items.
53    pub fn is_empty(&self) -> bool {
54        self.items.is_empty()
55    }
56}
57
58// ──────────────────────────────────────────────────────────────────────────────
59// BoolQResult
60// ──────────────────────────────────────────────────────────────────────────────
61
62/// Aggregated result from a BoolQ evaluation pass.
63#[derive(Debug, Clone)]
64pub struct BoolQResult {
65    /// Fraction of items answered correctly (0.0–1.0).
66    pub accuracy: f32,
67    /// Accuracy as a percentage (0.0–100.0).
68    pub accuracy_pct: f32,
69    /// Number of correctly answered items.
70    pub correct: usize,
71    /// Total items evaluated (excludes items beyond the shorter of dataset/completions).
72    pub total: usize,
73    /// Number of items where the model predicted "yes" (true).
74    pub yes_predicted: usize,
75    /// Number of items where the model predicted "no" (false).
76    pub no_predicted: usize,
77}
78
79// ──────────────────────────────────────────────────────────────────────────────
80// BoolQEvaluator
81// ──────────────────────────────────────────────────────────────────────────────
82
83/// Evaluator for BoolQ yes/no question answering.
84///
85/// The evaluator is stateless; all scoring logic is encoded in its methods.
86/// It implements [`Default`] for use with frameworks that require that bound.
87pub struct BoolQEvaluator;
88
89impl BoolQEvaluator {
90    /// Create a new evaluator (equivalent to [`Default::default`]).
91    pub fn new() -> Self {
92        Self
93    }
94
95    /// Build a prompt string for a BoolQ item.
96    ///
97    /// Format: `"Passage: {passage}\nQuestion: {question}\nAnswer:"`
98    pub fn build_prompt(&self, item: &BoolQItem) -> String {
99        format!(
100            "Passage: {}\nQuestion: {}\nAnswer:",
101            item.passage, item.question
102        )
103    }
104
105    /// Extract a boolean answer from a model completion string.
106    ///
107    /// Algorithm:
108    /// 1. Strip leading ASCII whitespace.
109    /// 2. Take the first 3 characters and lowercase them.
110    /// 3. If the prefix is `"yes"`, return `Some(true)`.
111    /// 4. If the prefix starts with `"no"`, return `Some(false)`.
112    /// 5. Otherwise return `None`.
113    ///
114    /// A string shorter than 2 characters always returns `None`.
115    pub fn extract_answer(completion: &str) -> Option<bool> {
116        let trimmed = completion.trim_start();
117        // Need at least "no" (2 chars) to match anything.
118        if trimmed.len() < 2 {
119            return None;
120        }
121        // Collect up to 3 chars for case-insensitive prefix matching.
122        let prefix: String = trimmed.chars().take(3).collect::<String>().to_lowercase();
123        if prefix.starts_with("yes") {
124            Some(true)
125        } else if prefix.starts_with("no") {
126            Some(false)
127        } else {
128            None
129        }
130    }
131
132    /// Score a single completion against the gold boolean answer.
133    ///
134    /// Returns `true` iff [`extract_answer`](Self::extract_answer) produces
135    /// `Some(gold)`, `false` otherwise (including when extraction fails).
136    pub fn score(&self, completion: &str, gold: bool) -> bool {
137        Self::extract_answer(completion) == Some(gold)
138    }
139
140    /// Evaluate a list of string completions against the dataset.
141    ///
142    /// Only the first `min(dataset.len(), completions.len())` items are scored.
143    /// Completions where [`extract_answer`](Self::extract_answer) returns `None`
144    /// are counted as wrong but not added to `yes_predicted` or `no_predicted`.
145    pub fn evaluate_completions(
146        &self,
147        dataset: &BoolQDataset,
148        completions: &[String],
149    ) -> BoolQResult {
150        let n = dataset.items.len().min(completions.len());
151        let mut correct = 0usize;
152        let mut yes_predicted = 0usize;
153        let mut no_predicted = 0usize;
154
155        for (i, completion) in completions.iter().enumerate().take(n) {
156            let prediction = Self::extract_answer(completion);
157            match prediction {
158                Some(true) => yes_predicted += 1,
159                Some(false) => no_predicted += 1,
160                None => {}
161            }
162            if prediction == Some(dataset.items[i].answer) {
163                correct += 1;
164            }
165        }
166
167        let total = n;
168        let accuracy = if total == 0 {
169            0.0_f32
170        } else {
171            correct as f32 / total as f32
172        };
173        BoolQResult {
174            accuracy,
175            accuracy_pct: accuracy * 100.0,
176            correct,
177            total,
178            yes_predicted,
179            no_predicted,
180        }
181    }
182
183    /// Evaluate using logit pairs `[logit_yes, logit_no]` per item.
184    ///
185    /// Prediction: if `logit_pairs[i][0] > logit_pairs[i][1]` then `true` (yes),
186    /// otherwise `false` (no). The prediction is compared to `dataset.items[i].answer`.
187    ///
188    /// Only the first `min(dataset.len(), logit_pairs.len())` items are scored.
189    pub fn evaluate_logits(&self, dataset: &BoolQDataset, logit_pairs: &[[f32; 2]]) -> BoolQResult {
190        let n = dataset.items.len().min(logit_pairs.len());
191        let mut correct = 0usize;
192        let mut yes_predicted = 0usize;
193        let mut no_predicted = 0usize;
194
195        for (i, pair) in logit_pairs.iter().enumerate().take(n) {
196            let pred_yes = pair[0] > pair[1];
197            if pred_yes {
198                yes_predicted += 1;
199            } else {
200                no_predicted += 1;
201            }
202            if pred_yes == dataset.items[i].answer {
203                correct += 1;
204            }
205        }
206
207        let total = n;
208        let accuracy = if total == 0 {
209            0.0_f32
210        } else {
211            correct as f32 / total as f32
212        };
213        BoolQResult {
214            accuracy,
215            accuracy_pct: accuracy * 100.0,
216            correct,
217            total,
218            yes_predicted,
219            no_predicted,
220        }
221    }
222}
223
224impl Default for BoolQEvaluator {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230// ──────────────────────────────────────────────────────────────────────────────
231// Unit tests
232// ──────────────────────────────────────────────────────────────────────────────
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn extract_answer_yes() {
240        assert_eq!(
241            BoolQEvaluator::extract_answer("Yes, that is correct."),
242            Some(true)
243        );
244    }
245
246    #[test]
247    fn extract_answer_no() {
248        assert_eq!(
249            BoolQEvaluator::extract_answer("No, it is not."),
250            Some(false)
251        );
252    }
253
254    #[test]
255    fn extract_answer_case_insensitive() {
256        assert_eq!(BoolQEvaluator::extract_answer("YES"), Some(true));
257        assert_eq!(BoolQEvaluator::extract_answer("NO"), Some(false));
258        assert_eq!(BoolQEvaluator::extract_answer("yes."), Some(true));
259    }
260
261    #[test]
262    fn extract_answer_leading_whitespace() {
263        assert_eq!(BoolQEvaluator::extract_answer("  yes"), Some(true));
264        assert_eq!(BoolQEvaluator::extract_answer("\t\nno"), Some(false));
265    }
266
267    #[test]
268    fn extract_answer_none_for_garbage() {
269        assert_eq!(BoolQEvaluator::extract_answer("maybe"), None);
270        assert_eq!(BoolQEvaluator::extract_answer(""), None);
271        assert_eq!(BoolQEvaluator::extract_answer("I don't know"), None);
272    }
273
274    #[test]
275    fn extract_answer_short_string_no_panic() {
276        assert_eq!(BoolQEvaluator::extract_answer("y"), None);
277        assert_eq!(BoolQEvaluator::extract_answer("n"), None);
278    }
279}