Skip to main content

helios_persistence/advisor/
analysis.rs

1//! Configuration analysis for composite storage.
2//!
3//! This module provides analysis of composite storage configurations,
4//! identifying issues, gaps, and opportunities for optimization.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::composite::{BackendRole, CompositeConfig, QueryAnalyzer, QueryFeature, QueryRouter};
9use crate::core::{BackendCapability, BackendKind};
10use crate::types::SearchQuery;
11
12/// Analyzer for composite storage configurations.
13pub struct ConfigurationAnalyzer {
14    /// Query analyzer for capability detection.
15    query_analyzer: QueryAnalyzer,
16}
17
18impl ConfigurationAnalyzer {
19    /// Creates a new configuration analyzer.
20    pub fn new() -> Self {
21        Self {
22            query_analyzer: QueryAnalyzer::new(),
23        }
24    }
25
26    /// Analyzes a configuration and returns detailed results.
27    pub fn analyze(&self, config: &CompositeConfig) -> AnalysisResult {
28        let capability_coverage = self.analyze_capability_coverage(config);
29        let gap_analysis = self.analyze_gaps(config, &capability_coverage);
30        let redundancy_report = self.analyze_redundancy(config);
31        let issues = self.find_issues(config, &capability_coverage, &redundancy_report);
32        let recommendations = self.generate_recommendations(config, &issues, &gap_analysis);
33
34        AnalysisResult {
35            is_valid: issues.iter().all(|i| i.severity != IssueSeverity::Error),
36            capability_coverage,
37            gap_analysis,
38            redundancy_report,
39            issues,
40            recommendations,
41        }
42    }
43
44    /// Validates a configuration for correctness.
45    pub fn validate(&self, config: &CompositeConfig) -> ValidationResult {
46        let mut errors = Vec::new();
47        let mut warnings = Vec::new();
48
49        // Check for primary backend
50        let primaries: Vec<_> = config
51            .backends
52            .iter()
53            .filter(|b| b.role == BackendRole::Primary)
54            .collect();
55
56        if primaries.is_empty() {
57            errors.push("Configuration must have exactly one primary backend".to_string());
58        } else if primaries.len() > 1 {
59            errors.push(format!(
60                "Configuration has {} primary backends (expected 1)",
61                primaries.len()
62            ));
63        }
64
65        // Check for duplicate backend IDs
66        let mut seen_ids = HashSet::new();
67        for backend in &config.backends {
68            if !seen_ids.insert(&backend.id) {
69                errors.push(format!("Duplicate backend ID: {}", backend.id));
70            }
71        }
72
73        // Check for valid failover targets
74        let backend_ids: HashSet<_> = config.backends.iter().map(|b| &b.id).collect();
75        for backend in &config.backends {
76            if let Some(ref failover) = backend.failover_to {
77                if !backend_ids.contains(failover) {
78                    errors.push(format!(
79                        "Backend '{}' has invalid failover target: '{}'",
80                        backend.id, failover
81                    ));
82                }
83            }
84        }
85
86        // Check for circular failover references
87        if let Some(cycle) = self.find_failover_cycle(config) {
88            errors.push(format!("Circular failover chain detected: {}", cycle));
89        }
90
91        // Warnings for common issues
92        if config.backends.iter().filter(|b| b.enabled).count() == 1 {
93            warnings.push("Only one backend enabled - no redundancy".to_string());
94        }
95
96        // Check for disabled backends that are failover targets
97        for backend in &config.backends {
98            if let Some(ref failover) = backend.failover_to {
99                if let Some(target) = config.backends.iter().find(|b| &b.id == failover) {
100                    if !target.enabled {
101                        warnings.push(format!(
102                            "Failover target '{}' for backend '{}' is disabled",
103                            failover, backend.id
104                        ));
105                    }
106                }
107            }
108        }
109
110        ValidationResult {
111            is_valid: errors.is_empty(),
112            errors,
113            warnings,
114        }
115    }
116
117    /// Simulates query routing for a given query.
118    pub fn simulate_query(&self, query: &SearchQuery, config: &CompositeConfig) -> QuerySimulation {
119        let analysis = self.query_analyzer.analyze(query);
120        let router = QueryRouter::new(config.clone());
121        let routing = router.route(query);
122
123        let estimated_cost = match &routing {
124            Ok(decision) => {
125                // Estimate based on complexity and target backend
126                let base_cost = match decision.primary_target.as_str() {
127                    t if config
128                        .backends
129                        .iter()
130                        .find(|b| b.id == t)
131                        .map(|b| b.kind == BackendKind::Sqlite)
132                        .unwrap_or(false) =>
133                    {
134                        1.0
135                    }
136                    t if config
137                        .backends
138                        .iter()
139                        .find(|b| b.id == t)
140                        .map(|b| b.kind == BackendKind::Elasticsearch)
141                        .unwrap_or(false) =>
142                    {
143                        2.0
144                    }
145                    _ => 1.5,
146                };
147                base_cost * (1.0 + analysis.complexity_score as f64 * 0.1)
148            }
149            Err(_) => 10.0, // High cost for failed routing
150        };
151
152        QuerySimulation {
153            query_features: analysis.features.iter().cloned().collect(),
154            complexity_score: analysis.complexity_score,
155            routing_decision: routing.as_ref().map(|d| d.primary_target.clone()).ok(),
156            auxiliary_targets: routing
157                .as_ref()
158                .map(|d| d.auxiliary_targets.values().cloned().collect())
159                .unwrap_or_default(),
160            estimated_cost,
161            routing_error: routing.as_ref().err().map(|e| format!("{:?}", e)),
162        }
163    }
164
165    /// Analyzes capability coverage across all backends.
166    fn analyze_capability_coverage(&self, config: &CompositeConfig) -> CapabilityCoverage {
167        let mut covered_capabilities = HashSet::new();
168        let mut capability_backends: HashMap<BackendCapability, Vec<String>> = HashMap::new();
169
170        for backend in &config.backends {
171            if !backend.enabled {
172                continue;
173            }
174
175            let capabilities = backend.effective_capabilities();
176            for cap in capabilities {
177                covered_capabilities.insert(cap);
178                capability_backends
179                    .entry(cap)
180                    .or_default()
181                    .push(backend.id.clone());
182            }
183        }
184
185        // All potentially desirable capabilities
186        let all_capabilities: HashSet<_> = [
187            BackendCapability::Crud,
188            BackendCapability::Versioning,
189            BackendCapability::BasicSearch,
190            BackendCapability::InstanceHistory,
191            BackendCapability::TypeHistory,
192            BackendCapability::Transactions,
193            BackendCapability::ChainedSearch,
194            BackendCapability::FullTextSearch,
195            BackendCapability::TerminologySearch,
196            BackendCapability::Include,
197            BackendCapability::Revinclude,
198        ]
199        .into_iter()
200        .collect();
201
202        let missing_capabilities: HashSet<_> = all_capabilities
203            .difference(&covered_capabilities)
204            .cloned()
205            .collect();
206
207        CapabilityCoverage {
208            covered: covered_capabilities,
209            missing: missing_capabilities,
210            capability_backends,
211            coverage_percentage: 0.0, // Calculated below
212        }
213    }
214
215    /// Analyzes gaps in the configuration.
216    fn analyze_gaps(
217        &self,
218        _config: &CompositeConfig,
219        coverage: &CapabilityCoverage,
220    ) -> GapAnalysis {
221        let mut feature_gaps = Vec::new();
222
223        // Check for missing critical capabilities
224        let critical = [BackendCapability::Crud, BackendCapability::BasicSearch];
225
226        for cap in critical {
227            if coverage.missing.contains(&cap) {
228                feature_gaps.push(FeatureGap {
229                    capability: cap,
230                    impact: GapImpact::High,
231                    suggestion: format!("Add a backend that supports {:?}", cap),
232                });
233            }
234        }
235
236        // Check for missing advanced features
237        let advanced = [
238            BackendCapability::ChainedSearch,
239            BackendCapability::FullTextSearch,
240            BackendCapability::TerminologySearch,
241        ];
242
243        for cap in advanced {
244            if coverage.missing.contains(&cap) {
245                feature_gaps.push(FeatureGap {
246                    capability: cap,
247                    impact: GapImpact::Medium,
248                    suggestion: format!("Consider adding a specialized backend for {:?}", cap),
249                });
250            }
251        }
252
253        // Estimate completeness
254        let total_features = critical.len() + advanced.len();
255        let covered_features = total_features - feature_gaps.len();
256        let completeness = covered_features as f64 / total_features as f64;
257
258        GapAnalysis {
259            feature_gaps,
260            completeness_score: completeness,
261            recommendations: Vec::new(), // Filled later
262        }
263    }
264
265    /// Analyzes redundancy in the configuration.
266    fn analyze_redundancy(&self, config: &CompositeConfig) -> RedundancyReport {
267        let mut overlapping_capabilities: HashMap<BackendCapability, Vec<String>> = HashMap::new();
268        let mut redundant_backends = Vec::new();
269
270        // Find capabilities provided by multiple backends
271        for backend in &config.backends {
272            if !backend.enabled {
273                continue;
274            }
275
276            let capabilities = backend.effective_capabilities();
277            for cap in capabilities {
278                overlapping_capabilities
279                    .entry(cap)
280                    .or_default()
281                    .push(backend.id.clone());
282            }
283        }
284
285        // Filter to only those with multiple providers
286        overlapping_capabilities.retain(|_, v| v.len() > 1);
287
288        // Find backends that are fully redundant (all capabilities covered by others)
289        for backend in &config.backends {
290            if !backend.enabled || backend.role == BackendRole::Primary {
291                continue;
292            }
293
294            let capabilities = backend.effective_capabilities();
295            let all_covered = capabilities.iter().all(|cap| {
296                overlapping_capabilities
297                    .get(cap)
298                    .map(|backends| backends.iter().any(|b| b != &backend.id))
299                    .unwrap_or(false)
300            });
301
302            if all_covered && !capabilities.is_empty() {
303                redundant_backends.push(RedundantBackend {
304                    backend_id: backend.id.clone(),
305                    covered_by: overlapping_capabilities
306                        .values()
307                        .flatten()
308                        .filter(|b| *b != &backend.id)
309                        .cloned()
310                        .collect::<HashSet<_>>()
311                        .into_iter()
312                        .collect(),
313                    reason: "All capabilities provided by other backends".to_string(),
314                });
315            }
316        }
317
318        // Calculate redundancy score
319        let total_capability_assignments: usize =
320            overlapping_capabilities.values().map(|v| v.len()).sum();
321        let unique_capabilities = overlapping_capabilities.len();
322        let redundancy_score = if unique_capabilities > 0 {
323            (total_capability_assignments - unique_capabilities) as f64
324                / total_capability_assignments as f64
325        } else {
326            0.0
327        };
328
329        RedundancyReport {
330            overlapping_capabilities,
331            redundant_backends,
332            redundancy_score,
333        }
334    }
335
336    /// Finds issues in the configuration.
337    fn find_issues(
338        &self,
339        config: &CompositeConfig,
340        coverage: &CapabilityCoverage,
341        redundancy: &RedundancyReport,
342    ) -> Vec<ConfigurationIssue> {
343        let mut issues = Vec::new();
344
345        // Critical: Missing primary
346        let primary_count = config
347            .backends
348            .iter()
349            .filter(|b| b.role == BackendRole::Primary)
350            .count();
351
352        if primary_count == 0 {
353            issues.push(ConfigurationIssue {
354                severity: IssueSeverity::Error,
355                category: IssueCategory::MissingRequirement,
356                message: "No primary backend configured".to_string(),
357                suggestion: Some("Add a backend with role Primary".to_string()),
358            });
359        } else if primary_count > 1 {
360            issues.push(ConfigurationIssue {
361                severity: IssueSeverity::Error,
362                category: IssueCategory::Configuration,
363                message: "Multiple primary backends configured".to_string(),
364                suggestion: Some("Only one backend should have role Primary".to_string()),
365            });
366        }
367
368        // Missing critical capabilities
369        for cap in &coverage.missing {
370            let severity = match cap {
371                BackendCapability::Crud => IssueSeverity::Error,
372                BackendCapability::BasicSearch => IssueSeverity::Warning,
373                _ => IssueSeverity::Info,
374            };
375
376            issues.push(ConfigurationIssue {
377                severity,
378                category: IssueCategory::MissingCapability,
379                message: format!("Missing capability: {:?}", cap),
380                suggestion: Some(format!("Add a backend that supports {:?}", cap)),
381            });
382        }
383
384        // High redundancy warning
385        if redundancy.redundancy_score > 0.5 {
386            issues.push(ConfigurationIssue {
387                severity: IssueSeverity::Warning,
388                category: IssueCategory::Redundancy,
389                message: format!(
390                    "High redundancy detected ({:.0}%)",
391                    redundancy.redundancy_score * 100.0
392                ),
393                suggestion: Some("Consider consolidating backends".to_string()),
394            });
395        }
396
397        issues
398    }
399
400    /// Generates recommendations based on analysis.
401    fn generate_recommendations(
402        &self,
403        _config: &CompositeConfig,
404        issues: &[ConfigurationIssue],
405        gap_analysis: &GapAnalysis,
406    ) -> Vec<Recommendation> {
407        let mut recommendations = Vec::new();
408
409        // Recommendations from issues
410        for issue in issues {
411            if let Some(ref suggestion) = issue.suggestion {
412                let priority = match issue.severity {
413                    IssueSeverity::Error => RecommendationPriority::Critical,
414                    IssueSeverity::Warning => RecommendationPriority::High,
415                    IssueSeverity::Info => RecommendationPriority::Medium,
416                };
417
418                recommendations.push(Recommendation {
419                    priority,
420                    title: format!("Fix: {}", issue.message),
421                    description: suggestion.clone(),
422                    impact: format!("Resolves {:?} issue", issue.category),
423                });
424            }
425        }
426
427        // Recommendations from gaps
428        for gap in &gap_analysis.feature_gaps {
429            recommendations.push(Recommendation {
430                priority: match gap.impact {
431                    GapImpact::High => RecommendationPriority::High,
432                    GapImpact::Medium => RecommendationPriority::Medium,
433                    GapImpact::Low => RecommendationPriority::Low,
434                },
435                title: format!("Add support for {:?}", gap.capability),
436                description: gap.suggestion.clone(),
437                impact: format!("Enables {:?} operations", gap.capability),
438            });
439        }
440
441        recommendations
442    }
443
444    /// Finds circular failover chains.
445    fn find_failover_cycle(&self, config: &CompositeConfig) -> Option<String> {
446        for backend in &config.backends {
447            let mut visited = HashSet::new();
448            let mut current = backend.id.clone();
449
450            while let Some(next) = config
451                .backends
452                .iter()
453                .find(|b| b.id == current)
454                .and_then(|b| b.failover_to.clone())
455            {
456                if !visited.insert(current.clone()) {
457                    return Some(format!("{} -> {}", current, next));
458                }
459                current = next;
460            }
461        }
462        None
463    }
464}
465
466impl Default for ConfigurationAnalyzer {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472/// Result of configuration analysis.
473#[derive(Debug, Clone)]
474pub struct AnalysisResult {
475    /// Whether the configuration is valid.
476    pub is_valid: bool,
477
478    /// Capability coverage analysis.
479    pub capability_coverage: CapabilityCoverage,
480
481    /// Gap analysis.
482    pub gap_analysis: GapAnalysis,
483
484    /// Redundancy report.
485    pub redundancy_report: RedundancyReport,
486
487    /// Configuration issues found.
488    pub issues: Vec<ConfigurationIssue>,
489
490    /// Recommendations for improvement.
491    pub recommendations: Vec<Recommendation>,
492}
493
494/// Result of configuration validation.
495#[derive(Debug, Clone)]
496pub struct ValidationResult {
497    /// Whether the configuration is valid.
498    pub is_valid: bool,
499
500    /// Validation errors.
501    pub errors: Vec<String>,
502
503    /// Validation warnings.
504    pub warnings: Vec<String>,
505}
506
507/// Analysis of capability coverage.
508#[derive(Debug, Clone)]
509pub struct CapabilityCoverage {
510    /// Capabilities covered by at least one backend.
511    pub covered: HashSet<BackendCapability>,
512
513    /// Capabilities not covered by any backend.
514    pub missing: HashSet<BackendCapability>,
515
516    /// Map of capability to backends providing it.
517    pub capability_backends: HashMap<BackendCapability, Vec<String>>,
518
519    /// Overall coverage percentage.
520    pub coverage_percentage: f64,
521}
522
523/// Analysis of capability gaps.
524#[derive(Debug, Clone)]
525pub struct GapAnalysis {
526    /// Feature gaps found.
527    pub feature_gaps: Vec<FeatureGap>,
528
529    /// Completeness score (0.0 to 1.0).
530    pub completeness_score: f64,
531
532    /// Recommendations for filling gaps.
533    pub recommendations: Vec<String>,
534}
535
536/// A feature gap in the configuration.
537#[derive(Debug, Clone)]
538pub struct FeatureGap {
539    /// Missing capability.
540    pub capability: BackendCapability,
541
542    /// Impact of missing this capability.
543    pub impact: GapImpact,
544
545    /// Suggestion for addressing the gap.
546    pub suggestion: String,
547}
548
549/// Impact level of a feature gap.
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum GapImpact {
552    /// Critical missing feature.
553    High,
554    /// Important but not critical.
555    Medium,
556    /// Nice to have.
557    Low,
558}
559
560/// Report on configuration redundancy.
561#[derive(Debug, Clone)]
562pub struct RedundancyReport {
563    /// Capabilities with multiple providers.
564    pub overlapping_capabilities: HashMap<BackendCapability, Vec<String>>,
565
566    /// Potentially redundant backends.
567    pub redundant_backends: Vec<RedundantBackend>,
568
569    /// Overall redundancy score (0.0 to 1.0).
570    pub redundancy_score: f64,
571}
572
573/// A potentially redundant backend.
574#[derive(Debug, Clone)]
575pub struct RedundantBackend {
576    /// Backend identifier.
577    pub backend_id: String,
578
579    /// Backends that cover this one's capabilities.
580    pub covered_by: Vec<String>,
581
582    /// Reason for redundancy.
583    pub reason: String,
584}
585
586/// A configuration issue.
587#[derive(Debug, Clone)]
588pub struct ConfigurationIssue {
589    /// Issue severity.
590    pub severity: IssueSeverity,
591
592    /// Issue category.
593    pub category: IssueCategory,
594
595    /// Issue message.
596    pub message: String,
597
598    /// Suggestion for fixing the issue.
599    pub suggestion: Option<String>,
600}
601
602/// Severity of a configuration issue.
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
604pub enum IssueSeverity {
605    /// Configuration will not work.
606    Error,
607    /// Configuration may have problems.
608    Warning,
609    /// Informational finding.
610    Info,
611}
612
613/// Category of configuration issue.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum IssueCategory {
616    /// Missing requirement.
617    MissingRequirement,
618    /// Missing capability.
619    MissingCapability,
620    /// Configuration error.
621    Configuration,
622    /// Redundancy issue.
623    Redundancy,
624}
625
626/// A recommendation for improvement.
627#[derive(Debug, Clone)]
628pub struct Recommendation {
629    /// Priority of the recommendation.
630    pub priority: RecommendationPriority,
631
632    /// Recommendation title.
633    pub title: String,
634
635    /// Detailed description.
636    pub description: String,
637
638    /// Expected impact.
639    pub impact: String,
640}
641
642/// Priority of a recommendation.
643#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
644pub enum RecommendationPriority {
645    /// Must address immediately.
646    Critical,
647    /// Should address soon.
648    High,
649    /// Worth considering.
650    Medium,
651    /// Nice to have.
652    Low,
653}
654
655/// Result of query simulation.
656#[derive(Debug, Clone)]
657pub struct QuerySimulation {
658    /// Detected query features.
659    pub query_features: Vec<QueryFeature>,
660
661    /// Query complexity score.
662    pub complexity_score: u8,
663
664    /// Primary routing target.
665    pub routing_decision: Option<String>,
666
667    /// Auxiliary routing targets.
668    pub auxiliary_targets: Vec<String>,
669
670    /// Estimated query cost.
671    pub estimated_cost: f64,
672
673    /// Routing error (if any).
674    pub routing_error: Option<String>,
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::composite::{BackendEntry, CompositeConfigBuilder};
681
682    #[test]
683    fn test_analyzer_creation() {
684        let analyzer = ConfigurationAnalyzer::new();
685        // Just verify we can create an analyzer and run analysis
686        let _analysis = analyzer
687            .query_analyzer
688            .analyze(&SearchQuery::new("Patient"));
689    }
690
691    #[test]
692    fn test_validation_no_primary() {
693        let analyzer = ConfigurationAnalyzer::new();
694        let config = CompositeConfig {
695            backends: vec![BackendEntry::new(
696                "secondary",
697                BackendRole::Search,
698                BackendKind::Elasticsearch,
699            )],
700            ..Default::default()
701        };
702
703        let result = analyzer.validate(&config);
704        assert!(!result.is_valid);
705        assert!(result.errors.iter().any(|e| e.contains("primary")));
706    }
707
708    #[test]
709    fn test_validation_valid_config() {
710        let analyzer = ConfigurationAnalyzer::new();
711        let config = CompositeConfigBuilder::new()
712            .primary("sqlite", BackendKind::Sqlite)
713            .build()
714            .unwrap();
715
716        let result = analyzer.validate(&config);
717        assert!(result.is_valid);
718    }
719
720    #[test]
721    fn test_analysis_capability_coverage() {
722        let analyzer = ConfigurationAnalyzer::new();
723        let config = CompositeConfigBuilder::new()
724            .primary("sqlite", BackendKind::Sqlite)
725            .search_backend("es", BackendKind::Elasticsearch)
726            .build()
727            .unwrap();
728
729        let result = analyzer.analyze(&config);
730        assert!(!result.capability_coverage.covered.is_empty());
731    }
732
733    #[test]
734    fn test_query_simulation() {
735        let analyzer = ConfigurationAnalyzer::new();
736        let config = CompositeConfigBuilder::new()
737            .primary("sqlite", BackendKind::Sqlite)
738            .build()
739            .unwrap();
740
741        let query = SearchQuery::new("Patient");
742        let simulation = analyzer.simulate_query(&query, &config);
743
744        assert!(simulation.estimated_cost > 0.0);
745    }
746}