1use serde::{Deserialize, Serialize};
2
3pub use roboticus_db::efficiency::{
4 RecommendationModelStats as ModelStats, RecommendationUserProfile as UserProfile,
5};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub enum RecommendationCategory {
11 QueryCrafting,
12 ModelSelection,
13 SessionManagement,
14 MemoryLeverage,
15 CostOptimization,
16 ToolUsage,
17 Configuration,
18}
19
20impl RecommendationCategory {
21 pub fn label(&self) -> &'static str {
22 match self {
23 Self::QueryCrafting => "Query Crafting",
24 Self::ModelSelection => "Model Selection",
25 Self::SessionManagement => "Session Management",
26 Self::MemoryLeverage => "Memory Leverage",
27 Self::CostOptimization => "Cost Optimization",
28 Self::ToolUsage => "Tool Usage",
29 Self::Configuration => "Configuration",
30 }
31 }
32
33 pub fn icon(&self) -> &'static str {
34 match self {
35 Self::QueryCrafting => "pencil",
36 Self::ModelSelection => "cpu",
37 Self::SessionManagement => "chat",
38 Self::MemoryLeverage => "memory",
39 Self::CostOptimization => "dollar",
40 Self::ToolUsage => "wrench",
41 Self::Configuration => "gear",
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
47pub enum Priority {
48 Low,
49 Medium,
50 High,
51}
52
53impl Priority {
54 fn ordinal(&self) -> u8 {
55 match self {
56 Self::Low => 0,
57 Self::Medium => 1,
58 Self::High => 2,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Evidence {
65 pub metric: String,
66 pub value: String,
67 pub context: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Impact {
72 pub monthly_savings: Option<f64>,
73 pub quality_change: Option<String>,
74 pub description: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Recommendation {
79 pub category: RecommendationCategory,
80 pub priority: Priority,
81 pub title: String,
82 pub explanation: String,
83 pub action: String,
84 pub evidence: Vec<Evidence>,
85 pub estimated_impact: Option<Impact>,
86}
87
88pub trait RecommendationRule: Send + Sync {
91 fn name(&self) -> &str;
92 fn category(&self) -> RecommendationCategory;
93 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation>;
94}
95
96pub struct RecommendationEngine {
99 rules: Vec<Box<dyn RecommendationRule>>,
100}
101
102impl Default for RecommendationEngine {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl RecommendationEngine {
109 pub fn new() -> Self {
110 Self {
111 rules: vec![
112 Box::new(SpecificityCorrelation),
113 Box::new(FollowUpPatterns),
114 Box::new(ParetoOptimalModels),
115 Box::new(ComplexityMismatch),
116 Box::new(ModelStrengths),
117 Box::new(SessionLengthSweet),
118 Box::new(StaleSessionCost),
119 Box::new(MemoryUnderutilized),
120 Box::new(MemoryOverloaded),
121 Box::new(SystemPromptROI),
122 Box::new(CachingOpportunity),
123 Box::new(ToolCostAwareness),
124 Box::new(FallbackChainTuning),
125 Box::new(HighCostPerTurn),
126 ],
127 }
128 }
129
130 pub fn generate(&self, profile: &UserProfile) -> Vec<Recommendation> {
131 let mut recs: Vec<Recommendation> = self
132 .rules
133 .iter()
134 .filter_map(|r| r.evaluate(profile))
135 .collect();
136 recs.sort_by(|a, b| b.priority.ordinal().cmp(&a.priority.ordinal()));
137 recs
138 }
139}
140
141struct SpecificityCorrelation;
144
145impl RecommendationRule for SpecificityCorrelation {
146 fn name(&self) -> &str {
147 "specificity_correlation"
148 }
149 fn category(&self) -> RecommendationCategory {
150 RecommendationCategory::QueryCrafting
151 }
152 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
153 if profile.total_turns < 10 {
154 return None;
155 }
156 if profile.avg_tokens_per_turn < 50.0 {
157 Some(Recommendation {
158 category: self.category(),
159 priority: Priority::Medium,
160 title: "Short queries may reduce response quality".into(),
161 explanation: "Your average query is quite short. Longer, more specific prompts \
162 tend to produce higher-quality responses with fewer follow-up turns."
163 .into(),
164 action: "Try including context, constraints, and desired format in your queries."
165 .into(),
166 evidence: vec![Evidence {
167 metric: "avg_tokens_per_turn".into(),
168 value: format!("{:.0}", profile.avg_tokens_per_turn),
169 context: "tokens per user message (recommended: 80+)".into(),
170 }],
171 estimated_impact: Some(Impact {
172 monthly_savings: None,
173 quality_change: Some("Potential quality improvement".into()),
174 description: "More specific queries reduce back-and-forth turns".into(),
175 }),
176 })
177 } else {
178 None
179 }
180 }
181}
182
183struct FollowUpPatterns;
184
185impl RecommendationRule for FollowUpPatterns {
186 fn name(&self) -> &str {
187 "follow_up_patterns"
188 }
189 fn category(&self) -> RecommendationCategory {
190 RecommendationCategory::QueryCrafting
191 }
192 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
193 if profile.total_sessions < 3 {
194 return None;
195 }
196 if profile.avg_session_length > 20.0 {
197 Some(Recommendation {
198 category: self.category(),
199 priority: Priority::Medium,
200 title: "Sessions with many turns may indicate unclear initial queries".into(),
201 explanation: format!(
202 "Your sessions average {:.0} turns. Quality often degrades after 10-15 turns \
203 due to context window pressure. Starting fresh sessions with clear, complete \
204 instructions often yields better results.",
205 profile.avg_session_length
206 ),
207 action: "Consider starting new sessions instead of extending long conversations."
208 .into(),
209 evidence: vec![Evidence {
210 metric: "avg_session_length".into(),
211 value: format!("{:.1}", profile.avg_session_length),
212 context: "average turns per session".into(),
213 }],
214 estimated_impact: None,
215 })
216 } else {
217 None
218 }
219 }
220}
221
222struct ParetoOptimalModels;
225
226impl RecommendationRule for ParetoOptimalModels {
227 fn name(&self) -> &str {
228 "pareto_optimal_models"
229 }
230 fn category(&self) -> RecommendationCategory {
231 RecommendationCategory::ModelSelection
232 }
233 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
234 if profile.model_stats.len() < 2 {
235 return None;
236 }
237 let mut dominated: Vec<(String, String)> = Vec::new();
238 let models: Vec<(&String, &ModelStats)> = profile.model_stats.iter().collect();
239 for (i, (name_a, stats_a)) in models.iter().enumerate() {
240 for (name_b, stats_b) in models.iter().skip(i + 1) {
241 if stats_b.avg_cost <= stats_a.avg_cost
242 && stats_b.avg_output_density >= stats_a.avg_output_density
243 && (stats_b.avg_cost < stats_a.avg_cost
244 || stats_b.avg_output_density > stats_a.avg_output_density)
245 {
246 dominated.push(((*name_a).clone(), (*name_b).clone()));
247 } else if stats_a.avg_cost <= stats_b.avg_cost
248 && stats_a.avg_output_density >= stats_b.avg_output_density
249 && (stats_a.avg_cost < stats_b.avg_cost
250 || stats_a.avg_output_density > stats_b.avg_output_density)
251 {
252 dominated.push(((*name_b).clone(), (*name_a).clone()));
253 }
254 }
255 }
256
257 if dominated.is_empty() {
258 return None;
259 }
260
261 let (worse, better) = &dominated[0];
262 Some(Recommendation {
263 category: self.category(),
264 priority: Priority::High,
265 title: format!("{worse} is dominated by {better} on cost/quality"),
266 explanation: format!(
267 "{better} is both cheaper and produces higher output density than {worse}. \
268 Consider consolidating traffic to the dominant model."
269 ),
270 action: format!("Route {worse} traffic to {better} for better cost-efficiency."),
271 evidence: vec![
272 Evidence {
273 metric: "dominated_model".into(),
274 value: worse.clone(),
275 context: "higher cost AND lower quality".into(),
276 },
277 Evidence {
278 metric: "dominant_model".into(),
279 value: better.clone(),
280 context: "lower cost AND higher quality".into(),
281 },
282 ],
283 estimated_impact: profile.model_stats.get(worse).map(|s| Impact {
284 monthly_savings: Some(s.avg_cost * s.turns as f64 * 0.3),
285 quality_change: Some("quality improvement expected".into()),
286 description: "Eliminate dominated model usage".into(),
287 }),
288 })
289 }
290}
291
292struct ComplexityMismatch;
293
294impl RecommendationRule for ComplexityMismatch {
295 fn name(&self) -> &str {
296 "complexity_mismatch"
297 }
298 fn category(&self) -> RecommendationCategory {
299 RecommendationCategory::ModelSelection
300 }
301 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
302 let expensive_models: Vec<(&String, &ModelStats)> = profile
303 .model_stats
304 .iter()
305 .filter(|(_, s)| s.avg_cost > 0.02 && s.avg_output_density < 0.15)
306 .collect();
307
308 if expensive_models.is_empty() {
309 return None;
310 }
311
312 let (model, stats) = expensive_models[0];
313 Some(Recommendation {
314 category: self.category(),
315 priority: Priority::High,
316 title: format!("{model} may be overkill for simple queries"),
317 explanation: format!(
318 "{model} costs ${:.4}/turn but has low output density ({:.3}), suggesting \
319 queries may be too simple for this model tier.",
320 stats.avg_cost, stats.avg_output_density
321 ),
322 action: "Route simpler queries to a lighter, cheaper model.".into(),
323 evidence: vec![
324 Evidence {
325 metric: "avg_cost".into(),
326 value: format!("${:.4}", stats.avg_cost),
327 context: format!("per turn for {model}"),
328 },
329 Evidence {
330 metric: "avg_output_density".into(),
331 value: format!("{:.3}", stats.avg_output_density),
332 context: "low density suggests simple queries".into(),
333 },
334 ],
335 estimated_impact: Some(Impact {
336 monthly_savings: Some(stats.avg_cost * stats.turns as f64 * 0.5),
337 quality_change: Some("similar quality at lower cost".into()),
338 description: "Switch simple queries to cheaper model".into(),
339 }),
340 })
341 }
342}
343
344struct ModelStrengths;
345
346impl RecommendationRule for ModelStrengths {
347 fn name(&self) -> &str {
348 "model_strengths"
349 }
350 fn category(&self) -> RecommendationCategory {
351 RecommendationCategory::ModelSelection
352 }
353 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
354 if profile.models_used.len() < 2 || profile.total_turns < 20 {
355 return None;
356 }
357
358 let mut best_density: Option<(&String, f64)> = None;
359 let mut best_cost: Option<(&String, f64)> = None;
360
361 for (name, stats) in &profile.model_stats {
362 if stats.turns < 5 {
363 continue;
364 }
365 match &best_density {
366 None => best_density = Some((name, stats.avg_output_density)),
367 Some((_, d)) if stats.avg_output_density > *d => {
368 best_density = Some((name, stats.avg_output_density));
369 }
370 _ => {}
371 }
372 match &best_cost {
373 None => best_cost = Some((name, stats.avg_cost)),
374 Some((_, c)) if stats.avg_cost < *c => {
375 best_cost = Some((name, stats.avg_cost));
376 }
377 _ => {}
378 }
379 }
380
381 if let (Some((quality_model, _)), Some((cost_model, _))) = (&best_density, &best_cost)
382 && quality_model != cost_model
383 {
384 return Some(Recommendation {
385 category: self.category(),
386 priority: Priority::Low,
387 title: "Different models excel at different dimensions".into(),
388 explanation: format!(
389 "{} produces the highest output density while {} is the most cost-efficient. \
390 Consider routing complex queries to the quality model and simple ones to \
391 the cost model.",
392 quality_model, cost_model
393 ),
394 action: "Implement complexity-based routing between models.".into(),
395 evidence: vec![
396 Evidence {
397 metric: "best_quality_model".into(),
398 value: (*quality_model).clone(),
399 context: "highest output density".into(),
400 },
401 Evidence {
402 metric: "best_cost_model".into(),
403 value: (*cost_model).clone(),
404 context: "lowest cost per turn".into(),
405 },
406 ],
407 estimated_impact: None,
408 });
409 }
410 None
411 }
412}
413
414struct SessionLengthSweet;
417
418impl RecommendationRule for SessionLengthSweet {
419 fn name(&self) -> &str {
420 "session_length_sweet"
421 }
422 fn category(&self) -> RecommendationCategory {
423 RecommendationCategory::SessionManagement
424 }
425 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
426 if profile.total_sessions < 3 {
427 return None;
428 }
429 if profile.avg_session_length > 15.0 && profile.avg_session_length <= 20.0 {
430 Some(Recommendation {
431 category: self.category(),
432 priority: Priority::Low,
433 title: "Sessions approaching optimal length ceiling".into(),
434 explanation: format!(
435 "Average session length is {:.0} turns. Research suggests quality peaks around \
436 8-12 turns before context pressure degrades output.",
437 profile.avg_session_length
438 ),
439 action:
440 "Monitor quality in longer sessions and consider proactive session splitting."
441 .into(),
442 evidence: vec![Evidence {
443 metric: "avg_session_length".into(),
444 value: format!("{:.1}", profile.avg_session_length),
445 context: "turns per session (sweet spot: 8-12)".into(),
446 }],
447 estimated_impact: None,
448 })
449 } else {
450 None
451 }
452 }
453}
454
455struct StaleSessionCost;
456
457impl RecommendationRule for StaleSessionCost {
458 fn name(&self) -> &str {
459 "stale_session_cost"
460 }
461 fn category(&self) -> RecommendationCategory {
462 RecommendationCategory::SessionManagement
463 }
464 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
465 if profile.total_sessions < 5 || profile.avg_session_length <= 25.0 {
466 return None;
467 }
468 let wasted_token_pct = (profile.avg_session_length - 12.0) / profile.avg_session_length;
469 let estimated_waste = profile.total_cost * wasted_token_pct * 0.3;
470
471 Some(Recommendation {
472 category: self.category(),
473 priority: Priority::High,
474 title: "Long sessions accumulate expensive history tokens".into(),
475 explanation: format!(
476 "Sessions averaging {:.0} turns means each new turn carries ~{:.0}% \
477 redundant history context, driving up input token costs.",
478 profile.avg_session_length,
479 wasted_token_pct * 100.0
480 ),
481 action: "Enable automatic session archiving after 12-15 turns and start fresh.".into(),
482 evidence: vec![
483 Evidence {
484 metric: "avg_session_length".into(),
485 value: format!("{:.0}", profile.avg_session_length),
486 context: "turns per session".into(),
487 },
488 Evidence {
489 metric: "estimated_waste".into(),
490 value: format!("${:.4}", estimated_waste),
491 context: "estimated wasted cost on stale context".into(),
492 },
493 ],
494 estimated_impact: Some(Impact {
495 monthly_savings: Some(estimated_waste),
496 quality_change: Some("quality may improve with fresh context".into()),
497 description: "Reduce input token waste from stale history".into(),
498 }),
499 })
500 }
501}
502
503struct MemoryUnderutilized;
506
507impl RecommendationRule for MemoryUnderutilized {
508 fn name(&self) -> &str {
509 "memory_underutilized"
510 }
511 fn category(&self) -> RecommendationCategory {
512 RecommendationCategory::MemoryLeverage
513 }
514 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
515 if profile.total_turns < 20 {
516 return None;
517 }
518 if profile.memory_retrieval_rate < 0.2 {
519 Some(Recommendation {
520 category: self.category(),
521 priority: Priority::Medium,
522 title: "Memory system is underutilized".into(),
523 explanation: format!(
524 "Only {:.0}% of turns retrieve stored memories. The memory system can reduce \
525 repetitive context and improve personalization.",
526 profile.memory_retrieval_rate * 100.0
527 ),
528 action:
529 "Store frequently referenced facts in semantic memory to reduce prompt repetition."
530 .into(),
531 evidence: vec![Evidence {
532 metric: "memory_retrieval_rate".into(),
533 value: format!("{:.0}%", profile.memory_retrieval_rate * 100.0),
534 context: "percentage of turns using memory retrieval".into(),
535 }],
536 estimated_impact: Some(Impact {
537 monthly_savings: None,
538 quality_change: Some("better personalization and consistency".into()),
539 description: "Leverage memory for repeated context".into(),
540 }),
541 })
542 } else {
543 None
544 }
545 }
546}
547
548struct MemoryOverloaded;
549
550impl RecommendationRule for MemoryOverloaded {
551 fn name(&self) -> &str {
552 "memory_overloaded"
553 }
554 fn category(&self) -> RecommendationCategory {
555 RecommendationCategory::MemoryLeverage
556 }
557 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
558 if profile.total_turns < 20 {
559 return None;
560 }
561 if profile.memory_retrieval_rate > 0.9 && profile.avg_tokens_per_turn > 2000.0 {
562 Some(Recommendation {
563 category: self.category(),
564 priority: Priority::Medium,
565 title: "Memory tokens may be crowding out conversation history".into(),
566 explanation: format!(
567 "Memory is retrieved on {:.0}% of turns with an average of {:.0} tokens/turn. \
568 Excessive memory injection can crowd out useful conversation history.",
569 profile.memory_retrieval_rate * 100.0,
570 profile.avg_tokens_per_turn
571 ),
572 action:
573 "Review and prune stored memories; increase relevance threshold for retrieval."
574 .into(),
575 evidence: vec![
576 Evidence {
577 metric: "memory_retrieval_rate".into(),
578 value: format!("{:.0}%", profile.memory_retrieval_rate * 100.0),
579 context: "memory retrieval rate".into(),
580 },
581 Evidence {
582 metric: "avg_tokens_per_turn".into(),
583 value: format!("{:.0}", profile.avg_tokens_per_turn),
584 context: "high token count may indicate memory bloat".into(),
585 },
586 ],
587 estimated_impact: Some(Impact {
588 monthly_savings: Some(profile.total_cost * 0.15),
589 quality_change: Some("better balance of memory vs history".into()),
590 description: "Reduce memory token overhead".into(),
591 }),
592 })
593 } else {
594 None
595 }
596 }
597}
598
599struct SystemPromptROI;
602
603impl RecommendationRule for SystemPromptROI {
604 fn name(&self) -> &str {
605 "system_prompt_roi"
606 }
607 fn category(&self) -> RecommendationCategory {
608 RecommendationCategory::CostOptimization
609 }
610 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
611 if profile.total_turns < 10 || profile.avg_tokens_per_turn < 1500.0 {
612 return None;
613 }
614 let system_cost_estimate = profile.total_cost * 0.3;
615 Some(Recommendation {
616 category: self.category(),
617 priority: Priority::Medium,
618 title: "Large input tokens suggest heavy system prompt".into(),
619 explanation: format!(
620 "Average input is {:.0} tokens/turn. If a large system prompt accounts for a \
621 significant portion, consider trimming non-essential instructions.",
622 profile.avg_tokens_per_turn
623 ),
624 action: "Audit your system prompt for redundant instructions. Move static context \
625 to memory retrieval."
626 .into(),
627 evidence: vec![Evidence {
628 metric: "avg_tokens_per_turn".into(),
629 value: format!("{:.0}", profile.avg_tokens_per_turn),
630 context: "tokens per turn (system prompt is re-sent each turn)".into(),
631 }],
632 estimated_impact: Some(Impact {
633 monthly_savings: Some(system_cost_estimate * 0.2),
634 quality_change: None,
635 description: "Reduce per-turn system prompt overhead".into(),
636 }),
637 })
638 }
639}
640
641struct CachingOpportunity;
642
643impl RecommendationRule for CachingOpportunity {
644 fn name(&self) -> &str {
645 "caching_opportunity"
646 }
647 fn category(&self) -> RecommendationCategory {
648 RecommendationCategory::CostOptimization
649 }
650 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
651 if profile.total_turns < 10 {
652 return None;
653 }
654 if profile.cache_hit_rate < 0.15 {
655 let potential_savings = profile.total_cost * 0.2;
656 Some(Recommendation {
657 category: self.category(),
658 priority: Priority::High,
659 title: "Low cache hit rate — significant savings possible".into(),
660 explanation: format!(
661 "Cache hit rate is only {:.1}%. Enabling or tuning semantic caching for \
662 repeated/similar queries could save ~20% of inference costs.",
663 profile.cache_hit_rate * 100.0
664 ),
665 action: "Enable semantic caching and review cache TTL settings.".into(),
666 evidence: vec![Evidence {
667 metric: "cache_hit_rate".into(),
668 value: format!("{:.1}%", profile.cache_hit_rate * 100.0),
669 context: "current cache hit rate".into(),
670 }],
671 estimated_impact: Some(Impact {
672 monthly_savings: Some(potential_savings),
673 quality_change: None,
674 description: "Cache repeated queries to avoid redundant inference".into(),
675 }),
676 })
677 } else {
678 None
679 }
680 }
681}
682
683struct ToolCostAwareness;
684
685impl RecommendationRule for ToolCostAwareness {
686 fn name(&self) -> &str {
687 "tool_cost_awareness"
688 }
689 fn category(&self) -> RecommendationCategory {
690 RecommendationCategory::CostOptimization
691 }
692 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
693 if profile.total_turns < 10 {
694 return None;
695 }
696 if profile.tool_success_rate < 0.7 {
697 Some(Recommendation {
698 category: self.category(),
699 priority: Priority::Medium,
700 title: "Tool calls have a high failure rate".into(),
701 explanation: format!(
702 "Tool success rate is {:.0}%. Failed tool calls still cost tokens for the \
703 request and response. Improving tool reliability reduces wasted spend.",
704 profile.tool_success_rate * 100.0
705 ),
706 action:
707 "Review failing tool calls. Consider better error handling or input validation."
708 .into(),
709 evidence: vec![Evidence {
710 metric: "tool_success_rate".into(),
711 value: format!("{:.0}%", profile.tool_success_rate * 100.0),
712 context: "tool call success rate".into(),
713 }],
714 estimated_impact: Some(Impact {
715 monthly_savings: Some(
716 profile.total_cost * (1.0 - profile.tool_success_rate) * 0.1,
717 ),
718 quality_change: Some("fewer errors, smoother interactions".into()),
719 description: "Reduce wasted inference from failed tool calls".into(),
720 }),
721 })
722 } else {
723 None
724 }
725 }
726}
727
728struct FallbackChainTuning;
731
732impl RecommendationRule for FallbackChainTuning {
733 fn name(&self) -> &str {
734 "fallback_chain_tuning"
735 }
736 fn category(&self) -> RecommendationCategory {
737 RecommendationCategory::Configuration
738 }
739 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
740 if profile.models_used.len() < 2 || profile.total_turns < 20 {
741 return None;
742 }
743 let primary = profile.models_used.first()?;
744 let primary_stats = profile.model_stats.get(primary)?;
745 let total_non_primary: i64 = profile
746 .model_stats
747 .iter()
748 .filter(|(k, _)| *k != primary)
749 .map(|(_, s)| s.turns)
750 .sum();
751
752 let fallback_rate = total_non_primary as f64 / profile.total_turns as f64;
753 if fallback_rate > 0.3 {
754 Some(Recommendation {
755 category: self.category(),
756 priority: Priority::High,
757 title: format!("Primary model ({primary}) falls back {:.0}% of the time", fallback_rate * 100.0),
758 explanation: format!(
759 "{:.0}% of turns use fallback models instead of {primary}. This suggests \
760 the primary provider may be unreliable or rate-limited.",
761 fallback_rate * 100.0
762 ),
763 action: "Check circuit breaker status; consider switching primary model or adding API key budget.".into(),
764 evidence: vec![
765 Evidence {
766 metric: "primary_turns".into(),
767 value: format!("{}", primary_stats.turns),
768 context: format!("turns on {primary}"),
769 },
770 Evidence {
771 metric: "fallback_turns".into(),
772 value: format!("{total_non_primary}"),
773 context: "turns on fallback models".into(),
774 },
775 ],
776 estimated_impact: None,
777 })
778 } else {
779 None
780 }
781 }
782}
783
784struct HighCostPerTurn;
785
786impl RecommendationRule for HighCostPerTurn {
787 fn name(&self) -> &str {
788 "high_cost_per_turn"
789 }
790 fn category(&self) -> RecommendationCategory {
791 RecommendationCategory::Configuration
792 }
793 fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation> {
794 if profile.total_turns < 5 {
795 return None;
796 }
797 let avg_cost = profile.total_cost / profile.total_turns as f64;
798 if avg_cost > 0.05 {
799 Some(Recommendation {
800 category: self.category(),
801 priority: if avg_cost > 0.10 {
802 Priority::High
803 } else {
804 Priority::Medium
805 },
806 title: format!("Average cost per turn is ${:.4}", avg_cost),
807 explanation: format!(
808 "At ${:.4}/turn, your inference costs are above the typical threshold. \
809 Over {} turns, this totals ${:.2}.",
810 avg_cost, profile.total_turns, profile.total_cost
811 ),
812 action: "Review model selection, context window size, and caching settings.".into(),
813 evidence: vec![
814 Evidence {
815 metric: "avg_cost_per_turn".into(),
816 value: format!("${:.4}", avg_cost),
817 context: "above $0.05/turn threshold".into(),
818 },
819 Evidence {
820 metric: "total_cost".into(),
821 value: format!("${:.4}", profile.total_cost),
822 context: format!("over {} turns", profile.total_turns),
823 },
824 ],
825 estimated_impact: Some(Impact {
826 monthly_savings: Some(profile.total_cost * 0.3),
827 quality_change: None,
828 description: "Reduce overall inference spend".into(),
829 }),
830 })
831 } else {
832 None
833 }
834 }
835}
836
837pub struct LlmRecommendationAnalyzer;
840
841impl LlmRecommendationAnalyzer {
842 pub fn build_prompt(profile: &UserProfile, heuristic_recs: &[Recommendation]) -> String {
843 let mut prompt = String::from(
844 "You are an AI usage optimization expert. Analyze the following user profile and \
845 heuristic recommendations, then provide deeper, actionable insights.\n\n",
846 );
847
848 prompt.push_str(&format!(
849 "## User Profile\n\
850 - Total sessions: {}\n\
851 - Total turns: {}\n\
852 - Total cost: ${:.4}\n\
853 - Models used: {}\n\
854 - Avg session length: {:.1} turns\n\
855 - Avg tokens/turn: {:.0}\n\
856 - Cache hit rate: {:.1}%\n\
857 - Tool success rate: {:.1}%\n\n",
858 profile.total_sessions,
859 profile.total_turns,
860 profile.total_cost,
861 profile.models_used.join(", "),
862 profile.avg_session_length,
863 profile.avg_tokens_per_turn,
864 profile.cache_hit_rate * 100.0,
865 profile.tool_success_rate * 100.0,
866 ));
867
868 prompt.push_str("## Model Breakdown\n");
869 for (name, stats) in &profile.model_stats {
870 prompt.push_str(&format!(
871 "- {name}: {turns} turns, ${cost:.4}/turn, density={density:.3}, cache={cache:.0}%\n",
872 turns = stats.turns,
873 cost = stats.avg_cost,
874 density = stats.avg_output_density,
875 cache = stats.cache_hit_rate * 100.0,
876 ));
877 }
878
879 prompt.push_str("\n## Heuristic Recommendations Already Generated\n");
880 for (i, rec) in heuristic_recs.iter().enumerate() {
881 prompt.push_str(&format!(
882 "{}. [{:?}] {}: {}\n",
883 i + 1,
884 rec.priority,
885 rec.title,
886 rec.explanation
887 ));
888 }
889
890 prompt.push_str(
891 "\n## Your Task\n\
892 Provide 3-5 additional recommendations that go beyond the heuristics above. \
893 Focus on:\n\
894 1. Cross-cutting patterns the heuristics missed\n\
895 2. Workflow optimizations specific to the model mix\n\
896 3. Cost/quality trade-offs with concrete numbers\n\
897 4. Configuration changes with expected impact\n\n\
898 Format each recommendation as:\n\
899 **Title**: ...\n\
900 **Priority**: High/Medium/Low\n\
901 **Explanation**: ...\n\
902 **Action**: ...\n\
903 **Expected Impact**: ...\n",
904 );
905
906 prompt
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use std::collections::HashMap;
913
914 use super::*;
915
916 fn base_profile() -> UserProfile {
917 UserProfile {
918 total_sessions: 10,
919 total_turns: 100,
920 total_cost: 1.5,
921 avg_quality: Some(0.8),
922 grade_coverage: 0.5,
923 models_used: vec!["claude-4".into(), "gpt-4".into()],
924 model_stats: HashMap::from([
925 (
926 "claude-4".into(),
927 ModelStats {
928 turns: 60,
929 avg_cost: 0.02,
930 avg_quality: Some(0.85),
931 cache_hit_rate: 0.3,
932 avg_output_density: 0.25,
933 },
934 ),
935 (
936 "gpt-4".into(),
937 ModelStats {
938 turns: 40,
939 avg_cost: 0.01,
940 avg_quality: Some(0.75),
941 cache_hit_rate: 0.4,
942 avg_output_density: 0.20,
943 },
944 ),
945 ]),
946 avg_session_length: 10.0,
947 avg_tokens_per_turn: 200.0,
948 tool_success_rate: 0.9,
949 cache_hit_rate: 0.35,
950 memory_retrieval_rate: 0.5,
951 }
952 }
953
954 #[test]
955 fn engine_produces_sorted_recommendations() {
956 let engine = RecommendationEngine::new();
957 let mut profile = base_profile();
958 profile.cache_hit_rate = 0.05;
959 profile.avg_tokens_per_turn = 30.0;
960
961 let recs = engine.generate(&profile);
962 assert!(!recs.is_empty());
963
964 for window in recs.windows(2) {
965 assert!(
966 window[0].priority.ordinal() >= window[1].priority.ordinal(),
967 "recommendations not sorted by priority"
968 );
969 }
970 }
971
972 #[test]
973 fn specificity_fires_for_short_queries() {
974 let rule = SpecificityCorrelation;
975 let mut profile = base_profile();
976 profile.avg_tokens_per_turn = 25.0;
977 assert!(rule.evaluate(&profile).is_some());
978
979 profile.avg_tokens_per_turn = 150.0;
980 assert!(rule.evaluate(&profile).is_none());
981 }
982
983 #[test]
984 fn follow_up_fires_for_long_sessions() {
985 let rule = FollowUpPatterns;
986 let mut profile = base_profile();
987 profile.avg_session_length = 25.0;
988 assert!(rule.evaluate(&profile).is_some());
989
990 profile.avg_session_length = 5.0;
991 assert!(rule.evaluate(&profile).is_none());
992 }
993
994 #[test]
995 fn caching_opportunity_fires_for_low_hit_rate() {
996 let rule = CachingOpportunity;
997 let mut profile = base_profile();
998 profile.cache_hit_rate = 0.05;
999 let rec = rule.evaluate(&profile);
1000 assert!(rec.is_some());
1001 assert_eq!(rec.unwrap().priority, Priority::High);
1002
1003 profile.cache_hit_rate = 0.5;
1004 assert!(rule.evaluate(&profile).is_none());
1005 }
1006
1007 #[test]
1008 fn high_cost_fires_above_threshold() {
1009 let rule = HighCostPerTurn;
1010 let mut profile = base_profile();
1011 profile.total_cost = 15.0;
1012 profile.total_turns = 100;
1013 let rec = rule.evaluate(&profile);
1014 assert!(rec.is_some());
1015 assert_eq!(rec.unwrap().priority, Priority::High);
1016 }
1017
1018 #[test]
1019 fn high_cost_silent_below_threshold() {
1020 let rule = HighCostPerTurn;
1021 let mut profile = base_profile();
1022 profile.total_cost = 1.0;
1023 profile.total_turns = 100;
1024 assert!(rule.evaluate(&profile).is_none());
1025 }
1026
1027 #[test]
1028 fn pareto_detects_dominated_model() {
1029 let rule = ParetoOptimalModels;
1030 let mut profile = base_profile();
1031 profile.model_stats.insert(
1032 "expensive-bad".into(),
1033 ModelStats {
1034 turns: 20,
1035 avg_cost: 0.05,
1036 avg_quality: None,
1037 cache_hit_rate: 0.1,
1038 avg_output_density: 0.10,
1039 },
1040 );
1041 profile.model_stats.insert(
1042 "cheap-good".into(),
1043 ModelStats {
1044 turns: 20,
1045 avg_cost: 0.01,
1046 avg_quality: None,
1047 cache_hit_rate: 0.5,
1048 avg_output_density: 0.30,
1049 },
1050 );
1051 assert!(rule.evaluate(&profile).is_some());
1052 }
1053
1054 #[test]
1055 fn tool_cost_fires_for_low_success() {
1056 let rule = ToolCostAwareness;
1057 let mut profile = base_profile();
1058 profile.tool_success_rate = 0.5;
1059 assert!(rule.evaluate(&profile).is_some());
1060
1061 profile.tool_success_rate = 0.95;
1062 assert!(rule.evaluate(&profile).is_none());
1063 }
1064
1065 #[test]
1066 fn fallback_fires_for_high_rate() {
1067 let rule = FallbackChainTuning;
1068 let mut profile = base_profile();
1069 profile.model_stats.get_mut("gpt-4").unwrap().turns = 80;
1070 profile.model_stats.get_mut("claude-4").unwrap().turns = 20;
1071 profile.total_turns = 100;
1072 assert!(rule.evaluate(&profile).is_some());
1073 }
1074
1075 #[test]
1076 fn stale_session_fires_for_long_sessions() {
1077 let rule = StaleSessionCost;
1078 let mut profile = base_profile();
1079 profile.avg_session_length = 30.0;
1080 assert!(rule.evaluate(&profile).is_some());
1081
1082 profile.avg_session_length = 8.0;
1083 assert!(rule.evaluate(&profile).is_none());
1084 }
1085
1086 #[test]
1087 fn memory_underutilized_fires() {
1088 let rule = MemoryUnderutilized;
1089 let mut profile = base_profile();
1090 profile.memory_retrieval_rate = 0.05;
1091 assert!(rule.evaluate(&profile).is_some());
1092
1093 profile.memory_retrieval_rate = 0.6;
1094 assert!(rule.evaluate(&profile).is_none());
1095 }
1096
1097 #[test]
1098 fn llm_prompt_contains_profile_data() {
1099 let profile = base_profile();
1100 let recs = RecommendationEngine::new().generate(&profile);
1101 let prompt = LlmRecommendationAnalyzer::build_prompt(&profile, &recs);
1102 assert!(prompt.contains("Total sessions: 10"));
1103 assert!(prompt.contains("claude-4"));
1104 assert!(prompt.contains("Your Task"));
1105 }
1106
1107 #[test]
1108 fn tool_cost_fires_for_zero_success_rate() {
1109 let rule = ToolCostAwareness;
1110 let mut profile = base_profile();
1111 profile.tool_success_rate = 0.0;
1112 let rec = rule.evaluate(&profile);
1113 assert!(rec.is_some(), "0% tool success should trigger the rule");
1114 let rec = rec.unwrap();
1115 assert!(rec.explanation.contains("0%"));
1116 }
1117
1118 #[test]
1119 fn tool_cost_silent_for_high_success() {
1120 let rule = ToolCostAwareness;
1121 let mut profile = base_profile();
1122 profile.tool_success_rate = 0.85;
1123 assert!(
1124 rule.evaluate(&profile).is_none(),
1125 "85% success rate should not trigger"
1126 );
1127 }
1128
1129 #[test]
1130 fn tool_cost_silent_for_insufficient_data() {
1131 let rule = ToolCostAwareness;
1132 let mut profile = base_profile();
1133 profile.total_turns = 5;
1134 profile.tool_success_rate = 0.0;
1135 assert!(
1136 rule.evaluate(&profile).is_none(),
1137 "should not fire with < 10 turns"
1138 );
1139 }
1140
1141 #[test]
1142 fn engine_no_recs_for_minimal_profile() {
1143 let engine = RecommendationEngine::new();
1144 let profile = UserProfile {
1145 total_sessions: 1,
1146 total_turns: 2,
1147 total_cost: 0.001,
1148 avg_quality: None,
1149 grade_coverage: 0.0,
1150 models_used: vec!["test".into()],
1151 model_stats: HashMap::from([(
1152 "test".into(),
1153 ModelStats {
1154 turns: 2,
1155 avg_cost: 0.0005,
1156 avg_quality: None,
1157 cache_hit_rate: 0.0,
1158 avg_output_density: 0.3,
1159 },
1160 )]),
1161 avg_session_length: 2.0,
1162 avg_tokens_per_turn: 100.0,
1163 tool_success_rate: 1.0,
1164 cache_hit_rate: 0.5,
1165 memory_retrieval_rate: 0.5,
1166 };
1167 let recs = engine.generate(&profile);
1168 assert!(recs.is_empty(), "minimal profile should produce no recs");
1169 }
1170
1171 #[test]
1174 fn category_label_covers_all_variants() {
1175 assert_eq!(
1176 RecommendationCategory::QueryCrafting.label(),
1177 "Query Crafting"
1178 );
1179 assert_eq!(
1180 RecommendationCategory::ModelSelection.label(),
1181 "Model Selection"
1182 );
1183 assert_eq!(
1184 RecommendationCategory::SessionManagement.label(),
1185 "Session Management"
1186 );
1187 assert_eq!(
1188 RecommendationCategory::MemoryLeverage.label(),
1189 "Memory Leverage"
1190 );
1191 assert_eq!(
1192 RecommendationCategory::CostOptimization.label(),
1193 "Cost Optimization"
1194 );
1195 assert_eq!(RecommendationCategory::ToolUsage.label(), "Tool Usage");
1196 assert_eq!(
1197 RecommendationCategory::Configuration.label(),
1198 "Configuration"
1199 );
1200 }
1201
1202 #[test]
1203 fn category_icon_covers_all_variants() {
1204 assert_eq!(RecommendationCategory::QueryCrafting.icon(), "pencil");
1205 assert_eq!(RecommendationCategory::ModelSelection.icon(), "cpu");
1206 assert_eq!(RecommendationCategory::SessionManagement.icon(), "chat");
1207 assert_eq!(RecommendationCategory::MemoryLeverage.icon(), "memory");
1208 assert_eq!(RecommendationCategory::CostOptimization.icon(), "dollar");
1209 assert_eq!(RecommendationCategory::ToolUsage.icon(), "wrench");
1210 assert_eq!(RecommendationCategory::Configuration.icon(), "gear");
1211 }
1212
1213 #[test]
1216 fn specificity_silent_for_insufficient_data() {
1217 let rule = SpecificityCorrelation;
1218 let mut profile = base_profile();
1219 profile.total_turns = 5;
1220 profile.avg_tokens_per_turn = 10.0;
1221 assert!(rule.evaluate(&profile).is_none());
1222 }
1223
1224 #[test]
1225 fn specificity_name_and_category() {
1226 let rule = SpecificityCorrelation;
1227 assert_eq!(rule.name(), "specificity_correlation");
1228 assert_eq!(rule.category(), RecommendationCategory::QueryCrafting);
1229 }
1230
1231 #[test]
1234 fn follow_up_silent_for_insufficient_sessions() {
1235 let rule = FollowUpPatterns;
1236 let mut profile = base_profile();
1237 profile.total_sessions = 2;
1238 profile.avg_session_length = 30.0;
1239 assert!(rule.evaluate(&profile).is_none());
1240 }
1241
1242 #[test]
1243 fn follow_up_name_and_category() {
1244 let rule = FollowUpPatterns;
1245 assert_eq!(rule.name(), "follow_up_patterns");
1246 assert_eq!(rule.category(), RecommendationCategory::QueryCrafting);
1247 }
1248
1249 #[test]
1252 fn pareto_silent_for_single_model() {
1253 let rule = ParetoOptimalModels;
1254 let mut profile = base_profile();
1255 profile.model_stats.clear();
1256 profile.model_stats.insert(
1257 "only-model".into(),
1258 ModelStats {
1259 turns: 50,
1260 avg_cost: 0.02,
1261 avg_quality: None,
1262 cache_hit_rate: 0.3,
1263 avg_output_density: 0.25,
1264 },
1265 );
1266 assert!(rule.evaluate(&profile).is_none());
1267 }
1268
1269 #[test]
1270 fn pareto_silent_when_no_domination() {
1271 let rule = ParetoOptimalModels;
1272 let mut profile = base_profile();
1273 profile.model_stats.clear();
1274 profile.model_stats.insert(
1276 "model-a".into(),
1277 ModelStats {
1278 turns: 20,
1279 avg_cost: 0.01,
1280 avg_quality: None,
1281 cache_hit_rate: 0.3,
1282 avg_output_density: 0.10,
1283 },
1284 );
1285 profile.model_stats.insert(
1287 "model-b".into(),
1288 ModelStats {
1289 turns: 20,
1290 avg_cost: 0.05,
1291 avg_quality: None,
1292 cache_hit_rate: 0.3,
1293 avg_output_density: 0.30,
1294 },
1295 );
1296 assert!(rule.evaluate(&profile).is_none());
1297 }
1298
1299 #[test]
1300 fn pareto_name_and_category() {
1301 let rule = ParetoOptimalModels;
1302 assert_eq!(rule.name(), "pareto_optimal_models");
1303 assert_eq!(rule.category(), RecommendationCategory::ModelSelection);
1304 }
1305
1306 #[test]
1309 fn complexity_mismatch_fires_for_expensive_low_density() {
1310 let rule = ComplexityMismatch;
1311 let mut profile = base_profile();
1312 profile.model_stats.insert(
1313 "over-powered".into(),
1314 ModelStats {
1315 turns: 30,
1316 avg_cost: 0.03,
1317 avg_quality: None,
1318 cache_hit_rate: 0.2,
1319 avg_output_density: 0.10,
1320 },
1321 );
1322 let rec = rule.evaluate(&profile);
1323 assert!(
1324 rec.is_some(),
1325 "expensive model with low density should trigger"
1326 );
1327 let rec = rec.unwrap();
1328 assert_eq!(rec.priority, Priority::High);
1329 assert!(rec.title.contains("over-powered"));
1330 }
1331
1332 #[test]
1333 fn complexity_mismatch_silent_for_cheap_models() {
1334 let rule = ComplexityMismatch;
1335 let mut profile = base_profile();
1336 profile.model_stats.clear();
1337 profile.model_stats.insert(
1338 "cheap".into(),
1339 ModelStats {
1340 turns: 30,
1341 avg_cost: 0.005,
1342 avg_quality: None,
1343 cache_hit_rate: 0.2,
1344 avg_output_density: 0.10,
1345 },
1346 );
1347 assert!(rule.evaluate(&profile).is_none());
1348 }
1349
1350 #[test]
1351 fn complexity_mismatch_name_and_category() {
1352 let rule = ComplexityMismatch;
1353 assert_eq!(rule.name(), "complexity_mismatch");
1354 assert_eq!(rule.category(), RecommendationCategory::ModelSelection);
1355 }
1356
1357 #[test]
1360 fn model_strengths_fires_when_different_best_models() {
1361 let rule = ModelStrengths;
1362 let mut profile = base_profile();
1363 profile.total_turns = 50;
1364 profile.models_used = vec!["quality-model".into(), "cost-model".into()];
1365 profile.model_stats.clear();
1366 profile.model_stats.insert(
1367 "quality-model".into(),
1368 ModelStats {
1369 turns: 25,
1370 avg_cost: 0.05,
1371 avg_quality: Some(0.9),
1372 cache_hit_rate: 0.3,
1373 avg_output_density: 0.40,
1374 },
1375 );
1376 profile.model_stats.insert(
1377 "cost-model".into(),
1378 ModelStats {
1379 turns: 25,
1380 avg_cost: 0.005,
1381 avg_quality: Some(0.7),
1382 cache_hit_rate: 0.3,
1383 avg_output_density: 0.15,
1384 },
1385 );
1386 let rec = rule.evaluate(&profile);
1387 assert!(rec.is_some(), "different best models should trigger");
1388 let rec = rec.unwrap();
1389 assert_eq!(rec.priority, Priority::Low);
1390 assert!(rec.explanation.contains("quality-model"));
1391 assert!(rec.explanation.contains("cost-model"));
1392 }
1393
1394 #[test]
1395 fn model_strengths_silent_for_single_model() {
1396 let rule = ModelStrengths;
1397 let mut profile = base_profile();
1398 profile.models_used = vec!["only-one".into()];
1399 assert!(rule.evaluate(&profile).is_none());
1400 }
1401
1402 #[test]
1403 fn model_strengths_silent_for_few_turns() {
1404 let rule = ModelStrengths;
1405 let mut profile = base_profile();
1406 profile.total_turns = 10;
1407 assert!(rule.evaluate(&profile).is_none());
1408 }
1409
1410 #[test]
1411 fn model_strengths_name_and_category() {
1412 let rule = ModelStrengths;
1413 assert_eq!(rule.name(), "model_strengths");
1414 assert_eq!(rule.category(), RecommendationCategory::ModelSelection);
1415 }
1416
1417 #[test]
1420 fn session_length_sweet_fires_in_range() {
1421 let rule = SessionLengthSweet;
1422 let mut profile = base_profile();
1423 profile.avg_session_length = 18.0;
1424 let rec = rule.evaluate(&profile);
1425 assert!(rec.is_some(), "session length 15-20 should trigger");
1426 assert_eq!(rec.unwrap().priority, Priority::Low);
1427 }
1428
1429 #[test]
1430 fn session_length_sweet_silent_outside_range() {
1431 let rule = SessionLengthSweet;
1432 let mut profile = base_profile();
1433 profile.avg_session_length = 10.0;
1434 assert!(rule.evaluate(&profile).is_none());
1435
1436 profile.avg_session_length = 25.0;
1437 assert!(rule.evaluate(&profile).is_none());
1438 }
1439
1440 #[test]
1441 fn session_length_sweet_silent_for_few_sessions() {
1442 let rule = SessionLengthSweet;
1443 let mut profile = base_profile();
1444 profile.total_sessions = 2;
1445 profile.avg_session_length = 18.0;
1446 assert!(rule.evaluate(&profile).is_none());
1447 }
1448
1449 #[test]
1450 fn session_length_sweet_name_and_category() {
1451 let rule = SessionLengthSweet;
1452 assert_eq!(rule.name(), "session_length_sweet");
1453 assert_eq!(rule.category(), RecommendationCategory::SessionManagement);
1454 }
1455
1456 #[test]
1459 fn stale_session_name_and_category() {
1460 let rule = StaleSessionCost;
1461 assert_eq!(rule.name(), "stale_session_cost");
1462 assert_eq!(rule.category(), RecommendationCategory::SessionManagement);
1463 }
1464
1465 #[test]
1466 fn stale_session_silent_for_few_sessions() {
1467 let rule = StaleSessionCost;
1468 let mut profile = base_profile();
1469 profile.total_sessions = 3;
1470 profile.avg_session_length = 30.0;
1471 assert!(rule.evaluate(&profile).is_none());
1472 }
1473
1474 #[test]
1477 fn memory_underutilized_silent_for_few_turns() {
1478 let rule = MemoryUnderutilized;
1479 let mut profile = base_profile();
1480 profile.total_turns = 10;
1481 profile.memory_retrieval_rate = 0.05;
1482 assert!(rule.evaluate(&profile).is_none());
1483 }
1484
1485 #[test]
1486 fn memory_underutilized_name_and_category() {
1487 let rule = MemoryUnderutilized;
1488 assert_eq!(rule.name(), "memory_underutilized");
1489 assert_eq!(rule.category(), RecommendationCategory::MemoryLeverage);
1490 }
1491
1492 #[test]
1495 fn memory_overloaded_fires_when_crowding() {
1496 let rule = MemoryOverloaded;
1497 let mut profile = base_profile();
1498 profile.total_turns = 30;
1499 profile.memory_retrieval_rate = 0.95;
1500 profile.avg_tokens_per_turn = 3000.0;
1501 let rec = rule.evaluate(&profile);
1502 assert!(
1503 rec.is_some(),
1504 "high memory retrieval + high tokens should trigger"
1505 );
1506 let rec = rec.unwrap();
1507 assert_eq!(rec.priority, Priority::Medium);
1508 assert!(rec.explanation.contains("95%"));
1509 }
1510
1511 #[test]
1512 fn memory_overloaded_silent_when_balanced() {
1513 let rule = MemoryOverloaded;
1514 let mut profile = base_profile();
1515 profile.total_turns = 30;
1516 profile.memory_retrieval_rate = 0.5;
1517 profile.avg_tokens_per_turn = 500.0;
1518 assert!(rule.evaluate(&profile).is_none());
1519 }
1520
1521 #[test]
1522 fn memory_overloaded_silent_for_few_turns() {
1523 let rule = MemoryOverloaded;
1524 let mut profile = base_profile();
1525 profile.total_turns = 10;
1526 profile.memory_retrieval_rate = 0.95;
1527 profile.avg_tokens_per_turn = 3000.0;
1528 assert!(rule.evaluate(&profile).is_none());
1529 }
1530
1531 #[test]
1532 fn memory_overloaded_name_and_category() {
1533 let rule = MemoryOverloaded;
1534 assert_eq!(rule.name(), "memory_overloaded");
1535 assert_eq!(rule.category(), RecommendationCategory::MemoryLeverage);
1536 }
1537
1538 #[test]
1541 fn system_prompt_roi_fires_for_heavy_prompts() {
1542 let rule = SystemPromptROI;
1543 let mut profile = base_profile();
1544 profile.total_turns = 50;
1545 profile.avg_tokens_per_turn = 2000.0;
1546 let rec = rule.evaluate(&profile);
1547 assert!(rec.is_some(), "high tokens/turn should trigger");
1548 let rec = rec.unwrap();
1549 assert_eq!(rec.priority, Priority::Medium);
1550 assert!(rec.explanation.contains("2000"));
1551 }
1552
1553 #[test]
1554 fn system_prompt_roi_silent_for_low_tokens() {
1555 let rule = SystemPromptROI;
1556 let mut profile = base_profile();
1557 profile.total_turns = 50;
1558 profile.avg_tokens_per_turn = 500.0;
1559 assert!(rule.evaluate(&profile).is_none());
1560 }
1561
1562 #[test]
1563 fn system_prompt_roi_silent_for_few_turns() {
1564 let rule = SystemPromptROI;
1565 let mut profile = base_profile();
1566 profile.total_turns = 5;
1567 profile.avg_tokens_per_turn = 2000.0;
1568 assert!(rule.evaluate(&profile).is_none());
1569 }
1570
1571 #[test]
1572 fn system_prompt_roi_name_and_category() {
1573 let rule = SystemPromptROI;
1574 assert_eq!(rule.name(), "system_prompt_roi");
1575 assert_eq!(rule.category(), RecommendationCategory::CostOptimization);
1576 }
1577
1578 #[test]
1581 fn caching_opportunity_name_and_category() {
1582 let rule = CachingOpportunity;
1583 assert_eq!(rule.name(), "caching_opportunity");
1584 assert_eq!(rule.category(), RecommendationCategory::CostOptimization);
1585 }
1586
1587 #[test]
1588 fn caching_opportunity_silent_for_few_turns() {
1589 let rule = CachingOpportunity;
1590 let mut profile = base_profile();
1591 profile.total_turns = 5;
1592 profile.cache_hit_rate = 0.01;
1593 assert!(rule.evaluate(&profile).is_none());
1594 }
1595
1596 #[test]
1599 fn tool_cost_name_and_category() {
1600 let rule = ToolCostAwareness;
1601 assert_eq!(rule.name(), "tool_cost_awareness");
1602 assert_eq!(rule.category(), RecommendationCategory::CostOptimization);
1603 }
1604
1605 #[test]
1608 fn fallback_chain_name_and_category() {
1609 let rule = FallbackChainTuning;
1610 assert_eq!(rule.name(), "fallback_chain_tuning");
1611 assert_eq!(rule.category(), RecommendationCategory::Configuration);
1612 }
1613
1614 #[test]
1615 fn fallback_silent_for_single_model() {
1616 let rule = FallbackChainTuning;
1617 let mut profile = base_profile();
1618 profile.models_used = vec!["only-one".into()];
1619 assert!(rule.evaluate(&profile).is_none());
1620 }
1621
1622 #[test]
1623 fn fallback_silent_for_few_turns() {
1624 let rule = FallbackChainTuning;
1625 let mut profile = base_profile();
1626 profile.total_turns = 10;
1627 assert!(rule.evaluate(&profile).is_none());
1628 }
1629
1630 #[test]
1631 fn fallback_silent_for_low_fallback_rate() {
1632 let rule = FallbackChainTuning;
1633 let mut profile = base_profile();
1634 profile.total_turns = 100;
1635 profile.model_stats.get_mut("claude-4").unwrap().turns = 90;
1637 profile.model_stats.get_mut("gpt-4").unwrap().turns = 10;
1638 assert!(rule.evaluate(&profile).is_none());
1639 }
1640
1641 #[test]
1644 fn high_cost_name_and_category() {
1645 let rule = HighCostPerTurn;
1646 assert_eq!(rule.name(), "high_cost_per_turn");
1647 assert_eq!(rule.category(), RecommendationCategory::Configuration);
1648 }
1649
1650 #[test]
1651 fn high_cost_medium_priority_between_thresholds() {
1652 let rule = HighCostPerTurn;
1653 let mut profile = base_profile();
1654 profile.total_cost = 8.0;
1656 profile.total_turns = 100;
1657 let rec = rule.evaluate(&profile);
1658 assert!(rec.is_some());
1659 assert_eq!(rec.unwrap().priority, Priority::Medium);
1660 }
1661
1662 #[test]
1663 fn high_cost_silent_for_few_turns() {
1664 let rule = HighCostPerTurn;
1665 let mut profile = base_profile();
1666 profile.total_turns = 3;
1667 profile.total_cost = 100.0;
1668 assert!(rule.evaluate(&profile).is_none());
1669 }
1670
1671 #[test]
1674 fn priority_ordinal_all_variants() {
1675 assert_eq!(Priority::Low.ordinal(), 0);
1676 assert_eq!(Priority::Medium.ordinal(), 1);
1677 assert_eq!(Priority::High.ordinal(), 2);
1678 }
1679
1680 #[test]
1683 fn engine_default_same_as_new() {
1684 let engine = RecommendationEngine::default();
1685 let profile = base_profile();
1686 let recs = engine.generate(&profile);
1687 let _ = recs;
1689 }
1690}