feat(topology): LLM-judge scorer + async Scorer trait
Make Scorer async and add JudgeScorer (behind the `provider` feature): asks a cm-llm model to rate a run's output 0-100 vs the task and normalizes to [0,1], giving the comparison harness real quality numbers. Robust integer parsing (handles "Score: 92/100", clamps >100); provider errors score 0.0. 11 tests with --features provider (judge incl. parse + scripted-provider score); core stays 7. Clippy clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7370adb78e
commit
3725c96e4f
@@ -0,0 +1,121 @@
|
||||
//! An LLM-judge [`Scorer`]: ask a model to rate how well a run's output
|
||||
//! accomplishes the task, on a 0–100 scale, normalized to `[0,1]`.
|
||||
//!
|
||||
//! Gives the comparison harness real quality numbers (vs the deterministic
|
||||
//! scorers used in tests). Behind the `provider` feature.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider};
|
||||
use futures::StreamExt;
|
||||
|
||||
use crate::{RunRecord, Scorer};
|
||||
|
||||
/// Scores run quality with an LLM via any `cm-llm` provider.
|
||||
pub struct JudgeScorer {
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
}
|
||||
|
||||
impl JudgeScorer {
|
||||
/// Build a judge over a shared provider, model id, and token budget.
|
||||
pub fn new(provider: Arc<dyn LlmProvider>, model: impl Into<String>, max_tokens: u32) -> Self {
|
||||
JudgeScorer {
|
||||
provider,
|
||||
model: model.into(),
|
||||
max_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the first integer in the text and map 0..100 → 0.0..1.0.
|
||||
/// Returns 0.0 if no integer is present.
|
||||
fn parse_score(s: &str) -> f64 {
|
||||
let mut digits = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_digit() {
|
||||
digits.push(c);
|
||||
} else if !digits.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
digits
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|n| (n / 100.0).clamp(0.0, 1.0))
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
impl Scorer for JudgeScorer {
|
||||
async fn score(&self, task: &str, record: &RunRecord) -> f64 {
|
||||
let request = ChatRequest {
|
||||
system: "You are a strict evaluator. Rate how well RESULT accomplishes TASK on a \
|
||||
scale from 0 to 100. Reply with ONLY the integer."
|
||||
.to_string(),
|
||||
messages: vec![ChatMessage {
|
||||
role: ChatRole::User,
|
||||
parts: vec![ContentPart::text(format!(
|
||||
"TASK:\n{task}\n\nRESULT:\n{}",
|
||||
record.final_output
|
||||
))],
|
||||
}],
|
||||
tools: vec![],
|
||||
model: self.model.clone(),
|
||||
max_tokens: self.max_tokens,
|
||||
};
|
||||
|
||||
let mut stream = match self.provider.stream(request).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return 0.0,
|
||||
};
|
||||
let mut text = String::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Ok(LlmEvent::TextDelta(t)) = event {
|
||||
text.push_str(&t);
|
||||
}
|
||||
}
|
||||
parse_score(&text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{RunMetrics, RunRecord};
|
||||
use cm_topology::TopologyKind;
|
||||
|
||||
fn record(output: &str) -> RunRecord {
|
||||
RunRecord {
|
||||
kind: TopologyKind::Flat,
|
||||
steps: vec![],
|
||||
final_output: output.to_string(),
|
||||
totals: RunMetrics::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_score_handles_text_and_garbage() {
|
||||
assert!((parse_score("85") - 0.85).abs() < 1e-9);
|
||||
assert!((parse_score("Score: 92/100") - 0.92).abs() < 1e-9);
|
||||
assert!((parse_score("over") - 0.0).abs() < 1e-9);
|
||||
assert!((parse_score("150") - 1.0).abs() < 1e-9); // clamped
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn judge_returns_normalized_score() {
|
||||
// Scenario keyed on "RESULT" (always in the judge prompt) returns "85".
|
||||
let toml = r#"
|
||||
[[scenario]]
|
||||
marker = "RESULT"
|
||||
[[scenario.turns]]
|
||||
[[scenario.turns.events]]
|
||||
type = "text"
|
||||
text = "85"
|
||||
"#;
|
||||
let provider = Arc::new(cm_llm::ScriptedProvider::from_toml(toml).unwrap());
|
||||
let judge = JudgeScorer::new(provider, "test-model", 16);
|
||||
let score = judge.score("write a poem", &record("a fine poem")).await;
|
||||
assert!((score - 0.85).abs() < 1e-9, "got {score}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user