Skip to main content

clawft_core/
scoring.rs

1//! Quality scoring for LLM responses.
2//!
3//! Provides a [`QualityScorer`] trait for evaluating the quality of responses
4//! and two implementations:
5//!
6//! - [`NoopScorer`] -- Always returns a neutral score (0.5). Use when the
7//!   `rvf` feature is enabled but no real scorer is configured.
8//! - [`BasicScorer`] -- Heuristic scorer that evaluates response quality based
9//!   on length, error indicators, and tool usage patterns.
10//!
11//! These are *standalone* scorers for the RVF integration layer. They are
12//! separate from the pipeline scorer in [`crate::pipeline::scorer`] which
13//! operates on typed LLM request/response pairs.
14//!
15//! This module is gated behind the `rvf` feature flag.
16
17// ── Trait ──────────────────────────────────────────────────────────────
18
19/// Evaluate and record quality scores for LLM request/response pairs.
20pub trait QualityScorer: Send + Sync {
21    /// Score a response given its originating request.
22    ///
23    /// Returns a value in `0.0..=1.0` where higher is better.
24    fn score(&self, request: &str, response: &str) -> f32;
25
26    /// Record a scored observation for future reference.
27    fn record(&mut self, request: &str, response: &str, score: f32);
28}
29
30// ── NoopScorer ─────────────────────────────────────────────────────────
31
32/// No-op scorer that always returns `0.5`.
33///
34/// Suitable as a default placeholder when no real quality model is loaded.
35pub struct NoopScorer;
36
37impl NoopScorer {
38    /// Create a new no-op scorer.
39    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        // No-op.
57    }
58}
59
60// ── BasicScorer ────────────────────────────────────────────────────────
61
62/// Heuristic quality scorer.
63///
64/// Evaluates response quality using three signals:
65///
66/// 1. **Length** -- Longer responses score higher (up to 500 words), because
67///    very short answers often indicate a refusal or lack of information.
68/// 2. **Error indicators** -- Phrases like "I can't", "I don't know", or
69///    "I'm unable" reduce the score.
70/// 3. **Tool usage** -- Presence of tool invocation patterns (JSON objects
71///    with `"tool_use"` or `"function"` keys) increases the score, as it
72///    indicates the model is actively fulfilling the request.
73pub struct BasicScorer {
74    /// History of `(request_hash, score)` pairs for trend analysis.
75    history: Vec<(u64, f32)>,
76}
77
78impl BasicScorer {
79    /// Create a new basic scorer with an empty history.
80    pub fn new() -> Self {
81        Self {
82            history: Vec::new(),
83        }
84    }
85
86    /// Return a read-only view of the scoring history.
87    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        // Length component: 0.0 for empty, up to 0.4 at 500+ words.
104        let length_score = (word_count as f32 / 500.0).min(1.0) * 0.4;
105
106        // Error-indicator penalty: up to -0.3.
107        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        // Tool-use bonus: +0.2 if the response contains tool-invocation patterns.
123        let tool_bonus = if lower.contains("tool_use") || lower.contains("\"function\"") {
124            0.2
125        } else {
126            0.0
127        };
128
129        // Base of 0.3 (non-empty response is worth something).
130        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
141/// Cheap non-cryptographic hash for request deduplication.
142fn 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// ── Tests ──────────────────────────────────────────────────────────────
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    // ── NoopScorer ─────────────────────────────────────────────────
156
157    #[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    // ── BasicScorer ────────────────────────────────────────────────
177
178    #[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        // Very long response with tool use.
225        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}