Skip to main content

roboticus_agent/
recommendations.rs

1use serde::{Deserialize, Serialize};
2
3pub use roboticus_db::efficiency::{
4    RecommendationModelStats as ModelStats, RecommendationUserProfile as UserProfile,
5};
6
7// ── Data types ────────────────────────────────────────────────
8
9#[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
88// ── Rule trait ────────────────────────────────────────────────
89
90pub trait RecommendationRule: Send + Sync {
91    fn name(&self) -> &str;
92    fn category(&self) -> RecommendationCategory;
93    fn evaluate(&self, profile: &UserProfile) -> Option<Recommendation>;
94}
95
96// ── Engine ────────────────────────────────────────────────────
97
98pub 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
141// ── Query Crafting rules ──────────────────────────────────────
142
143struct 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
222// ── Model Selection rules ─────────────────────────────────────
223
224struct 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
414// ── Session Management rules ──────────────────────────────────
415
416struct 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
503// ── Memory Leverage rules ─────────────────────────────────────
504
505struct 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
599// ── Cost Optimization rules ───────────────────────────────────
600
601struct 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
728// ── Configuration rules ───────────────────────────────────────
729
730struct 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
837// ── LLM-powered deep analysis ──────────────────────────────────
838
839pub 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    // ── Coverage for RecommendationCategory label / icon ─────────
1172
1173    #[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    // ── Coverage for SpecificityCorrelation edge cases ────────────
1214
1215    #[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    // ── Coverage for FollowUpPatterns edge cases ─────────────────
1232
1233    #[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    // ── Coverage for ParetoOptimalModels edge cases ──────────────
1250
1251    #[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        // model-a: cheaper but lower density
1275        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        // model-b: more expensive but higher density (trade-off, no domination)
1286        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    // ── Coverage for ComplexityMismatch ───────────────────────────
1307
1308    #[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    // ── Coverage for ModelStrengths ───────────────────────────────
1358
1359    #[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    // ── Coverage for SessionLengthSweet ───────────────────────────
1418
1419    #[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    // ── Coverage for StaleSessionCost ─────────────────────────────
1457
1458    #[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    // ── Coverage for MemoryUnderutilized ──────────────────────────
1475
1476    #[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    // ── Coverage for MemoryOverloaded ─────────────────────────────
1493
1494    #[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    // ── Coverage for SystemPromptROI ──────────────────────────────
1539
1540    #[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    // ── Coverage for CachingOpportunity ───────────────────────────
1579
1580    #[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    // ── Coverage for ToolCostAwareness ────────────────────────────
1597
1598    #[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    // ── Coverage for FallbackChainTuning ──────────────────────────
1606
1607    #[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        // 10% fallback rate — below 30% threshold
1636        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    // ── Coverage for HighCostPerTurn ──────────────────────────────
1642
1643    #[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        // avg_cost = 8.0/100 = 0.08 (above 0.05 but below 0.10 -> Medium)
1655        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    // ── Priority ordinal coverage ────────────────────────────────
1672
1673    #[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    // ── Engine default trait ──────────────────────────────────────
1681
1682    #[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        // Just verify it works; same as new()
1688        let _ = recs;
1689    }
1690}