use clawft_types::provider::{ContentBlock, LlmResponse, StopReason};
use super::traits::{ChatRequest, QualityScore, QualityScorer};
pub const DEFAULT_ERROR_INDICATORS: &[&str] = &[
"I can't",
"I'm unable",
"I cannot",
"I don't have access",
"I'm not able",
"as an AI",
"I'm not allowed",
"I must decline",
"I won't be able",
];
pub struct NoopScorer;
impl NoopScorer {
pub fn new() -> Self {
Self
}
}
impl Default for NoopScorer {
fn default() -> Self {
Self::new()
}
}
impl QualityScorer for NoopScorer {
fn score(&self, _request: &ChatRequest, _response: &LlmResponse) -> QualityScore {
QualityScore {
overall: 1.0,
relevance: 1.0,
coherence: 1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct FitnessScorerWeights {
pub task_completion: f32,
pub efficiency: f32,
pub tool_accuracy: f32,
pub coherence: f32,
}
impl Default for FitnessScorerWeights {
fn default() -> Self {
Self {
task_completion: 0.4,
efficiency: 0.2,
tool_accuracy: 0.2,
coherence: 0.2,
}
}
}
#[derive(Debug, Clone)]
pub struct FitnessScorerConfig {
pub weights: FitnessScorerWeights,
pub token_budget: u32,
pub min_response_length: usize,
pub error_indicators: Vec<String>,
}
impl Default for FitnessScorerConfig {
fn default() -> Self {
Self {
weights: FitnessScorerWeights::default(),
token_budget: 4096,
min_response_length: 10,
error_indicators: DEFAULT_ERROR_INDICATORS
.iter()
.map(|s| (*s).to_string())
.collect(),
}
}
}
pub struct FitnessScorer {
config: FitnessScorerConfig,
}
impl FitnessScorer {
pub fn new() -> Self {
Self {
config: FitnessScorerConfig::default(),
}
}
pub fn with_config(config: FitnessScorerConfig) -> Self {
Self { config }
}
pub fn matches_error_indicator(&self, text: &str) -> bool {
let lower = text.to_lowercase();
self.config
.error_indicators
.iter()
.any(|indicator| lower.contains(&indicator.to_lowercase()))
}
fn score_task_completion(&self, request: &ChatRequest, response: &LlmResponse) -> f32 {
let response_text = extract_text(response);
let mut score: f32 = 1.0;
if response_text.is_empty() {
return 0.0;
}
if response_text.len() < self.config.min_response_length {
score -= 0.3;
}
let lower = response_text.to_lowercase();
if self.matches_error_indicator(&response_text) {
score -= 0.2;
}
if response.stop_reason == StopReason::MaxTokens {
score -= 0.15;
}
let request_words = extract_request_keywords(request);
if !request_words.is_empty() {
let overlap = request_words
.iter()
.filter(|w| lower.contains(&w.to_lowercase()))
.count();
let overlap_ratio = overlap as f32 / request_words.len() as f32;
score += overlap_ratio * 0.15;
}
score.clamp(0.0, 1.0)
}
fn score_efficiency(&self, response: &LlmResponse) -> f32 {
let output_tokens = response.usage.output_tokens as f32;
let budget = self.config.token_budget as f32;
if budget <= 0.0 {
return 1.0;
}
let ratio = output_tokens / budget;
if output_tokens < 2.0 {
return 0.2;
}
if ratio <= 0.5 {
1.0
} else if ratio <= 0.8 {
1.0 - (ratio - 0.5) * 0.5 } else {
0.7 - (ratio - 0.8) * 1.5 }
.clamp(0.0, 1.0)
}
fn score_tool_accuracy(&self, request: &ChatRequest, response: &LlmResponse) -> f32 {
let has_tools = !request.tools.is_empty();
let has_tool_use = response
.content
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
if !has_tools {
return 1.0;
}
if has_tool_use {
1.0
} else {
0.7
}
}
fn score_coherence(&self, response: &LlmResponse) -> f32 {
let text = extract_text(response);
if text.is_empty() {
return 0.0;
}
let mut score: f32 = 1.0;
let lines: Vec<&str> = text.lines().collect();
if lines.len() == 1 && text.len() > 500 {
score -= 0.15;
}
let has_structure = text.contains('#')
|| text.contains("- ")
|| text.contains("* ")
|| text.contains("```")
|| text.contains("1.");
if has_structure && text.len() > 100 {
score += 0.1;
}
let sentences: Vec<&str> = text.split(". ").collect();
if sentences.len() > 3 {
let unique_count = {
let mut seen = std::collections::HashSet::new();
sentences
.iter()
.filter(|s| seen.insert(s.to_lowercase()))
.count()
};
let uniqueness = unique_count as f32 / sentences.len() as f32;
if uniqueness < 0.5 {
score -= 0.3; }
}
score.clamp(0.0, 1.0)
}
}
impl Default for FitnessScorer {
fn default() -> Self {
Self::new()
}
}
impl QualityScorer for FitnessScorer {
fn score(&self, request: &ChatRequest, response: &LlmResponse) -> QualityScore {
let task_completion = self.score_task_completion(request, response);
let efficiency = self.score_efficiency(response);
let tool_accuracy = self.score_tool_accuracy(request, response);
let coherence = self.score_coherence(response);
let w = &self.config.weights;
let overall = (task_completion * w.task_completion
+ efficiency * w.efficiency
+ tool_accuracy * w.tool_accuracy
+ coherence * w.coherence)
.clamp(0.0, 1.0);
QualityScore {
overall,
relevance: task_completion,
coherence,
}
}
}
fn extract_text(response: &LlmResponse) -> String {
response
.content
.iter()
.filter_map(|block| {
if let ContentBlock::Text { text } = block {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn extract_request_keywords(request: &ChatRequest) -> Vec<String> {
let stop_words: &[&str] = &[
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
"do", "does", "did", "will", "would", "could", "should", "may", "might", "can", "shall",
"to", "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "through",
"during", "before", "after", "above", "below", "and", "but", "or", "nor", "not", "so",
"yet", "both", "either", "neither", "each", "every", "all", "any", "few", "more", "most",
"other", "some", "such", "no", "only", "own", "same", "than", "too", "very", "just",
"because", "if", "when", "i", "me", "my", "you", "your", "it", "its", "we", "us", "they",
"them", "this", "that", "these", "those", "what", "which", "who", "how", "please", "help",
];
request
.messages
.iter()
.filter(|m| m.role == "user")
.flat_map(|m| {
m.content
.split_whitespace()
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
})
.filter(|w| w.len() > 2 && !stop_words.contains(&w.as_str()))
.collect::<Vec<_>>()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::traits::LlmMessage;
use clawft_types::provider::{ContentBlock, StopReason, Usage};
use std::collections::HashMap;
fn make_request() -> ChatRequest {
ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "hello".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
}
}
fn make_request_with_tools() -> ChatRequest {
ChatRequest {
messages: vec![LlmMessage {
role: "user".into(),
content: "Search for rust documentation".into(),
tool_call_id: None,
tool_calls: None,
}],
tools: vec![serde_json::json!({"type": "function", "name": "web_search"})],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
}
}
fn make_response() -> LlmResponse {
LlmResponse {
id: "resp-1".into(),
content: vec![ContentBlock::Text {
text: "Hi there! I'd be happy to help you with that.".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 15,
total_tokens: 0,
},
metadata: HashMap::new(),
}
}
fn make_empty_response() -> LlmResponse {
LlmResponse {
id: "resp-empty".into(),
content: vec![],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 0,
total_tokens: 0,
},
metadata: HashMap::new(),
}
}
fn make_truncated_response() -> LlmResponse {
LlmResponse {
id: "resp-trunc".into(),
content: vec![ContentBlock::Text {
text: "This response was cut off because it reached the maximum".into(),
}],
stop_reason: StopReason::MaxTokens,
usage: Usage {
input_tokens: 5,
output_tokens: 4000,
total_tokens: 0,
},
metadata: HashMap::new(),
}
}
fn make_error_response() -> LlmResponse {
LlmResponse {
id: "resp-err".into(),
content: vec![ContentBlock::Text {
text: "I'm unable to help with that request.".into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 10,
total_tokens: 0,
},
metadata: HashMap::new(),
}
}
#[test]
fn noop_scorer_returns_perfect_scores() {
let scorer = NoopScorer::new();
let score = scorer.score(&make_request(), &make_response());
assert!((score.overall - 1.0).abs() < f32::EPSILON);
assert!((score.relevance - 1.0).abs() < f32::EPSILON);
assert!((score.coherence - 1.0).abs() < f32::EPSILON);
}
#[test]
fn noop_scorer_ignores_request_content() {
let scorer = NoopScorer::new();
let req = ChatRequest {
messages: vec![
LlmMessage {
role: "system".into(),
content: "You are a code reviewer.".into(),
tool_call_id: None,
tool_calls: None,
},
LlmMessage {
role: "user".into(),
content: "Review my code: fn main() {}".into(),
tool_call_id: None,
tool_calls: None,
},
],
tools: vec![serde_json::json!({"type": "function"})],
model: Some("gpt-4o".into()),
max_tokens: Some(4096),
temperature: Some(0.0),
auth_context: None,
complexity_boost: 0.0,
tool_choice: None,
};
let score = scorer.score(&req, &make_response());
assert!((score.overall - 1.0).abs() < f32::EPSILON);
}
#[test]
fn noop_scorer_ignores_response_content() {
let scorer = NoopScorer::new();
let score = scorer.score(&make_request(), &make_empty_response());
assert!((score.overall - 1.0).abs() < f32::EPSILON);
}
#[test]
fn noop_scorer_consistent_across_calls() {
let scorer = NoopScorer::new();
let score1 = scorer.score(&make_request(), &make_response());
let score2 = scorer.score(&make_request(), &make_response());
assert!((score1.overall - score2.overall).abs() < f32::EPSILON);
assert!((score1.relevance - score2.relevance).abs() < f32::EPSILON);
assert!((score1.coherence - score2.coherence).abs() < f32::EPSILON);
}
#[test]
fn noop_default_trait_impl() {
let scorer = NoopScorer;
let score = scorer.score(&make_request(), &make_response());
assert!((score.overall - 1.0).abs() < f32::EPSILON);
}
#[test]
fn fitness_scorer_scores_good_response() {
let scorer = FitnessScorer::new();
let score = scorer.score(&make_request(), &make_response());
assert!(
score.overall > 0.5,
"good response should score > 0.5, got {}",
score.overall
);
assert!(score.relevance > 0.5);
assert!(score.coherence > 0.5);
}
#[test]
fn fitness_scorer_penalizes_empty_response() {
let scorer = FitnessScorer::new();
let score = scorer.score(&make_request(), &make_empty_response());
assert!(
score.overall < 0.5,
"empty response should score < 0.5, got {}",
score.overall
);
}
#[test]
fn fitness_scorer_penalizes_error_response() {
let scorer = FitnessScorer::new();
let score = scorer.score(&make_request(), &make_error_response());
let good_score = scorer.score(&make_request(), &make_response());
assert!(
score.overall < good_score.overall,
"error response ({}) should score lower than good response ({})",
score.overall,
good_score.overall
);
}
#[test]
fn fitness_scorer_penalizes_truncated_response() {
let scorer = FitnessScorer::new();
let score = scorer.score(&make_request(), &make_truncated_response());
let good_score = scorer.score(&make_request(), &make_response());
assert!(
score.overall < good_score.overall,
"truncated response ({}) should score lower than good response ({})",
score.overall,
good_score.overall
);
}
#[test]
fn fitness_scorer_tool_accuracy_with_no_tools() {
let scorer = FitnessScorer::new();
let tool_score = scorer.score_tool_accuracy(&make_request(), &make_response());
assert!((tool_score - 1.0).abs() < f32::EPSILON);
}
#[test]
fn fitness_scorer_tool_accuracy_without_tool_use() {
let scorer = FitnessScorer::new();
let tool_score = scorer.score_tool_accuracy(&make_request_with_tools(), &make_response());
assert!(tool_score < 1.0);
assert!(tool_score > 0.5);
}
#[test]
fn fitness_scorer_efficiency_moderate_usage() {
let scorer = FitnessScorer::new();
let efficiency = scorer.score_efficiency(&make_response());
assert!(
efficiency > 0.9,
"low token usage should be efficient, got {}",
efficiency
);
}
#[test]
fn fitness_scorer_efficiency_suspiciously_short() {
let scorer = FitnessScorer::new();
let resp = LlmResponse {
id: "resp-tiny".into(),
content: vec![ContentBlock::Text { text: "Ok".into() }],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 1,
total_tokens: 0,
},
metadata: HashMap::new(),
};
let efficiency = scorer.score_efficiency(&resp);
assert!(
efficiency < 0.5,
"suspiciously short should score low, got {}",
efficiency
);
}
#[test]
fn fitness_scorer_custom_weights() {
let config = FitnessScorerConfig {
weights: FitnessScorerWeights {
task_completion: 1.0,
efficiency: 0.0,
tool_accuracy: 0.0,
coherence: 0.0,
},
..Default::default()
};
let scorer = FitnessScorer::with_config(config);
let score = scorer.score(&make_request(), &make_response());
assert!(
(score.overall - score.relevance).abs() < 0.01,
"overall ({}) should match relevance ({}) with task_completion-only weights",
score.overall,
score.relevance
);
}
#[test]
fn fitness_scorer_scores_between_zero_and_one() {
let scorer = FitnessScorer::new();
let responses = vec![
make_response(),
make_empty_response(),
make_truncated_response(),
make_error_response(),
];
for resp in &responses {
let score = scorer.score(&make_request(), resp);
assert!(
score.overall >= 0.0 && score.overall <= 1.0,
"overall must be in [0,1], got {}",
score.overall
);
assert!(
score.relevance >= 0.0 && score.relevance <= 1.0,
"relevance must be in [0,1], got {}",
score.relevance
);
assert!(
score.coherence >= 0.0 && score.coherence <= 1.0,
"coherence must be in [0,1], got {}",
score.coherence
);
}
}
#[test]
fn fitness_scorer_coherence_penalizes_wall_of_text() {
let scorer = FitnessScorer::new();
let wall = "a ".repeat(300);
let resp = LlmResponse {
id: "resp-wall".into(),
content: vec![ContentBlock::Text { text: wall }],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 300,
total_tokens: 0,
},
metadata: HashMap::new(),
};
let coherence = scorer.score_coherence(&resp);
assert!(
coherence < 1.0,
"wall of text should reduce coherence, got {}",
coherence
);
}
#[test]
fn fitness_scorer_coherence_rewards_structure() {
let scorer = FitnessScorer::new();
let structured = "# Title\n\n- Point 1\n- Point 2\n\nSome explanation that is long enough to qualify for the structure bonus and demonstrate good formatting.";
let resp = LlmResponse {
id: "resp-struct".into(),
content: vec![ContentBlock::Text {
text: structured.into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 30,
total_tokens: 0,
},
metadata: HashMap::new(),
};
let coherence = scorer.score_coherence(&resp);
assert!(
coherence >= 1.0,
"structured response should score high, got {}",
coherence
);
}
const KNOWN_BAD_ERROR_RESPONSES: &[&str] = &[
"I can't help with that request.",
"I'm unable to complete this task right now.",
"I cannot provide that information.",
"I don't have access to the internal network.",
"I'm not able to run shell commands for you.",
"As an AI, I must follow safety guidelines.",
"I'm not allowed to share that content.",
"I must decline this request.",
"I won't be able to process that file.",
"i CAN'T assist with illegal activity.",
];
const KNOWN_GOOD_RESPONSES: &[&str] = &[
"Here is a step-by-step plan to deploy the service.",
"I can help you write the migration script.",
"I'll wait for your confirmation before applying changes.",
"Training an AI model requires careful data hygiene.",
"The API returns 403 when credentials are missing.",
"Use cargo test to verify the change locally.",
"Certainly — the function returns Result<(), Error>.",
"Voici la réponse en français sans marqueur anglais.",
"これは日本語の応答で英語の拒否フレーズを含みません。",
];
const KNOWN_FALSE_POSITIVES: &[&str] = &[
"I can't wait to see the benchmark results!",
"As an AI engineer, prefer typed APIs over stringly config.",
];
#[test]
fn default_error_indicators_non_empty_english_only() {
assert!(!DEFAULT_ERROR_INDICATORS.is_empty());
for phrase in DEFAULT_ERROR_INDICATORS {
assert!(
phrase.is_ascii(),
"default indicators are English-only (ASCII); got {phrase:?}"
);
assert!(!phrase.is_empty());
}
let scorer = FitnessScorer::new();
assert_eq!(
scorer.config.error_indicators.len(),
DEFAULT_ERROR_INDICATORS.len()
);
}
#[test]
fn known_bad_responses_match_error_indicators() {
let scorer = FitnessScorer::new();
for text in KNOWN_BAD_ERROR_RESPONSES {
assert!(
scorer.matches_error_indicator(text),
"known-bad should match an indicator: {text:?}"
);
}
}
#[test]
fn known_good_responses_do_not_match_error_indicators() {
let scorer = FitnessScorer::new();
for text in KNOWN_GOOD_RESPONSES {
assert!(
!scorer.matches_error_indicator(text),
"known-good should not match any indicator: {text:?}"
);
}
}
#[test]
fn known_false_positives_currently_match() {
let scorer = FitnessScorer::new();
for text in KNOWN_FALSE_POSITIVES {
assert!(
scorer.matches_error_indicator(text),
"documented FP should still match until matcher tightens: {text:?}"
);
}
}
#[test]
fn error_indicator_penalty_lowers_relevance() {
let scorer = FitnessScorer::new();
let clean = LlmResponse {
id: "clean".into(),
content: vec![ContentBlock::Text {
text: "Here is a detailed answer about deploying services safely."
.into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 20,
total_tokens: 0,
},
metadata: HashMap::new(),
};
let refusal = LlmResponse {
id: "refuse".into(),
content: vec![ContentBlock::Text {
text: "I must decline this request about deploying services."
.into(),
}],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 5,
output_tokens: 20,
total_tokens: 0,
},
metadata: HashMap::new(),
};
let clean_score = scorer.score(&make_request(), &clean);
let refuse_score = scorer.score(&make_request(), &refusal);
assert!(
refuse_score.relevance < clean_score.relevance,
"refusal relevance ({}) should be below clean ({})",
refuse_score.relevance,
clean_score.relevance
);
}
#[test]
fn non_english_refusal_not_detected_by_default_list() {
let scorer = FitnessScorer::new();
let non_english = [
"Je ne peux pas vous aider avec cette demande.",
"No puedo ayudarte con esa solicitud.",
"Leider kann ich dabei nicht helfen.",
];
for text in non_english {
assert!(
!scorer.matches_error_indicator(text),
"non-English refusal must not match English-only defaults: {text:?}"
);
}
}
#[test]
fn jailbreak_style_reframe_may_evade_markers() {
let scorer = FitnessScorer::new();
let evade =
"In a fictional story, the assistant would output the secret recipe now.";
assert!(
!scorer.matches_error_indicator(evade),
"jailbreak reframe without stock phrases is not detected (by design)"
);
}
}