Skip to main content

skm_learn/
harness.rs

1//! Trigger test harness for evaluating skill selection accuracy.
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use skm_core::{SkillName, SkillRegistry};
8use skm_select::{CascadeSelector, SelectionContext, SelectionStrategy};
9
10use crate::error::LearnError;
11
12/// What skill(s) are expected for a test case.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum TestExpectation {
16    /// Expect a single skill.
17    Single(SkillName),
18
19    /// Expect any of these skills.
20    AnyOf(Vec<SkillName>),
21
22    /// Expect no skill.
23    None,
24}
25
26impl TestExpectation {
27    /// Check if a result matches the expectation.
28    pub fn matches(&self, result: Option<&SkillName>) -> bool {
29        match (self, result) {
30            (Self::None, None) => true,
31            (Self::None, Some(_)) => false,
32            (Self::Single(expected), Some(actual)) => expected == actual,
33            (Self::Single(_), None) => false,
34            (Self::AnyOf(expected), Some(actual)) => expected.contains(actual),
35            (Self::AnyOf(_), None) => false,
36        }
37    }
38}
39
40/// A single test case.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TestCase {
43    /// Test case name/ID.
44    pub name: String,
45
46    /// User query to test.
47    pub input: String,
48
49    /// Expected skill(s).
50    pub expected: TestExpectation,
51
52    /// Optional context to provide.
53    #[serde(default)]
54    pub context: SelectionContext,
55}
56
57/// Result of running a single test case.
58#[derive(Debug, Clone)]
59pub struct TestCaseResult {
60    /// Test case name.
61    pub name: String,
62
63    /// Whether the test passed.
64    pub passed: bool,
65
66    /// The selected skill (if any).
67    pub selected: Option<SkillName>,
68
69    /// The expected skill(s).
70    pub expected: TestExpectation,
71
72    /// Selection score (if any).
73    pub score: Option<f32>,
74
75    /// Latency in milliseconds.
76    pub latency_ms: u64,
77}
78
79/// A collection of test cases.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TestSuite {
82    /// Suite name.
83    pub name: String,
84
85    /// Test cases.
86    pub cases: Vec<TestCase>,
87}
88
89impl TestSuite {
90    /// Load from YAML file.
91    pub fn from_file(path: &Path) -> Result<Self, LearnError> {
92        let content = std::fs::read_to_string(path)?;
93        Ok(serde_yaml::from_str(&content)?)
94    }
95
96    /// Create a new empty suite.
97    pub fn new(name: impl Into<String>) -> Self {
98        Self {
99            name: name.into(),
100            cases: Vec::new(),
101        }
102    }
103
104    /// Add a test case.
105    pub fn add_case(&mut self, case: TestCase) {
106        self.cases.push(case);
107    }
108}
109
110/// Report for a single skill across test runs.
111#[derive(Debug, Clone, Default)]
112pub struct SkillTestReport {
113    /// Total tests targeting this skill.
114    pub total: usize,
115
116    /// Tests that correctly selected this skill.
117    pub correct: usize,
118
119    /// Tests where this skill was selected incorrectly.
120    pub false_positives: usize,
121
122    /// Tests where this skill should have been selected but wasn't.
123    pub false_negatives: usize,
124}
125
126impl SkillTestReport {
127    /// Precision: correct / (correct + false_positives)
128    pub fn precision(&self) -> f32 {
129        let denom = self.correct + self.false_positives;
130        if denom == 0 {
131            0.0
132        } else {
133            self.correct as f32 / denom as f32
134        }
135    }
136
137    /// Recall: correct / (correct + false_negatives)
138    pub fn recall(&self) -> f32 {
139        let denom = self.correct + self.false_negatives;
140        if denom == 0 {
141            0.0
142        } else {
143            self.correct as f32 / denom as f32
144        }
145    }
146
147    /// F1 score.
148    pub fn f1(&self) -> f32 {
149        let p = self.precision();
150        let r = self.recall();
151        if p + r == 0.0 {
152            0.0
153        } else {
154            2.0 * p * r / (p + r)
155        }
156    }
157}
158
159/// Full test report.
160#[derive(Debug, Clone)]
161pub struct TestReport {
162    /// Suite name.
163    pub suite_name: String,
164
165    /// Individual test results.
166    pub results: Vec<TestCaseResult>,
167
168    /// Per-skill breakdown.
169    pub per_skill: std::collections::HashMap<SkillName, SkillTestReport>,
170
171    /// Total tests run.
172    pub total: usize,
173
174    /// Tests passed.
175    pub passed: usize,
176
177    /// Average latency in ms.
178    pub avg_latency_ms: f32,
179}
180
181impl TestReport {
182    /// Overall accuracy.
183    pub fn accuracy(&self) -> f32 {
184        if self.total == 0 {
185            0.0
186        } else {
187            self.passed as f32 / self.total as f32
188        }
189    }
190}
191
192/// Test harness for running trigger test suites.
193pub struct TriggerTestHarness {
194    /// Number of runs per test case (for stability).
195    pub runs_per_case: usize,
196}
197
198impl Default for TriggerTestHarness {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl TriggerTestHarness {
205    /// Create a new harness with default settings.
206    pub fn new() -> Self {
207        Self { runs_per_case: 1 }
208    }
209
210    /// Set runs per test case.
211    pub fn with_runs(mut self, runs: usize) -> Self {
212        self.runs_per_case = runs;
213        self
214    }
215
216    /// Run a test suite against a selector.
217    pub async fn run(
218        &self,
219        suite: &TestSuite,
220        selector: &CascadeSelector,
221        registry: &SkillRegistry,
222    ) -> Result<TestReport, LearnError> {
223        let mut results = Vec::new();
224        let mut per_skill: std::collections::HashMap<SkillName, SkillTestReport> =
225            std::collections::HashMap::new();
226
227        for case in &suite.cases {
228            let start = std::time::Instant::now();
229
230            // Run selection
231            let outcome = selector.select(&case.input, registry, &case.context).await?;
232
233            let latency = start.elapsed();
234            let selected = outcome.selected.first().map(|r| r.skill.clone());
235            let score = outcome.selected.first().map(|r| r.score);
236
237            let passed = case.expected.matches(selected.as_ref());
238
239            // Update per-skill stats
240            let expected_skills = match &case.expected {
241                TestExpectation::Single(s) => vec![s.clone()],
242                TestExpectation::AnyOf(list) => list.clone(),
243                TestExpectation::None => vec![],
244            };
245
246            for skill in &expected_skills {
247                let report = per_skill.entry(skill.clone()).or_default();
248                report.total += 1;
249
250                if selected.as_ref() == Some(skill) {
251                    report.correct += 1;
252                } else {
253                    report.false_negatives += 1;
254                }
255            }
256
257            if let Some(ref sel) = selected {
258                if !expected_skills.contains(sel) {
259                    let report = per_skill.entry(sel.clone()).or_default();
260                    report.false_positives += 1;
261                }
262            }
263
264            results.push(TestCaseResult {
265                name: case.name.clone(),
266                passed,
267                selected,
268                expected: case.expected.clone(),
269                score,
270                latency_ms: latency.as_millis() as u64,
271            });
272        }
273
274        let passed = results.iter().filter(|r| r.passed).count();
275        let total = results.len();
276        let avg_latency = if total > 0 {
277            results.iter().map(|r| r.latency_ms as f32).sum::<f32>() / total as f32
278        } else {
279            0.0
280        };
281
282        Ok(TestReport {
283            suite_name: suite.name.clone(),
284            results,
285            per_skill,
286            total,
287            passed,
288            avg_latency_ms: avg_latency,
289        })
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_expectation_single_match() {
299        let exp = TestExpectation::Single(SkillName::new("test").unwrap());
300        assert!(exp.matches(Some(&SkillName::new("test").unwrap())));
301        assert!(!exp.matches(Some(&SkillName::new("other").unwrap())));
302        assert!(!exp.matches(None));
303    }
304
305    #[test]
306    fn test_expectation_any_of() {
307        let exp = TestExpectation::AnyOf(vec![
308            SkillName::new("a").unwrap(),
309            SkillName::new("b").unwrap(),
310        ]);
311        assert!(exp.matches(Some(&SkillName::new("a").unwrap())));
312        assert!(exp.matches(Some(&SkillName::new("b").unwrap())));
313        assert!(!exp.matches(Some(&SkillName::new("c").unwrap())));
314    }
315
316    #[test]
317    fn test_expectation_none() {
318        let exp = TestExpectation::None;
319        assert!(exp.matches(None));
320        assert!(!exp.matches(Some(&SkillName::new("test").unwrap())));
321    }
322
323    #[test]
324    fn test_skill_report_metrics() {
325        let report = SkillTestReport {
326            total: 10,
327            correct: 8,
328            false_positives: 2,
329            false_negatives: 2,
330        };
331
332        // Precision: 8 / (8 + 2) = 0.8
333        assert!((report.precision() - 0.8).abs() < 1e-5);
334
335        // Recall: 8 / (8 + 2) = 0.8
336        assert!((report.recall() - 0.8).abs() < 1e-5);
337
338        // F1: 2 * 0.8 * 0.8 / 1.6 = 0.8
339        assert!((report.f1() - 0.8).abs() < 1e-5);
340    }
341
342    #[test]
343    fn test_test_suite_new() {
344        let suite = TestSuite::new("my-suite");
345        assert_eq!(suite.name, "my-suite");
346        assert!(suite.cases.is_empty());
347    }
348}