Skip to main content

agentforge_scorer/
deterministic.rs

1use agentforge_core::{
2    AgentFile, DimensionScore, Result, Scenario, ScoringMethod, ToolCallStep, Trace, TraceStep,
3};
4use jsonschema::Validator;
5
6/// Result of all deterministic assertions for a single trace.
7#[derive(Debug, Default)]
8pub struct DeterministicResult {
9    pub tool_selection: DimensionScore,
10    pub argument_correctness: DimensionScore,
11    pub schema_compliance: DimensionScore,
12    pub instruction_adherence: DimensionScore,
13    pub path_efficiency: DimensionScore,
14    /// List of failure reasons to assist cluster classification
15    pub failure_reasons: Vec<String>,
16}
17
18/// Run all deterministic assertions on a trace.
19#[allow(clippy::field_reassign_with_default)]
20pub fn run_deterministic_checks(
21    trace: &Trace,
22    scenario: &Scenario,
23    agent: &AgentFile,
24) -> DeterministicResult {
25    let mut result = DeterministicResult::default();
26
27    // 1. Tool selection accuracy
28    result.tool_selection = check_tool_selection(trace, scenario);
29    if result.tool_selection.value < 1.0 {
30        result.failure_reasons.push(format!(
31            "Tool selection failed (score={:.2})",
32            result.tool_selection.value
33        ));
34    }
35
36    // 2. Argument correctness
37    result.argument_correctness = check_argument_correctness(trace, scenario);
38    if result.argument_correctness.value < 1.0 {
39        result.failure_reasons.push(format!(
40            "Argument correctness failed (score={:.2})",
41            result.argument_correctness.value
42        ));
43    }
44
45    // 3. Output schema compliance
46    result.schema_compliance = check_schema_compliance(trace, scenario, agent);
47    if result.schema_compliance.value < 1.0 {
48        result.failure_reasons.push(format!(
49            "Schema compliance failed (score={:.2})",
50            result.schema_compliance.value
51        ));
52    }
53
54    // 4. Instruction adherence (constraint keyword check)
55    result.instruction_adherence = check_constraint_keywords(trace, agent);
56    if result.instruction_adherence.value < 1.0 {
57        result.failure_reasons.push(format!(
58            "Instruction adherence failed (score={:.2})",
59            result.instruction_adherence.value
60        ));
61    }
62
63    // 5. Path efficiency
64    result.path_efficiency = check_path_efficiency(trace, scenario);
65
66    result
67}
68
69/// Check if the required tools were called in the right order.
70fn check_tool_selection(trace: &Trace, scenario: &Scenario) -> DimensionScore {
71    let required_tools: Vec<&str> = scenario
72        .expected
73        .tool_calls
74        .iter()
75        .filter(|tc| tc.required)
76        .map(|tc| tc.tool_name.as_str())
77        .collect();
78
79    if required_tools.is_empty() {
80        // No required tools — trivially pass
81        return DimensionScore {
82            value: 1.0,
83            confidence: 1.0,
84            method: ScoringMethod::Deterministic,
85            rationale: Some("No required tools specified".to_string()),
86        };
87    }
88
89    let called_tools: Vec<&str> = trace
90        .steps
91        .iter()
92        .filter_map(|s| {
93            if let TraceStep::ToolCall(tc) = s {
94                Some(tc.tool_name.as_str())
95            } else {
96                None
97            }
98        })
99        .collect();
100
101    let matched = required_tools
102        .iter()
103        .filter(|&&rt| called_tools.contains(&rt))
104        .count();
105
106    let score = matched as f64 / required_tools.len() as f64;
107    let rationale = if score < 1.0 {
108        let missing: Vec<&str> = required_tools
109            .iter()
110            .filter(|&&rt| !called_tools.contains(&rt))
111            .copied()
112            .collect();
113        Some(format!("Missing required tools: {}", missing.join(", ")))
114    } else {
115        Some("All required tools called".to_string())
116    };
117
118    DimensionScore {
119        value: score,
120        confidence: 1.0,
121        method: ScoringMethod::Deterministic,
122        rationale,
123    }
124}
125
126/// Check that tool arguments match the expected schemas.
127fn check_argument_correctness(trace: &Trace, scenario: &Scenario) -> DimensionScore {
128    let tool_calls: Vec<&ToolCallStep> = trace
129        .steps
130        .iter()
131        .filter_map(|s| {
132            if let TraceStep::ToolCall(tc) = s {
133                Some(tc)
134            } else {
135                None
136            }
137        })
138        .collect();
139
140    if tool_calls.is_empty() {
141        // If no tools were expected, this is a pass; otherwise score 0
142        let has_required = scenario.expected.tool_calls.iter().any(|tc| tc.required);
143        return DimensionScore {
144            value: if has_required { 0.0 } else { 1.0 },
145            confidence: 1.0,
146            method: ScoringMethod::Deterministic,
147            rationale: if has_required {
148                Some("Required tools were not called".to_string())
149            } else {
150                Some("No tools called and none required".to_string())
151            },
152        };
153    }
154
155    let mut total_checks = 0usize;
156    let mut passed_checks = 0usize;
157    let mut failure_details = Vec::new();
158
159    for tc_step in &tool_calls {
160        // Find the expected tool definition for this tool name
161        let expected = scenario
162            .expected
163            .tool_calls
164            .iter()
165            .find(|etc| etc.tool_name == tc_step.tool_name);
166
167        if let Some(exp) = expected {
168            if let Some(arg_schema) = &exp.argument_schema {
169                total_checks += 1;
170                match validate_against_schema(&tc_step.arguments, arg_schema) {
171                    Ok(true) => {
172                        passed_checks += 1;
173                    }
174                    Ok(false) => {
175                        failure_details.push(format!(
176                            "Tool '{}' argument schema validation failed",
177                            tc_step.tool_name
178                        ));
179                    }
180                    Err(e) => {
181                        failure_details
182                            .push(format!("Tool '{}' schema error: {}", tc_step.tool_name, e));
183                    }
184                }
185            } else {
186                // No argument schema to check against — trivially pass
187                total_checks += 1;
188                passed_checks += 1;
189            }
190        }
191    }
192
193    let score = if total_checks == 0 {
194        1.0
195    } else {
196        passed_checks as f64 / total_checks as f64
197    };
198
199    DimensionScore {
200        value: score,
201        confidence: 1.0,
202        method: ScoringMethod::Deterministic,
203        rationale: if failure_details.is_empty() {
204            Some("All tool arguments valid".to_string())
205        } else {
206            Some(failure_details.join("; "))
207        },
208    }
209}
210
211/// Validate a value against a JSON Schema.
212fn validate_against_schema(value: &serde_json::Value, schema: &serde_json::Value) -> Result<bool> {
213    match Validator::new(schema) {
214        Ok(compiled) => Ok(compiled.is_valid(value)),
215        Err(e) => {
216            // Invalid schema — can't validate, return true to avoid false negatives
217            tracing::warn!(error = %e, "Invalid JSON schema in scenario");
218            Ok(true)
219        }
220    }
221}
222
223/// Check that the final output conforms to the declared output schema.
224fn check_schema_compliance(
225    trace: &Trace,
226    scenario: &Scenario,
227    agent: &AgentFile,
228) -> DimensionScore {
229    // Use scenario's expected output schema if present, else agent's output schema
230    let schema = scenario
231        .expected
232        .output_schema
233        .as_ref()
234        .or(agent.output_schema.as_ref());
235
236    let schema = match schema {
237        Some(s) => s,
238        None => {
239            return DimensionScore {
240                value: 1.0,
241                confidence: 0.5,
242                method: ScoringMethod::Deterministic,
243                rationale: Some("No output schema defined — skipping compliance check".to_string()),
244            }
245        }
246    };
247
248    let output = match &trace.final_output {
249        Some(o) => o,
250        None => {
251            return DimensionScore {
252                value: 0.0,
253                confidence: 1.0,
254                method: ScoringMethod::Deterministic,
255                rationale: Some("No final output captured".to_string()),
256            }
257        }
258    };
259
260    match validate_against_schema(output, schema) {
261        Ok(valid) => DimensionScore {
262            value: if valid { 1.0 } else { 0.0 },
263            confidence: 1.0,
264            method: ScoringMethod::Deterministic,
265            rationale: Some(if valid {
266                "Output matches schema".to_string()
267            } else {
268                "Output does not match schema".to_string()
269            }),
270        },
271        Err(e) => DimensionScore {
272            value: 0.5,
273            confidence: 0.3,
274            method: ScoringMethod::Deterministic,
275            rationale: Some(format!("Schema validation error: {e}")),
276        },
277    }
278}
279
280/// Check that the agent's output does not violate constraint keywords.
281fn check_constraint_keywords(trace: &Trace, agent: &AgentFile) -> DimensionScore {
282    if agent.constraints.is_empty() {
283        return DimensionScore {
284            value: 1.0,
285            confidence: 0.5,
286            method: ScoringMethod::Deterministic,
287            rationale: Some("No constraints defined".to_string()),
288        };
289    }
290
291    // Get all text content from the trace steps
292    let all_text = collect_assistant_text(trace);
293    if all_text.is_empty() {
294        return DimensionScore {
295            value: 1.0,
296            confidence: 0.5,
297            method: ScoringMethod::Deterministic,
298            rationale: Some("No assistant text to check".to_string()),
299        };
300    }
301
302    let all_text_lower = all_text.to_lowercase();
303
304    // Check constraints that have "never" or "do not" patterns — these are keyword-checkable
305    let mut violations = Vec::new();
306    for constraint in &agent.constraints {
307        let c_lower = constraint.to_lowercase();
308        // Extract the forbidden content (after "never" or "do not")
309        if let Some(after_never) = c_lower.strip_prefix("never ") {
310            // The forbidden content is roughly the rest of the constraint
311            // We check for exact keyword matches in the output
312            let forbidden_words: Vec<&str> = after_never.split_whitespace().take(3).collect();
313            for word in forbidden_words {
314                if word.len() > 4 && all_text_lower.contains(word) {
315                    // Heuristic: if the agent output contains words from the "never X" constraint,
316                    // flag it. This is imperfect — LLM judge will do semantic checking.
317                    violations.push(format!("Potential constraint breach: '{}'", constraint));
318                    break;
319                }
320            }
321        }
322    }
323
324    let score = if violations.is_empty() { 1.0 } else { 0.0 };
325    DimensionScore {
326        value: score,
327        confidence: if violations.is_empty() { 0.7 } else { 0.9 },
328        method: ScoringMethod::Deterministic,
329        rationale: if violations.is_empty() {
330            Some("No obvious constraint violations detected".to_string())
331        } else {
332            Some(violations.join("; "))
333        },
334    }
335}
336
337/// Check path efficiency: compare actual tool calls to the minimum expected.
338fn check_path_efficiency(trace: &Trace, scenario: &Scenario) -> DimensionScore {
339    let expected_min = scenario
340        .expected
341        .tool_calls
342        .iter()
343        .filter(|tc| tc.required)
344        .count();
345
346    let actual_calls = trace
347        .steps
348        .iter()
349        .filter(|s| matches!(s, TraceStep::ToolCall(_)))
350        .count();
351
352    if expected_min == 0 {
353        // No expected tool calls — efficiency is 1.0 if agent didn't loop
354        let llm_call_count = trace
355            .steps
356            .iter()
357            .filter(|s| matches!(s, TraceStep::LlmCall(_)))
358            .count();
359        let score = if llm_call_count <= 3 { 1.0 } else { 0.5 };
360        return DimensionScore {
361            value: score,
362            confidence: 0.8,
363            method: ScoringMethod::Deterministic,
364            rationale: Some(format!(
365                "{} LLM calls for a no-tool scenario",
366                llm_call_count
367            )),
368        };
369    }
370
371    let score = if actual_calls == 0 {
372        0.0
373    } else if actual_calls <= expected_min {
374        1.0
375    } else {
376        // Penalize extra calls: score = expected/actual (capped at 1.0)
377        (expected_min as f64 / actual_calls as f64).min(1.0)
378    };
379
380    DimensionScore {
381        value: score,
382        confidence: 0.9,
383        method: ScoringMethod::Deterministic,
384        rationale: Some(format!(
385            "Expected ~{} tool calls, actual {} calls",
386            expected_min, actual_calls
387        )),
388    }
389}
390
391fn collect_assistant_text(trace: &Trace) -> String {
392    let mut texts = Vec::new();
393    for step in &trace.steps {
394        if let TraceStep::FinalOutput(fo) = step {
395            if let Some(resp) = fo.output.get("response").and_then(|r| r.as_str()) {
396                texts.push(resp.to_string());
397            }
398        }
399    }
400    texts.join(" ")
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use agentforge_core::{
407        DifficultyTier, ExpectedToolCall, FailureCluster, FinalOutputStep, ModelConfig,
408        ModelProvider, ScenarioExpected, ScenarioInput, ScenarioSource, ToolCallStep, TraceStatus,
409    };
410    use chrono::Utc;
411    use uuid::Uuid;
412
413    fn make_scenario(tool_name: &str, required: bool) -> Scenario {
414        Scenario {
415            id: Uuid::new_v4(),
416            agent_id: Uuid::new_v4(),
417            input: ScenarioInput {
418                user_message: "test".to_string(),
419                conversation_history: vec![],
420                context: None,
421            },
422            expected: ScenarioExpected {
423                tool_calls: vec![ExpectedToolCall {
424                    tool_name: tool_name.to_string(),
425                    required,
426                    argument_schema: Some(serde_json::json!({
427                        "type": "object",
428                        "properties": {"id": {"type": "string"}},
429                        "required": ["id"]
430                    })),
431                }],
432                output_schema: Some(serde_json::json!({
433                    "type": "object",
434                    "properties": {"response": {"type": "string"}},
435                    "required": ["response"]
436                })),
437                pass_criteria: "Agent should call the tool".to_string(),
438                min_turns: Some(1),
439                max_turns: Some(5),
440            },
441            difficulty: DifficultyTier::Easy,
442            domain: None,
443            source: ScenarioSource::SchemaDerived,
444            tags: vec![],
445            created_at: Utc::now(),
446        }
447    }
448
449    fn make_trace_with_tool_call(tool_name: &str, args: serde_json::Value) -> Trace {
450        let run_id = Uuid::new_v4();
451        let scenario_id = Uuid::new_v4();
452        Trace {
453            id: Uuid::new_v4(),
454            run_id,
455            scenario_id,
456            status: TraceStatus::Pass,
457            steps: vec![
458                TraceStep::ToolCall(ToolCallStep {
459                    index: 0,
460                    tool_name: tool_name.to_string(),
461                    call_id: "call_1".to_string(),
462                    arguments: args,
463                    timestamp: Utc::now(),
464                }),
465                TraceStep::FinalOutput(FinalOutputStep {
466                    index: 1,
467                    output: serde_json::json!({"response": "Order status is 'shipped'."}),
468                    timestamp: Utc::now(),
469                }),
470            ],
471            final_output: Some(serde_json::json!({"response": "Order status is 'shipped'."})),
472            scores: None,
473            aggregate_score: None,
474            failure_cluster: FailureCluster::NoFailure,
475            failure_reason: None,
476            review_needed: false,
477            llm_calls: 1,
478            tool_invocations: 1,
479            input_tokens: 50,
480            output_tokens: 20,
481            latency_ms: 500,
482            retry_count: 0,
483            seed: 0,
484            created_at: Utc::now(),
485        }
486    }
487
488    fn make_simple_agent() -> AgentFile {
489        AgentFile {
490            agentforge_schema_version: "1".to_string(),
491            name: "test".to_string(),
492            version: "1.0.0".to_string(),
493            model: ModelConfig {
494                provider: ModelProvider::Openai,
495                model_id: "gpt-4o".to_string(),
496                temperature: None,
497                max_tokens: None,
498                top_p: None,
499            },
500            system_prompt: "You are helpful.".to_string(),
501            tools: vec![],
502            output_schema: Some(serde_json::json!({
503                "type": "object",
504                "properties": {"response": {"type": "string"}},
505                "required": ["response"]
506            })),
507            constraints: vec!["Never share passwords.".to_string()],
508            eval_hints: None,
509            metadata: None,
510        }
511    }
512
513    #[test]
514    fn tool_selection_passes_when_required_tool_called() {
515        let scenario = make_scenario("get_order", true);
516        let trace = make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
517        let score = check_tool_selection(&trace, &scenario);
518        assert_eq!(score.value, 1.0);
519    }
520
521    #[test]
522    fn tool_selection_fails_when_required_tool_missing() {
523        let scenario = make_scenario("get_order", true);
524        // Trace calls the wrong tool
525        let trace = make_trace_with_tool_call("wrong_tool", serde_json::json!({}));
526        let score = check_tool_selection(&trace, &scenario);
527        assert_eq!(score.value, 0.0);
528    }
529
530    #[test]
531    fn schema_compliance_passes_for_valid_output() {
532        let scenario = make_scenario("get_order", false);
533        let trace = make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
534        let agent = make_simple_agent();
535        let score = check_schema_compliance(&trace, &scenario, &agent);
536        assert_eq!(score.value, 1.0);
537    }
538
539    #[test]
540    fn schema_compliance_fails_for_missing_required_field() {
541        let scenario = make_scenario("get_order", false);
542        let mut trace =
543            make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
544        // Override final_output with invalid data
545        trace.final_output = Some(serde_json::json!({"action_taken": "resolved"})); // missing "response"
546        let agent = make_simple_agent();
547        let score = check_schema_compliance(&trace, &scenario, &agent);
548        assert_eq!(score.value, 0.0);
549    }
550
551    #[test]
552    fn constraint_check_passes_when_no_violations() {
553        let trace = make_trace_with_tool_call("get_order", serde_json::json!({}));
554        let agent = make_simple_agent();
555        let score = check_constraint_keywords(&trace, &agent);
556        // "Never share passwords" — trace doesn't mention passwords
557        assert_eq!(score.value, 1.0);
558    }
559
560    #[test]
561    fn argument_correctness_passes_for_valid_args() {
562        let scenario = make_scenario("get_order", true);
563        let trace = make_trace_with_tool_call("get_order", serde_json::json!({"id": "ORD-123"}));
564        let score = check_argument_correctness(&trace, &scenario);
565        assert_eq!(score.value, 1.0);
566    }
567}