1use 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#[derive(Debug, Clone, Copy)]
16pub enum MergeStrategy {
17 MaxScore,
19
20 WeightedAverage,
22
23 RRF { k: f32 },
25}
26
27impl Default for MergeStrategy {
28 fn default() -> Self {
29 Self::MaxScore
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct CascadeConfig {
36 pub exhaustive: bool,
39
40 pub timeout: Duration,
42
43 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 pub fn exhaustive(mut self) -> Self {
60 self.exhaustive = true;
61 self
62 }
63
64 pub fn with_timeout(mut self, timeout: Duration) -> Self {
66 self.timeout = timeout;
67 self
68 }
69
70 pub fn with_merge_strategy(mut self, strategy: MergeStrategy) -> Self {
72 self.merge_strategy = strategy;
73 self
74 }
75}
76
77#[derive(Debug)]
79pub struct SelectionOutcome {
80 pub selected: Vec<SelectionResult>,
82
83 pub strategies_used: Vec<String>,
85
86 pub total_latency: Duration,
88
89 pub per_strategy_latency: Vec<(String, Duration)>,
91
92 pub fallback_used: bool,
94}
95
96pub struct CascadeSelector {
100 strategies: Vec<(Box<dyn SelectionStrategy>, Confidence)>,
101 config: CascadeConfig,
102}
103
104impl CascadeSelector {
105 pub fn builder() -> CascadeSelectorBuilder {
107 CascadeSelectorBuilder::new()
108 }
109
110 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 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 if strategy.name() == "llm" {
138 fallback_used = true;
139 }
140
141 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 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 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 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 results
197 .sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
198
199 let mut seen = std::collections::HashSet::new();
201 results.retain(|r| seen.insert(r.skill.clone()));
202
203 results
204 }
205 MergeStrategy::WeightedAverage => {
206 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 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 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 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 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 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
300pub struct CascadeSelectorBuilder {
302 strategies: Vec<(Box<dyn SelectionStrategy>, Confidence)>,
303 config: CascadeConfig,
304}
305
306impl CascadeSelectorBuilder {
307 pub fn new() -> Self {
309 Self {
310 strategies: Vec::new(),
311 config: CascadeConfig::default(),
312 }
313 }
314
315 pub fn with_triggers(mut self, strategy: TriggerStrategy) -> Self {
318 self.strategies
319 .push((Box::new(strategy), Confidence::High));
320 self
321 }
322
323 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 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 pub fn config(mut self, config: CascadeConfig) -> Self {
348 self.config = config;
349 self
350 }
351
352 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(®istry).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", ®istry, &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(®istry).await.unwrap();
415 let selector = CascadeSelector::builder().with_triggers(trigger).build();
416
417 let ctx = SelectionContext::new();
418 let outcome = selector.select("play music", ®istry, &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 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 assert_eq!(merged.len(), 2);
495 }
496}