Skip to main content

skm_select/
cascade.rs

1//! Cascading skill selector.
2
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use skm_core::{SkillMetadata, SkillRegistry};
7use skm_embed::EmbeddingProvider;
8
9use crate::error::SelectError;
10use crate::semantic::{SemanticConfig, SemanticStrategy};
11use crate::strategy::{Confidence, SelectionContext, SelectionResult, SelectionStrategy};
12use crate::trigger::TriggerStrategy;
13
14/// How to merge results from multiple strategies.
15#[derive(Debug, Clone, Copy)]
16pub enum MergeStrategy {
17    /// Take the highest-scoring result across all strategies.
18    MaxScore,
19
20    /// Weighted average of scores, with strategy-level weights.
21    WeightedAverage,
22
23    /// Reciprocal Rank Fusion across strategy rankings.
24    RRF { k: f32 },
25}
26
27impl Default for MergeStrategy {
28    fn default() -> Self {
29        Self::MaxScore
30    }
31}
32
33/// Configuration for the cascade selector.
34#[derive(Debug, Clone)]
35pub struct CascadeConfig {
36    /// If true, always run all strategies and merge results.
37    /// If false (default), stop at first confident result.
38    pub exhaustive: bool,
39
40    /// Maximum total latency budget. Skip slow strategies if exceeded.
41    pub timeout: Duration,
42
43    /// How to merge results from multiple strategies.
44    pub merge_strategy: MergeStrategy,
45}
46
47impl Default for CascadeConfig {
48    fn default() -> Self {
49        Self {
50            exhaustive: false,
51            timeout: Duration::from_secs(5),
52            merge_strategy: MergeStrategy::MaxScore,
53        }
54    }
55}
56
57impl CascadeConfig {
58    /// Enable exhaustive mode.
59    pub fn exhaustive(mut self) -> Self {
60        self.exhaustive = true;
61        self
62    }
63
64    /// Set timeout.
65    pub fn with_timeout(mut self, timeout: Duration) -> Self {
66        self.timeout = timeout;
67        self
68    }
69
70    /// Set merge strategy.
71    pub fn with_merge_strategy(mut self, strategy: MergeStrategy) -> Self {
72        self.merge_strategy = strategy;
73        self
74    }
75}
76
77/// Complete outcome of a selection, with full audit trail.
78#[derive(Debug)]
79pub struct SelectionOutcome {
80    /// Final ranked results.
81    pub selected: Vec<SelectionResult>,
82
83    /// Which strategies ran.
84    pub strategies_used: Vec<String>,
85
86    /// Total latency.
87    pub total_latency: Duration,
88
89    /// Per-strategy latency.
90    pub per_strategy_latency: Vec<(String, Duration)>,
91
92    /// Did we reach LLM fallback?
93    pub fallback_used: bool,
94}
95
96/// Cascading skill selector.
97///
98/// Tries strategies in order, stopping when confidence is high enough.
99pub struct CascadeSelector {
100    strategies: Vec<(Box<dyn SelectionStrategy>, Confidence)>,
101    config: CascadeConfig,
102}
103
104impl CascadeSelector {
105    /// Create a new cascade selector builder.
106    pub fn builder() -> CascadeSelectorBuilder {
107        CascadeSelectorBuilder::new()
108    }
109
110    /// Select the best skill(s) for a query.
111    pub async fn select(
112        &self,
113        query: &str,
114        registry: &SkillRegistry,
115        ctx: &SelectionContext,
116    ) -> Result<SelectionOutcome, SelectError> {
117        let start = Instant::now();
118        let catalog = registry.catalog().await;
119        let candidates: Vec<_> = catalog.iter().collect();
120
121        let mut all_results: Vec<SelectionResult> = Vec::new();
122        let mut strategies_used = Vec::new();
123        let mut per_strategy_latency = Vec::new();
124        let mut fallback_used = false;
125
126        for (strategy, stop_confidence) in &self.strategies {
127            // Check timeout
128            if start.elapsed() >= self.config.timeout {
129                tracing::warn!("Cascade timeout exceeded, stopping");
130                break;
131            }
132
133            let strategy_start = Instant::now();
134            strategies_used.push(strategy.name().to_string());
135
136            // Track if this is LLM fallback
137            if strategy.name() == "llm" {
138                fallback_used = true;
139            }
140
141            // Run strategy
142            match strategy.select(query, &candidates, ctx).await {
143                Ok(results) => {
144                    per_strategy_latency
145                        .push((strategy.name().to_string(), strategy_start.elapsed()));
146
147                    if !results.is_empty() {
148                        // Check if we should stop
149                        let best_confidence = results
150                            .iter()
151                            .map(|r| r.confidence)
152                            .max()
153                            .unwrap_or(Confidence::None);
154
155                        all_results.extend(results);
156
157                        if !self.config.exhaustive && best_confidence >= *stop_confidence {
158                            tracing::debug!(
159                                "Stopping cascade at {} with confidence {:?}",
160                                strategy.name(),
161                                best_confidence
162                            );
163                            break;
164                        }
165                    }
166                }
167                Err(e) => {
168                    tracing::warn!("Strategy {} failed: {}", strategy.name(), e);
169                    per_strategy_latency
170                        .push((strategy.name().to_string(), strategy_start.elapsed()));
171                }
172            }
173        }
174
175        // Merge results
176        let selected = self.merge_results(all_results);
177
178        Ok(SelectionOutcome {
179            selected,
180            strategies_used,
181            total_latency: start.elapsed(),
182            per_strategy_latency,
183            fallback_used,
184        })
185    }
186
187    /// Merge results from multiple strategies.
188    fn merge_results(&self, mut results: Vec<SelectionResult>) -> Vec<SelectionResult> {
189        if results.is_empty() {
190            return results;
191        }
192
193        match self.config.merge_strategy {
194            MergeStrategy::MaxScore => {
195                // Sort by score descending
196                results
197                    .sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
198
199                // Deduplicate by skill name, keeping highest score
200                let mut seen = std::collections::HashSet::new();
201                results.retain(|r| seen.insert(r.skill.clone()));
202
203                results
204            }
205            MergeStrategy::WeightedAverage => {
206                // Group by skill
207                let mut by_skill: std::collections::HashMap<_, Vec<_>> =
208                    std::collections::HashMap::new();
209                for r in results {
210                    by_skill.entry(r.skill.clone()).or_default().push(r);
211                }
212
213                // Average scores for each skill
214                let mut merged: Vec<_> = by_skill
215                    .into_iter()
216                    .map(|(skill, group)| {
217                        let avg_score =
218                            group.iter().map(|r| r.score).sum::<f32>() / group.len() as f32;
219                        let best = group.into_iter().max_by(|a, b| {
220                            a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal)
221                        }).unwrap();
222                        
223                        SelectionResult {
224                            skill,
225                            score: avg_score,
226                            confidence: Confidence::from_score(avg_score),
227                            strategy: best.strategy,
228                            reasoning: best.reasoning,
229                        }
230                    })
231                    .collect();
232
233                merged.sort_by(|a, b| {
234                    b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)
235                });
236                merged
237            }
238            MergeStrategy::RRF { k } => {
239                // Reciprocal Rank Fusion
240                let mut scores: std::collections::HashMap<_, f32> =
241                    std::collections::HashMap::new();
242                let mut best_results: std::collections::HashMap<_, SelectionResult> =
243                    std::collections::HashMap::new();
244
245                // Group results by strategy
246                let mut by_strategy: std::collections::HashMap<_, Vec<_>> =
247                    std::collections::HashMap::new();
248                for r in results {
249                    by_strategy
250                        .entry(r.strategy.clone())
251                        .or_default()
252                        .push(r);
253                }
254
255                // Calculate RRF scores
256                for (_, mut strategy_results) in by_strategy {
257                    strategy_results.sort_by(|a, b| {
258                        b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)
259                    });
260
261                    for (rank, r) in strategy_results.into_iter().enumerate() {
262                        let rrf_score = 1.0 / (k + rank as f32 + 1.0);
263                        *scores.entry(r.skill.clone()).or_default() += rrf_score;
264
265                        best_results
266                            .entry(r.skill.clone())
267                            .and_modify(|existing| {
268                                if r.score > existing.score {
269                                    *existing = r.clone();
270                                }
271                            })
272                            .or_insert(r);
273                    }
274                }
275
276                // Build final results
277                let mut merged: Vec<_> = scores
278                    .into_iter()
279                    .map(|(skill, score)| {
280                        let best = best_results.remove(&skill).unwrap();
281                        SelectionResult {
282                            skill,
283                            score,
284                            confidence: Confidence::from_score(score.min(1.0)),
285                            strategy: best.strategy,
286                            reasoning: best.reasoning,
287                        }
288                    })
289                    .collect();
290
291                merged.sort_by(|a, b| {
292                    b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)
293                });
294                merged
295            }
296        }
297    }
298}
299
300/// Builder for ergonomic cascade construction.
301pub struct CascadeSelectorBuilder {
302    strategies: Vec<(Box<dyn SelectionStrategy>, Confidence)>,
303    config: CascadeConfig,
304}
305
306impl CascadeSelectorBuilder {
307    /// Create a new builder.
308    pub fn new() -> Self {
309        Self {
310            strategies: Vec::new(),
311            config: CascadeConfig::default(),
312        }
313    }
314
315    /// Add trigger strategy as first cascade level.
316    /// Stops cascade if confidence >= High.
317    pub fn with_triggers(mut self, strategy: TriggerStrategy) -> Self {
318        self.strategies
319            .push((Box::new(strategy), Confidence::High));
320        self
321    }
322
323    /// Add semantic strategy as second cascade level.
324    pub fn with_semantic(
325        mut self,
326        provider: Arc<dyn EmbeddingProvider>,
327        index: skm_embed::EmbeddingIndex,
328        config: SemanticConfig,
329    ) -> Self {
330        let strategy = SemanticStrategy::new(provider, index, config);
331        self.strategies
332            .push((Box::new(strategy), Confidence::Medium));
333        self
334    }
335
336    /// Add a custom strategy at a specific cascade position.
337    pub fn with_custom(
338        mut self,
339        strategy: Box<dyn SelectionStrategy>,
340        stop_confidence: Confidence,
341    ) -> Self {
342        self.strategies.push((strategy, stop_confidence));
343        self
344    }
345
346    /// Set the cascade config.
347    pub fn config(mut self, config: CascadeConfig) -> Self {
348        self.config = config;
349        self
350    }
351
352    /// Build the CascadeSelector.
353    pub fn build(self) -> CascadeSelector {
354        CascadeSelector {
355            strategies: self.strategies,
356            config: self.config,
357        }
358    }
359}
360
361impl Default for CascadeSelectorBuilder {
362    fn default() -> Self {
363        Self::new()
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use skm_core::SkillName;
371    use std::fs;
372    use tempfile::TempDir;
373
374    async fn setup_registry() -> (TempDir, SkillRegistry) {
375        let temp = TempDir::new().unwrap();
376
377        let skill = r#"---
378name: pdf-skill
379description: Extract text from PDF files
380metadata:
381  triggers: "pdf, extract text"
382---
383
384Instructions here.
385"#;
386
387        let skill_dir = temp.path().join("pdf-skill");
388        fs::create_dir_all(&skill_dir).unwrap();
389        fs::write(skill_dir.join("SKILL.md"), skill).unwrap();
390
391        let registry = SkillRegistry::new(&[temp.path()]).await.unwrap();
392        (temp, registry)
393    }
394
395    #[tokio::test]
396    async fn test_cascade_trigger_only() {
397        let (_temp, registry) = setup_registry().await;
398
399        let trigger = TriggerStrategy::from_registry(&registry).await.unwrap();
400        let selector = CascadeSelector::builder().with_triggers(trigger).build();
401
402        let ctx = SelectionContext::new();
403        let outcome = selector.select("extract pdf text", &registry, &ctx).await.unwrap();
404
405        assert!(!outcome.selected.is_empty());
406        assert_eq!(outcome.selected[0].skill.as_str(), "pdf-skill");
407        assert!(outcome.strategies_used.contains(&"trigger".to_string()));
408    }
409
410    #[tokio::test]
411    async fn test_cascade_no_match() {
412        let (_temp, registry) = setup_registry().await;
413
414        let trigger = TriggerStrategy::from_registry(&registry).await.unwrap();
415        let selector = CascadeSelector::builder().with_triggers(trigger).build();
416
417        let ctx = SelectionContext::new();
418        let outcome = selector.select("play music", &registry, &ctx).await.unwrap();
419
420        assert!(outcome.selected.is_empty());
421    }
422
423    #[test]
424    fn test_merge_max_score() {
425        let selector = CascadeSelector::builder()
426            .config(CascadeConfig::default())
427            .build();
428
429        let results = vec![
430            SelectionResult::new(
431                SkillName::new("skill-a").unwrap(),
432                0.8,
433                Confidence::High,
434                "trigger",
435            ),
436            SelectionResult::new(
437                SkillName::new("skill-a").unwrap(),
438                0.6,
439                Confidence::Medium,
440                "semantic",
441            ),
442            SelectionResult::new(
443                SkillName::new("skill-b").unwrap(),
444                0.7,
445                Confidence::High,
446                "trigger",
447            ),
448        ];
449
450        let merged = selector.merge_results(results);
451
452        // skill-a should have highest score (0.8), then skill-b (0.7)
453        assert_eq!(merged.len(), 2);
454        assert_eq!(merged[0].skill.as_str(), "skill-a");
455        assert_eq!(merged[0].score, 0.8);
456    }
457
458    #[test]
459    fn test_merge_rrf() {
460        let selector = CascadeSelector::builder()
461            .config(CascadeConfig::default().with_merge_strategy(MergeStrategy::RRF { k: 60.0 }))
462            .build();
463
464        let results = vec![
465            SelectionResult::new(
466                SkillName::new("skill-a").unwrap(),
467                0.9,
468                Confidence::High,
469                "trigger",
470            ),
471            SelectionResult::new(
472                SkillName::new("skill-b").unwrap(),
473                0.7,
474                Confidence::Medium,
475                "trigger",
476            ),
477            SelectionResult::new(
478                SkillName::new("skill-b").unwrap(),
479                0.8,
480                Confidence::High,
481                "semantic",
482            ),
483            SelectionResult::new(
484                SkillName::new("skill-a").unwrap(),
485                0.6,
486                Confidence::Medium,
487                "semantic",
488            ),
489        ];
490
491        let merged = selector.merge_results(results);
492
493        // Both skills should have RRF scores
494        assert_eq!(merged.len(), 2);
495    }
496}