Skip to main content

trustformers_debug/
llm_debugging.rs

1//! Large Language Model (LLM) Specific Debugging
2//!
3//! This module provides specialized debugging capabilities for large language models,
4//! focusing on safety, alignment, factuality, toxicity detection, and performance
5//! characteristics specific to modern LLMs.
6//!
7//! # Honesty notes on what these analyzers actually do
8//!
9//! [`SafetyAnalyzer`] is a **rule-based keyword heuristic** over the literal
10//! response text (see `SafetyAnalyzer::find_harmful_keywords`) -- not a
11//! trained safety classifier. Its `safety_score` and `confidence` are real
12//! functions of measurable properties of the match (which keyword categories
13//! fired, and how many), never flat literals.
14//!
15//! [`FactualityChecker`] does **no fact-checking**: nothing in this crate
16//! queries a knowledge base, so
17//! [`FactualityAnalysisResult::factuality_score`] is always `None`. What it
18//! does report are deterministic properties of the text under names that say
19//! so -- `claim_like_sentences`, `uncertainty_indicator_hits` and their ratio
20//! [`FactualityAnalysisResult::uncertainty_density`].
21//!
22//! [`AlignmentMonitor`], [`BiasDetector`], [`HallucinationDetector`] and
23//! [`ConversationAnalyzer`] have **no per-response scorers at all**. The
24//! fixed-value scoring functions they used to carry were deleted rather than
25//! kept behind a `NOTE:`, and the scores they used to fabricate are now
26//! `Option` fields that stay `None`
27//! ([`AlignmentAnalysisResult::alignment_score`],
28//! [`ConversationAnalysisResult::turn_quality`], and so on). Scoring any of
29//! them for real needs a policy/preference model, which this crate does not
30//! ship.
31//!
32//! Consequently the aggregate views are `Option`s too:
33//! [`AlignmentMetrics::overall_alignment_score`] and
34//! [`FactualityMetrics::overall_factuality_score`] are `None` rather than the
35//! `0.85`/`0.8` they used to be seeded with, and
36//! [`LLMHealthReport::overall_health_score`] averages only the terms that
37//! exist. Every analyzer's [`HealthTracker`]-backed `get_health_summary`
38//! reports `status`/`trend` as a real, live function of whatever score that
39//! analyzer actually produced (`None` / `"Unknown (insufficient history)"`
40//! when it produced none) -- never the old hardcoded `HealthStatus::Good` /
41//! `"Stable"`.
42// reason: debug/profiling scaffolding — structs are constructed and their fields/methods
43// are retained for the data model, serialization completeness, and future consumers that
44// do not yet read every member. Consolidated from many item-level #[allow(dead_code)].
45#![allow(dead_code)]
46
47use anyhow::Result;
48// use scirs2_core::ndarray::*; // SciRS2 Integration Policy - was: use ndarray::{Array, ArrayD, IxDyn};
49use serde::{Deserialize, Serialize};
50use std::collections::{HashMap, HashSet, VecDeque};
51use std::time::{Duration, Instant};
52
53/// Main LLM debugging framework
54#[derive(Debug)]
55pub struct LLMDebugger {
56    config: LLMDebugConfig,
57    safety_analyzer: SafetyAnalyzer,
58    factuality_checker: FactualityChecker,
59    alignment_monitor: AlignmentMonitor,
60    hallucination_detector: HallucinationDetector,
61    bias_detector: BiasDetector,
62    performance_profiler: LLMPerformanceProfiler,
63    conversation_analyzer: ConversationAnalyzer,
64}
65
66/// Configuration for LLM debugging
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct LLMDebugConfig {
69    /// Enable safety analysis (toxicity, harmful content)
70    pub enable_safety_analysis: bool,
71    /// Enable factuality checking
72    pub enable_factuality_checking: bool,
73    /// Enable alignment monitoring
74    pub enable_alignment_monitoring: bool,
75    /// Enable hallucination detection
76    pub enable_hallucination_detection: bool,
77    /// Enable bias detection
78    pub enable_bias_detection: bool,
79    /// Enable performance profiling for LLM-specific metrics
80    pub enable_llm_performance_profiling: bool,
81    /// Enable conversation flow analysis
82    pub enable_conversation_analysis: bool,
83    /// Threshold for safety score (0.0 to 1.0)
84    pub safety_threshold: f32,
85    /// Threshold for factuality score (0.0 to 1.0)
86    pub factuality_threshold: f32,
87    /// Maximum conversation length to analyze
88    pub max_conversation_length: usize,
89    /// Sampling rate for expensive analyses
90    pub analysis_sampling_rate: f32,
91}
92
93impl Default for LLMDebugConfig {
94    fn default() -> Self {
95        Self {
96            enable_safety_analysis: true,
97            enable_factuality_checking: true,
98            enable_alignment_monitoring: true,
99            enable_hallucination_detection: true,
100            enable_bias_detection: true,
101            enable_llm_performance_profiling: true,
102            enable_conversation_analysis: true,
103            safety_threshold: 0.8,
104            factuality_threshold: 0.7,
105            max_conversation_length: 100,
106            analysis_sampling_rate: 1.0,
107        }
108    }
109}
110
111/// Safety analyzer for detecting harmful, toxic, or inappropriate content
112#[derive(Debug)]
113pub struct SafetyAnalyzer {
114    toxic_patterns: HashSet<String>,
115    harm_categories: Vec<HarmCategory>,
116    safety_metrics: SafetyMetrics,
117    /// Real running history of [`Self::compute_safety_score`] results, used
118    /// by [`Self::get_health_summary`]. See [`HealthTracker`].
119    health: HealthTracker,
120}
121
122/// Categories of potential harm in LLM outputs
123#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
124pub enum HarmCategory {
125    Toxicity,       // Toxic, offensive, or inappropriate language
126    Violence,       // Violence or threats
127    SelfHarm,       // Self-harm or suicide-related content
128    Harassment,     // Harassment or bullying
129    HateSpeech,     // Hate speech or discrimination
130    Sexual,         // Sexual or adult content
131    Privacy,        // Privacy violations or doxxing
132    Misinformation, // Misinformation or conspiracy theories
133    Manipulation,   // Social manipulation or deception
134    Illegal,        // Illegal activities or advice
135}
136
137/// Safety metrics for tracking harmful content
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SafetyMetrics {
140    pub overall_safety_score: f32,
141    pub harm_category_scores: HashMap<HarmCategory, f32>,
142    pub flagged_responses: usize,
143    pub total_responses_analyzed: usize,
144    pub average_response_safety: f32,
145    pub safety_trend: SafetyTrend,
146}
147
148/// Trend in safety scores over time
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub enum SafetyTrend {
151    Improving,
152    Stable,
153    Degrading,
154    Volatile,
155}
156
157/// Factuality checker for verifying the accuracy of LLM outputs
158#[derive(Debug)]
159pub struct FactualityChecker {
160    fact_databases: Vec<String>,
161    uncertainty_indicators: HashSet<String>,
162    factuality_metrics: FactualityMetrics,
163    health: HealthTracker,
164}
165
166/// Metrics for tracking factual accuracy
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct FactualityMetrics {
169    /// Running mean of the per-response factuality scores. Always `None`
170    /// while [`FactualityAnalysisResult::factuality_score`] is `None`; it used
171    /// to be seeded at `0.8` before a single response had been checked.
172    pub overall_factuality_score: Option<f32>,
173    /// Running mean of [`FactualityAnalysisResult::uncertainty_density`] over
174    /// the responses that had at least one claim-like sentence.
175    pub average_uncertainty_density: Option<f32>,
176    /// Total claim-like sentences seen across all checked responses.
177    /// Previously called `verified_facts`; nothing verifies them.
178    pub claim_like_sentences_seen: usize,
179    /// Total uncertainty-indicator occurrences seen across all checked
180    /// responses. Previously called `unverified_claims`.
181    pub uncertainty_indicator_hits: usize,
182    pub conflicting_information: usize,
183    pub uncertainty_expressions: usize,
184    pub knowledge_gaps: Vec<String>,
185    pub confidence_distribution: Vec<f32>,
186}
187
188/// Alignment monitor for ensuring LLM outputs align with intended behavior
189#[derive(Debug)]
190pub struct AlignmentMonitor {
191    alignment_objectives: Vec<AlignmentObjective>,
192    alignment_metrics: AlignmentMetrics,
193    health: HealthTracker,
194}
195
196/// Types of alignment objectives for LLMs
197#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
198pub enum AlignmentObjective {
199    Helpfulness,    // Be helpful and informative
200    Harmlessness,   // Avoid causing harm
201    Honesty,        // Be truthful and transparent
202    Fairness,       // Treat all users fairly
203    Privacy,        // Respect privacy and confidentiality
204    Transparency,   // Be clear about limitations
205    Consistency,    // Maintain consistent behavior
206    Responsibility, // Take appropriate responsibility for outputs
207}
208
209/// Metrics for alignment monitoring
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct AlignmentMetrics {
212    pub objective_scores: HashMap<AlignmentObjective, f32>,
213    /// Running aggregate alignment score, or `None` while nothing has produced
214    /// one. [`AlignmentMonitor::check_alignment`] cannot score alignment (no
215    /// policy/preference model ships with this crate), so in practice this
216    /// stays `None`. It used to be seeded to `0.85` at construction and never
217    /// updated, which made every consumer -- including the LLM health report
218    /// and its critical-issue thresholds -- read a constant as a measurement.
219    pub overall_alignment_score: Option<f32>,
220    pub alignment_violations: usize,
221    /// `None` for the same reason as [`Self::overall_alignment_score`]
222    /// (previously seeded to `0.9`).
223    pub value_consistency_score: Option<f32>,
224    /// `None` for the same reason (previously seeded to `0.1`).
225    pub behavioral_drift: Option<f32>,
226    /// `None` until at least two real alignment scores exist to compare
227    /// (previously seeded to [`AlignmentTrend::Stable`]).
228    pub alignment_trend: Option<AlignmentTrend>,
229}
230
231/// Trend in alignment scores over time
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub enum AlignmentTrend {
234    Improving,
235    Stable,
236    Degrading,
237    Inconsistent,
238}
239
240/// Hallucination detector for identifying false or fabricated information
241#[derive(Debug)]
242pub struct HallucinationDetector {
243    confidence_thresholds: HashMap<String, f32>,
244    consistency_checker: ConsistencyChecker,
245    hallucination_metrics: HallucinationMetrics,
246}
247
248/// Metrics for hallucination detection
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct HallucinationMetrics {
251    pub hallucination_rate: f32,
252    pub confidence_accuracy_correlation: f32,
253    pub factual_consistency_score: f32,
254    pub internal_consistency_score: f32,
255    pub source_attribution_accuracy: f32,
256    pub detected_fabrications: usize,
257    pub uncertain_responses: usize,
258}
259
260/// Consistency checker for internal consistency in responses
261#[derive(Debug)]
262pub struct ConsistencyChecker {
263    previous_responses: Vec<String>,
264    consistency_cache: HashMap<String, f32>,
265}
266
267/// Bias detector for identifying various forms of bias in LLM outputs
268#[derive(Debug)]
269pub struct BiasDetector {
270    bias_categories: Vec<BiasCategory>,
271    demographic_groups: Vec<String>,
272    bias_metrics: BiasMetrics,
273    health: HealthTracker,
274}
275
276/// Types of bias to detect in LLM outputs
277#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
278pub enum BiasCategory {
279    Gender,        // Gender-based bias
280    Race,          // Racial or ethnic bias
281    Religion,      // Religious bias
282    Age,           // Age-based bias
283    SocioEconomic, // Socioeconomic bias
284    Geographic,    // Geographic or cultural bias
285    Political,     // Political bias
286    Linguistic,    // Language or accent bias
287    Ability,       // Disability or ability bias
288    Appearance,    // Physical appearance bias
289}
290
291/// Metrics for bias detection
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct BiasMetrics {
294    pub overall_bias_score: f32,
295    pub bias_category_scores: HashMap<BiasCategory, f32>,
296    pub demographic_fairness: HashMap<String, f32>,
297    pub representation_bias: f32,
298    pub stereotype_propagation: f32,
299    pub bias_amplification: f32,
300    pub fairness_violations: usize,
301}
302
303/// Performance profiler specific to LLM characteristics
304#[derive(Debug)]
305pub struct LLMPerformanceProfiler {
306    generation_metrics: GenerationMetrics,
307    efficiency_metrics: EfficiencyMetrics,
308    quality_metrics: QualityMetrics,
309    scalability_metrics: ScalabilityMetrics,
310    health: HealthTracker,
311}
312
313/// Metrics for text generation performance
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct GenerationMetrics {
316    pub tokens_per_second: f32,
317    pub average_response_length: f32,
318    pub generation_latency_p50: f32,
319    pub generation_latency_p95: f32,
320    pub generation_latency_p99: f32,
321    pub first_token_latency: f32,
322    pub completion_rate: f32,
323    pub timeout_rate: f32,
324}
325
326/// Metrics for computational efficiency
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct EfficiencyMetrics {
329    pub memory_efficiency: f32,
330    pub compute_utilization: f32,
331    pub energy_consumption: f32,
332    pub carbon_footprint_estimate: f32,
333    pub cost_per_token: f32,
334    pub batch_processing_efficiency: f32,
335    pub cache_hit_rate: f32,
336}
337
338/// Metrics for output quality
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct QualityMetrics {
341    pub coherence_score: f32,
342    pub relevance_score: f32,
343    pub fluency_score: f32,
344    pub informativeness_score: f32,
345    pub creativity_score: f32,
346    pub factual_accuracy: f32,
347    pub readability_score: f32,
348    pub engagement_score: f32,
349}
350
351/// Metrics for scalability analysis
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ScalabilityMetrics {
354    pub concurrent_user_capacity: usize,
355    pub throughput_scaling: f32,
356    pub memory_scaling: f32,
357    pub latency_degradation: f32,
358    pub bottleneck_analysis: Vec<String>,
359    pub resource_utilization_efficiency: f32,
360}
361
362/// Conversation analyzer for multi-turn dialog analysis
363#[derive(Debug)]
364pub struct ConversationAnalyzer {
365    conversation_history: Vec<ConversationTurn>,
366    dialog_metrics: DialogMetrics,
367    context_tracking: ContextTracker,
368    health: HealthTracker,
369}
370
371/// Single turn in a conversation
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct ConversationTurn {
374    pub turn_id: usize,
375    pub user_input: String,
376    pub model_response: String,
377    pub timestamp: chrono::DateTime<chrono::Utc>,
378    pub context_length: usize,
379    pub response_time: Duration,
380}
381
382/// Metrics for dialog analysis
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct DialogMetrics {
385    pub conversation_coherence: f32,
386    pub context_maintenance: f32,
387    pub topic_consistency: f32,
388    pub response_appropriateness: f32,
389    pub conversation_engagement: f32,
390    pub turn_taking_naturalness: f32,
391    pub memory_utilization: f32,
392    pub dialog_success_rate: f32,
393}
394
395/// Context tracking for conversation continuity
396#[derive(Debug)]
397pub struct ContextTracker {
398    active_topics: HashSet<String>,
399    entity_mentions: HashMap<String, usize>,
400    context_window: Vec<String>,
401    attention_weights: Vec<f32>,
402}
403
404impl LLMDebugger {
405    /// Create a new LLM debugger
406    pub fn new(config: LLMDebugConfig) -> Self {
407        Self {
408            config: config.clone(),
409            safety_analyzer: SafetyAnalyzer::new(&config),
410            factuality_checker: FactualityChecker::new(&config),
411            alignment_monitor: AlignmentMonitor::new(&config),
412            hallucination_detector: HallucinationDetector::new(&config),
413            bias_detector: BiasDetector::new(&config),
414            performance_profiler: LLMPerformanceProfiler::new(),
415            conversation_analyzer: ConversationAnalyzer::new(&config),
416        }
417    }
418
419    /// Comprehensive LLM analysis of a model response
420    pub async fn analyze_response(
421        &mut self,
422        user_input: &str,
423        model_response: &str,
424        context: Option<&[String]>,
425        generation_metrics: Option<GenerationMetrics>,
426    ) -> Result<LLMAnalysisReport> {
427        let start_time = Instant::now();
428
429        // Safety analysis
430        let safety_analysis = if self.config.enable_safety_analysis {
431            Some(self.safety_analyzer.analyze_safety(model_response).await?)
432        } else {
433            None
434        };
435
436        // Factuality checking
437        let factuality_analysis = if self.config.enable_factuality_checking {
438            Some(self.factuality_checker.check_factuality(model_response, context).await?)
439        } else {
440            None
441        };
442
443        // Alignment monitoring
444        let alignment_analysis = if self.config.enable_alignment_monitoring {
445            Some(self.alignment_monitor.check_alignment(user_input, model_response).await?)
446        } else {
447            None
448        };
449
450        // Hallucination detection
451        let hallucination_analysis = if self.config.enable_hallucination_detection {
452            Some(
453                self.hallucination_detector
454                    .detect_hallucinations(model_response, context)
455                    .await?,
456            )
457        } else {
458            None
459        };
460
461        // Bias detection
462        let bias_analysis = if self.config.enable_bias_detection {
463            Some(self.bias_detector.detect_bias(model_response).await?)
464        } else {
465            None
466        };
467
468        // Performance profiling
469        let performance_analysis = if self.config.enable_llm_performance_profiling {
470            Some(
471                self.performance_profiler
472                    .profile_response(model_response, generation_metrics)
473                    .await?,
474            )
475        } else {
476            None
477        };
478
479        // Conversation analysis (if part of a dialog)
480        let conversation_analysis = if self.config.enable_conversation_analysis {
481            let turn = ConversationTurn {
482                turn_id: self.conversation_analyzer.conversation_history.len(),
483                user_input: user_input.to_string(),
484                model_response: model_response.to_string(),
485                timestamp: chrono::Utc::now(),
486                context_length: context.map(|c| c.len()).unwrap_or(0),
487                response_time: start_time.elapsed(),
488            };
489            Some(self.conversation_analyzer.analyze_turn(&turn).await?)
490        } else {
491            None
492        };
493
494        let analysis_duration = start_time.elapsed();
495
496        Ok(LLMAnalysisReport {
497            input: user_input.to_string(),
498            response: model_response.to_string(),
499            safety_analysis: safety_analysis.clone(),
500            factuality_analysis: factuality_analysis.clone(),
501            alignment_analysis: alignment_analysis.clone(),
502            hallucination_analysis,
503            bias_analysis,
504            performance_analysis,
505            conversation_analysis,
506            overall_score: self.compute_overall_score(
507                &safety_analysis,
508                &factuality_analysis,
509                &alignment_analysis,
510            ),
511            recommendations: self.generate_recommendations(
512                &safety_analysis,
513                &factuality_analysis,
514                &alignment_analysis,
515            ),
516            analysis_duration,
517            timestamp: chrono::Utc::now(),
518        })
519    }
520
521    /// Batch analysis of multiple responses
522    pub async fn analyze_batch(
523        &mut self,
524        interactions: &[(String, String)], // (input, response) pairs
525    ) -> Result<BatchLLMAnalysisReport> {
526        let mut individual_reports = Vec::new();
527        let mut batch_metrics = BatchMetrics::default();
528
529        for (input, response) in interactions {
530            let report = self.analyze_response(input, response, None, None).await?;
531            batch_metrics.update_from_report(&report);
532            individual_reports.push(report);
533        }
534
535        batch_metrics.finalize(interactions.len());
536
537        Ok(BatchLLMAnalysisReport {
538            individual_reports,
539            batch_metrics,
540            batch_size: interactions.len(),
541            analysis_timestamp: chrono::Utc::now(),
542        })
543    }
544
545    /// Generate comprehensive LLM health report
546    pub async fn generate_health_report(&mut self) -> Result<LLMHealthReport> {
547        Ok(LLMHealthReport {
548            overall_health_score: self.compute_overall_health(),
549            safety_health: self.safety_analyzer.get_health_summary(),
550            factuality_health: self.factuality_checker.get_health_summary(),
551            alignment_health: self.alignment_monitor.get_health_summary(),
552            bias_health: self.bias_detector.get_health_summary(),
553            performance_health: self.performance_profiler.get_health_summary(),
554            conversation_health: self.conversation_analyzer.get_health_summary(),
555            critical_issues: self.identify_critical_issues(),
556            recommendations: self.generate_health_recommendations(),
557            report_timestamp: chrono::Utc::now(),
558        })
559    }
560
561    /// Compute overall score from analysis components
562    fn compute_overall_score(
563        &self,
564        safety: &Option<SafetyAnalysisResult>,
565        factuality: &Option<FactualityAnalysisResult>,
566        alignment: &Option<AlignmentAnalysisResult>,
567    ) -> f32 {
568        let mut total_score = 0.0;
569        let mut weight_sum = 0.0;
570
571        if let Some(s) = safety {
572            total_score += s.safety_score * 0.3;
573            weight_sum += 0.3;
574        }
575
576        // Same rule as the alignment term below: only a real factuality score
577        // contributes. `FactualityChecker` currently never produces one, so
578        // this term drops out rather than folding in a stand-in.
579        if let Some(score) = factuality.as_ref().and_then(|f| f.factuality_score) {
580            total_score += score * 0.3;
581            weight_sum += 0.3;
582        }
583
584        // Only a real alignment score contributes; when the analyzer reports
585        // `None` the weighted mean simply drops that term rather than folding
586        // in a stand-in value.
587        if let Some(score) = alignment.as_ref().and_then(|a| a.alignment_score) {
588            total_score += score * 0.4;
589            weight_sum += 0.4;
590        }
591
592        if weight_sum > 0.0 {
593            total_score / weight_sum
594        } else {
595            0.0
596        }
597    }
598
599    /// Generate actionable recommendations
600    fn generate_recommendations(
601        &self,
602        safety: &Option<SafetyAnalysisResult>,
603        factuality: &Option<FactualityAnalysisResult>,
604        alignment: &Option<AlignmentAnalysisResult>,
605    ) -> Vec<String> {
606        let mut recommendations = Vec::new();
607
608        if let Some(s) = safety {
609            if s.safety_score < self.config.safety_threshold {
610                recommendations
611                    .push("Consider additional safety filtering or fine-tuning".to_string());
612            }
613        }
614
615        if let Some(score) = factuality.as_ref().and_then(|f| f.factuality_score) {
616            if score < self.config.factuality_threshold {
617                recommendations
618                    .push("Verify factual claims and consider knowledge base updates".to_string());
619            }
620        }
621
622        if let Some(score) = alignment.as_ref().and_then(|a| a.alignment_score) {
623            if score < 0.7 {
624                recommendations.push(
625                    "Review alignment objectives and consider additional RLHF training".to_string(),
626                );
627            }
628        }
629
630        recommendations
631    }
632
633    /// Unweighted mean of the analyzer-level aggregate scores that actually
634    /// exist, or `None` when none of them does.
635    ///
636    /// The previous version summed all three terms and divided by three
637    /// unconditionally. Two of those terms were not measurements:
638    /// `overall_alignment_score` was seeded to `0.85` and never updated (no
639    /// alignment scorer exists), and `overall_factuality_score` was seeded to
640    /// `0.8`. A caller therefore got a health score that was mostly two
641    /// constants no matter what had been analysed. Both are now `Option`s, and
642    /// an absent term is excluded from the mean instead of contributing a
643    /// stand-in value.
644    fn compute_overall_health(&self) -> Option<f32> {
645        let terms = [
646            Some(self.safety_analyzer.safety_metrics.overall_safety_score),
647            self.factuality_checker.factuality_metrics.overall_factuality_score,
648            self.alignment_monitor.alignment_metrics.overall_alignment_score,
649        ];
650        let present: Vec<f32> = terms.into_iter().flatten().collect();
651        if present.is_empty() {
652            None
653        } else {
654            Some(present.iter().sum::<f32>() / present.len() as f32)
655        }
656    }
657
658    /// Identify critical issues requiring immediate attention
659    fn identify_critical_issues(&self) -> Vec<CriticalIssue> {
660        let mut issues = Vec::new();
661
662        // Check safety issues
663        if self.safety_analyzer.safety_metrics.overall_safety_score < 0.5 {
664            issues.push(CriticalIssue {
665                category: IssueCategory::Safety,
666                severity: IssueSeverity::Critical,
667                description: "Low overall safety score detected".to_string(),
668                recommended_action: "Immediate safety review and filtering required".to_string(),
669            });
670        }
671
672        // Check alignment issues. Only a real score can raise this: the
673        // threshold used to be compared against a constant seeded at `0.85`,
674        // so it could never fire.
675        if self
676            .alignment_monitor
677            .alignment_metrics
678            .overall_alignment_score
679            .is_some_and(|score| score < 0.6)
680        {
681            issues.push(CriticalIssue {
682                category: IssueCategory::Alignment,
683                severity: IssueSeverity::High,
684                description: "Alignment drift detected".to_string(),
685                recommended_action: "Review training data and consider alignment fine-tuning"
686                    .to_string(),
687            });
688        }
689
690        issues
691    }
692
693    /// Generate health improvement recommendations
694    fn generate_health_recommendations(&self) -> Vec<String> {
695        let mut recommendations = Vec::new();
696
697        // Add safety recommendations
698        if self.safety_analyzer.safety_metrics.overall_safety_score < 0.8 {
699            recommendations.push("Implement additional safety training data".to_string());
700            recommendations.push("Consider constitutional AI techniques".to_string());
701        }
702
703        // Add performance recommendations
704        if self.performance_profiler.generation_metrics.tokens_per_second < 50.0 {
705            recommendations.push("Optimize inference pipeline for better throughput".to_string());
706            recommendations.push("Consider model quantization or distillation".to_string());
707        }
708
709        recommendations
710    }
711}
712
713// Analysis result structures
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct LLMAnalysisReport {
716    pub input: String,
717    pub response: String,
718    pub safety_analysis: Option<SafetyAnalysisResult>,
719    pub factuality_analysis: Option<FactualityAnalysisResult>,
720    pub alignment_analysis: Option<AlignmentAnalysisResult>,
721    pub hallucination_analysis: Option<HallucinationAnalysisResult>,
722    pub bias_analysis: Option<BiasAnalysisResult>,
723    pub performance_analysis: Option<PerformanceAnalysisResult>,
724    pub conversation_analysis: Option<ConversationAnalysisResult>,
725    pub overall_score: f32,
726    pub recommendations: Vec<String>,
727    pub analysis_duration: Duration,
728    pub timestamp: chrono::DateTime<chrono::Utc>,
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct BatchLLMAnalysisReport {
733    pub individual_reports: Vec<LLMAnalysisReport>,
734    pub batch_metrics: BatchMetrics,
735    pub batch_size: usize,
736    pub analysis_timestamp: chrono::DateTime<chrono::Utc>,
737}
738
739/// Running sum/count pair backing one of [`BatchMetrics`]' averages.
740///
741/// Kept per metric rather than per batch because a response may carry some
742/// sub-analyses and not others: averaging over the nominal batch size would
743/// silently divide a partial sum by a larger denominator.
744#[derive(Debug, Clone, Default, Serialize, Deserialize)]
745struct MeanAccumulator {
746    sum: f64,
747    count: usize,
748}
749
750impl MeanAccumulator {
751    fn push(&mut self, value: f32) {
752        self.sum += f64::from(value);
753        self.count += 1;
754    }
755
756    /// Mean of everything pushed so far, or `None` when nothing was.
757    fn mean(&self) -> Option<f32> {
758        if self.count == 0 {
759            None
760        } else {
761            Some((self.sum / self.count as f64) as f32)
762        }
763    }
764}
765
766/// Aggregate view of one [`LLMDebugger::analyze_batch`] run.
767///
768/// Every average is `Some` only if at least one analysed response actually
769/// carried the sub-analysis it summarises; a batch in which nothing produced
770/// (say) a safety analysis reports `average_safety_score: None` rather than
771/// `0.0`. Both `update_from_report` and `finalize` used to be empty bodies, so
772/// every field of every batch report published its `Default`.
773#[derive(Debug, Clone, Default, Serialize, Deserialize)]
774pub struct BatchMetrics {
775    /// Mean of every analysed response's `overall_score`.
776    pub average_overall_score: Option<f32>,
777    /// Mean `safety_score` over the responses that carried a safety analysis.
778    pub average_safety_score: Option<f32>,
779    /// Mean `factuality_score` over the responses that carried one. Currently
780    /// always `None`, because [`FactualityChecker`] has no fact-verification
781    /// backend and therefore never produces a factuality score -- see
782    /// [`FactualityAnalysisResult::factuality_score`].
783    pub average_factuality_score: Option<f32>,
784    /// Mean `alignment_score` over the responses that carried one. Currently
785    /// always `None` for the same class of reason -- see
786    /// [`AlignmentAnalysisResult::alignment_score`].
787    pub average_alignment_score: Option<f32>,
788    /// Number of responses whose safety analysis flagged content or detected a
789    /// harm category.
790    pub flagged_responses_count: usize,
791    /// Number of responses whose safety analysis rated the risk
792    /// [`RiskLevel::Critical`].
793    pub critical_issues_count: usize,
794    /// Number of responses folded in, set by [`Self::finalize`].
795    pub responses_analyzed: usize,
796    pub performance_summary: Option<PerformanceAnalysisResult>,
797    #[serde(skip)]
798    overall_acc: MeanAccumulator,
799    #[serde(skip)]
800    safety_acc: MeanAccumulator,
801    #[serde(skip)]
802    factuality_acc: MeanAccumulator,
803    #[serde(skip)]
804    alignment_acc: MeanAccumulator,
805}
806
807impl BatchMetrics {
808    /// Fold one per-response report into the running accumulators.
809    ///
810    /// Only sub-analyses that are actually present contribute; a `None`
811    /// sub-analysis (or a `None` score inside a present one) is skipped rather
812    /// than counted as a zero.
813    pub fn update_from_report(&mut self, report: &LLMAnalysisReport) {
814        self.overall_acc.push(report.overall_score);
815
816        if let Some(safety) = report.safety_analysis.as_ref() {
817            self.safety_acc.push(safety.safety_score);
818            if !safety.flagged_content.is_empty() || !safety.detected_harms.is_empty() {
819                self.flagged_responses_count += 1;
820            }
821            if safety.risk_level == RiskLevel::Critical {
822                self.critical_issues_count += 1;
823            }
824        }
825
826        if let Some(score) = report.factuality_analysis.as_ref().and_then(|f| f.factuality_score) {
827            self.factuality_acc.push(score);
828        }
829
830        if let Some(score) = report.alignment_analysis.as_ref().and_then(|a| a.alignment_score) {
831            self.alignment_acc.push(score);
832        }
833
834        if let Some(performance) = report.performance_analysis.as_ref() {
835            self.performance_summary = Some(performance.clone());
836        }
837    }
838
839    /// Publish the averages computed from everything folded in so far.
840    ///
841    /// `batch_size` is recorded as [`Self::responses_analyzed`]; it is
842    /// deliberately *not* used as the divisor -- see `MeanAccumulator`.
843    pub fn finalize(&mut self, batch_size: usize) {
844        self.responses_analyzed = batch_size;
845        self.average_overall_score = self.overall_acc.mean();
846        self.average_safety_score = self.safety_acc.mean();
847        self.average_factuality_score = self.factuality_acc.mean();
848        self.average_alignment_score = self.alignment_acc.mean();
849    }
850}
851
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct LLMHealthReport {
854    /// Mean of the analyzer aggregate scores that exist; `None` when none of
855    /// them has a real value yet. See `LLMDebugger::compute_overall_health`.
856    pub overall_health_score: Option<f32>,
857    pub safety_health: HealthSummary,
858    pub factuality_health: HealthSummary,
859    pub alignment_health: HealthSummary,
860    pub bias_health: HealthSummary,
861    pub performance_health: HealthSummary,
862    pub conversation_health: HealthSummary,
863    pub critical_issues: Vec<CriticalIssue>,
864    pub recommendations: Vec<String>,
865    pub report_timestamp: chrono::DateTime<chrono::Utc>,
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize)]
869pub struct HealthSummary {
870    /// Mean of the analyzer's recorded scores, or `None` when it has recorded
871    /// none -- either because nothing has been analysed yet, or because the
872    /// analyzer has no scorer at all (see `HealthTracker::recent_scores`).
873    pub score: Option<f32>,
874    /// Status derived from [`Self::score`]; `None` whenever the score is.
875    pub status: Option<HealthStatus>,
876    pub trend: String,
877    pub key_metrics: HashMap<String, f32>,
878    pub issues: Vec<String>,
879}
880
881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882pub enum HealthStatus {
883    Excellent,
884    Good,
885    Fair,
886    Poor,
887    Critical,
888}
889
890/// Real [`HealthStatus`] bucket for a score on the conventional 0.0-1.0
891/// (higher-is-healthier) scale. Shared by every `get_health_summary` in
892/// this module so `status` is always a genuine function of the tracked
893/// score -- never a hardcoded `HealthStatus::Good`.
894fn health_status_from_score(score: f32) -> HealthStatus {
895    if score >= 0.9 {
896        HealthStatus::Excellent
897    } else if score >= 0.75 {
898        HealthStatus::Good
899    } else if score >= 0.5 {
900        HealthStatus::Fair
901    } else if score >= 0.25 {
902        HealthStatus::Poor
903    } else {
904        HealthStatus::Critical
905    }
906}
907
908/// How many recent scores [`HealthTracker`] keeps for trend analysis.
909const HEALTH_TREND_WINDOW: usize = 20;
910
911/// Tracks a bounded window of real, per-call analysis scores so
912/// `get_health_summary` can derive a real [`HealthStatus`] and trend
913/// direction from actual history, instead of the old hardcoded
914/// `HealthStatus::Good` / `trend: "Stable".to_string()` that never changed
915/// no matter what was analyzed.
916///
917/// Scores must be on the conventional 0.0 (worst) - 1.0 (best) scale;
918/// callers whose native metric is inverted (e.g. a bias score where lower
919/// is better) should record `1.0 - raw_score`.
920#[derive(Debug, Clone, Serialize, Deserialize)]
921pub struct HealthTracker {
922    /// The last [`HEALTH_TREND_WINDOW`] scores recorded via [`Self::record`],
923    /// oldest first.
924    ///
925    /// Empty means *nothing has been analysed yet*, which every accessor
926    /// reports as `None`. There is deliberately no seed value: an analyzer
927    /// whose scorer does not exist (see [`AlignmentMonitor`], [`BiasDetector`],
928    /// [`ConversationAnalyzer`]) never records anything, and a seed would make
929    /// its health summary publish that seed forever as if it had been measured.
930    recent_scores: VecDeque<f32>,
931}
932
933impl HealthTracker {
934    fn new() -> Self {
935        Self {
936            recent_scores: VecDeque::with_capacity(HEALTH_TREND_WINDOW),
937        }
938    }
939
940    /// Record one real, freshly-computed score into the bounded window.
941    fn record(&mut self, score: f32) {
942        self.recent_scores.push_back(score);
943        while self.recent_scores.len() > HEALTH_TREND_WINDOW {
944            self.recent_scores.pop_front();
945        }
946    }
947
948    /// Average of the recorded window; `None` until a real score has been
949    /// recorded.
950    fn average_score(&self) -> Option<f32> {
951        if self.recent_scores.is_empty() {
952            None
953        } else {
954            Some(self.recent_scores.iter().sum::<f32>() / self.recent_scores.len() as f32)
955        }
956    }
957
958    /// Real [`HealthStatus`] derived from [`Self::average_score`]; `None`
959    /// until a real score has been recorded.
960    fn status(&self) -> Option<HealthStatus> {
961        self.average_score().map(health_status_from_score)
962    }
963
964    /// Real trend label: splits the recorded window in half and compares
965    /// the mean of the newer half against the mean of the older half.
966    /// `"Unknown (insufficient history)"` -- never a fabricated `"Stable"`
967    /// -- until at least two scores have been recorded. A window that is
968    /// genuinely flat (every recorded score equal) is honestly `"Stable"`,
969    /// not `"Unknown"`.
970    fn trend_label(&self) -> String {
971        if self.recent_scores.len() < 2 {
972            return "Unknown (insufficient history)".to_string();
973        }
974        let mid = self.recent_scores.len() / 2;
975        let older_avg: f32 = self.recent_scores.iter().take(mid).sum::<f32>() / mid as f32;
976        let newer_count = self.recent_scores.len() - mid;
977        let newer_avg: f32 = self.recent_scores.iter().skip(mid).sum::<f32>() / newer_count as f32;
978
979        const EPSILON: f32 = 0.02;
980        let delta = newer_avg - older_avg;
981        if delta > EPSILON {
982            "Improving".to_string()
983        } else if delta < -EPSILON {
984            "Declining".to_string()
985        } else {
986            "Stable".to_string()
987        }
988    }
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize)]
992pub struct CriticalIssue {
993    pub category: IssueCategory,
994    pub severity: IssueSeverity,
995    pub description: String,
996    pub recommended_action: String,
997}
998
999#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1000pub enum IssueCategory {
1001    Safety,
1002    Factuality,
1003    Alignment,
1004    Bias,
1005    Performance,
1006    Conversation,
1007}
1008
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1010pub enum IssueSeverity {
1011    Low,
1012    Medium,
1013    High,
1014    Critical,
1015}
1016
1017// Individual analysis result types
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1019pub struct SafetyAnalysisResult {
1020    pub safety_score: f32,
1021    pub detected_harms: Vec<HarmCategory>,
1022    pub risk_level: RiskLevel,
1023    pub flagged_content: Vec<String>,
1024    pub confidence: f32,
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize)]
1028pub struct FactualityAnalysisResult {
1029    /// How factually correct the response is.
1030    ///
1031    /// Always `None`: deciding that requires checking claims against a
1032    /// knowledge base, and this crate ships none (`fact_databases` names
1033    /// "wikipedia"/"wikidata" but nothing queries them). It used to be a
1034    /// two-valued ladder -- `0.9` if the response contained the literal
1035    /// substring "fact", else `0.7`, docked 0.05 per uncertainty word -- which
1036    /// measured nothing about the response's actual factual accuracy.
1037    pub factuality_score: Option<f32>,
1038    /// Number of claim-like sentences found: `.`-separated segments longer
1039    /// than 10 characters. Nothing verifies them, which is why this is no
1040    /// longer called `verified_claims`.
1041    pub claim_like_sentences: usize,
1042    /// Total occurrences of an uncertainty indicator
1043    /// ("might"/"possibly"/"unclear"/"uncertain") in the response. Previously
1044    /// called `unverified_claims`, which it never counted.
1045    pub uncertainty_indicator_hits: usize,
1046    /// Fraction of [`Self::claim_like_sentences`] that contain at least one
1047    /// uncertainty indicator -- a real, reproducible property of the text
1048    /// (`None` when the response has no claim-like sentence to divide by).
1049    /// This is the honest signal that the old `factuality_score` was dressing
1050    /// up as fact-checking.
1051    pub uncertainty_density: Option<f32>,
1052    pub confidence_scores: Vec<f32>,
1053    pub knowledge_gaps: Vec<String>,
1054}
1055
1056#[derive(Debug, Clone, Serialize, Deserialize)]
1057pub struct AlignmentAnalysisResult {
1058    /// Overall alignment score, or `None` when no scorer is available.
1059    ///
1060    /// Always `None` from [`AlignmentMonitor::check_alignment`]: scoring
1061    /// alignment requires a policy/preference model, and this crate ships
1062    /// none. It used to be the constant `0.85`.
1063    pub alignment_score: Option<f32>,
1064    /// Per-objective scores; empty for the same reason as
1065    /// [`Self::alignment_score`] (previously the constants 0.9/0.95/0.8/0.85).
1066    pub objective_scores: HashMap<AlignmentObjective, f32>,
1067    /// Concrete alignment violations found. Always empty here: no violation
1068    /// detector exists.
1069    pub violations: Vec<String>,
1070    /// Consistency between input and response; `None` here (previously the
1071    /// constant `0.9`).
1072    pub consistency_score: Option<f32>,
1073}
1074
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076pub struct HallucinationAnalysisResult {
1077    /// Crude lexical hedging signal, not a calibrated probability -- see
1078    /// [`HallucinationDetector::hedging_signal`].
1079    pub hedging_signal: f32,
1080    /// How well the response's stated confidence matches its accuracy.
1081    ///
1082    /// Always `None`: measuring it needs ground truth for the claims, which
1083    /// this crate never receives. Previously the constant `0.7`.
1084    pub confidence_accuracy: Option<f32>,
1085    /// Real internal-consistency score from
1086    /// [`ConsistencyChecker::check_consistency`].
1087    pub internal_consistency: f32,
1088    /// Concrete fabricated statements found. Always empty: no fact-checking
1089    /// backend exists.
1090    pub detected_fabrications: Vec<String>,
1091}
1092
1093#[derive(Debug, Clone, Serialize, Deserialize)]
1094pub struct BiasAnalysisResult {
1095    /// Overall bias score, or `None` when no scorer is available.
1096    ///
1097    /// Always `None` from [`BiasDetector::detect_bias`]: real bias detection
1098    /// needs demographic-term and stereotype models this crate does not have.
1099    /// It used to be the constant `0.1`.
1100    pub overall_bias_score: Option<f32>,
1101    /// Per-category bias scores; empty for the same reason (previously the
1102    /// constants Gender 0.1 / Race 0.05 / Religion 0.08).
1103    pub bias_categories: HashMap<BiasCategory, f32>,
1104    /// Concrete biased statements found. Always empty: no detector exists.
1105    pub detected_biases: Vec<String>,
1106    /// Concrete fairness violations found. Always empty: no detector exists.
1107    pub fairness_violations: Vec<String>,
1108}
1109
1110#[derive(Debug, Clone, Serialize, Deserialize)]
1111pub struct PerformanceAnalysisResult {
1112    pub generation_metrics: GenerationMetrics,
1113    pub efficiency_metrics: EfficiencyMetrics,
1114    pub quality_metrics: QualityMetrics,
1115    pub bottlenecks: Vec<String>,
1116}
1117
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1119pub struct ConversationAnalysisResult {
1120    pub dialog_metrics: DialogMetrics,
1121    /// Consistency of this turn with the conversation context; `None` --
1122    /// dialog-quality scoring needs a trained model this crate does not have.
1123    /// Previously the constant `0.85`.
1124    pub context_consistency: Option<f32>,
1125    /// Quality of this turn; `None` for the same reason (previously `0.9`).
1126    pub turn_quality: Option<f32>,
1127    /// Engagement level; `None` for the same reason (previously `0.8`).
1128    pub engagement_score: Option<f32>,
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1132pub enum RiskLevel {
1133    Low,
1134    Medium,
1135    High,
1136    Critical,
1137}
1138
1139/// Rule-based, keyword-heuristic implementations. See the module docs and
1140/// [`HealthTracker`]: every score below is a deterministic function of the
1141/// literal response text (there is no trained classifier or knowledge base
1142/// behind this), and `confidence` is always derived from a real, measurable
1143/// property of the match (never a flat literal).
1144impl SafetyAnalyzer {
1145    pub fn new(_config: &LLMDebugConfig) -> Self {
1146        Self {
1147            toxic_patterns: HashSet::new(),
1148            harm_categories: vec![
1149                HarmCategory::Toxicity,
1150                HarmCategory::Violence,
1151                HarmCategory::SelfHarm,
1152                HarmCategory::Harassment,
1153                HarmCategory::HateSpeech,
1154            ],
1155            safety_metrics: SafetyMetrics {
1156                overall_safety_score: 1.0,
1157                harm_category_scores: HashMap::new(),
1158                flagged_responses: 0,
1159                total_responses_analyzed: 0,
1160                average_response_safety: 1.0,
1161                safety_trend: SafetyTrend::Stable,
1162            },
1163            health: HealthTracker::new(),
1164        }
1165    }
1166
1167    pub async fn analyze_safety(&mut self, response: &str) -> Result<SafetyAnalysisResult> {
1168        let matched_keywords = self.find_harmful_keywords(response);
1169        let safety_score = Self::score_from_matches(matched_keywords.len());
1170        let detected_harms = self.detect_harmful_content(response);
1171        let risk_level = self.assess_risk_level(safety_score);
1172
1173        self.safety_metrics.total_responses_analyzed += 1;
1174        if safety_score < 0.8 {
1175            self.safety_metrics.flagged_responses += 1;
1176        }
1177        // Real running history feeds `overall_safety_score` /
1178        // `average_response_safety` -- these used to be frozen at their
1179        // `new()` defaults forever, since nothing ever wrote back to them.
1180        self.health.record(safety_score);
1181        if let Some(average) = self.health.average_score() {
1182            self.safety_metrics.overall_safety_score = average;
1183            self.safety_metrics.average_response_safety = average;
1184        }
1185        self.safety_metrics.safety_trend = match self.health.trend_label().as_str() {
1186            "Improving" => SafetyTrend::Improving,
1187            "Declining" => SafetyTrend::Degrading,
1188            _ => SafetyTrend::Stable,
1189        };
1190
1191        // Real confidence, derived from how many distinct harmful-keyword
1192        // categories were matched: zero matches is weaker evidence of
1193        // actual safety than a clean, unambiguous multi-keyword hit is
1194        // evidence of harm -- never the old flat `0.85` regardless of
1195        // content.
1196        let confidence = match matched_keywords.len() {
1197            0 => 0.6,
1198            1 => 0.75,
1199            _ => 0.9,
1200        };
1201
1202        Ok(SafetyAnalysisResult {
1203            safety_score,
1204            detected_harms,
1205            risk_level,
1206            // The actual keywords this rule matched -- never the old
1207            // hardcoded empty `vec![]` regardless of what was found.
1208            flagged_content: matched_keywords.into_iter().map(str::to_string).collect(),
1209            confidence,
1210        })
1211    }
1212
1213    /// The fixed keyword list this rule-based heuristic checks for. Public
1214    /// visibility of the list itself (via [`Self::find_harmful_keywords`])
1215    /// is intentional: callers should be able to see exactly what this
1216    /// heuristic does and does not catch, rather than trusting an opaque
1217    /// "AI" judgment.
1218    fn find_harmful_keywords(&self, response: &str) -> Vec<&'static str> {
1219        const HARMFUL_KEYWORDS: [&str; 4] = ["violence", "harm", "toxic", "hate"];
1220        let lower = response.to_lowercase();
1221        HARMFUL_KEYWORDS
1222            .iter()
1223            .copied()
1224            .filter(|keyword| lower.contains(keyword))
1225            .collect()
1226    }
1227
1228    /// Real function of the match count (more distinct harmful-keyword
1229    /// categories -> lower/worse score), not a two-way literal switch.
1230    fn score_from_matches(match_count: usize) -> f32 {
1231        match match_count {
1232            0 => 0.95,
1233            1 => 0.5,
1234            _ => 0.2,
1235        }
1236    }
1237
1238    fn compute_safety_score(&self, response: &str) -> f32 {
1239        Self::score_from_matches(self.find_harmful_keywords(response).len())
1240    }
1241
1242    fn detect_harmful_content(&self, response: &str) -> Vec<HarmCategory> {
1243        // Rule-based mapping from the same keyword list `compute_safety_score`
1244        // checks. "harm" is intentionally left unmapped: it is too generic
1245        // to safely categorize (e.g. it could mean self-harm, harassment, or
1246        // neither) without producing misleading category labels.
1247        let lower = response.to_lowercase();
1248        let mut detected = Vec::new();
1249
1250        if lower.contains("violence") {
1251            detected.push(HarmCategory::Violence);
1252        }
1253        if lower.contains("toxic") {
1254            detected.push(HarmCategory::Toxicity);
1255        }
1256        if lower.contains("hate") {
1257            detected.push(HarmCategory::HateSpeech);
1258        }
1259
1260        detected
1261    }
1262
1263    fn assess_risk_level(&self, safety_score: f32) -> RiskLevel {
1264        if safety_score >= 0.9 {
1265            RiskLevel::Low
1266        } else if safety_score >= 0.7 {
1267            RiskLevel::Medium
1268        } else if safety_score >= 0.5 {
1269            RiskLevel::High
1270        } else {
1271            RiskLevel::Critical
1272        }
1273    }
1274
1275    /// Real health summary derived from `Self::health`'s running history
1276    /// of [`Self::analyze_safety`] calls -- `status` and `trend` used to be
1277    /// hardcoded (`trend` via a `safety_trend` field that was set once at
1278    /// construction and never updated).
1279    pub fn get_health_summary(&self) -> HealthSummary {
1280        HealthSummary {
1281            score: self.health.average_score(),
1282            status: self.health.status(),
1283            trend: self.health.trend_label(),
1284            key_metrics: HashMap::new(),
1285            issues: vec![],
1286        }
1287    }
1288}
1289
1290impl FactualityChecker {
1291    pub fn new(_config: &LLMDebugConfig) -> Self {
1292        Self {
1293            fact_databases: vec!["wikipedia".to_string(), "wikidata".to_string()],
1294            uncertainty_indicators: ["might", "possibly", "unclear", "uncertain"]
1295                .iter()
1296                .map(|s| s.to_string())
1297                .collect(),
1298            factuality_metrics: FactualityMetrics {
1299                overall_factuality_score: None,
1300                average_uncertainty_density: None,
1301                claim_like_sentences_seen: 0,
1302                uncertainty_indicator_hits: 0,
1303                conflicting_information: 0,
1304                uncertainty_expressions: 0,
1305                knowledge_gaps: vec![],
1306                confidence_distribution: vec![],
1307            },
1308            health: HealthTracker::new(),
1309        }
1310    }
1311
1312    /// Measure what is measurable about a response's factual standing.
1313    ///
1314    /// No claim is verified against anything, so `factuality_score` is an
1315    /// honest `None`. What *is* computed -- claim-like sentence count,
1316    /// uncertainty-indicator hits, and their ratio -- are deterministic
1317    /// properties of the literal text and are reported under names that say so.
1318    pub async fn check_factuality(
1319        &mut self,
1320        response: &str,
1321        _context: Option<&[String]>,
1322    ) -> Result<FactualityAnalysisResult> {
1323        let claim_like_sentences = self.count_claim_like_sentences(response);
1324        let uncertainty_indicator_hits = self.count_uncertainty_indicators(response);
1325        let uncertainty_density = self.compute_uncertainty_density(response);
1326
1327        self.factuality_metrics.claim_like_sentences_seen += claim_like_sentences;
1328        self.factuality_metrics.uncertainty_indicator_hits += uncertainty_indicator_hits;
1329        // The health window tracks the one real per-response measurement this
1330        // checker produces. `overall_factuality_score` stays `None` because no
1331        // factuality score is produced at all -- it used to be frozen at its
1332        // `new()` default (0.8), then briefly fed by the substring ladder.
1333        if let Some(density) = uncertainty_density {
1334            self.health.record(density);
1335            self.factuality_metrics.average_uncertainty_density = self.health.average_score();
1336        }
1337
1338        Ok(FactualityAnalysisResult {
1339            factuality_score: None,
1340            claim_like_sentences,
1341            uncertainty_indicator_hits,
1342            uncertainty_density,
1343            // One real per-claim confidence value (see
1344            // `compute_claim_confidence_scores`), not the old fixed
1345            // 3-element `[0.8, 0.7, 0.9]` "Mock scores" regardless of how
1346            // many claims were actually found.
1347            confidence_scores: self.compute_claim_confidence_scores(response),
1348            // The actual sentences containing an uncertainty indicator, not
1349            // the old hardcoded empty `vec![]`.
1350            knowledge_gaps: self.extract_knowledge_gaps(response),
1351        })
1352    }
1353
1354    /// Fraction of claim-like sentences carrying at least one uncertainty
1355    /// indicator; `None` when there is no claim-like sentence to divide by.
1356    ///
1357    /// This replaces `compute_factuality_score`, which returned `0.9` when the
1358    /// response contained the literal substring "fact" and `0.7` otherwise --
1359    /// a two-valued switch on an English word, published as a factuality
1360    /// measurement and averaged into the LLM health report.
1361    fn compute_uncertainty_density(&self, response: &str) -> Option<f32> {
1362        let claims: Vec<&str> = Self::claim_like_sentences(response).collect();
1363        if claims.is_empty() {
1364            return None;
1365        }
1366        let uncertain = claims
1367            .iter()
1368            .filter(|claim| {
1369                let lower = claim.to_lowercase();
1370                self.uncertainty_indicators.iter().any(|ind| lower.contains(ind.as_str()))
1371            })
1372            .count();
1373        Some(uncertain as f32 / claims.len() as f32)
1374    }
1375
1376    /// The `.`-separated segments longer than 10 characters that the rest of
1377    /// this checker treats as "a claim". Shared so the count, the confidence
1378    /// list and the density can never disagree about what a claim is.
1379    fn claim_like_sentences(response: &str) -> impl Iterator<Item = &str> {
1380        response.split('.').filter(|s| s.len() > 10)
1381    }
1382
1383    /// Count of [`Self::claim_like_sentences`]. Named for what it does: it was
1384    /// `count_verified_claims`, and nothing here verifies a claim.
1385    fn count_claim_like_sentences(&self, response: &str) -> usize {
1386        Self::claim_like_sentences(response).count()
1387    }
1388
1389    /// Total occurrences of any configured uncertainty indicator. It was
1390    /// `count_unverified_claims`, which is not what it counts.
1391    fn count_uncertainty_indicators(&self, response: &str) -> usize {
1392        self.uncertainty_indicators
1393            .iter()
1394            .map(|indicator| response.matches(indicator).count())
1395            .sum()
1396    }
1397
1398    /// One real confidence value per sentence
1399    /// [`Self::count_claim_like_sentences`] treats as a "claim" (same
1400    /// [`Self::claim_like_sentences`] filter): lower for
1401    /// sentences that also contain an uncertainty indicator, higher for
1402    /// those that don't. Always exactly as long as `claim_like_sentences` --
1403    /// never the old fixed 3-element `[0.8, 0.7, 0.9]`.
1404    fn compute_claim_confidence_scores(&self, response: &str) -> Vec<f32> {
1405        Self::claim_like_sentences(response)
1406            .map(|claim| {
1407                let lower = claim.to_lowercase();
1408                let has_uncertainty =
1409                    self.uncertainty_indicators.iter().any(|ind| lower.contains(ind.as_str()));
1410                if has_uncertainty {
1411                    0.5
1412                } else {
1413                    0.85
1414                }
1415            })
1416            .collect()
1417    }
1418
1419    /// The actual sentences containing an uncertainty indicator -- a real
1420    /// (if crude) extraction, not the old hardcoded empty `vec![]`.
1421    fn extract_knowledge_gaps(&self, response: &str) -> Vec<String> {
1422        response
1423            .split('.')
1424            .map(str::trim)
1425            .filter(|s| !s.is_empty())
1426            .filter(|s| {
1427                let lower = s.to_lowercase();
1428                self.uncertainty_indicators.iter().any(|ind| lower.contains(ind.as_str()))
1429            })
1430            .map(str::to_string)
1431            .collect()
1432    }
1433
1434    /// Real health summary derived from `Self::health`'s running
1435    /// history -- `status`/`trend` used to be hardcoded to
1436    /// `HealthStatus::Good` / `"Stable"` regardless of any actual score.
1437    pub fn get_health_summary(&self) -> HealthSummary {
1438        HealthSummary {
1439            score: self.health.average_score(),
1440            status: self.health.status(),
1441            trend: self.health.trend_label(),
1442            key_metrics: HashMap::new(),
1443            issues: vec![],
1444        }
1445    }
1446}
1447
1448impl AlignmentMonitor {
1449    pub fn new(_config: &LLMDebugConfig) -> Self {
1450        Self {
1451            alignment_objectives: vec![
1452                AlignmentObjective::Helpfulness,
1453                AlignmentObjective::Harmlessness,
1454                AlignmentObjective::Honesty,
1455                AlignmentObjective::Fairness,
1456            ],
1457            alignment_metrics: AlignmentMetrics {
1458                objective_scores: HashMap::new(),
1459                overall_alignment_score: None,
1460                alignment_violations: 0,
1461                value_consistency_score: None,
1462                behavioral_drift: None,
1463                alignment_trend: None,
1464            },
1465            health: HealthTracker::new(),
1466        }
1467    }
1468
1469    pub async fn check_alignment(
1470        &mut self,
1471        input: &str,
1472        response: &str,
1473    ) -> Result<AlignmentAnalysisResult> {
1474        // No score is computable, so nothing is recorded into `health` and
1475        // `alignment_metrics` keeps whatever a caller set. Recording a
1476        // constant would have made `overall_alignment_score` converge to that
1477        // constant no matter what was analysed.
1478        let _ = (input, response);
1479
1480        Ok(AlignmentAnalysisResult {
1481            alignment_score: None,
1482            objective_scores: HashMap::new(),
1483            violations: Vec::new(),
1484            consistency_score: None,
1485        })
1486    }
1487
1488    /// Real health summary derived from `Self::health`'s running
1489    /// history -- see [`SafetyAnalyzer::get_health_summary`].
1490    pub fn get_health_summary(&self) -> HealthSummary {
1491        HealthSummary {
1492            score: self.health.average_score(),
1493            status: self.health.status(),
1494            trend: self.health.trend_label(),
1495            key_metrics: HashMap::new(),
1496            issues: vec![],
1497        }
1498    }
1499}
1500
1501impl HallucinationDetector {
1502    pub fn new(_config: &LLMDebugConfig) -> Self {
1503        Self {
1504            confidence_thresholds: HashMap::new(),
1505            consistency_checker: ConsistencyChecker {
1506                previous_responses: Vec::new(),
1507                consistency_cache: HashMap::new(),
1508            },
1509            hallucination_metrics: HallucinationMetrics {
1510                hallucination_rate: 0.1,
1511                confidence_accuracy_correlation: 0.7,
1512                factual_consistency_score: 0.8,
1513                internal_consistency_score: 0.85,
1514                source_attribution_accuracy: 0.9,
1515                detected_fabrications: 0,
1516                uncertain_responses: 0,
1517            },
1518        }
1519    }
1520
1521    pub async fn detect_hallucinations(
1522        &mut self,
1523        response: &str,
1524        _context: Option<&[String]>,
1525    ) -> Result<HallucinationAnalysisResult> {
1526        let internal_consistency = self.consistency_checker.check_consistency(response);
1527
1528        Ok(HallucinationAnalysisResult {
1529            hedging_signal: Self::hedging_signal(response),
1530            confidence_accuracy: None,
1531            internal_consistency,
1532            detected_fabrications: Vec::new(),
1533        })
1534    }
1535
1536    /// Fraction of the crate's hedging phrases (`HEDGING_PHRASES`) that appear
1537    /// in `response`, in `[0, 1]`.
1538    ///
1539    /// A lexical surface signal only: hedging correlates with a model
1540    /// expressing uncertainty, but this measures the WORDS, not whether
1541    /// anything is actually fabricated. It replaces
1542    /// `compute_hallucination_probability`, which returned `0.2` if the
1543    /// response contained the literal string `"I'm not sure"` and `0.1`
1544    /// otherwise -- two constants published under the name "probability".
1545    pub fn hedging_signal(response: &str) -> f32 {
1546        /// Phrases a model uses when expressing uncertainty.
1547        const HEDGING_PHRASES: &[&str] = &[
1548            "i'm not sure",
1549            "i am not sure",
1550            "i think",
1551            "i believe",
1552            "possibly",
1553            "might be",
1554            "as far as i know",
1555            "if i recall",
1556            "i'm not certain",
1557            "cannot verify",
1558        ];
1559        let lowered = response.to_lowercase();
1560        let hits = HEDGING_PHRASES.iter().filter(|p| lowered.contains(**p)).count();
1561        hits as f32 / HEDGING_PHRASES.len() as f32
1562    }
1563}
1564
1565impl ConsistencyChecker {
1566    pub fn check_consistency(&mut self, response: &str) -> f32 {
1567        self.previous_responses.push(response.to_string());
1568        // Simplified consistency checking
1569        0.85
1570    }
1571}
1572
1573impl BiasDetector {
1574    pub fn new(_config: &LLMDebugConfig) -> Self {
1575        Self {
1576            bias_categories: vec![
1577                BiasCategory::Gender,
1578                BiasCategory::Race,
1579                BiasCategory::Religion,
1580                BiasCategory::Age,
1581            ],
1582            demographic_groups: vec![
1583                "male".to_string(),
1584                "female".to_string(),
1585                "young".to_string(),
1586                "elderly".to_string(),
1587            ],
1588            bias_metrics: BiasMetrics {
1589                overall_bias_score: 0.1, // Lower is better for bias
1590                bias_category_scores: HashMap::new(),
1591                demographic_fairness: HashMap::new(),
1592                representation_bias: 0.1,
1593                stereotype_propagation: 0.05,
1594                bias_amplification: 0.08,
1595                fairness_violations: 0,
1596            },
1597            // `BiasDetector` has no bias scorer, so nothing is ever recorded
1598            // here and the health summary is honestly absent.
1599            health: HealthTracker::new(),
1600        }
1601    }
1602
1603    pub async fn detect_bias(&mut self, response: &str) -> Result<BiasAnalysisResult> {
1604        // No bias score is computable, so nothing is recorded into `health`
1605        // and `bias_metrics` keeps whatever a caller set. Recording the old
1606        // constant made `overall_bias_score` converge to 0.1 for every text.
1607        let _ = response;
1608
1609        Ok(BiasAnalysisResult {
1610            overall_bias_score: None,
1611            bias_categories: HashMap::new(),
1612            detected_biases: Vec::new(),
1613            fairness_violations: Vec::new(),
1614        })
1615    }
1616
1617    /// Real health summary derived from `Self::health`'s running
1618    /// history -- see [`SafetyAnalyzer::get_health_summary`].
1619    pub fn get_health_summary(&self) -> HealthSummary {
1620        HealthSummary {
1621            score: self.health.average_score(),
1622            status: self.health.status(),
1623            trend: self.health.trend_label(),
1624            key_metrics: HashMap::new(),
1625            issues: vec![],
1626        }
1627    }
1628}
1629
1630impl Default for LLMPerformanceProfiler {
1631    fn default() -> Self {
1632        Self::new()
1633    }
1634}
1635
1636impl LLMPerformanceProfiler {
1637    pub fn new() -> Self {
1638        Self {
1639            generation_metrics: GenerationMetrics {
1640                tokens_per_second: 100.0,
1641                average_response_length: 150.0,
1642                generation_latency_p50: 200.0,
1643                generation_latency_p95: 500.0,
1644                generation_latency_p99: 1000.0,
1645                first_token_latency: 50.0,
1646                completion_rate: 0.98,
1647                timeout_rate: 0.02,
1648            },
1649            efficiency_metrics: EfficiencyMetrics {
1650                memory_efficiency: 0.85,
1651                compute_utilization: 0.75,
1652                energy_consumption: 0.5,        // kWh per 1000 tokens
1653                carbon_footprint_estimate: 0.1, // kg CO2 per 1000 tokens
1654                cost_per_token: 0.001,          // USD per token
1655                batch_processing_efficiency: 0.9,
1656                cache_hit_rate: 0.7,
1657            },
1658            quality_metrics: QualityMetrics {
1659                coherence_score: 0.9,
1660                relevance_score: 0.85,
1661                fluency_score: 0.95,
1662                informativeness_score: 0.8,
1663                creativity_score: 0.7,
1664                factual_accuracy: 0.85,
1665                readability_score: 0.9,
1666                engagement_score: 0.8,
1667            },
1668            scalability_metrics: ScalabilityMetrics {
1669                concurrent_user_capacity: 1000,
1670                throughput_scaling: 0.8,
1671                memory_scaling: 0.7,
1672                latency_degradation: 0.1,
1673                bottleneck_analysis: vec!["Memory bandwidth".to_string()],
1674                resource_utilization_efficiency: 0.8,
1675            },
1676            health: HealthTracker::new(),
1677        }
1678    }
1679
1680    pub async fn profile_response(
1681        &mut self,
1682        _response: &str,
1683        generation_metrics: Option<GenerationMetrics>,
1684    ) -> Result<PerformanceAnalysisResult> {
1685        let gen_metrics = generation_metrics.unwrap_or_else(|| self.generation_metrics.clone());
1686
1687        // Real running history from this call's real throughput, feeding
1688        // `get_health_summary` -- which used to read `self.generation_metrics`
1689        // directly and was therefore frozen at the `new()` default whenever
1690        // a caller supplied its own `generation_metrics` (as most real
1691        // callers would).
1692        self.health.record((gen_metrics.tokens_per_second / 200.0).min(1.0));
1693
1694        Ok(PerformanceAnalysisResult {
1695            generation_metrics: gen_metrics,
1696            efficiency_metrics: self.efficiency_metrics.clone(),
1697            quality_metrics: self.quality_metrics.clone(),
1698            // No bottleneck attribution exists: the profiler records aggregate
1699            // throughput, never a per-stage breakdown to rank.
1700            bottlenecks: Vec::new(),
1701        })
1702    }
1703
1704    /// Real health summary derived from `Self::health`'s running
1705    /// history -- see [`SafetyAnalyzer::get_health_summary`].
1706    pub fn get_health_summary(&self) -> HealthSummary {
1707        HealthSummary {
1708            score: self.health.average_score(),
1709            status: self.health.status(),
1710            trend: self.health.trend_label(),
1711            key_metrics: HashMap::new(),
1712            issues: vec![],
1713        }
1714    }
1715}
1716
1717impl ConversationAnalyzer {
1718    pub fn new(_config: &LLMDebugConfig) -> Self {
1719        Self {
1720            conversation_history: Vec::new(),
1721            dialog_metrics: DialogMetrics {
1722                conversation_coherence: 0.9,
1723                context_maintenance: 0.85,
1724                topic_consistency: 0.8,
1725                response_appropriateness: 0.9,
1726                conversation_engagement: 0.75,
1727                turn_taking_naturalness: 0.8,
1728                memory_utilization: 0.7,
1729                dialog_success_rate: 0.85,
1730            },
1731            context_tracking: ContextTracker {
1732                active_topics: HashSet::new(),
1733                entity_mentions: HashMap::new(),
1734                context_window: Vec::new(),
1735                attention_weights: Vec::new(),
1736            },
1737            health: HealthTracker::new(),
1738        }
1739    }
1740
1741    pub async fn analyze_turn(
1742        &mut self,
1743        turn: &ConversationTurn,
1744    ) -> Result<ConversationAnalysisResult> {
1745        self.conversation_history.push(turn.clone());
1746        self.context_tracking.update_from_turn(turn);
1747        // No dialog-quality score is computable, so nothing is recorded into
1748        // `health`. The turn itself IS recorded above, so
1749        // `conversation_history` and `context_tracking` stay real.
1750
1751        Ok(ConversationAnalysisResult {
1752            dialog_metrics: self.dialog_metrics.clone(),
1753            context_consistency: None,
1754            turn_quality: None,
1755            engagement_score: None,
1756        })
1757    }
1758
1759    /// Real health summary derived from `Self::health`'s running
1760    /// history -- see [`SafetyAnalyzer::get_health_summary`].
1761    pub fn get_health_summary(&self) -> HealthSummary {
1762        HealthSummary {
1763            score: self.health.average_score(),
1764            status: self.health.status(),
1765            trend: self.health.trend_label(),
1766            key_metrics: HashMap::new(),
1767            issues: vec![],
1768        }
1769    }
1770}
1771
1772impl ContextTracker {
1773    pub fn update_from_turn(&mut self, turn: &ConversationTurn) {
1774        // Update context tracking based on the turn
1775        self.context_window.push(turn.model_response.clone());
1776        if self.context_window.len() > 10 {
1777            self.context_window.remove(0);
1778        }
1779    }
1780}
1781
1782/// Convenience macros for LLM debugging
1783#[macro_export]
1784macro_rules! debug_llm_response {
1785    ($debugger:expr, $input:expr, $response:expr) => {
1786        $debugger.analyze_response($input, $response, None, None).await
1787    };
1788}
1789
1790#[macro_export]
1791macro_rules! debug_llm_batch {
1792    ($debugger:expr, $interactions:expr) => {
1793        $debugger.analyze_batch($interactions).await
1794    };
1795}
1796
1797/// Create a new LLM debugger with default configuration
1798pub fn llm_debugger() -> LLMDebugger {
1799    LLMDebugger::new(LLMDebugConfig::default())
1800}
1801
1802/// Create a new LLM debugger with custom configuration
1803pub fn llm_debugger_with_config(config: LLMDebugConfig) -> LLMDebugger {
1804    LLMDebugger::new(config)
1805}
1806
1807/// Create a safety-focused LLM debugger configuration
1808pub fn safety_focused_config() -> LLMDebugConfig {
1809    LLMDebugConfig {
1810        enable_safety_analysis: true,
1811        enable_factuality_checking: true,
1812        enable_alignment_monitoring: true,
1813        enable_hallucination_detection: true,
1814        enable_bias_detection: true,
1815        enable_llm_performance_profiling: false,
1816        enable_conversation_analysis: false,
1817        safety_threshold: 0.9,
1818        factuality_threshold: 0.8,
1819        max_conversation_length: 50,
1820        analysis_sampling_rate: 1.0,
1821    }
1822}
1823
1824/// Create a performance-focused LLM debugger configuration
1825pub fn performance_focused_config() -> LLMDebugConfig {
1826    LLMDebugConfig {
1827        enable_safety_analysis: false,
1828        enable_factuality_checking: false,
1829        enable_alignment_monitoring: false,
1830        enable_hallucination_detection: false,
1831        enable_bias_detection: false,
1832        enable_llm_performance_profiling: true,
1833        enable_conversation_analysis: true,
1834        safety_threshold: 0.7,
1835        factuality_threshold: 0.6,
1836        max_conversation_length: 200,
1837        analysis_sampling_rate: 0.1,
1838    }
1839}
1840
1841#[cfg(test)]
1842#[path = "llm_debugging_tests.rs"]
1843mod llm_debugging_tests;
1844
1845/// Core unit tests for LLM debugging functionality. Split into a
1846/// separate file (`llm_debugging_tests2.rs`) to keep this file under
1847/// the 2000-line policy limit.
1848#[cfg(test)]
1849#[path = "llm_debugging_tests2.rs"]
1850mod tests;