Skip to main content

lens_core/semantic/
cross_encoder.rs

1//! # Cross-Encoder for Precision Boost
2//!
3//! Optional cross-encoder for highest-precision queries with strict budget constraints:
4//! - Query-specific activation based on complexity  
5//! - Tight budget constraints (≤50ms p95 inference)
6//! - Target: +1-2pp additional improvement on complex NL queries
7//! - Smart resource allocation and query routing
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tokio::sync::RwLock;
15use tracing::{debug, info, warn};
16
17/// Cross-encoder for precision boost on high-value queries
18pub struct CrossEncoder {
19    config: CrossEncoderConfig,
20    /// Model instance (mock for development)
21    model: Arc<RwLock<Option<CrossEncoderModel>>>,
22    /// Query complexity analyzer
23    complexity_analyzer: QueryComplexityAnalyzer,
24    /// Performance tracker
25    performance_tracker: Arc<RwLock<PerformanceTracker>>,
26    /// Budget manager for resource allocation
27    budget_manager: Arc<RwLock<BudgetManager>>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CrossEncoderConfig {
32    /// Enable cross-encoder
33    pub enabled: bool,
34    /// Maximum inference time budget (≤50ms p95)
35    pub max_inference_ms: u64,
36    /// Query complexity threshold for activation (0.0-1.0)
37    pub complexity_threshold: f32,
38    /// Top-K candidates to cross-encode
39    pub top_k: usize,
40    /// Model architecture
41    pub model_type: String,
42    /// Maximum batch size for efficiency
43    pub max_batch_size: usize,
44    /// Budget allocation strategy
45    pub budget_strategy: BudgetStrategy,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub enum BudgetStrategy {
50    /// Fixed time budget per query
51    FixedPerQuery,
52    /// Dynamic budget based on query importance
53    DynamicImportance,
54    /// Adaptive budget based on performance history
55    AdaptiveHistorical,
56}
57
58/// Mock cross-encoder model
59#[derive(Debug)]
60pub struct CrossEncoderModel {
61    model_type: String,
62    initialized: bool,
63    inference_time_ms: u64,
64}
65
66/// Query complexity analysis for activation decisions
67#[derive(Debug)]
68pub struct QueryComplexityAnalyzer {
69    /// NL vs code query classifier
70    nl_classifier: NLClassifier,
71    /// Complexity metrics cache
72    complexity_cache: HashMap<String, f32>,
73}
74
75/// Natural language query classifier
76#[derive(Debug)]
77pub struct NLClassifier {
78    /// Keywords indicating natural language queries
79    nl_indicators: Vec<String>,
80    /// Code-specific patterns
81    code_patterns: Vec<String>,
82}
83
84/// Performance tracking for cross-encoder
85#[derive(Debug, Default)]
86pub struct PerformanceTracker {
87    /// Inference latency percentiles
88    pub latency_p50_ms: f64,
89    pub latency_p95_ms: f64,
90    pub latency_p99_ms: f64,
91    /// Query activation statistics
92    pub queries_activated: u64,
93    pub queries_skipped: u64,
94    /// Accuracy improvements
95    pub precision_improvement: f32,
96    pub ndcg_improvement: f32,
97    /// Budget utilization
98    pub budget_utilization: f32,
99    /// Recent latency samples
100    latency_samples: Vec<u64>,
101}
102
103/// Budget manager for resource allocation
104#[derive(Debug)]
105pub struct BudgetManager {
106    /// Current budget allocation
107    pub current_budget_ms: u64,
108    /// Budget per time window
109    pub budget_per_window_ms: u64,
110    /// Time window for budget reset
111    pub window_duration: Duration,
112    /// Last budget reset time
113    pub last_reset: Instant,
114    /// Query priority scores
115    pub query_priorities: HashMap<String, f32>,
116}
117
118/// Query analysis result
119#[derive(Debug, Clone)]
120pub struct QueryAnalysis {
121    pub query: String,
122    pub complexity_score: f32,
123    pub is_natural_language: bool,
124    pub should_activate_cross_encoder: bool,
125    pub priority_score: f32,
126    pub estimated_benefit: f32,
127}
128
129/// Cross-encoder input pair
130#[derive(Debug, Clone)]
131pub struct CrossEncoderPair {
132    pub query: String,
133    pub candidate: String,
134    pub initial_score: f32,
135    pub metadata: HashMap<String, String>,
136}
137
138/// Cross-encoder result with confidence
139#[derive(Debug, Clone)]
140pub struct CrossEncoderResult {
141    pub query: String,
142    pub candidate: String,
143    pub relevance_score: f32,
144    pub confidence: f32,
145    pub inference_time_ms: u64,
146    pub model_version: String,
147}
148
149impl CrossEncoder {
150    /// Create new cross-encoder
151    pub async fn new(config: CrossEncoderConfig) -> Result<Self> {
152        info!("Creating cross-encoder with config: {:?}", config);
153        
154        if !config.enabled {
155            info!("Cross-encoder is disabled");
156        }
157        
158        let complexity_analyzer = QueryComplexityAnalyzer::new();
159        let performance_tracker = Arc::new(RwLock::new(PerformanceTracker::default()));
160        let budget_manager = Arc::new(RwLock::new(BudgetManager::new(config.max_inference_ms)));
161        
162        Ok(Self {
163            config,
164            model: Arc::new(RwLock::new(None)),
165            complexity_analyzer,
166            performance_tracker,
167            budget_manager,
168        })
169    }
170    
171    /// Initialize cross-encoder model
172    pub async fn initialize(&self) -> Result<()> {
173        if !self.config.enabled {
174            info!("Cross-encoder disabled, skipping initialization");
175            return Ok(());
176        }
177        
178        info!("Initializing cross-encoder model: {}", self.config.model_type);
179        
180        // Create mock model
181        let model = CrossEncoderModel {
182            model_type: self.config.model_type.clone(),
183            initialized: true,
184            inference_time_ms: 25, // Mock inference time
185        };
186        
187        *self.model.write().await = Some(model);
188        
189        info!("Cross-encoder initialized successfully");
190        Ok(())
191    }
192    
193    /// Analyze query to determine if cross-encoder should be activated
194    pub async fn analyze_query(&self, query: &str) -> Result<QueryAnalysis> {
195        // Calculate query complexity
196        let complexity_score = self.complexity_analyzer.calculate_complexity(query).await?;
197        
198        // Classify as natural language vs code
199        let is_natural_language = self.complexity_analyzer.is_natural_language(query);
200        
201        // Calculate priority score
202        let priority_score = self.calculate_priority_score(query, complexity_score, is_natural_language).await?;
203        
204        // Check budget availability
205        let budget_available = self.check_budget_availability(priority_score).await?;
206        
207        // Decision logic for activation
208        let should_activate = self.config.enabled &&
209                             complexity_score >= self.config.complexity_threshold &&
210                             is_natural_language &&
211                             budget_available;
212        
213        // Estimate potential benefit
214        let estimated_benefit = if should_activate {
215            self.estimate_benefit(complexity_score, is_natural_language)
216        } else {
217            0.0
218        };
219        
220        let analysis = QueryAnalysis {
221            query: query.to_string(),
222            complexity_score,
223            is_natural_language,
224            should_activate_cross_encoder: should_activate,
225            priority_score,
226            estimated_benefit,
227        };
228        
229        debug!("Query analysis: complexity={:.3}, NL={}, activate={}, benefit={:.3}",
230               complexity_score, is_natural_language, should_activate, estimated_benefit);
231        
232        Ok(analysis)
233    }
234    
235    /// Apply cross-encoder to re-score top candidates
236    pub async fn cross_encode(&self, pairs: Vec<CrossEncoderPair>) -> Result<Vec<CrossEncoderResult>> {
237        if !self.config.enabled || pairs.is_empty() {
238            return Ok(Vec::new());
239        }
240        
241        let start_time = Instant::now();
242        
243        // Check budget before proceeding
244        let query = &pairs[0].query;
245        let priority = self.calculate_query_priority(query).await?;
246        
247        if !self.allocate_budget(priority).await? {
248            debug!("Budget exhausted, skipping cross-encoder for query");
249            self.update_skip_statistics().await;
250            return Ok(Vec::new());
251        }
252        
253        // Take top-K candidates
254        let candidates_to_process = pairs.into_iter()
255            .take(self.config.top_k)
256            .collect::<Vec<_>>();
257        
258        // Batch process for efficiency
259        let results = self.batch_cross_encode(candidates_to_process).await
260            .context("Cross-encoder inference failed")?;
261        
262        let inference_time = start_time.elapsed().as_millis() as u64;
263        
264        // Update performance tracking
265        self.update_performance_metrics(inference_time, &results).await;
266        
267        // Check if we exceeded budget
268        if inference_time > self.config.max_inference_ms {
269            warn!("Cross-encoder exceeded budget: {}ms > {}ms", 
270                  inference_time, self.config.max_inference_ms);
271        }
272        
273        debug!("Cross-encoded {} pairs in {}ms", results.len(), inference_time);
274        
275        Ok(results)
276    }
277    
278    /// Get cross-encoder performance metrics
279    pub async fn get_metrics(&self) -> CrossEncoderMetrics {
280        let tracker = self.performance_tracker.read().await;
281        let budget = self.budget_manager.read().await;
282        
283        CrossEncoderMetrics {
284            enabled: self.config.enabled,
285            queries_activated: tracker.queries_activated,
286            queries_skipped: tracker.queries_skipped,
287            activation_rate: if tracker.queries_activated + tracker.queries_skipped > 0 {
288                tracker.queries_activated as f32 / (tracker.queries_activated + tracker.queries_skipped) as f32
289            } else {
290                0.0
291            },
292            latency_p50_ms: tracker.latency_p50_ms,
293            latency_p95_ms: tracker.latency_p95_ms,
294            latency_p99_ms: tracker.latency_p99_ms,
295            budget_utilization: budget.budget_utilization(),
296            precision_improvement: tracker.precision_improvement,
297            ndcg_improvement: tracker.ndcg_improvement,
298            meets_latency_target: tracker.latency_p95_ms <= self.config.max_inference_ms as f64,
299        }
300    }
301    
302    /// Update cross-encoder with performance feedback
303    pub async fn update_performance_feedback(&self, query: &str, actual_improvement: f32) -> Result<()> {
304        // Update performance estimates based on actual results
305        let mut tracker = self.performance_tracker.write().await;
306        
307        // Simple moving average update
308        let alpha = 0.1; // Learning rate
309        if actual_improvement > 0.0 {
310            tracker.precision_improvement = tracker.precision_improvement * (1.0 - alpha) + actual_improvement * alpha;
311        }
312        
313        // Update query priority based on performance
314        let mut budget = self.budget_manager.write().await;
315        let current_priority = budget.query_priorities.get(query).cloned().unwrap_or(0.5);
316        let new_priority = (current_priority + actual_improvement * 0.5).clamp(0.0, 1.0);
317        budget.query_priorities.insert(query.to_string(), new_priority);
318        
319        debug!("Updated performance feedback for query '{}': improvement={:.3}, new_priority={:.3}",
320               query, actual_improvement, new_priority);
321        
322        Ok(())
323    }
324    
325    // Private implementation methods
326    
327    async fn calculate_priority_score(&self, query: &str, complexity: f32, is_nl: bool) -> Result<f32> {
328        let mut score = complexity;
329        
330        // Bonus for natural language queries
331        if is_nl {
332            score += 0.2;
333        }
334        
335        // Historical performance bonus
336        let budget = self.budget_manager.read().await;
337        if let Some(historical_priority) = budget.query_priorities.get(query) {
338            score = (score + historical_priority) / 2.0;
339        }
340        
341        Ok(score.clamp(0.0, 1.0))
342    }
343    
344    async fn check_budget_availability(&self, priority: f32) -> Result<bool> {
345        let budget = self.budget_manager.read().await;
346        
347        // Check if we have budget remaining
348        let has_budget = budget.current_budget_ms > 0;
349        
350        // Check if priority is high enough for remaining budget
351        let priority_threshold = match budget.current_budget_ms {
352            0..=10 => 0.9,      // Very selective when budget is low
353            11..=25 => 0.7,     // Moderate selectivity
354            _ => priority,       // Use query priority when budget is available
355        };
356        
357        Ok(has_budget && priority >= priority_threshold)
358    }
359    
360    async fn allocate_budget(&self, priority: f32) -> Result<bool> {
361        let mut budget = self.budget_manager.write().await;
362        
363        // Reset budget if window has elapsed
364        if budget.last_reset.elapsed() >= budget.window_duration {
365            budget.current_budget_ms = budget.budget_per_window_ms;
366            budget.last_reset = Instant::now();
367            debug!("Budget reset: {}ms available", budget.current_budget_ms);
368        }
369        
370        // Estimate cost for this query
371        let estimated_cost = match self.config.budget_strategy {
372            BudgetStrategy::FixedPerQuery => 30, // Fixed 30ms estimate
373            BudgetStrategy::DynamicImportance => (20.0 + priority * 30.0) as u64,
374            BudgetStrategy::AdaptiveHistorical => {
375                let tracker = self.performance_tracker.read().await;
376                tracker.latency_p95_ms as u64
377            }
378        };
379        
380        if budget.current_budget_ms >= estimated_cost {
381            budget.current_budget_ms -= estimated_cost;
382            // No need to set budget_utilization here as it's computed by the budget_utilization() method
383            
384            debug!("Budget allocated: {}ms, remaining: {}ms", estimated_cost, budget.current_budget_ms);
385            Ok(true)
386        } else {
387            debug!("Budget allocation failed: need {}ms, have {}ms", estimated_cost, budget.current_budget_ms);
388            Ok(false)
389        }
390    }
391    
392    async fn batch_cross_encode(&self, pairs: Vec<CrossEncoderPair>) -> Result<Vec<CrossEncoderResult>> {
393        let model_guard = self.model.read().await;
394        let model = model_guard.as_ref()
395            .ok_or_else(|| anyhow::anyhow!("Cross-encoder model not initialized"))?;
396        
397        let mut results = Vec::with_capacity(pairs.len());
398        
399        // Process in batches for efficiency
400        for chunk in pairs.chunks(self.config.max_batch_size) {
401            let batch_start = Instant::now();
402            
403            for pair in chunk {
404                // Mock cross-encoder inference
405                let result = self.mock_cross_encode(pair, model).await?;
406                results.push(result);
407            }
408            
409            let batch_time = batch_start.elapsed().as_millis() as u64;
410            debug!("Processed batch of {} pairs in {}ms", chunk.len(), batch_time);
411            
412            // Check if we're approaching budget limits
413            if batch_time > self.config.max_inference_ms / 2 {
414                warn!("Batch processing time {}ms approaching budget limit", batch_time);
415            }
416        }
417        
418        Ok(results)
419    }
420    
421    async fn mock_cross_encode(&self, pair: &CrossEncoderPair, model: &CrossEncoderModel) -> Result<CrossEncoderResult> {
422        // Mock cross-encoder inference
423        // In real implementation, this would run the actual model
424        
425        // Simulate processing time
426        tokio::time::sleep(Duration::from_millis(model.inference_time_ms / 4)).await;
427        
428        // Mock relevance scoring based on simple heuristics
429        let query_lower = pair.query.to_lowercase();
430        let candidate_lower = pair.candidate.to_lowercase();
431        
432        // Term overlap scoring
433        let query_terms: Vec<&str> = query_lower.split_whitespace().collect();
434        let matches = query_terms.iter()
435            .filter(|term| candidate_lower.contains(*term))
436            .count();
437        
438        let term_overlap = matches as f32 / query_terms.len().max(1) as f32;
439        
440        // Combine with initial score
441        let relevance_score = (pair.initial_score * 0.6 + term_overlap * 0.4).clamp(0.0, 1.0);
442        
443        // Mock confidence based on score certainty
444        let confidence = if relevance_score > 0.8 || relevance_score < 0.2 {
445            0.9 // High confidence for extreme scores
446        } else {
447            0.6 // Lower confidence for middle scores
448        };
449        
450        Ok(CrossEncoderResult {
451            query: pair.query.clone(),
452            candidate: pair.candidate.clone(),
453            relevance_score,
454            confidence,
455            inference_time_ms: model.inference_time_ms,
456            model_version: model.model_type.clone(),
457        })
458    }
459    
460    fn estimate_benefit(&self, complexity: f32, is_nl: bool) -> f32 {
461        let mut benefit = complexity * 0.02; // Base 2% improvement per complexity unit
462        
463        if is_nl {
464            benefit += 0.015; // Additional 1.5% for NL queries
465        }
466        
467        benefit.clamp(0.0, 0.025) // Cap at 2.5% improvement estimate
468    }
469    
470    async fn calculate_query_priority(&self, query: &str) -> Result<f32> {
471        let budget = self.budget_manager.read().await;
472        Ok(budget.query_priorities.get(query).cloned().unwrap_or(0.5))
473    }
474    
475    async fn update_skip_statistics(&self) {
476        let mut tracker = self.performance_tracker.write().await;
477        tracker.queries_skipped += 1;
478    }
479    
480    async fn update_performance_metrics(&self, inference_time: u64, _results: &[CrossEncoderResult]) {
481        let mut tracker = self.performance_tracker.write().await;
482        
483        tracker.queries_activated += 1;
484        tracker.latency_samples.push(inference_time);
485        
486        // Keep only recent samples
487        if tracker.latency_samples.len() > 1000 {
488            tracker.latency_samples.drain(0..500);
489        }
490        
491        // Update percentiles
492        if !tracker.latency_samples.is_empty() {
493            let mut sorted_samples = tracker.latency_samples.clone();
494            sorted_samples.sort_unstable();
495            
496            let len = sorted_samples.len();
497            tracker.latency_p50_ms = sorted_samples[len / 2] as f64;
498            tracker.latency_p95_ms = sorted_samples[(len * 95) / 100] as f64;
499            tracker.latency_p99_ms = sorted_samples[(len * 99) / 100] as f64;
500        }
501    }
502}
503
504impl QueryComplexityAnalyzer {
505    pub fn new() -> Self {
506        Self {
507            nl_classifier: NLClassifier::new(),
508            complexity_cache: HashMap::new(),
509        }
510    }
511    
512    pub async fn calculate_complexity(&self, query: &str) -> Result<f32> {
513        // Simple complexity heuristics
514        let mut complexity = 0.0;
515        
516        // Length factor
517        let length_factor = (query.len() as f32 / 20.0).min(1.0);
518        complexity += length_factor * 0.3;
519        
520        // Word count factor
521        let word_count = query.split_whitespace().count();
522        let word_factor = (word_count as f32 / 5.0).min(1.0);
523        complexity += word_factor * 0.3;
524        
525        // Natural language indicators
526        if self.nl_classifier.is_natural_language_query(query) {
527            complexity += 0.4;
528        }
529        
530        Ok(complexity.clamp(0.0, 1.0))
531    }
532    
533    pub fn is_natural_language(&self, query: &str) -> bool {
534        self.nl_classifier.is_natural_language_query(query)
535    }
536}
537
538impl Default for NLClassifier {
539    fn default() -> Self {
540        Self::new()
541    }
542}
543
544impl NLClassifier {
545    pub fn new() -> Self {
546        let nl_indicators = vec![
547            "find".to_string(),
548            "show".to_string(),
549            "get".to_string(),
550            "how".to_string(),
551            "what".to_string(),
552            "where".to_string(),
553            "functions".to_string(),
554            "methods".to_string(),
555            "classes".to_string(),
556        ];
557        
558        let code_patterns = vec![
559            "def ".to_string(),
560            "function ".to_string(), // Add space to avoid matching "functions"
561            "class ".to_string(),
562            "import ".to_string(),
563            "const ".to_string(),
564            "let ".to_string(),
565            "fn ".to_string(),
566        ];
567        
568        Self {
569            nl_indicators,
570            code_patterns,
571        }
572    }
573    pub fn is_natural_language_query(&self, query: &str) -> bool {
574        let query_lower = query.to_lowercase();
575        
576        // Check for NL indicators
577        let has_nl_indicators = self.nl_indicators.iter()
578            .any(|indicator| query_lower.contains(indicator));
579        
580        // Check for code patterns (negative indicator for NL)
581        let has_code_patterns = self.code_patterns.iter()
582            .any(|pattern| query_lower.contains(pattern));
583        
584        
585        // Simple heuristic: NL if has indicators and no code patterns
586        has_nl_indicators && !has_code_patterns
587    }
588}
589
590impl BudgetManager {
591    pub fn new(max_inference_ms: u64) -> Self {
592        Self {
593            current_budget_ms: max_inference_ms * 60, // 60x inference budget per minute
594            budget_per_window_ms: max_inference_ms * 60,
595            window_duration: Duration::from_secs(60), // 1 minute window
596            last_reset: Instant::now(),
597            query_priorities: HashMap::new(),
598        }
599    }
600    
601    pub fn budget_utilization(&self) -> f32 {
602        1.0 - (self.current_budget_ms as f32 / self.budget_per_window_ms as f32)
603    }
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct CrossEncoderMetrics {
608    pub enabled: bool,
609    pub queries_activated: u64,
610    pub queries_skipped: u64,
611    pub activation_rate: f32,
612    pub latency_p50_ms: f64,
613    pub latency_p95_ms: f64,
614    pub latency_p99_ms: f64,
615    pub budget_utilization: f32,
616    pub precision_improvement: f32,
617    pub ndcg_improvement: f32,
618    pub meets_latency_target: bool,
619}
620
621/// Initialize cross-encoder
622pub async fn initialize_cross_encoder(config: &CrossEncoderConfig) -> Result<()> {
623    info!("Initializing cross-encoder");
624    info!("Enabled: {}, max inference: {}ms, complexity threshold: {}", 
625          config.enabled, config.max_inference_ms, config.complexity_threshold);
626    
627    if config.enabled {
628        // Validate performance constraints
629        if config.max_inference_ms > 50 {
630            warn!("Max inference time {}ms > 50ms target", config.max_inference_ms);
631        }
632        
633        if config.complexity_threshold < 0.5 {
634            warn!("Complexity threshold {} may activate too frequently", config.complexity_threshold);
635        }
636        
637        info!("Cross-encoder will target +1-2pp improvement on complex NL queries");
638    }
639    
640    info!("Cross-encoder initialization complete");
641    Ok(())
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[tokio::test]
649    async fn test_cross_encoder_creation() {
650        let config = CrossEncoderConfig {
651            enabled: true,
652            max_inference_ms: 50,
653            complexity_threshold: 0.7,
654            top_k: 10,
655            model_type: "cross-encoder-ms-marco-MiniLM-L-6-v2".to_string(),
656            max_batch_size: 8,
657            budget_strategy: BudgetStrategy::FixedPerQuery,
658        };
659        
660        let encoder = CrossEncoder::new(config).await.unwrap();
661        let metrics = encoder.get_metrics().await;
662        assert_eq!(metrics.queries_activated, 0);
663        assert!(metrics.enabled);
664    }
665
666    #[tokio::test]
667    async fn test_query_analysis() {
668        let config = CrossEncoderConfig {
669            enabled: true,
670            max_inference_ms: 50,
671            complexity_threshold: 0.5,
672            top_k: 10,
673            model_type: "test".to_string(),
674            max_batch_size: 8,
675            budget_strategy: BudgetStrategy::FixedPerQuery,
676        };
677        
678        let encoder = CrossEncoder::new(config).await.unwrap();
679        
680        let nl_query = "find all functions that handle user authentication";
681        let analysis = encoder.analyze_query(nl_query).await.unwrap();
682        
683        assert!(analysis.is_natural_language);
684        assert!(analysis.complexity_score > 0.0);
685    }
686
687    #[test]
688    fn test_nl_classifier() {
689        let classifier = NLClassifier::default();
690        
691        // Should classify as natural language
692        assert!(classifier.is_natural_language_query("find all functions that process user data"));
693        assert!(classifier.is_natural_language_query("show me methods for error handling"));
694        
695        // Should not classify as natural language
696        assert!(!classifier.is_natural_language_query("def process_user_data():"));
697        assert!(!classifier.is_natural_language_query("function authenticate(user)"));
698    }
699
700    #[tokio::test]
701    async fn test_complexity_calculation() {
702        let analyzer = QueryComplexityAnalyzer::new();
703        
704        let simple_query = "test";
705        let complex_query = "find all functions that handle user authentication and error processing";
706        
707        let simple_complexity = analyzer.calculate_complexity(simple_query).await.unwrap();
708        let complex_complexity = analyzer.calculate_complexity(complex_query).await.unwrap();
709        
710        assert!(complex_complexity > simple_complexity);
711        assert!(simple_complexity >= 0.0 && simple_complexity <= 1.0);
712        assert!(complex_complexity >= 0.0 && complex_complexity <= 1.0);
713    }
714
715    #[tokio::test]
716    async fn test_budget_management() {
717        let mut budget = BudgetManager::new(50);
718        
719        assert_eq!(budget.current_budget_ms, 3000); // 50 * 60
720        assert!(budget.current_budget_ms > 0);
721        
722        // Test budget utilization calculation
723        budget.current_budget_ms = 1500; // Half used
724        assert!((budget.budget_utilization() - 0.5).abs() < 0.01);
725    }
726}