Skip to main content

clawft_core/
intelligent_router.rs

1//! 3-tier intelligent routing with complexity scoring (ADR-026).
2//!
3//! Routes requests to one of three tiers:
4//! - **Tier 1**: Agent Booster (WASM) -- <1ms latency, $0 cost
5//! - **Tier 2**: Haiku -- ~500ms latency, low cost (complexity < 0.30)
6//! - **Tier 3**: Sonnet/Opus -- 2-5s latency, higher cost (complexity >= 0.30)
7//!
8//! The router maintains a policy store of cached routing decisions and a
9//! cost record history for analytics.
10//!
11//! This module is gated behind the `vector-memory` feature flag.
12
13use std::collections::HashMap;
14
15use crate::embeddings::{Embedder, EmbeddingError};
16use crate::vector_store::VectorStore;
17
18/// A routing decision made by the intelligent router.
19#[derive(Debug, Clone)]
20pub struct RoutingDecision {
21    /// The selected tier (1=WASM, 2=Haiku, 3=Sonnet/Opus).
22    pub tier: u8,
23    /// The model identifier to use.
24    pub model: String,
25    /// Human-readable reason for the routing choice.
26    pub reason: String,
27    /// Computed complexity score (0.0..1.0).
28    pub complexity_score: f32,
29}
30
31/// A record of cost/latency for a completed request.
32#[derive(Debug, Clone)]
33pub struct CostRecord {
34    /// The model that was used.
35    pub model: String,
36    /// Number of tokens consumed.
37    pub tokens: u64,
38    /// Monetary cost in USD.
39    pub cost: f32,
40    /// End-to-end latency in milliseconds.
41    pub latency_ms: u64,
42    /// Unix timestamp (seconds since epoch).
43    pub timestamp: u64,
44}
45
46/// Aggregate statistics for a model.
47#[derive(Debug, Clone)]
48pub struct ModelStats {
49    /// Total number of calls made to the model.
50    pub total_calls: u64,
51    /// Total monetary cost in USD.
52    pub total_cost: f32,
53    /// Average latency in milliseconds.
54    pub avg_latency_ms: f64,
55}
56
57/// Context provided to the router to influence the routing decision.
58#[derive(Debug, Clone, Default)]
59pub struct RoutingContext {
60    /// Tags that can influence routing (e.g. "AGENT_BOOSTER_AVAILABLE").
61    pub tags: Vec<String>,
62}
63
64/// Errors that can occur during intelligent routing.
65#[non_exhaustive]
66#[derive(Debug)]
67pub enum RoutingError {
68    /// An embedding operation failed.
69    Embedding(EmbeddingError),
70}
71
72impl std::fmt::Display for RoutingError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            RoutingError::Embedding(e) => write!(f, "routing embedding error: {e}"),
76        }
77    }
78}
79
80impl std::error::Error for RoutingError {}
81
82impl From<EmbeddingError> for RoutingError {
83    fn from(e: EmbeddingError) -> Self {
84        RoutingError::Embedding(e)
85    }
86}
87
88/// 3-tier intelligent router per ADR-026.
89///
90/// Uses a vector store for cached routing policies and an embedder for
91/// computing prompt similarity against those policies.
92pub struct IntelligentRouter {
93    policy_store: VectorStore,
94    embedder: Box<dyn Embedder>,
95    cost_records: Vec<CostRecord>,
96}
97
98impl IntelligentRouter {
99    /// Create a new intelligent router with the given embedder.
100    pub async fn new(embedder: Box<dyn Embedder>) -> Self {
101        Self {
102            policy_store: VectorStore::new(),
103            embedder,
104            cost_records: Vec::new(),
105        }
106    }
107
108    /// Route a request to the appropriate tier.
109    ///
110    /// Routing algorithm (in order):
111    /// 1. If context tags contain "AGENT_BOOSTER_AVAILABLE" -> Tier 1
112    /// 2. Search policy store; if best match > 0.85 -> use cached tier
113    /// 3. Compute complexity; < 0.30 -> Tier 2, else -> Tier 3
114    pub async fn route_request(
115        &self,
116        prompt: &str,
117        context: &RoutingContext,
118    ) -> Result<RoutingDecision, RoutingError> {
119        // Rule 1: Agent Booster tag.
120        if context.tags.iter().any(|t| t == "AGENT_BOOSTER_AVAILABLE") {
121            return Ok(RoutingDecision {
122                tier: 1,
123                model: "agent-booster-wasm".into(),
124                reason: "AGENT_BOOSTER_AVAILABLE tag present".into(),
125                complexity_score: 0.0,
126            });
127        }
128
129        // Rule 2: Check policy store for cached routing.
130        let prompt_embedding = self.embedder.embed(prompt).await?;
131        let policy_results = self.policy_store.search(&prompt_embedding, 5);
132
133        if let Some(best) = policy_results.first()
134            && best.score > 0.85
135            && let Some(tier_tag) = best.tags.first()
136            && let Ok(tier) = tier_tag.parse::<u8>()
137        {
138            let model = tier_to_model(tier);
139            return Ok(RoutingDecision {
140                tier,
141                model,
142                reason: format!(
143                    "cached policy match (score={:.3}): {}",
144                    best.score, best.text
145                ),
146                complexity_score: compute_complexity(prompt),
147            });
148        }
149
150        // Rule 3: Compute complexity.
151        let complexity = compute_complexity(prompt);
152        if complexity < 0.30 {
153            Ok(RoutingDecision {
154                tier: 2,
155                model: "claude-haiku-3.5".into(),
156                reason: format!("low complexity ({complexity:.3}) -> Tier 2"),
157                complexity_score: complexity,
158            })
159        } else {
160            Ok(RoutingDecision {
161                tier: 3,
162                model: "claude-sonnet-4.5".into(),
163                reason: format!("high complexity ({complexity:.3}) -> Tier 3"),
164                complexity_score: complexity,
165            })
166        }
167    }
168
169    /// Record a cost/latency observation for analytics.
170    pub async fn record_cost(&mut self, model: &str, tokens: u64, cost: f32, latency_ms: u64) {
171        let timestamp = std::time::SystemTime::now()
172            .duration_since(std::time::UNIX_EPOCH)
173            .unwrap_or_default()
174            .as_secs();
175
176        self.cost_records.push(CostRecord {
177            model: model.into(),
178            tokens,
179            cost,
180            latency_ms,
181            timestamp,
182        });
183    }
184
185    /// Update or add a routing policy based on observed patterns.
186    ///
187    /// If `success` is true, the pattern is cached with the given tier.
188    /// If `success` is false, the opposite action is taken (tier is adjusted).
189    pub async fn update_policy(&mut self, pattern: &str, tier: u8, success: bool) {
190        if !success {
191            // On failure, do not cache this routing decision.
192            return;
193        }
194
195        let embedding = match self.embedder.embed(pattern).await {
196            Ok(e) => e,
197            Err(_) => return,
198        };
199
200        let id = format!("policy-{}", self.policy_store.len());
201        self.policy_store.add(
202            id,
203            pattern.into(),
204            embedding,
205            vec![tier.to_string()],
206            HashMap::new(),
207        );
208    }
209
210    /// Get aggregate statistics for a specific model.
211    pub fn get_model_stats(&self, model: &str) -> ModelStats {
212        let matching: Vec<&CostRecord> = self
213            .cost_records
214            .iter()
215            .filter(|r| r.model == model)
216            .collect();
217
218        let total_calls = matching.len() as u64;
219        let total_cost: f32 = matching.iter().map(|r| r.cost).sum();
220        let avg_latency_ms = if matching.is_empty() {
221            0.0
222        } else {
223            matching.iter().map(|r| r.latency_ms as f64).sum::<f64>() / matching.len() as f64
224        };
225
226        ModelStats {
227            total_calls,
228            total_cost,
229            avg_latency_ms,
230        }
231    }
232}
233
234/// Compute a complexity score for a prompt.
235///
236/// Heuristic:
237/// - Token count: words / 1000, capped at 0.3
238/// - +0.2 if contains code fences (```)
239/// - +0.1 if contains reasoning words ("analyze", "design", "architect", "explain", "compare")
240/// - +0.2 if contains multi-step indicators ("first", "then", "finally", "step 1")
241/// - Result clamped to 0.0..1.0
242pub fn compute_complexity(prompt: &str) -> f32 {
243    let lower = prompt.to_lowercase();
244    let word_count = lower.split_whitespace().count();
245
246    // Base: word count / 1000, capped at 0.3
247    let base = (word_count as f32 / 1000.0).min(0.3);
248
249    // Code fences.
250    let code_bonus = if lower.contains("```") { 0.2 } else { 0.0 };
251
252    // Reasoning words.
253    let reasoning_words = ["analyze", "design", "architect", "explain", "compare"];
254    let reasoning_bonus = if reasoning_words.iter().any(|w| lower.contains(w)) {
255        0.1
256    } else {
257        0.0
258    };
259
260    // Multi-step indicators.
261    let step_words = ["first", "then", "finally", "step 1"];
262    let step_bonus = if step_words.iter().any(|w| lower.contains(w)) {
263        0.2
264    } else {
265        0.0
266    };
267
268    (base + code_bonus + reasoning_bonus + step_bonus).clamp(0.0, 1.0)
269}
270
271/// Map a tier number to a default model name.
272fn tier_to_model(tier: u8) -> String {
273    match tier {
274        1 => "agent-booster-wasm".into(),
275        2 => "claude-haiku-3.5".into(),
276        _ => "claude-sonnet-4.5".into(),
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::embeddings::hash_embedder::HashEmbedder;
284
285    fn make_embedder() -> Box<dyn Embedder> {
286        Box::new(HashEmbedder::new(64))
287    }
288
289    // ── Tier 1: Agent Booster ──────────────────────────────────────
290
291    #[tokio::test]
292    async fn tier1_agent_booster_available() {
293        let router = IntelligentRouter::new(make_embedder()).await;
294        let ctx = RoutingContext {
295            tags: vec!["AGENT_BOOSTER_AVAILABLE".into()],
296        };
297
298        let decision = router.route_request("simple task", &ctx).await.unwrap();
299        assert_eq!(decision.tier, 1);
300        assert_eq!(decision.model, "agent-booster-wasm");
301        assert!(decision.reason.contains("AGENT_BOOSTER_AVAILABLE"));
302    }
303
304    #[tokio::test]
305    async fn tier1_booster_takes_priority_over_complexity() {
306        let router = IntelligentRouter::new(make_embedder()).await;
307        let ctx = RoutingContext {
308            tags: vec!["AGENT_BOOSTER_AVAILABLE".into()],
309        };
310
311        // Even a complex prompt should go to Tier 1 with the tag.
312        let decision = router
313            .route_request(
314                "Analyze and design a complex architecture with ```code``` first then finally",
315                &ctx,
316            )
317            .await
318            .unwrap();
319        assert_eq!(decision.tier, 1);
320    }
321
322    // ── Tier 2: Simple prompt ──────────────────────────────────────
323
324    #[tokio::test]
325    async fn tier2_simple_prompt() {
326        let router = IntelligentRouter::new(make_embedder()).await;
327        let ctx = RoutingContext::default();
328
329        let decision = router.route_request("hello", &ctx).await.unwrap();
330        assert_eq!(decision.tier, 2);
331        assert_eq!(decision.model, "claude-haiku-3.5");
332        assert!(decision.complexity_score < 0.30);
333    }
334
335    #[tokio::test]
336    async fn tier2_short_question() {
337        let router = IntelligentRouter::new(make_embedder()).await;
338        let ctx = RoutingContext::default();
339
340        let decision = router.route_request("what is 2 + 2?", &ctx).await.unwrap();
341        assert_eq!(decision.tier, 2);
342    }
343
344    // ── Tier 3: Complex prompt ─────────────────────────────────────
345
346    #[tokio::test]
347    async fn tier3_complex_prompt_with_code() {
348        let router = IntelligentRouter::new(make_embedder()).await;
349        let ctx = RoutingContext::default();
350
351        let decision = router
352            .route_request("explain this ```rust\nfn main() {}```", &ctx)
353            .await
354            .unwrap();
355        assert_eq!(decision.tier, 3);
356        assert_eq!(decision.model, "claude-sonnet-4.5");
357        assert!(decision.complexity_score >= 0.30);
358    }
359
360    #[tokio::test]
361    async fn tier3_complex_prompt_with_reasoning_and_code() {
362        let router = IntelligentRouter::new(make_embedder()).await;
363        let ctx = RoutingContext::default();
364
365        // Reasoning (0.1) + code fences (0.2) => 0.3+
366        let decision = router
367            .route_request(
368                "analyze the performance of this ```rust\nfn main() {}``` and compare approaches",
369                &ctx,
370            )
371            .await
372            .unwrap();
373        assert_eq!(decision.tier, 3);
374        assert!(decision.complexity_score >= 0.30);
375    }
376
377    #[tokio::test]
378    async fn tier3_complex_prompt_with_steps_and_code() {
379        let router = IntelligentRouter::new(make_embedder()).await;
380        let ctx = RoutingContext::default();
381
382        // Steps (0.2) + code fences (0.2) => 0.4+
383        let decision = router
384            .route_request(
385                "first set up the database schema ```sql\nCREATE TABLE users```, then create the API, finally deploy",
386                &ctx,
387            )
388            .await
389            .unwrap();
390        assert_eq!(decision.tier, 3);
391        assert!(decision.complexity_score >= 0.30);
392    }
393
394    // ── Policy cache ───────────────────────────────────────────────
395
396    #[tokio::test]
397    async fn policy_cache_update_and_match() {
398        let mut router = IntelligentRouter::new(make_embedder()).await;
399        let ctx = RoutingContext::default();
400
401        // Cache a policy for a specific pattern.
402        router
403            .update_policy("convert temperature from celsius to fahrenheit", 2, true)
404            .await;
405
406        // Route a very similar request.
407        let decision = router
408            .route_request("convert temperature from celsius to fahrenheit", &ctx)
409            .await
410            .unwrap();
411
412        // Should use cached policy (Tier 2).
413        assert_eq!(decision.tier, 2);
414        assert!(decision.reason.contains("cached policy match"));
415    }
416
417    #[tokio::test]
418    async fn policy_cache_failure_not_stored() {
419        let mut router = IntelligentRouter::new(make_embedder()).await;
420
421        // Report a failure -- should NOT be cached.
422        router
423            .update_policy("test pattern that failed", 2, false)
424            .await;
425
426        // Policy store should be empty.
427        assert!(router.policy_store.is_empty());
428    }
429
430    // ── Cost recording and stats ────────────────────────────────────
431
432    #[tokio::test]
433    async fn cost_recording_and_stats() {
434        let mut router = IntelligentRouter::new(make_embedder()).await;
435
436        router
437            .record_cost("claude-haiku-3.5", 100, 0.001, 500)
438            .await;
439        router
440            .record_cost("claude-haiku-3.5", 200, 0.002, 600)
441            .await;
442        router
443            .record_cost("claude-sonnet-4.5", 500, 0.01, 2000)
444            .await;
445
446        let haiku_stats = router.get_model_stats("claude-haiku-3.5");
447        assert_eq!(haiku_stats.total_calls, 2);
448        assert!((haiku_stats.total_cost - 0.003).abs() < 0.0001);
449        assert!((haiku_stats.avg_latency_ms - 550.0).abs() < 0.01);
450
451        let sonnet_stats = router.get_model_stats("claude-sonnet-4.5");
452        assert_eq!(sonnet_stats.total_calls, 1);
453        assert!((sonnet_stats.total_cost - 0.01).abs() < 0.0001);
454        assert!((sonnet_stats.avg_latency_ms - 2000.0).abs() < 0.01);
455    }
456
457    #[tokio::test]
458    async fn stats_for_unknown_model() {
459        let router = IntelligentRouter::new(make_embedder()).await;
460        let stats = router.get_model_stats("unknown-model");
461        assert_eq!(stats.total_calls, 0);
462        assert!((stats.total_cost - 0.0).abs() < f32::EPSILON);
463        assert!((stats.avg_latency_ms - 0.0).abs() < f64::EPSILON);
464    }
465
466    // ── compute_complexity tests ───────────────────────────────────
467
468    #[test]
469    fn complexity_empty_prompt() {
470        let c = compute_complexity("");
471        assert!((c - 0.0).abs() < f32::EPSILON);
472    }
473
474    #[test]
475    fn complexity_short_prompt() {
476        let c = compute_complexity("hello world");
477        assert!(c < 0.30, "short prompt should be low complexity: {c}");
478    }
479
480    #[test]
481    fn complexity_code_fences() {
482        let c = compute_complexity("look at this ```code here```");
483        assert!(c >= 0.2, "code fences should add 0.2: {c}");
484    }
485
486    #[test]
487    fn complexity_reasoning_words() {
488        let c = compute_complexity("analyze this data");
489        assert!(c >= 0.1, "reasoning words should add 0.1: {c}");
490    }
491
492    #[test]
493    fn complexity_multi_step() {
494        let c = compute_complexity("first do this then do that");
495        assert!(c >= 0.2, "multi-step indicators should add 0.2: {c}");
496    }
497
498    #[test]
499    fn complexity_all_heuristics() {
500        let c = compute_complexity(
501            "first analyze the code ```fn main()``` then design the architecture finally deploy",
502        );
503        // base (small) + code(0.2) + reasoning(0.1) + steps(0.2) = ~0.5+
504        assert!(c >= 0.5, "all heuristics should sum up: {c}");
505        assert!(c <= 1.0, "should be clamped to 1.0: {c}");
506    }
507
508    #[test]
509    fn complexity_clamped_to_max() {
510        // Even with everything, should not exceed 1.0.
511        let c = compute_complexity(
512            "first analyze then design the architect explain compare ```code``` finally step 1",
513        );
514        assert!(c <= 1.0, "must be clamped to 1.0: {c}");
515    }
516
517    #[test]
518    fn complexity_case_insensitive() {
519        let c1 = compute_complexity("ANALYZE this");
520        let c2 = compute_complexity("analyze this");
521        assert!(
522            (c1 - c2).abs() < f32::EPSILON,
523            "should be case-insensitive: {c1} vs {c2}"
524        );
525    }
526
527    // ── tier_to_model ──────────────────────────────────────────────
528
529    #[test]
530    fn tier_to_model_mapping() {
531        assert_eq!(tier_to_model(1), "agent-booster-wasm");
532        assert_eq!(tier_to_model(2), "claude-haiku-3.5");
533        assert_eq!(tier_to_model(3), "claude-sonnet-4.5");
534        assert_eq!(tier_to_model(99), "claude-sonnet-4.5"); // fallback
535    }
536
537    // ── RoutingContext default ──────────────────────────────────────
538
539    #[test]
540    fn routing_context_default() {
541        let ctx = RoutingContext::default();
542        assert!(ctx.tags.is_empty());
543    }
544
545    // ── RoutingError display ───────────────────────────────────────
546
547    #[test]
548    fn routing_error_display() {
549        let err = RoutingError::Embedding(EmbeddingError::Internal("test".into()));
550        assert!(format!("{err}").contains("test"));
551    }
552}