1pub trait QualityScorer: Send + Sync {
21 fn score(&self, request: &str, response: &str) -> f32;
25
26 fn record(&mut self, request: &str, response: &str, score: f32);
28}
29
30pub struct NoopScorer;
36
37impl NoopScorer {
38 pub fn new() -> Self {
40 Self
41 }
42}
43
44impl Default for NoopScorer {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl QualityScorer for NoopScorer {
51 fn score(&self, _request: &str, _response: &str) -> f32 {
52 0.5
53 }
54
55 fn record(&mut self, _request: &str, _response: &str, _score: f32) {
56 }
58}
59
60pub struct BasicScorer {
74 history: Vec<(u64, f32)>,
76}
77
78impl BasicScorer {
79 pub fn new() -> Self {
81 Self {
82 history: Vec::new(),
83 }
84 }
85
86 pub fn history(&self) -> &[(u64, f32)] {
88 &self.history
89 }
90}
91
92impl Default for BasicScorer {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl QualityScorer for BasicScorer {
99 fn score(&self, _request: &str, response: &str) -> f32 {
100 let lower = response.to_lowercase();
101 let word_count = lower.split_whitespace().count();
102
103 let length_score = (word_count as f32 / 500.0).min(1.0) * 0.4;
105
106 let error_phrases = [
108 "i can't",
109 "i cannot",
110 "i don't know",
111 "i'm unable",
112 "i am unable",
113 "as an ai",
114 "i'm not able",
115 ];
116 let error_penalty = if error_phrases.iter().any(|p| lower.contains(p)) {
117 0.3
118 } else {
119 0.0
120 };
121
122 let tool_bonus = if lower.contains("tool_use") || lower.contains("\"function\"") {
124 0.2
125 } else {
126 0.0
127 };
128
129 let base = if word_count > 0 { 0.3 } else { 0.0 };
131
132 (base + length_score + tool_bonus - error_penalty).clamp(0.0, 1.0)
133 }
134
135 fn record(&mut self, request: &str, _response: &str, score: f32) {
136 let hash = simple_hash(request);
137 self.history.push((hash, score));
138 }
139}
140
141fn simple_hash(s: &str) -> u64 {
143 use std::hash::{DefaultHasher, Hash, Hasher};
144 let mut hasher = DefaultHasher::new();
145 s.hash(&mut hasher);
146 hasher.finish()
147}
148
149#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
158 fn noop_always_returns_half() {
159 let scorer = NoopScorer::new();
160 assert!((scorer.score("any request", "any response") - 0.5).abs() < f32::EPSILON);
161 assert!((scorer.score("", "") - 0.5).abs() < f32::EPSILON);
162 }
163
164 #[test]
165 fn noop_record_does_not_panic() {
166 let mut scorer = NoopScorer::new();
167 scorer.record("req", "resp", 0.5);
168 }
169
170 #[test]
171 fn noop_default_trait() {
172 let scorer = NoopScorer::default();
173 assert!((scorer.score("x", "y") - 0.5).abs() < f32::EPSILON);
174 }
175
176 #[test]
179 fn basic_long_response_scores_higher_than_short() {
180 let scorer = BasicScorer::new();
181 let short_score = scorer.score("question", "yes");
182 let long_score = scorer.score("question", &"word ".repeat(200));
183 assert!(
184 long_score > short_score,
185 "long ({long_score}) should beat short ({short_score})"
186 );
187 }
188
189 #[test]
190 fn basic_error_response_scores_lower() {
191 let scorer = BasicScorer::new();
192 let good_score = scorer.score("question", "Here is the answer with details.");
193 let error_score = scorer.score("question", "I can't help with that request.");
194 assert!(
195 good_score > error_score,
196 "good ({good_score}) should beat error ({error_score})"
197 );
198 }
199
200 #[test]
201 fn basic_tool_use_gets_bonus() {
202 let scorer = BasicScorer::new();
203 let no_tool = scorer.score("question", "Here is the answer.");
204 let with_tool = scorer.score("question", r#"Here is the answer. {"tool_use": "search"}"#);
205 assert!(
206 with_tool > no_tool,
207 "tool_use ({with_tool}) should beat no tool ({no_tool})"
208 );
209 }
210
211 #[test]
212 fn basic_empty_response_scores_zero() {
213 let scorer = BasicScorer::new();
214 let score = scorer.score("question", "");
215 assert!(
216 score.abs() < f32::EPSILON,
217 "empty response should score 0.0, got {score}"
218 );
219 }
220
221 #[test]
222 fn basic_score_clamped_to_unit_range() {
223 let scorer = BasicScorer::new();
224 let response = format!("{} tool_use \"function\"", "word ".repeat(1000));
226 let score = scorer.score("q", &response);
227 assert!(score <= 1.0, "score should be <= 1.0, got {score}");
228 assert!(score >= 0.0, "score should be >= 0.0, got {score}");
229 }
230
231 #[test]
232 fn basic_record_stores_history() {
233 let mut scorer = BasicScorer::new();
234 scorer.record("req1", "resp1", 0.8);
235 scorer.record("req2", "resp2", 0.6);
236 assert_eq!(scorer.history().len(), 2);
237 assert!((scorer.history()[0].1 - 0.8).abs() < f32::EPSILON);
238 assert!((scorer.history()[1].1 - 0.6).abs() < f32::EPSILON);
239 }
240
241 #[test]
242 fn basic_default_trait() {
243 let scorer = BasicScorer::default();
244 assert!(scorer.history().is_empty());
245 }
246
247 #[test]
248 fn basic_multiple_error_phrases_detected() {
249 let scorer = BasicScorer::new();
250 let phrases = [
251 "I cannot do that.",
252 "I don't know the answer.",
253 "I'm unable to help.",
254 "I am unable to process.",
255 "As an AI, I can't.",
256 "I'm not able to assist.",
257 ];
258 for phrase in &phrases {
259 let score = scorer.score("question", phrase);
260 let clean_score = scorer.score("question", "Here is the answer.");
261 assert!(
262 clean_score > score,
263 "clean ({clean_score}) should beat error phrase '{phrase}' ({score})"
264 );
265 }
266 }
267}