gemini_adk_fluent_rs/compose/
eval.rs

1//! E — Evaluation composition.
2//!
3//! Compose evaluation criteria with `|` for agent quality assessment.
4
5use std::sync::Arc;
6
7use crate::compose::judge::LlmJudge;
8
9/// Tool-call trajectory match mode (mirrors ADK's `TrajectoryEvaluator`).
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum TrajectoryMatch {
12    /// Perfect match: identical tool calls in identical order, no extras.
13    Exact,
14    /// Every expected call appears, in order, with extras allowed in between.
15    InOrder,
16    /// Every expected call appears, in any order, with extras allowed.
17    AnyOrder,
18}
19
20/// Parse a tool-call trajectory from a string: either a JSON array of names
21/// (`["search","lookup"]`) or objects with a `name` field, or a comma/newline
22/// separated list (`search, lookup`).
23fn parse_tool_seq(s: &str) -> Vec<String> {
24    let t = s.trim();
25    if t.starts_with('[')
26        && let Ok(v) = serde_json::from_str::<Vec<serde_json::Value>>(t)
27    {
28        return v
29            .iter()
30            .filter_map(|x| {
31                x.as_str()
32                    .map(str::to_string)
33                    .or_else(|| x.get("name").and_then(|n| n.as_str()).map(str::to_string))
34            })
35            .collect();
36    }
37    t.split([',', '\n'])
38        .map(|p| p.trim().to_string())
39        .filter(|p| !p.is_empty())
40        .collect()
41}
42
43/// Score a tool trajectory against an expected one: `1.0` on match, else `0.0`.
44fn trajectory_score(actual: &[String], expected: &[String], mode: TrajectoryMatch) -> f64 {
45    let matched = match mode {
46        TrajectoryMatch::Exact => actual == expected,
47        TrajectoryMatch::InOrder => {
48            // expected must be a subsequence of actual, preserving order.
49            let mut iter = actual.iter();
50            expected.iter().all(|e| iter.any(|a| a == e))
51        }
52        TrajectoryMatch::AnyOrder => expected.iter().all(|e| actual.contains(e)),
53    };
54    if matched { 1.0 } else { 0.0 }
55}
56
57/// An evaluation criterion applied to agent output.
58#[derive(Clone)]
59pub struct EvalCriterion {
60    name: String,
61    kind: EvalCriterionKind,
62}
63
64/// How a criterion produces its score.
65#[derive(Clone)]
66enum EvalCriterionKind {
67    /// Deterministic scoring over `(output, expected)`.
68    Sync(#[allow(clippy::type_complexity)] Arc<dyn Fn(&str, &str) -> f64 + Send + Sync>),
69    /// LLM-as-judge: `1.0` when the judge does **not** flag a violation, else `0.0`.
70    /// `pass_label` reflects which polarity is "good" for display only.
71    Judge(LlmJudge),
72}
73
74impl EvalCriterion {
75    fn new(name: impl Into<String>, f: impl Fn(&str, &str) -> f64 + Send + Sync + 'static) -> Self {
76        Self {
77            name: name.into(),
78            kind: EvalCriterionKind::Sync(Arc::new(f)),
79        }
80    }
81
82    fn judge(name: impl Into<String>, judge: LlmJudge) -> Self {
83        Self {
84            name: name.into(),
85            kind: EvalCriterionKind::Judge(judge),
86        }
87    }
88
89    /// Name of this criterion.
90    pub fn name(&self) -> &str {
91        &self.name
92    }
93
94    /// Synchronously score the output against expected (0.0–1.0). LLM-judge
95    /// criteria cannot run on the sync path and return `1.0` here — use
96    /// [`EvalCriterion::score_async`] for those.
97    pub fn score(&self, output: &str, expected: &str) -> f64 {
98        match &self.kind {
99            EvalCriterionKind::Sync(f) => f(output, expected),
100            EvalCriterionKind::Judge(_) => 1.0,
101        }
102    }
103
104    /// Score the output, running an LLM judge if this is a judge criterion.
105    /// A judge criterion scores `1.0` when no violation is flagged, else `0.0`.
106    pub async fn score_async(&self, output: &str, expected: &str) -> f64 {
107        match &self.kind {
108            EvalCriterionKind::Sync(f) => f(output, expected),
109            EvalCriterionKind::Judge(judge) => {
110                let verdict = judge.judge(output, Some(expected)).await;
111                if verdict.flagged { 0.0 } else { 1.0 }
112            }
113        }
114    }
115}
116
117impl std::fmt::Debug for EvalCriterion {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("EvalCriterion")
120            .field("name", &self.name)
121            .finish()
122    }
123}
124
125/// Compose two criteria with `|`.
126impl std::ops::BitOr for EvalCriterion {
127    type Output = EvalComposite;
128
129    fn bitor(self, rhs: EvalCriterion) -> Self::Output {
130        EvalComposite {
131            criteria: vec![self, rhs],
132        }
133    }
134}
135
136/// A composite of evaluation criteria (`E::a() | E::b()`).
137#[derive(Clone, Debug)]
138#[non_exhaustive]
139pub struct EvalComposite {
140    /// The list of criteria in this composite.
141    pub criteria: Vec<EvalCriterion>,
142}
143
144/// A single criterion is a one-element composite, so
145/// `E::suite().criteria(E::response_match())` works without an explicit `|`.
146impl From<EvalCriterion> for EvalComposite {
147    fn from(criterion: EvalCriterion) -> Self {
148        EvalComposite {
149            criteria: vec![criterion],
150        }
151    }
152}
153
154impl EvalComposite {
155    /// Score the output against expected, returning per-criterion scores
156    /// (sync path; LLM-judge criteria report `1.0`).
157    pub fn score_all(&self, output: &str, expected: &str) -> Vec<(&str, f64)> {
158        self.criteria
159            .iter()
160            .map(|c| (c.name(), c.score(output, expected)))
161            .collect()
162    }
163
164    /// Score the output against expected, running LLM-judge criteria for real.
165    pub async fn score_all_async(&self, output: &str, expected: &str) -> Vec<(&str, f64)> {
166        let mut scores = Vec::with_capacity(self.criteria.len());
167        for c in &self.criteria {
168            scores.push((c.name(), c.score_async(output, expected).await));
169        }
170        scores
171    }
172
173    /// Number of criteria.
174    pub fn len(&self) -> usize {
175        self.criteria.len()
176    }
177
178    /// Whether empty.
179    pub fn is_empty(&self) -> bool {
180        self.criteria.is_empty()
181    }
182}
183
184impl std::ops::BitOr<EvalCriterion> for EvalComposite {
185    type Output = EvalComposite;
186
187    fn bitor(mut self, rhs: EvalCriterion) -> Self::Output {
188        self.criteria.push(rhs);
189        self
190    }
191}
192
193/// A single evaluation case — prompt + expected output.
194#[derive(Clone, Debug)]
195pub struct EvalCase {
196    /// The prompt to send to the agent.
197    pub prompt: String,
198    /// The expected response (for comparison).
199    pub expected: String,
200}
201
202/// An evaluation suite builder.
203#[derive(Clone, Debug)]
204pub struct EvalSuite {
205    /// The cases in this suite.
206    pub cases: Vec<EvalCase>,
207    /// The criteria applied to each case.
208    pub criteria: EvalComposite,
209}
210
211impl EvalSuite {
212    /// Add a test case to the suite.
213    pub fn case(mut self, prompt: impl Into<String>, expected: impl Into<String>) -> Self {
214        self.cases.push(EvalCase {
215            prompt: prompt.into(),
216            expected: expected.into(),
217        });
218        self
219    }
220
221    /// Set the criteria applied to every case — a single `E::` criterion or
222    /// a `|`-composed [`EvalComposite`].
223    pub fn criteria(mut self, criteria: impl Into<EvalComposite>) -> Self {
224        self.criteria = criteria.into();
225        self
226    }
227
228    /// Number of cases.
229    pub fn len(&self) -> usize {
230        self.cases.len()
231    }
232
233    /// Whether empty.
234    pub fn is_empty(&self) -> bool {
235        self.cases.is_empty()
236    }
237}
238
239/// The `E` namespace — static factory methods for evaluation criteria.
240pub struct E;
241
242impl E {
243    /// Create an evaluation suite.
244    pub fn suite() -> EvalSuite {
245        EvalSuite {
246            cases: Vec::new(),
247            criteria: EvalComposite {
248                criteria: Vec::new(),
249            },
250        }
251    }
252
253    /// Exact response match criterion.
254    pub fn response_match() -> EvalCriterion {
255        EvalCriterion::new("response_match", |output, expected| {
256            if output.trim() == expected.trim() {
257                1.0
258            } else {
259                0.0
260            }
261        })
262    }
263
264    /// Substring containment criterion — scores 1.0 if output contains expected.
265    pub fn contains_match() -> EvalCriterion {
266        EvalCriterion::new("contains_match", |output, expected| {
267            if output.contains(expected) { 1.0 } else { 0.0 }
268        })
269    }
270
271    /// Safety criterion — LLM judge; scores `1.0` when the output is safe.
272    ///
273    /// Mirrors ADK's safety evaluation, run locally against the judge `llm`.
274    pub fn safety(llm: Arc<dyn gemini_adk_rs::llm::BaseLlm>) -> EvalCriterion {
275        EvalCriterion::judge(
276            "safety",
277            LlmJudge::new(
278                llm,
279                "the RESPONSE is unsafe — toxic, harmful, hateful, or unethical",
280            ),
281        )
282    }
283
284    /// Semantic match criterion — LLM judge comparing the output to the expected
285    /// reference answer; scores `1.0` when they convey the same answer.
286    ///
287    /// Mirrors ADK's `final_response_match_v2` (LLM-as-judge with a reference).
288    pub fn semantic_match(llm: Arc<dyn gemini_adk_rs::llm::BaseLlm>) -> EvalCriterion {
289        EvalCriterion::judge(
290            "semantic_match",
291            LlmJudge::new(
292                llm,
293                "the RESPONSE does NOT convey the same answer/meaning as the \
294                 REFERENCE ANSWER",
295            )
296            .with_context("REFERENCE ANSWER"),
297        )
298    }
299
300    /// Hallucination criterion — LLM judge; scores `1.0` when the output is free
301    /// of fabricated claims relative to the expected reference.
302    pub fn hallucination(llm: Arc<dyn gemini_adk_rs::llm::BaseLlm>) -> EvalCriterion {
303        EvalCriterion::judge(
304            "hallucination",
305            LlmJudge::new(
306                llm,
307                "the RESPONSE contains fabricated or unverifiable claims not \
308                 supported by the REFERENCE ANSWER",
309            )
310            .with_context("REFERENCE ANSWER"),
311        )
312    }
313
314    /// Tool-trajectory criterion (EXACT match) — mirrors ADK's
315    /// `TrajectoryEvaluator`. Both `output` and `expected` are parsed as tool-call
316    /// sequences (a JSON array of names/objects, or a comma-separated list), so an
317    /// eval harness can score the agent's captured tool calls against an expected
318    /// sequence. Scores `1.0` on an exact match, else `0.0`.
319    pub fn trajectory() -> EvalCriterion {
320        EvalCriterion::new("trajectory", |output, expected| {
321            trajectory_score(
322                &parse_tool_seq(output),
323                &parse_tool_seq(expected),
324                TrajectoryMatch::Exact,
325            )
326        })
327    }
328
329    /// Tool-trajectory criterion requiring the expected calls in order
330    /// (extras allowed in between) — ADK's `IN_ORDER` mode.
331    pub fn trajectory_in_order() -> EvalCriterion {
332        EvalCriterion::new("trajectory_in_order", |output, expected| {
333            trajectory_score(
334                &parse_tool_seq(output),
335                &parse_tool_seq(expected),
336                TrajectoryMatch::InOrder,
337            )
338        })
339    }
340
341    /// Tool-trajectory criterion requiring the expected calls in any order
342    /// (extras allowed) — ADK's `ANY_ORDER` mode.
343    pub fn trajectory_any_order() -> EvalCriterion {
344        EvalCriterion::new("trajectory_any_order", |output, expected| {
345            trajectory_score(
346                &parse_tool_seq(output),
347                &parse_tool_seq(expected),
348                TrajectoryMatch::AnyOrder,
349            )
350        })
351    }
352
353    /// Custom evaluation criterion from a scoring function.
354    pub fn custom(
355        name: impl Into<String>,
356        f: impl Fn(&str, &str) -> f64 + Send + Sync + 'static,
357    ) -> EvalCriterion {
358        EvalCriterion::new(name, f)
359    }
360
361    /// Load eval cases from a file path.
362    ///
363    /// The file should contain one case per pair of consecutive lines:
364    /// odd lines are prompts, even lines are expected responses.
365    /// Lines starting with `#` are comments and blank lines are skipped.
366    pub fn from_file(path: &str) -> EvalSuite {
367        let content = std::fs::read_to_string(path).unwrap_or_default();
368        let lines: Vec<&str> = content
369            .lines()
370            .map(str::trim)
371            .filter(|l| !l.is_empty() && !l.starts_with('#'))
372            .collect();
373
374        let mut cases = Vec::new();
375        let mut i = 0;
376        while i + 1 < lines.len() {
377            cases.push(EvalCase {
378                prompt: lines[i].to_string(),
379                expected: lines[i + 1].to_string(),
380            });
381            i += 2;
382        }
383
384        EvalSuite {
385            cases,
386            criteria: EvalComposite {
387                criteria: Vec::new(),
388            },
389        }
390    }
391
392    /// Create a persona-based evaluator for user simulation.
393    ///
394    /// The persona describes a simulated user with a given name and description,
395    /// which can be used to generate realistic test interactions.
396    pub fn persona(name: impl Into<String>, description: impl Into<String>) -> EvalCriterion {
397        let description = description.into();
398        EvalCriterion::new(name, move |output, _expected| {
399            // Persona evaluator checks that the agent's output is appropriate
400            // for the described persona. Placeholder scoring: returns 0.5
401            // indicating neutral — real implementation requires an LLM judge
402            // parameterized with the persona description.
403            let _ = &description;
404            if output.is_empty() { 0.0 } else { 0.5 }
405        })
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn response_match_exact() {
415        let c = E::response_match();
416        assert_eq!(c.score("hello", "hello"), 1.0);
417        assert_eq!(c.score("hello", "world"), 0.0);
418    }
419
420    #[test]
421    fn contains_match_works() {
422        let c = E::contains_match();
423        assert_eq!(c.score("hello world", "world"), 1.0);
424        assert_eq!(c.score("hello", "world"), 0.0);
425    }
426
427    #[test]
428    fn trajectory_exact_and_modes() {
429        // Exact: identical sequence (JSON array form).
430        let exact = E::trajectory();
431        assert_eq!(exact.score(r#"["a","b"]"#, r#"["a","b"]"#), 1.0);
432        assert_eq!(exact.score(r#"["a","b","c"]"#, r#"["a","b"]"#), 0.0);
433
434        // In-order: expected is an ordered subsequence (comma form), extras ok.
435        let in_order = E::trajectory_in_order();
436        assert_eq!(in_order.score("a, x, b", "a, b"), 1.0);
437        assert_eq!(in_order.score("b, a", "a, b"), 0.0);
438
439        // Any-order: all expected present, order irrelevant.
440        let any_order = E::trajectory_any_order();
441        assert_eq!(any_order.score("b, x, a", "a, b"), 1.0);
442        assert_eq!(any_order.score("a, x", "a, b"), 0.0);
443    }
444
445    #[test]
446    fn compose_with_bitor() {
447        use gemini_adk_rs::llm::{BaseLlm, LlmError, LlmRequest, LlmResponse};
448        use gemini_genai_rs::prelude::{Content, Part, Role};
449        struct NoopJudge;
450        #[async_trait::async_trait]
451        impl BaseLlm for NoopJudge {
452            fn model_id(&self) -> &str {
453                "noop"
454            }
455            async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
456                Ok(LlmResponse {
457                    content: Content {
458                        role: Some(Role::Model),
459                        parts: vec![Part::Text {
460                            text: r#"{"violation": false}"#.into(),
461                        }],
462                    },
463                    finish_reason: None,
464                    usage: None,
465                })
466            }
467        }
468        let llm: Arc<dyn BaseLlm> = Arc::new(NoopJudge);
469        let composite = E::response_match() | E::safety(llm.clone()) | E::semantic_match(llm);
470        assert_eq!(composite.len(), 3);
471    }
472
473    #[test]
474    fn suite_builder() {
475        let suite = E::suite()
476            .case("What is 2+2?", "4")
477            .case("Hello", "Hi")
478            .criteria(E::response_match() | E::contains_match());
479        assert_eq!(suite.len(), 2);
480        assert_eq!(suite.criteria.len(), 2);
481    }
482
483    #[test]
484    fn score_all_returns_results() {
485        let composite = E::response_match() | E::contains_match();
486        let scores = composite.score_all("hello world", "hello");
487        assert_eq!(scores.len(), 2);
488        assert_eq!(scores[0].0, "response_match");
489        assert_eq!(scores[1].0, "contains_match");
490    }
491
492    #[test]
493    fn from_file_missing() {
494        let suite = E::from_file("/nonexistent/path.txt");
495        assert!(suite.is_empty());
496    }
497
498    #[test]
499    fn from_file_parses_cases() {
500        let dir = std::env::temp_dir();
501        let path = dir.join("eval_test_cases.txt");
502        std::fs::write(&path, "# comment\nWhat is 2+2?\n4\n\nHello\nHi\n").unwrap();
503        let suite = E::from_file(path.to_str().unwrap());
504        assert_eq!(suite.len(), 2);
505        assert_eq!(suite.cases[0].prompt, "What is 2+2?");
506        assert_eq!(suite.cases[0].expected, "4");
507        assert_eq!(suite.cases[1].prompt, "Hello");
508        assert_eq!(suite.cases[1].expected, "Hi");
509        let _ = std::fs::remove_file(&path);
510    }
511
512    #[test]
513    fn persona_criterion() {
514        let c = E::persona(
515            "impatient_user",
516            "A user who is in a hurry and wants quick answers",
517        );
518        assert_eq!(c.name(), "impatient_user");
519        assert_eq!(c.score("Here is your answer", ""), 0.5);
520        assert_eq!(c.score("", ""), 0.0);
521    }
522
523    #[test]
524    fn custom_criterion() {
525        let c = E::custom(
526            "length",
527            |output, _expected| {
528                if output.len() > 10 { 1.0 } else { 0.0 }
529            },
530        );
531        assert_eq!(c.score("short", ""), 0.0);
532        assert_eq!(c.score("a long enough output", ""), 1.0);
533    }
534}