mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
* chore: remove dead code (LlmEvaluator, chunk_by_paragraphs, bundled channel installer, Reasoning::safety) Delete unused code flagged in #648: - evaluation/success.rs: delete LlmEvaluator struct/impl, remove #[allow(dead_code)] from RuleBasedEvaluator methods - workspace/chunker.rs: delete chunk_by_paragraphs() and its tests (zero production callers) - extensions/manager.rs: delete install_bundled_channel_from_artifacts() (hot-activation never shipped) - llm/reasoning.rs: remove unused safety field from Reasoning struct; cascade removal through ContextCompactor, HeartbeatRunner, LlmSoftwareBuilder, and all callers Closes #648 [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: move RuleBasedEvaluator into test module to fix dead_code warning RuleBasedEvaluator has no production callers -- it was only used in tests of itself. Moving it into #[cfg(test)] eliminates the clippy dead_code error that broke CI. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
496 lines
17 KiB
Rust
496 lines
17 KiB
Rust
//! Success evaluation for jobs.
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::context::{ActionRecord, JobContext};
|
|
use crate::error::EvaluationError;
|
|
|
|
/// Result of evaluating job success.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EvaluationResult {
|
|
/// Whether the job was successful.
|
|
pub success: bool,
|
|
/// Confidence in the evaluation (0-1).
|
|
pub confidence: f64,
|
|
/// Detailed reasoning.
|
|
pub reasoning: String,
|
|
/// Specific issues found.
|
|
pub issues: Vec<String>,
|
|
/// Suggestions for improvement.
|
|
pub suggestions: Vec<String>,
|
|
/// Quality score (0-100).
|
|
pub quality_score: u32,
|
|
}
|
|
|
|
impl EvaluationResult {
|
|
/// Create a successful evaluation.
|
|
pub fn success(reasoning: impl Into<String>, quality_score: u32) -> Self {
|
|
Self {
|
|
success: true,
|
|
confidence: 0.9,
|
|
reasoning: reasoning.into(),
|
|
issues: vec![],
|
|
suggestions: vec![],
|
|
quality_score,
|
|
}
|
|
}
|
|
|
|
/// Create a failed evaluation.
|
|
pub fn failure(reasoning: impl Into<String>, issues: Vec<String>) -> Self {
|
|
Self {
|
|
success: false,
|
|
confidence: 0.9,
|
|
reasoning: reasoning.into(),
|
|
issues,
|
|
suggestions: vec![],
|
|
quality_score: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trait for success evaluators.
|
|
#[async_trait]
|
|
pub trait SuccessEvaluator: Send + Sync {
|
|
/// Evaluate whether a job was completed successfully.
|
|
async fn evaluate(
|
|
&self,
|
|
job: &JobContext,
|
|
actions: &[ActionRecord],
|
|
output: Option<&str>,
|
|
) -> Result<EvaluationResult, EvaluationError>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::context::{ActionRecord, JobContext};
|
|
use crate::error::EvaluationError;
|
|
|
|
/// Rule-based success evaluator (test-only; no production callers).
|
|
struct RuleBasedEvaluator {
|
|
min_action_success_rate: f64,
|
|
max_failures: u32,
|
|
}
|
|
|
|
impl RuleBasedEvaluator {
|
|
fn new() -> Self {
|
|
Self {
|
|
min_action_success_rate: 0.8,
|
|
max_failures: 3,
|
|
}
|
|
}
|
|
|
|
fn with_min_success_rate(mut self, rate: f64) -> Self {
|
|
self.min_action_success_rate = rate;
|
|
self
|
|
}
|
|
|
|
fn with_max_failures(mut self, max: u32) -> Self {
|
|
self.max_failures = max;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Default for RuleBasedEvaluator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl SuccessEvaluator for RuleBasedEvaluator {
|
|
async fn evaluate(
|
|
&self,
|
|
job: &JobContext,
|
|
actions: &[ActionRecord],
|
|
_output: Option<&str>,
|
|
) -> Result<EvaluationResult, EvaluationError> {
|
|
let mut issues = Vec::new();
|
|
|
|
if actions.is_empty() {
|
|
return Ok(EvaluationResult::failure(
|
|
"No actions were taken",
|
|
vec!["No actions recorded".to_string()],
|
|
));
|
|
}
|
|
|
|
let successful = actions.iter().filter(|a| a.success).count();
|
|
let total = actions.len();
|
|
let success_rate = successful as f64 / total as f64;
|
|
|
|
if success_rate < self.min_action_success_rate {
|
|
issues.push(format!(
|
|
"Action success rate {:.1}% below threshold {:.1}%",
|
|
success_rate * 100.0,
|
|
self.min_action_success_rate * 100.0
|
|
));
|
|
}
|
|
|
|
let failures = actions.iter().filter(|a| !a.success).count() as u32;
|
|
if failures > self.max_failures {
|
|
issues.push(format!(
|
|
"Too many failures: {} (max {})",
|
|
failures, self.max_failures
|
|
));
|
|
}
|
|
|
|
for action in actions.iter().filter(|a| !a.success) {
|
|
if let Some(ref error) = action.error
|
|
&& (error.to_lowercase().contains("critical")
|
|
|| error.to_lowercase().contains("fatal"))
|
|
{
|
|
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
|
}
|
|
}
|
|
|
|
if job.state != crate::context::JobState::Completed
|
|
&& job.state != crate::context::JobState::Submitted
|
|
{
|
|
issues.push(format!("Job not in completed state: {:?}", job.state));
|
|
}
|
|
|
|
let quality_score = if issues.is_empty() {
|
|
let base_score = (success_rate * 80.0) as u32;
|
|
let completion_bonus = if job.state == crate::context::JobState::Completed {
|
|
20
|
|
} else {
|
|
0
|
|
};
|
|
(base_score + completion_bonus).min(100)
|
|
} else {
|
|
((success_rate * 50.0) as u32).min(50)
|
|
};
|
|
|
|
if issues.is_empty() {
|
|
Ok(EvaluationResult::success(
|
|
format!(
|
|
"Job completed successfully with {}/{} actions succeeding ({:.1}%)",
|
|
successful,
|
|
total,
|
|
success_rate * 100.0
|
|
),
|
|
quality_score,
|
|
))
|
|
} else {
|
|
Ok(EvaluationResult {
|
|
success: false,
|
|
confidence: 0.85,
|
|
reasoning: format!("Job had {} issues", issues.len()),
|
|
issues,
|
|
suggestions: vec![
|
|
"Review failed actions for common patterns".to_string(),
|
|
"Consider adjusting retry logic".to_string(),
|
|
],
|
|
quality_score,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rule_based_evaluator_success() {
|
|
let evaluator = RuleBasedEvaluator::new();
|
|
|
|
let mut job = JobContext::new("Test", "Test job");
|
|
job.transition_to(crate::context::JobState::InProgress, None)
|
|
.unwrap();
|
|
job.transition_to(crate::context::JobState::Completed, None)
|
|
.unwrap();
|
|
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
];
|
|
|
|
let result = evaluator.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(result.success);
|
|
assert!(result.quality_score > 80);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rule_based_evaluator_failure() {
|
|
let evaluator = RuleBasedEvaluator::new().with_max_failures(1);
|
|
|
|
let job = JobContext::new("Test", "Test job");
|
|
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(false),
|
|
create_action(false),
|
|
];
|
|
|
|
let result = evaluator.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(!result.issues.is_empty());
|
|
}
|
|
|
|
fn create_action(success: bool) -> ActionRecord {
|
|
create_action_with_error(success, "Test error")
|
|
}
|
|
|
|
fn create_action_with_error(success: bool, error_msg: &str) -> ActionRecord {
|
|
let mut action = ActionRecord::new(0, "test", serde_json::json!({}));
|
|
if success {
|
|
action = action.succeed(
|
|
None,
|
|
serde_json::json!({}),
|
|
std::time::Duration::from_secs(1),
|
|
);
|
|
} else {
|
|
action = action.fail(error_msg, std::time::Duration::from_secs(1));
|
|
}
|
|
action
|
|
}
|
|
|
|
fn completed_job(title: &str) -> JobContext {
|
|
let mut job = JobContext::new(title, "test job");
|
|
job.transition_to(crate::context::JobState::InProgress, None)
|
|
.unwrap();
|
|
job.transition_to(crate::context::JobState::Completed, None)
|
|
.unwrap();
|
|
job
|
|
}
|
|
|
|
// --- EvaluationResult construction ---
|
|
|
|
#[test]
|
|
fn test_evaluation_result_success_defaults() {
|
|
let result = EvaluationResult::success("all good", 85);
|
|
assert!(result.success);
|
|
assert_eq!(result.confidence, 0.9);
|
|
assert_eq!(result.reasoning, "all good");
|
|
assert!(result.issues.is_empty());
|
|
assert!(result.suggestions.is_empty());
|
|
assert_eq!(result.quality_score, 85);
|
|
}
|
|
|
|
#[test]
|
|
fn test_evaluation_result_failure_defaults() {
|
|
let issues = vec!["bad thing".to_string(), "worse thing".to_string()];
|
|
let result = EvaluationResult::failure("went wrong", issues.clone());
|
|
assert!(!result.success);
|
|
assert_eq!(result.confidence, 0.9);
|
|
assert_eq!(result.reasoning, "went wrong");
|
|
assert_eq!(result.issues, issues);
|
|
assert_eq!(result.quality_score, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_evaluation_result_serde_roundtrip() {
|
|
let result = EvaluationResult {
|
|
success: true,
|
|
confidence: 0.75,
|
|
reasoning: "looks fine".to_string(),
|
|
issues: vec!["minor".to_string()],
|
|
suggestions: vec!["try harder".to_string()],
|
|
quality_score: 60,
|
|
};
|
|
let json = serde_json::to_string(&result).unwrap();
|
|
let deserialized: EvaluationResult = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.success, result.success);
|
|
assert_eq!(deserialized.confidence, result.confidence);
|
|
assert_eq!(deserialized.reasoning, result.reasoning);
|
|
assert_eq!(deserialized.issues, result.issues);
|
|
assert_eq!(deserialized.suggestions, result.suggestions);
|
|
assert_eq!(deserialized.quality_score, result.quality_score);
|
|
}
|
|
|
|
// --- RuleBasedEvaluator builder ---
|
|
|
|
#[test]
|
|
fn test_rule_based_evaluator_default() {
|
|
let eval = RuleBasedEvaluator::default();
|
|
assert_eq!(eval.min_action_success_rate, 0.8);
|
|
assert_eq!(eval.max_failures, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rule_based_evaluator_builder_methods() {
|
|
let eval = RuleBasedEvaluator::new()
|
|
.with_min_success_rate(0.5)
|
|
.with_max_failures(10);
|
|
assert_eq!(eval.min_action_success_rate, 0.5);
|
|
assert_eq!(eval.max_failures, 10);
|
|
}
|
|
|
|
// --- RuleBasedEvaluator::evaluate edge cases ---
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_actions_fails() {
|
|
let eval = RuleBasedEvaluator::new();
|
|
let job = completed_job("empty");
|
|
let result = eval.evaluate(&job, &[], None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(result.issues.iter().any(|i| i.contains("No actions")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_all_actions_succeed_completed_job_gets_100() {
|
|
let eval = RuleBasedEvaluator::new();
|
|
let job = completed_job("perfect");
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(result.success);
|
|
// 100% success rate -> base 80, completion bonus 20 -> 100
|
|
assert_eq!(result.quality_score, 100);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_quality_score_no_completion_bonus_for_pending_job() {
|
|
// Even if all actions succeed, a non-completed job gets flagged
|
|
let eval = RuleBasedEvaluator::new();
|
|
let job = JobContext::new("pending", "still pending");
|
|
let actions = vec![create_action(true)];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
// Job not in completed state => issues present
|
|
assert!(!result.success);
|
|
assert!(
|
|
result
|
|
.issues
|
|
.iter()
|
|
.any(|i| i.contains("not in completed state"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_submitted_state_counts_as_completed() {
|
|
let eval = RuleBasedEvaluator::new();
|
|
let mut job = JobContext::new("submitted", "test");
|
|
job.transition_to(crate::context::JobState::InProgress, None)
|
|
.unwrap();
|
|
job.transition_to(crate::context::JobState::Completed, None)
|
|
.unwrap();
|
|
job.transition_to(crate::context::JobState::Submitted, None)
|
|
.unwrap();
|
|
let actions = vec![create_action(true)];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
// Submitted is treated like completed for state check (no issue),
|
|
// but completion bonus only applies for Completed state
|
|
assert!(result.success);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_success_rate_below_threshold_fails() {
|
|
let eval = RuleBasedEvaluator::new().with_min_success_rate(0.9);
|
|
let job = completed_job("threshold");
|
|
// 4 out of 5 = 80%, below 90% threshold
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(false),
|
|
];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(
|
|
result
|
|
.issues
|
|
.iter()
|
|
.any(|i| i.contains("success rate") && i.contains("below threshold"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_too_many_failures_flagged() {
|
|
let eval = RuleBasedEvaluator::new().with_max_failures(1);
|
|
let job = completed_job("failures");
|
|
// 8 successes, 2 failures: rate is 80% (passes default 0.8) but failures > max 1
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(false),
|
|
create_action(false),
|
|
];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(
|
|
result
|
|
.issues
|
|
.iter()
|
|
.any(|i| i.contains("Too many failures"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_critical_error_detected() {
|
|
let eval = RuleBasedEvaluator::new().with_max_failures(10);
|
|
let job = completed_job("critical");
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action_with_error(false, "A CRITICAL system failure occurred"),
|
|
];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(result.issues.iter().any(|i| i.contains("Critical error")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fatal_error_detected() {
|
|
let eval = RuleBasedEvaluator::new().with_max_failures(10);
|
|
let job = completed_job("fatal");
|
|
let actions = vec![
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action(true),
|
|
create_action_with_error(false, "Fatal: disk full"),
|
|
];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(result.issues.iter().any(|i| i.contains("Critical error")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_quality_score_capped_at_50_with_issues() {
|
|
let eval = RuleBasedEvaluator::new()
|
|
.with_min_success_rate(0.0)
|
|
.with_max_failures(100);
|
|
// Job not completed => issues present, quality capped
|
|
let job = JobContext::new("capped", "test");
|
|
let actions = vec![create_action(true)];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(result.quality_score <= 50);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_failed_result_includes_suggestions() {
|
|
let eval = RuleBasedEvaluator::new().with_max_failures(0);
|
|
let job = completed_job("suggestions");
|
|
let actions = vec![create_action(false)];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(!result.success);
|
|
assert!(!result.suggestions.is_empty());
|
|
assert_eq!(result.confidence, 0.85);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_single_successful_action_completed_job() {
|
|
let eval = RuleBasedEvaluator::new();
|
|
let job = completed_job("single");
|
|
let actions = vec![create_action(true)];
|
|
let result = eval.evaluate(&job, &actions, None).await.unwrap();
|
|
assert!(result.success);
|
|
// 100% rate -> base 80, + 20 completion = 100
|
|
assert_eq!(result.quality_score, 100);
|
|
assert!(result.reasoning.contains("1/1"));
|
|
}
|
|
}
|