Skip to main content

skm_select/
strategy.rs

1//! SelectionStrategy trait and related types.
2
3use std::collections::HashMap;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use skm_core::{SkillMetadata, SkillName};
9
10use crate::error::SelectError;
11
12/// Confidence level for a selection result.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
14pub enum Confidence {
15    /// No match at all.
16    None,
17
18    /// Weak signal, definitely try next strategy.
19    Low,
20
21    /// Decent match, but slower strategy might do better.
22    Medium,
23
24    /// Strong match, no need for slower strategies.
25    High,
26
27    /// Single clear match, proceed without fallback.
28    Definite,
29}
30
31impl Confidence {
32    /// Check if this confidence level is high enough to stop the cascade.
33    pub fn is_high_enough(&self, threshold: Confidence) -> bool {
34        *self >= threshold
35    }
36
37    /// Convert to a numeric score (0.0 - 1.0).
38    pub fn as_score(&self) -> f32 {
39        match self {
40            Self::None => 0.0,
41            Self::Low => 0.25,
42            Self::Medium => 0.5,
43            Self::High => 0.75,
44            Self::Definite => 1.0,
45        }
46    }
47
48    /// Create from a numeric score.
49    pub fn from_score(score: f32) -> Self {
50        if score >= 0.9 {
51            Self::Definite
52        } else if score >= 0.7 {
53            Self::High
54        } else if score >= 0.5 {
55            Self::Medium
56        } else if score >= 0.25 {
57            Self::Low
58        } else {
59            Self::None
60        }
61    }
62}
63
64impl Default for Confidence {
65    fn default() -> Self {
66        Self::None
67    }
68}
69
70/// Latency class for a strategy.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LatencyClass {
73    /// Trigger matching: µs latency.
74    Microseconds,
75
76    /// Semantic search: ms latency.
77    Milliseconds,
78
79    /// LLM classification: s latency.
80    Seconds,
81}
82
83/// Result of a selection operation.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SelectionResult {
86    /// The selected skill.
87    pub skill: SkillName,
88
89    /// Normalized score (0.0 - 1.0).
90    pub score: f32,
91
92    /// Confidence level.
93    pub confidence: Confidence,
94
95    /// Which strategy produced this result.
96    pub strategy: String,
97
98    /// Optional reasoning (for LLM strategies).
99    pub reasoning: Option<String>,
100}
101
102impl SelectionResult {
103    /// Create a new selection result.
104    pub fn new(
105        skill: SkillName,
106        score: f32,
107        confidence: Confidence,
108        strategy: impl Into<String>,
109    ) -> Self {
110        Self {
111            skill,
112            score,
113            confidence,
114            strategy: strategy.into(),
115            reasoning: None,
116        }
117    }
118
119    /// Add reasoning.
120    pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
121        self.reasoning = Some(reasoning.into());
122        self
123    }
124}
125
126/// Context available during selection.
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128pub struct SelectionContext {
129    /// Recent conversation history.
130    pub conversation_history: Vec<String>,
131
132    /// Currently active/loaded skills.
133    pub active_skills: Vec<SkillName>,
134
135    /// User locale (e.g., "zh-CN", "en-US").
136    pub user_locale: Option<String>,
137
138    /// Custom context data.
139    pub custom: HashMap<String, String>,
140}
141
142impl SelectionContext {
143    /// Create a new empty context.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Add conversation history.
149    pub fn with_history(mut self, history: Vec<String>) -> Self {
150        self.conversation_history = history;
151        self
152    }
153
154    /// Add active skills.
155    pub fn with_active_skills(mut self, skills: Vec<SkillName>) -> Self {
156        self.active_skills = skills;
157        self
158    }
159
160    /// Set user locale.
161    pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
162        self.user_locale = Some(locale.into());
163        self
164    }
165
166    /// Add custom context data.
167    pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
168        self.custom.insert(key.into(), value.into());
169        self
170    }
171}
172
173/// A skill selection strategy.
174///
175/// Strategies are composable and async. The cascade selector runs strategies
176/// in order until one returns with sufficient confidence.
177#[async_trait]
178pub trait SelectionStrategy: Send + Sync {
179    /// Rank skills for a given query. Returns scored candidates.
180    async fn select(
181        &self,
182        query: &str,
183        candidates: &[&SkillMetadata],
184        ctx: &SelectionContext,
185    ) -> Result<Vec<SelectionResult>, SelectError>;
186
187    /// Strategy name for logging/metrics.
188    fn name(&self) -> &str;
189
190    /// Expected latency class.
191    fn latency_class(&self) -> LatencyClass;
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_confidence_ordering() {
200        assert!(Confidence::Definite > Confidence::High);
201        assert!(Confidence::High > Confidence::Medium);
202        assert!(Confidence::Medium > Confidence::Low);
203        assert!(Confidence::Low > Confidence::None);
204    }
205
206    #[test]
207    fn test_confidence_as_score() {
208        assert_eq!(Confidence::None.as_score(), 0.0);
209        assert_eq!(Confidence::Definite.as_score(), 1.0);
210    }
211
212    #[test]
213    fn test_confidence_from_score() {
214        assert_eq!(Confidence::from_score(0.95), Confidence::Definite);
215        assert_eq!(Confidence::from_score(0.8), Confidence::High);
216        assert_eq!(Confidence::from_score(0.6), Confidence::Medium);
217        assert_eq!(Confidence::from_score(0.3), Confidence::Low);
218        assert_eq!(Confidence::from_score(0.1), Confidence::None);
219    }
220
221    #[test]
222    fn test_is_high_enough() {
223        assert!(Confidence::Definite.is_high_enough(Confidence::High));
224        assert!(Confidence::High.is_high_enough(Confidence::High));
225        assert!(!Confidence::Medium.is_high_enough(Confidence::High));
226    }
227
228    #[test]
229    fn test_selection_result() {
230        let skill = SkillName::new("test").unwrap();
231        let result = SelectionResult::new(skill.clone(), 0.9, Confidence::High, "trigger")
232            .with_reasoning("Matched keyword");
233
234        assert_eq!(result.skill, skill);
235        assert_eq!(result.score, 0.9);
236        assert_eq!(result.reasoning, Some("Matched keyword".to_string()));
237    }
238
239    #[test]
240    fn test_selection_context() {
241        let ctx = SelectionContext::new()
242            .with_locale("zh-CN")
243            .with_custom("key", "value");
244
245        assert_eq!(ctx.user_locale, Some("zh-CN".to_string()));
246        assert_eq!(ctx.custom.get("key"), Some(&"value".to_string()));
247    }
248}