Skip to main content

trustformers_debug/
memory_profiler.rs

1//! Advanced memory profiling for TrustformeRS models.
2//!
3//! This module provides comprehensive memory profiling capabilities including:
4//! - Heap allocation tracking
5//! - Memory leak detection
6//! - Peak memory analysis
7//! - Allocation patterns
8//! - GC pressure analysis
9//! - Memory fragmentation monitoring
10//!
11//! # Example
12//!
13//! ```no_run
14//! use trustformers_debug::{MemoryProfiler, MemoryProfilingConfig};
15//!
16//! # async fn run() -> anyhow::Result<()> {
17//! let config = MemoryProfilingConfig::default();
18//! let mut profiler = MemoryProfiler::new(config);
19//!
20//! profiler.start().await?;
21//! // ... run model training/inference ...
22//! let report = profiler.stop().await?;
23//!
24//! println!("Peak memory usage: {} MB", report.peak_memory_mb);
25//! println!("Memory leaks detected: {}", report.potential_leaks.len());
26//! # Ok(())
27//! # }
28//! ```
29
30use anyhow::Result;
31use serde::{Deserialize, Serialize};
32use std::collections::{HashMap, VecDeque};
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::{Arc, Mutex};
35use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
36use tokio::time::interval;
37use uuid::Uuid;
38
39/// Cap on the number of periodic timeline snapshots retained in memory, so the
40/// background sampler cannot grow the profiler's own footprint without bound.
41const MAX_TIMELINE_SNAPSHOTS: usize = 10_000;
42
43/// Configuration for memory profiling
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct MemoryProfilingConfig {
46    /// Enable heap allocation tracking
47    pub enable_heap_tracking: bool,
48    /// Enable leak detection
49    pub enable_leak_detection: bool,
50    /// Enable allocation pattern analysis
51    pub enable_pattern_analysis: bool,
52    /// Enable memory fragmentation monitoring
53    pub enable_fragmentation_monitoring: bool,
54    /// Enable GC pressure analysis
55    pub enable_gc_pressure_analysis: bool,
56    /// Sampling interval for memory measurements (milliseconds)
57    pub sampling_interval_ms: u64,
58    /// Maximum number of allocation records to keep
59    pub max_allocation_records: usize,
60    /// Threshold for considering an allocation "large" (bytes)
61    pub large_allocation_threshold: usize,
62    /// Window size for detecting allocation patterns (seconds)
63    pub pattern_analysis_window_secs: u64,
64    /// Threshold for leak detection (allocations alive for this duration)
65    pub leak_detection_threshold_secs: u64,
66}
67
68impl Default for MemoryProfilingConfig {
69    fn default() -> Self {
70        Self {
71            enable_heap_tracking: true,
72            enable_leak_detection: true,
73            enable_pattern_analysis: true,
74            enable_fragmentation_monitoring: true,
75            enable_gc_pressure_analysis: true,
76            sampling_interval_ms: 100, // 100ms sampling
77            max_allocation_records: 100000,
78            large_allocation_threshold: 1024 * 1024, // 1MB
79            pattern_analysis_window_secs: 60,        // 1 minute window
80            leak_detection_threshold_secs: 300,      // 5 minutes
81        }
82    }
83}
84
85/// Allocation record for tracking individual allocations
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct AllocationRecord {
88    pub id: Uuid,
89    pub size: usize,
90    pub timestamp: SystemTime,
91    pub stack_trace: Vec<String>,
92    pub allocation_type: AllocationType,
93    pub freed: bool,
94    pub freed_at: Option<SystemTime>,
95    pub tags: Vec<String>, // For categorizing allocations
96}
97
98/// Type of allocation
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub enum AllocationType {
101    Tensor,
102    Buffer,
103    Weights,
104    Gradients,
105    Activations,
106    Cache,
107    Temporary,
108    Other(String),
109}
110
111/// Memory usage snapshot at a point in time
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct MemorySnapshot {
114    pub timestamp: SystemTime,
115    pub total_heap_bytes: usize,
116    pub used_heap_bytes: usize,
117    pub free_heap_bytes: usize,
118    pub peak_heap_bytes: usize,
119    pub allocation_count: usize,
120    pub free_count: usize,
121    pub fragmentation_ratio: f64,
122    pub gc_pressure_score: f64,
123    pub allocations_by_type: HashMap<AllocationType, usize>,
124    pub allocations_by_size: HashMap<String, usize>, // Size buckets
125}
126
127/// Memory leak information
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct MemoryLeak {
130    pub allocation_id: Uuid,
131    pub size: usize,
132    pub age_seconds: f64,
133    /// The real allocation timestamp (from the original
134    /// [`AllocationRecord`]), not derived from `age_seconds` at report time
135    /// -- so callers reconstructing an [`AllocationRecord`] from this leak
136    /// (e.g. `MemoryProfiler::detect_leak_pattern`'s `examples`) never
137    /// have to fabricate "allocated just now".
138    pub timestamp: SystemTime,
139    pub allocation_type: AllocationType,
140    pub stack_trace: Vec<String>,
141    pub tags: Vec<String>,
142    pub severity: LeakSeverity,
143}
144
145/// Severity of memory leak
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub enum LeakSeverity {
148    Low,      // Small allocations, short-lived
149    Medium,   // Moderate size or moderately old
150    High,     // Large allocations or very old
151    Critical, // Very large or extremely old
152}
153
154/// Allocation pattern detected by analysis
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct AllocationPattern {
157    pub pattern_type: PatternType,
158    pub description: String,
159    pub confidence: f64,   // 0.0 to 1.0
160    pub impact_score: f64, // 0.0 to 1.0 (higher = more concerning)
161    pub recommendations: Vec<String>,
162    pub examples: Vec<AllocationRecord>,
163}
164
165/// An allocation freed within this window of being made counts as
166/// short-lived for churn detection.
167const SHORT_LIVED_THRESHOLD: Duration = Duration::from_secs(1);
168
169/// Type of allocation pattern
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub enum PatternType {
172    MemoryLeak,           // Consistent growth without deallocation
173    ChurningAllocations,  // Rapid alloc/free cycles
174    FragmentationCausing, // Allocations that cause fragmentation
175    LargeAllocations,     // Unexpectedly large allocations
176    UnbalancedTypes,      // Disproportionate allocation types
177    PeakUsageSpikes,      // Sudden memory usage spikes
178}
179
180/// Memory fragmentation analysis
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct FragmentationAnalysis {
183    pub fragmentation_ratio: f64,
184    pub largest_free_block: usize,
185    pub total_free_memory: usize,
186    pub free_block_count: usize,
187    pub average_free_block_size: f64,
188    pub fragmentation_severity: FragmentationSeverity,
189    pub recommendations: Vec<String>,
190}
191
192/// Fragmentation severity levels
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub enum FragmentationSeverity {
195    Low,    // < 10% fragmentation
196    Medium, // 10-30% fragmentation
197    High,   // 30-60% fragmentation
198    Severe, // > 60% fragmentation
199}
200
201/// Garbage collection pressure analysis
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct GCPressureAnalysis {
204    pub pressure_score: f64,    // 0.0 to 1.0
205    pub allocation_rate: f64,   // allocations per second
206    pub deallocation_rate: f64, // deallocations per second
207    pub churn_rate: f64,        // alloc/dealloc cycles per second
208    pub pressure_level: GCPressureLevel,
209    pub contributing_factors: Vec<String>,
210    pub recommendations: Vec<String>,
211}
212
213/// GC pressure levels
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub enum GCPressureLevel {
216    Low,
217    Medium,
218    High,
219    Critical,
220}
221
222/// Comprehensive memory profiling report
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct MemoryProfilingReport {
225    pub session_id: Uuid,
226    pub start_time: SystemTime,
227    pub end_time: SystemTime,
228    pub duration_secs: f64,
229    pub config: MemoryProfilingConfig,
230
231    // Summary statistics
232    pub peak_memory_mb: f64,
233    pub average_memory_mb: f64,
234    pub total_allocations: usize,
235    pub total_deallocations: usize,
236    pub net_allocations: i64,
237
238    // Memory timeline
239    pub memory_timeline: Vec<MemorySnapshot>,
240
241    // Leak detection
242    pub potential_leaks: Vec<MemoryLeak>,
243    pub leak_summary: HashMap<AllocationType, usize>,
244
245    // Pattern analysis
246    pub detected_patterns: Vec<AllocationPattern>,
247
248    // Fragmentation analysis
249    pub fragmentation_analysis: FragmentationAnalysis,
250
251    // GC pressure analysis
252    pub gc_pressure_analysis: GCPressureAnalysis,
253
254    // Allocation statistics
255    pub allocations_by_type: HashMap<AllocationType, AllocationTypeStats>,
256    pub allocations_by_size_bucket: HashMap<String, usize>,
257
258    // Performance metrics
259    pub profiling_overhead_ms: f64,
260    /// Fraction (`0.0..=1.0`) of the expected periodic samples the background
261    /// sampler actually took, computed from real elapsed time and the
262    /// configured sampling interval. `None` when heap tracking was disabled,
263    /// since no sampling was ever expected.
264    pub sampling_accuracy: Option<f64>,
265}
266
267/// Statistics for each allocation type
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct AllocationTypeStats {
270    pub total_allocations: usize,
271    pub total_deallocations: usize,
272    pub current_count: usize,
273    pub total_bytes_allocated: usize,
274    pub total_bytes_deallocated: usize,
275    pub current_bytes: usize,
276    pub peak_count: usize,
277    pub peak_bytes: usize,
278    pub average_allocation_size: f64,
279    pub largest_allocation: usize,
280}
281
282/// Memory profiler implementation
283#[derive(Debug)]
284pub struct MemoryProfiler {
285    config: MemoryProfilingConfig,
286    session_id: Uuid,
287    start_time: Option<Instant>,
288    allocations: Arc<Mutex<HashMap<Uuid, AllocationRecord>>>,
289    memory_timeline: Arc<Mutex<VecDeque<MemorySnapshot>>>,
290    type_stats: Arc<Mutex<HashMap<AllocationType, AllocationTypeStats>>>,
291    running: Arc<Mutex<bool>>,
292    profiling_start_time: Option<Instant>,
293    /// Number of periodic snapshots the background sampler actually took.
294    /// Compared against the expected count (elapsed time / sampling interval)
295    /// to report a real `sampling_accuracy` instead of a fabricated constant.
296    samples_taken: Arc<AtomicU64>,
297}
298
299impl MemoryProfiler {
300    /// Create a new memory profiler
301    pub fn new(config: MemoryProfilingConfig) -> Self {
302        Self {
303            config,
304            session_id: Uuid::new_v4(),
305            start_time: None,
306            allocations: Arc::new(Mutex::new(HashMap::new())),
307            memory_timeline: Arc::new(Mutex::new(VecDeque::new())),
308            type_stats: Arc::new(Mutex::new(HashMap::new())),
309            running: Arc::new(Mutex::new(false)),
310            profiling_start_time: None,
311            samples_taken: Arc::new(AtomicU64::new(0)),
312        }
313    }
314
315    /// Start memory profiling
316    pub async fn start(&mut self) -> Result<()> {
317        let mut running = self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
318        if *running {
319            return Err(anyhow::anyhow!("Memory profiler is already running"));
320        }
321
322        *running = true;
323        self.start_time = Some(Instant::now());
324        self.profiling_start_time = Some(Instant::now());
325
326        // Start periodic sampling
327        if self.config.enable_heap_tracking {
328            self.start_sampling().await?;
329        }
330
331        tracing::info!("Memory profiler started for session {}", self.session_id);
332        Ok(())
333    }
334
335    /// Stop memory profiling and generate report
336    pub async fn stop(&mut self) -> Result<MemoryProfilingReport> {
337        {
338            let mut running = self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
339            if !*running {
340                return Err(anyhow::anyhow!("Memory profiler is not running"));
341            }
342            *running = false;
343        }
344        // Guard is dropped here so background sampling task can check the flag and exit
345
346        let end_time = SystemTime::now();
347        let start_time = self
348            .start_time
349            .ok_or_else(|| anyhow::anyhow!("start_time should be set when profiler is running"))?;
350        let duration =
351            end_time.duration_since(UNIX_EPOCH)?.as_secs_f64() - start_time.elapsed().as_secs_f64();
352
353        // Calculate profiling overhead
354        let profiling_overhead = if let Some(prof_start) = self.profiling_start_time {
355            prof_start.elapsed().as_millis() as f64 * 0.01 // Estimated 1% overhead
356        } else {
357            0.0
358        };
359
360        let report = self.generate_report(end_time, duration, profiling_overhead).await?;
361
362        tracing::info!("Memory profiler stopped for session {}", self.session_id);
363        Ok(report)
364    }
365
366    /// Record an allocation
367    pub fn record_allocation(
368        &self,
369        size: usize,
370        allocation_type: AllocationType,
371        tags: Vec<String>,
372    ) -> Result<Uuid> {
373        let running = self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
374        if !*running {
375            return Err(anyhow::anyhow!("Memory profiler is not running"));
376        }
377
378        let allocation_id = Uuid::new_v4();
379        let record = AllocationRecord {
380            id: allocation_id,
381            size,
382            timestamp: SystemTime::now(),
383            stack_trace: self.capture_stack_trace(),
384            allocation_type: allocation_type.clone(),
385            freed: false,
386            freed_at: None,
387            tags,
388        };
389
390        // Store allocation record
391        let mut allocations =
392            self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
393        allocations.insert(allocation_id, record);
394
395        // Update type statistics
396        self.update_type_stats(&allocation_type, size, true);
397
398        Ok(allocation_id)
399    }
400
401    /// Record a deallocation
402    pub fn record_deallocation(&self, allocation_id: Uuid) -> Result<()> {
403        let running = self.running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
404        if !*running {
405            return Ok(()); // Silently ignore if not running
406        }
407
408        let mut allocations =
409            self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
410        if let Some(record) = allocations.get_mut(&allocation_id) {
411            record.freed = true;
412            record.freed_at = Some(SystemTime::now());
413
414            // Update type statistics
415            self.update_type_stats(&record.allocation_type, record.size, false);
416        }
417
418        Ok(())
419    }
420
421    /// Tag an existing allocation
422    pub fn tag_allocation(&self, allocation_id: Uuid, tag: String) -> Result<()> {
423        let mut allocations =
424            self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
425        if let Some(record) = allocations.get_mut(&allocation_id) {
426            record.tags.push(tag);
427        }
428        Ok(())
429    }
430
431    /// Get current memory usage snapshot
432    pub fn get_memory_snapshot(&self) -> Result<MemorySnapshot> {
433        let allocations = self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
434        let timeline = self.memory_timeline.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
435
436        let (allocation_rate, deallocation_rate) = allocation_rates_from_timeline(&timeline);
437        Ok(snapshot_from_allocations(
438            &allocations,
439            allocation_rate,
440            deallocation_rate,
441        ))
442    }
443
444    /// Detect memory leaks
445    pub fn detect_leaks(&self) -> Result<Vec<MemoryLeak>> {
446        let allocations = self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
447        let now = SystemTime::now();
448        let threshold = Duration::from_secs(self.config.leak_detection_threshold_secs);
449        let mut leaks = Vec::new();
450
451        for record in allocations.values() {
452            if !record.freed {
453                let age = now.duration_since(record.timestamp)?;
454                if age > threshold {
455                    let age_seconds = age.as_secs_f64();
456                    let severity = self.classify_leak_severity(record.size, age_seconds);
457
458                    leaks.push(MemoryLeak {
459                        allocation_id: record.id,
460                        size: record.size,
461                        age_seconds,
462                        timestamp: record.timestamp,
463                        allocation_type: record.allocation_type.clone(),
464                        stack_trace: record.stack_trace.clone(),
465                        tags: record.tags.clone(),
466                        severity,
467                    });
468                }
469            }
470        }
471
472        // Sort by severity and size
473        leaks.sort_by(|a, b| b.severity.cmp(&a.severity).then(b.size.cmp(&a.size)));
474
475        Ok(leaks)
476    }
477
478    /// Analyze allocation patterns
479    pub fn analyze_patterns(&self) -> Result<Vec<AllocationPattern>> {
480        let mut patterns = Vec::new();
481
482        // Detect memory leak patterns
483        if let Ok(leak_pattern) = self.detect_leak_pattern() {
484            patterns.push(leak_pattern);
485        }
486
487        // Detect churning allocation patterns
488        if let Ok(churn_pattern) = self.detect_churn_pattern() {
489            patterns.push(churn_pattern);
490        }
491
492        // Detect large allocation patterns
493        if let Ok(large_alloc_pattern) = self.detect_large_allocation_pattern() {
494            patterns.push(large_alloc_pattern);
495        }
496
497        // Detect fragmentation-causing patterns
498        if let Ok(frag_pattern) = self.detect_fragmentation_pattern() {
499            patterns.push(frag_pattern);
500        }
501
502        Ok(patterns)
503    }
504
505    /// Analyze memory fragmentation
506    pub fn analyze_fragmentation(&self) -> Result<FragmentationAnalysis> {
507        let snapshot = self.get_memory_snapshot()?;
508
509        let fragmentation_ratio = snapshot.fragmentation_ratio;
510        let severity = match fragmentation_ratio {
511            r if r < 0.1 => FragmentationSeverity::Low,
512            r if r < 0.3 => FragmentationSeverity::Medium,
513            r if r < 0.6 => FragmentationSeverity::High,
514            _ => FragmentationSeverity::Severe,
515        };
516
517        let recommendations = match severity {
518            FragmentationSeverity::Low => {
519                vec!["Memory fragmentation is low. Continue current practices.".to_string()]
520            },
521            FragmentationSeverity::Medium => vec![
522                "Consider pooling allocations of similar sizes.".to_string(),
523                "Monitor for increasing fragmentation trends.".to_string(),
524            ],
525            FragmentationSeverity::High => vec![
526                "Implement memory pooling for frequent allocations.".to_string(),
527                "Consider compaction strategies for long-running processes.".to_string(),
528                "Review allocation patterns for optimization opportunities.".to_string(),
529            ],
530            FragmentationSeverity::Severe => vec![
531                "Critical fragmentation detected. Immediate action required.".to_string(),
532                "Implement custom allocators with compaction.".to_string(),
533                "Consider restarting the process to reset memory layout.".to_string(),
534                "Review and optimize allocation strategies.".to_string(),
535            ],
536        };
537
538        Ok(FragmentationAnalysis {
539            fragmentation_ratio,
540            largest_free_block: snapshot.free_heap_bytes, // Simplified
541            total_free_memory: snapshot.free_heap_bytes,
542            free_block_count: snapshot.free_count,
543            average_free_block_size: if snapshot.free_count > 0 {
544                snapshot.free_heap_bytes as f64 / snapshot.free_count as f64
545            } else {
546                0.0
547            },
548            fragmentation_severity: severity,
549            recommendations,
550        })
551    }
552
553    /// Analyze GC pressure
554    pub fn analyze_gc_pressure(&self) -> Result<GCPressureAnalysis> {
555        // Computed first (acquires and releases its own locks) so `memory_timeline`
556        // below isn't reentrant-locked while this call is in flight.
557        let fragmentation_ratio = self.get_memory_snapshot()?.fragmentation_ratio;
558
559        let timeline = self.memory_timeline.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
560
561        let (allocation_rate, deallocation_rate) = self.calculate_allocation_rates(&timeline);
562        let pressure_score = self.calculate_gc_pressure_score(
563            allocation_rate,
564            deallocation_rate,
565            fragmentation_ratio,
566        );
567        let churn_rate = allocation_rate.min(deallocation_rate);
568
569        let pressure_level = match pressure_score {
570            p if p < 0.25 => GCPressureLevel::Low,
571            p if p < 0.5 => GCPressureLevel::Medium,
572            p if p < 0.75 => GCPressureLevel::High,
573            _ => GCPressureLevel::Critical,
574        };
575
576        let mut contributing_factors = Vec::new();
577        let mut recommendations = Vec::new();
578
579        if allocation_rate > 1000.0 {
580            contributing_factors.push("High allocation rate".to_string());
581            recommendations.push("Consider object pooling or reuse strategies".to_string());
582        }
583
584        if churn_rate > 500.0 {
585            contributing_factors.push("High allocation churn".to_string());
586            recommendations.push("Reduce temporary object creation".to_string());
587        }
588
589        if pressure_level == GCPressureLevel::Critical {
590            recommendations
591                .push("Consider manual memory management for critical paths".to_string());
592        }
593
594        Ok(GCPressureAnalysis {
595            pressure_score,
596            allocation_rate,
597            deallocation_rate,
598            churn_rate,
599            pressure_level,
600            contributing_factors,
601            recommendations,
602        })
603    }
604
605    // Private helper methods
606
607    async fn start_sampling(&self) -> Result<()> {
608        let interval_duration = Duration::from_millis(self.config.sampling_interval_ms);
609        let mut interval = interval(interval_duration);
610        let timeline = Arc::clone(&self.memory_timeline);
611        let allocations = Arc::clone(&self.allocations);
612        let running = Arc::clone(&self.running);
613        let samples_taken = Arc::clone(&self.samples_taken);
614
615        tokio::spawn(async move {
616            loop {
617                interval.tick().await;
618
619                let is_running = {
620                    let running_guard =
621                        running.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
622                    *running_guard
623                };
624
625                if !is_running {
626                    break;
627                }
628
629                // Take a real snapshot of the allocation table tracked so far and
630                // append it to the timeline, using the same computation as
631                // `get_memory_snapshot` so on-demand and periodic snapshots agree.
632                {
633                    let allocations_guard =
634                        allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
635                    let mut timeline_guard =
636                        timeline.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
637                    let (allocation_rate, deallocation_rate) =
638                        allocation_rates_from_timeline(&timeline_guard);
639                    let snapshot = snapshot_from_allocations(
640                        &allocations_guard,
641                        allocation_rate,
642                        deallocation_rate,
643                    );
644                    timeline_guard.push_back(snapshot);
645                    while timeline_guard.len() > MAX_TIMELINE_SNAPSHOTS {
646                        timeline_guard.pop_front();
647                    }
648                }
649                samples_taken.fetch_add(1, Ordering::Relaxed);
650            }
651        });
652
653        Ok(())
654    }
655
656    pub async fn generate_report(
657        &self,
658        end_time: SystemTime,
659        duration_secs: f64,
660        profiling_overhead_ms: f64,
661    ) -> Result<MemoryProfilingReport> {
662        // Extract data from locked mutexes first, then drop guards before calling
663        // analysis methods that also need to acquire these locks.
664        let (
665            total_allocations,
666            total_deallocations,
667            net_allocations,
668            peak_memory_mb,
669            average_memory_mb,
670            allocations_by_size_bucket,
671            timeline_snapshot,
672            type_stats_snapshot,
673        ) = {
674            let allocations =
675                self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
676            let timeline =
677                self.memory_timeline.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
678            let type_stats =
679                self.type_stats.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
680
681            let total_allocs = allocations.len();
682            let total_deallocs = allocations.values().filter(|r| r.freed).count();
683            let net_allocs = total_allocs as i64 - total_deallocs as i64;
684
685            // Calculate summary statistics
686            let peak_mem = timeline
687                .iter()
688                .map(|s| s.peak_heap_bytes as f64 / 1024.0 / 1024.0)
689                .fold(0.0, f64::max);
690
691            let avg_mem = if !timeline.is_empty() {
692                timeline.iter().map(|s| s.used_heap_bytes as f64 / 1024.0 / 1024.0).sum::<f64>()
693                    / timeline.len() as f64
694            } else {
695                0.0
696            };
697
698            // Create size buckets
699            let mut size_buckets = HashMap::new();
700            for record in allocations.values() {
701                let bucket = size_bucket(record.size);
702                *size_buckets.entry(bucket).or_insert(0) += 1;
703            }
704
705            let timeline_snap: Vec<_> = timeline.iter().cloned().collect();
706            let type_stats_snap = type_stats.clone();
707
708            (
709                total_allocs,
710                total_deallocs,
711                net_allocs,
712                peak_mem,
713                avg_mem,
714                size_buckets,
715                timeline_snap,
716                type_stats_snap,
717            )
718        };
719        // Guards are dropped here -- analysis methods can now safely acquire locks
720
721        let potential_leaks = self.detect_leaks()?;
722        let detected_patterns = self.analyze_patterns()?;
723        let fragmentation_analysis = self.analyze_fragmentation()?;
724        let gc_pressure_analysis = self.analyze_gc_pressure()?;
725
726        let mut leak_summary = HashMap::new();
727        for leak in &potential_leaks {
728            *leak_summary.entry(leak.allocation_type.clone()).or_insert(0) += 1;
729        }
730
731        Ok(MemoryProfilingReport {
732            session_id: self.session_id,
733            start_time: UNIX_EPOCH
734                + Duration::from_secs_f64(
735                    SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs_f64() - duration_secs,
736                ),
737            end_time,
738            duration_secs,
739            config: self.config.clone(),
740            peak_memory_mb,
741            average_memory_mb,
742            total_allocations,
743            total_deallocations,
744            net_allocations,
745            memory_timeline: timeline_snapshot,
746            potential_leaks,
747            leak_summary,
748            detected_patterns,
749            fragmentation_analysis,
750            gc_pressure_analysis,
751            allocations_by_type: type_stats_snapshot,
752            allocations_by_size_bucket,
753            profiling_overhead_ms,
754            sampling_accuracy: self.compute_sampling_accuracy(duration_secs),
755        })
756    }
757
758    /// Real sampling-accuracy signal: how many of the periodic snapshots the
759    /// background sampler was expected to take (elapsed time / configured
760    /// interval) it actually took. `None` when heap tracking was never enabled,
761    /// since no sampling was ever expected to happen.
762    fn compute_sampling_accuracy(&self, duration_secs: f64) -> Option<f64> {
763        if !self.config.enable_heap_tracking {
764            return None;
765        }
766        let interval_secs = (self.config.sampling_interval_ms.max(1) as f64) / 1000.0;
767        let expected_samples = (duration_secs / interval_secs).max(1.0);
768        let actual_samples = self.samples_taken.load(Ordering::Relaxed) as f64;
769        Some((actual_samples / expected_samples).min(1.0))
770    }
771
772    /// Capture a real stack trace at the allocation site via
773    /// `std::backtrace::Backtrace::force_capture`, which (unlike `capture()`)
774    /// ignores `RUST_BACKTRACE`/`RUST_LIB_BACKTRACE` and always attempts to
775    /// resolve frames, so allocation traces never silently depend on the
776    /// caller's environment. Each returned string is one real stack frame
777    /// (function symbol); if the platform cannot resolve frames at all, a
778    /// single explicit "unavailable" entry is returned instead of fabricating
779    /// frame names.
780    fn capture_stack_trace(&self) -> Vec<String> {
781        let backtrace = std::backtrace::Backtrace::force_capture();
782        match backtrace.status() {
783            std::backtrace::BacktraceStatus::Captured => {
784                let frames = parse_backtrace_frames(&backtrace);
785                if frames.is_empty() {
786                    vec!["<stack trace captured but no frames could be resolved>".to_string()]
787                } else {
788                    frames
789                }
790            },
791            std::backtrace::BacktraceStatus::Unsupported => {
792                vec!["<stack trace unavailable: unsupported on this platform>".to_string()]
793            },
794            _ => vec!["<stack trace unavailable>".to_string()],
795        }
796    }
797
798    fn update_type_stats(
799        &self,
800        allocation_type: &AllocationType,
801        size: usize,
802        is_allocation: bool,
803    ) {
804        let mut type_stats =
805            self.type_stats.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
806        let stats = type_stats.entry(allocation_type.clone()).or_insert(AllocationTypeStats {
807            total_allocations: 0,
808            total_deallocations: 0,
809            current_count: 0,
810            total_bytes_allocated: 0,
811            total_bytes_deallocated: 0,
812            current_bytes: 0,
813            peak_count: 0,
814            peak_bytes: 0,
815            average_allocation_size: 0.0,
816            largest_allocation: 0,
817        });
818
819        if is_allocation {
820            stats.total_allocations += 1;
821            stats.current_count += 1;
822            stats.total_bytes_allocated += size;
823            stats.current_bytes += size;
824            stats.peak_count = stats.peak_count.max(stats.current_count);
825            stats.peak_bytes = stats.peak_bytes.max(stats.current_bytes);
826            stats.largest_allocation = stats.largest_allocation.max(size);
827        } else {
828            stats.total_deallocations += 1;
829            stats.current_count = stats.current_count.saturating_sub(1);
830            stats.total_bytes_deallocated += size;
831            stats.current_bytes = stats.current_bytes.saturating_sub(size);
832        }
833
834        stats.average_allocation_size = if stats.total_allocations > 0 {
835            stats.total_bytes_allocated as f64 / stats.total_allocations as f64
836        } else {
837            0.0
838        };
839    }
840
841    fn classify_leak_severity(&self, size: usize, age_seconds: f64) -> LeakSeverity {
842        let large_size = size > self.config.large_allocation_threshold;
843        let old_age = age_seconds > 1800.0; // 30 minutes
844        let very_old_age = age_seconds > 3600.0; // 1 hour
845
846        match (large_size, old_age, very_old_age) {
847            (true, _, true) => LeakSeverity::Critical,
848            (true, true, _) => LeakSeverity::High,
849            (true, false, _) => LeakSeverity::Medium,
850            (false, true, _) => LeakSeverity::Medium,
851            _ => LeakSeverity::Low,
852        }
853    }
854
855    /// GC-pressure heuristic derived from *measured* allocation churn and heap
856    /// fragmentation (both real, caller-supplied signals) rather than a fixed
857    /// constant. See [`gc_pressure_score`] for the formula.
858    fn calculate_gc_pressure_score(
859        &self,
860        allocation_rate: f64,
861        deallocation_rate: f64,
862        fragmentation_ratio: f64,
863    ) -> f64 {
864        gc_pressure_score(allocation_rate, deallocation_rate, fragmentation_ratio)
865    }
866
867    fn calculate_allocation_rates(&self, timeline: &VecDeque<MemorySnapshot>) -> (f64, f64) {
868        allocation_rates_from_timeline(timeline)
869    }
870
871    // Pattern detection methods
872
873    fn detect_leak_pattern(&self) -> Result<AllocationPattern> {
874        let leaks = self.detect_leaks()?;
875        let high_severity_leaks = leaks
876            .iter()
877            .filter(|l| l.severity == LeakSeverity::High || l.severity == LeakSeverity::Critical)
878            .count();
879
880        let confidence = if leaks.len() > 10 { 0.9 } else { 0.5 };
881        let impact_score = (high_severity_leaks as f64 / (leaks.len().max(1)) as f64).min(1.0);
882
883        Ok(AllocationPattern {
884            pattern_type: PatternType::MemoryLeak,
885            description: format!("Detected {} potential memory leaks", leaks.len()),
886            confidence,
887            impact_score,
888            recommendations: vec![
889                "Review long-lived allocations for proper cleanup".to_string(),
890                "Implement RAII patterns for automatic resource management".to_string(),
891            ],
892            examples: leaks
893                .into_iter()
894                .take(3)
895                .map(|leak| {
896                    // Convert leak to allocation record for example, using
897                    // the leak's real allocation timestamp (not "now").
898                    AllocationRecord {
899                        id: leak.allocation_id,
900                        size: leak.size,
901                        timestamp: leak.timestamp,
902                        stack_trace: leak.stack_trace,
903                        allocation_type: leak.allocation_type,
904                        freed: false,
905                        freed_at: None,
906                        tags: leak.tags,
907                    }
908                })
909                .collect(),
910        })
911    }
912
913    /// Detect allocation churn: allocations that were freed within
914    /// [`SHORT_LIVED_THRESHOLD`] of being made.
915    fn detect_churn_pattern(&self) -> Result<AllocationPattern> {
916        let allocations = self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
917        let short_lived: Vec<AllocationRecord> = allocations
918            .values()
919            .filter(|record| {
920                if let (Some(_freed_at), false) = (record.freed_at, record.freed) {
921                    false // Contradiction, skip
922                } else if record.freed {
923                    if let Some(freed_at) = record.freed_at {
924                        freed_at.duration_since(record.timestamp).unwrap_or(Duration::from_secs(0))
925                            < SHORT_LIVED_THRESHOLD
926                    } else {
927                        false
928                    }
929                } else {
930                    false
931                }
932            })
933            .cloned()
934            .collect();
935        let short_lived_count = short_lived.len();
936
937        let total_count = allocations.len();
938        let churn_ratio = if total_count > 0 {
939            short_lived_count as f64 / total_count as f64
940        } else {
941            0.0
942        };
943
944        Ok(AllocationPattern {
945            pattern_type: PatternType::ChurningAllocations,
946            description: format!(
947                "High allocation churn detected: {:.1}% short-lived allocations",
948                churn_ratio * 100.0
949            ),
950            confidence: if churn_ratio > 0.5 { 0.8 } else { 0.4 },
951            impact_score: churn_ratio,
952            recommendations: vec![
953                "Consider object pooling for frequently allocated objects".to_string(),
954                "Reduce temporary object creation in hot paths".to_string(),
955            ],
956            // Real short-lived allocation records, the largest first, so the
957            // examples point at the churn worth fixing. This was an empty vec.
958            examples: {
959                let mut examples = short_lived;
960                examples.sort_by_key(|record| std::cmp::Reverse(record.size));
961                examples.truncate(3);
962                examples
963            },
964        })
965    }
966
967    fn detect_large_allocation_pattern(&self) -> Result<AllocationPattern> {
968        let allocations = self.allocations.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
969        let large_allocations: Vec<_> = allocations
970            .values()
971            .filter(|record| record.size > self.config.large_allocation_threshold)
972            .cloned()
973            .collect();
974
975        let impact_score = if !allocations.is_empty() {
976            large_allocations.len() as f64 / allocations.len() as f64
977        } else {
978            0.0
979        };
980
981        Ok(AllocationPattern {
982            pattern_type: PatternType::LargeAllocations,
983            description: format!(
984                "Found {} large allocations (>{}MB)",
985                large_allocations.len(),
986                self.config.large_allocation_threshold / 1024 / 1024
987            ),
988            confidence: if large_allocations.len() > 5 { 0.9 } else { 0.6 },
989            impact_score,
990            recommendations: vec![
991                "Review large allocations for optimization opportunities".to_string(),
992                "Consider streaming or chunked processing for large data".to_string(),
993            ],
994            examples: large_allocations.into_iter().take(3).collect(),
995        })
996    }
997
998    fn detect_fragmentation_pattern(&self) -> Result<AllocationPattern> {
999        let fragmentation = self.analyze_fragmentation()?;
1000
1001        Ok(AllocationPattern {
1002            pattern_type: PatternType::FragmentationCausing,
1003            description: format!(
1004                "Memory fragmentation at {:.1}%",
1005                fragmentation.fragmentation_ratio * 100.0
1006            ),
1007            confidence: 0.8,
1008            impact_score: fragmentation.fragmentation_ratio,
1009            recommendations: fragmentation.recommendations,
1010            // Fragmentation is a property of the free-space layout between
1011            // allocations, not of any individual allocation, so there is no
1012            // per-record example to point at.
1013            examples: Vec::new(),
1014        })
1015    }
1016}
1017
1018// ============================================================================
1019// Free functions shared between on-demand snapshotting (`get_memory_snapshot`)
1020// and the periodic background sampler (`start_sampling`), so both derive their
1021// numbers from the exact same real computation instead of two implementations
1022// that could silently drift apart.
1023// ============================================================================
1024
1025/// Classify an allocation size into a human-readable bucket label.
1026fn size_bucket(size: usize) -> String {
1027    match size {
1028        0..=1024 => "0-1KB".to_string(),
1029        1025..=10240 => "1-10KB".to_string(),
1030        10241..=102400 => "10-100KB".to_string(),
1031        102401..=1048576 => "100KB-1MB".to_string(),
1032        1048577..=10485760 => "1-10MB".to_string(),
1033        _ => ">10MB".to_string(),
1034    }
1035}
1036
1037/// GC-pressure heuristic in `[0.0, 1.0]` derived from measured allocation
1038/// churn (allocations/deallocations per second, saturating at a reference
1039/// rate of 1000/s) blended with real heap fragmentation. This is a documented
1040/// heuristic over real signals, not a fabricated constant: it moves when the
1041/// underlying allocation behavior moves, and is reproducible for identical
1042/// inputs.
1043fn gc_pressure_score(
1044    allocation_rate: f64,
1045    deallocation_rate: f64,
1046    fragmentation_ratio: f64,
1047) -> f64 {
1048    let churn = allocation_rate.min(deallocation_rate).max(0.0);
1049    let churn_component = (churn / 1000.0).min(1.0);
1050    let fragmentation_component = fragmentation_ratio.clamp(0.0, 1.0);
1051    (0.7 * churn_component + 0.3 * fragmentation_component).clamp(0.0, 1.0)
1052}
1053
1054/// Allocation/deallocation rates (events per second) computed from the first
1055/// and last entries of a real timeline of snapshots. Returns `(0.0, 0.0)` when
1056/// fewer than two samples exist yet (no rate can be observed).
1057fn allocation_rates_from_timeline(timeline: &VecDeque<MemorySnapshot>) -> (f64, f64) {
1058    if timeline.len() < 2 {
1059        return (0.0, 0.0);
1060    }
1061
1062    let first = &timeline[0];
1063    let last = &timeline[timeline.len() - 1];
1064
1065    let duration = last
1066        .timestamp
1067        .duration_since(first.timestamp)
1068        .unwrap_or(Duration::from_secs(1))
1069        .as_secs_f64()
1070        .max(f64::EPSILON);
1071
1072    let allocation_rate = (last.allocation_count as f64 - first.allocation_count as f64) / duration;
1073    let deallocation_rate = (last.free_count as f64 - first.free_count as f64) / duration;
1074
1075    (allocation_rate.max(0.0), deallocation_rate.max(0.0))
1076}
1077
1078/// Build a real `MemorySnapshot` from the current allocation table plus
1079/// already-computed rate signals. Used both for on-demand snapshots and for
1080/// each periodic sample the background sampler takes.
1081fn snapshot_from_allocations(
1082    allocations: &HashMap<Uuid, AllocationRecord>,
1083    allocation_rate: f64,
1084    deallocation_rate: f64,
1085) -> MemorySnapshot {
1086    let mut total_heap = 0usize;
1087    let mut used_heap = 0usize;
1088    let mut allocation_count = 0usize;
1089    let mut free_count = 0usize;
1090    let mut allocations_by_type: HashMap<AllocationType, usize> = HashMap::new();
1091    let mut allocations_by_size: HashMap<String, usize> = HashMap::new();
1092
1093    for record in allocations.values() {
1094        total_heap += record.size;
1095
1096        if !record.freed {
1097            used_heap += record.size;
1098            allocation_count += 1;
1099            *allocations_by_type.entry(record.allocation_type.clone()).or_insert(0) += record.size;
1100            *allocations_by_size.entry(size_bucket(record.size)).or_insert(0) += 1;
1101        } else {
1102            free_count += 1;
1103        }
1104    }
1105
1106    let free_heap = total_heap.saturating_sub(used_heap);
1107    let fragmentation_ratio =
1108        if total_heap > 0 { free_heap as f64 / total_heap as f64 } else { 0.0 };
1109    let gc_pressure_score =
1110        gc_pressure_score(allocation_rate, deallocation_rate, fragmentation_ratio);
1111
1112    MemorySnapshot {
1113        timestamp: SystemTime::now(),
1114        total_heap_bytes: total_heap,
1115        used_heap_bytes: used_heap,
1116        free_heap_bytes: free_heap,
1117        peak_heap_bytes: used_heap, // Simplified: current usage, not a tracked running peak.
1118        allocation_count,
1119        free_count,
1120        fragmentation_ratio,
1121        gc_pressure_score,
1122        allocations_by_type,
1123        allocations_by_size,
1124    }
1125}
1126
1127/// Parse one real symbol name per frame out of `Backtrace`'s `Debug` output.
1128///
1129/// `std::backtrace::Backtrace` does not expose a stable structured frame API
1130/// (its `Debug` impl is documented as "likely to change over time"), so this
1131/// scans for the `fn: "..."` entries the standard library's renderer emits
1132/// per frame — robust to the surrounding formatting/whitespace, and to
1133/// optional `file:`/`line:` fields being present or absent per frame.
1134fn parse_backtrace_frames(backtrace: &std::backtrace::Backtrace) -> Vec<String> {
1135    let rendered = format!("{backtrace:?}");
1136    const NEEDLE: &str = "fn: \"";
1137    let mut frames = Vec::new();
1138    let mut rest = rendered.as_str();
1139    while let Some(start) = rest.find(NEEDLE) {
1140        rest = &rest[start + NEEDLE.len()..];
1141        match rest.find('"') {
1142            Some(end) => {
1143                frames.push(rest[..end].to_string());
1144                rest = &rest[end + 1..];
1145            },
1146            None => break,
1147        }
1148    }
1149    frames
1150}
1151
1152impl PartialOrd for LeakSeverity {
1153    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1154        Some(self.cmp(other))
1155    }
1156}
1157
1158impl Ord for LeakSeverity {
1159    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1160        let self_val = match self {
1161            LeakSeverity::Low => 0,
1162            LeakSeverity::Medium => 1,
1163            LeakSeverity::High => 2,
1164            LeakSeverity::Critical => 3,
1165        };
1166        let other_val = match other {
1167            LeakSeverity::Low => 0,
1168            LeakSeverity::Medium => 1,
1169            LeakSeverity::High => 2,
1170            LeakSeverity::Critical => 3,
1171        };
1172        self_val.cmp(&other_val)
1173    }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use super::*;
1179
1180    // ---- Wave 6c debug-sweep2 -------------------------------------------
1181
1182    #[test]
1183    fn churn_pattern_carries_real_short_lived_allocation_examples() {
1184        let profiler = MemoryProfiler::new(MemoryProfilingConfig::default());
1185        let now = std::time::SystemTime::now();
1186        {
1187            let mut allocations = profiler.allocations.lock().unwrap_or_else(|p| p.into_inner());
1188            for (index, size) in [(0_usize, 4096_usize), (1, 64), (2, 1024)] {
1189                let id = Uuid::new_v4();
1190                allocations.insert(
1191                    id,
1192                    AllocationRecord {
1193                        id,
1194                        size,
1195                        timestamp: now,
1196                        stack_trace: Vec::new(),
1197                        allocation_type: AllocationType::Tensor,
1198                        freed: true,
1199                        freed_at: Some(now + Duration::from_millis(10)),
1200                        tags: vec![format!("alloc_{index}")],
1201                    },
1202                );
1203            }
1204        }
1205
1206        let pattern = profiler.detect_churn_pattern().expect("pattern");
1207        // Was an unconditional empty vec.
1208        assert_eq!(
1209            pattern.examples.len(),
1210            3,
1211            "all three short-lived allocations qualify"
1212        );
1213        assert_eq!(pattern.examples[0].size, 4096, "largest first");
1214        assert!(pattern.examples.windows(2).all(|w| w[0].size >= w[1].size));
1215        assert!(
1216            (pattern.impact_score - 1.0).abs() < 1e-9,
1217            "every allocation churned"
1218        );
1219    }
1220    use tokio;
1221
1222    #[tokio::test(flavor = "multi_thread")]
1223    async fn test_memory_profiler_basic() -> Result<()> {
1224        let config = MemoryProfilingConfig {
1225            sampling_interval_ms: 1000, // Slower sampling for faster tests
1226            ..Default::default()
1227        };
1228        let mut profiler = MemoryProfiler::new(config);
1229
1230        // Wrap in a generous (but still bounded) timeout to guard against a
1231        // hang. `record_allocation` now captures a *real* stack trace via
1232        // `std::backtrace::Backtrace::force_capture`, and the first call in a
1233        // process pays a one-time symbol-resolution cost against this
1234        // binary's (large) debug info -- observed ~0.5s even though every
1235        // later call is sub-millisecond. 500ms was tuned for the old fake,
1236        // zero-cost placeholder and is no longer a realistic budget for real
1237        // work; several seconds of headroom keeps this a fast test while
1238        // still catching an actual hang. Raised again from 5s to 60s after the
1239        // 5s budget was observed to expire on a machine under heavy parallel
1240        // build load (the same run took 5.0s idle and >43s at load average
1241        // 16): this timeout is a hang guard, not a latency assertion, and a
1242        // minute still fails fast on a real deadlock.
1243        let test_result = tokio::time::timeout(Duration::from_secs(60), async {
1244            profiler.start().await?;
1245
1246            // Record some allocations
1247            let alloc_id1 = profiler.record_allocation(
1248                1024,
1249                AllocationType::Tensor,
1250                vec!["test".to_string()],
1251            )?;
1252
1253            let _alloc_id2 = profiler.record_allocation(
1254                2048,
1255                AllocationType::Buffer,
1256                vec!["test".to_string()],
1257            )?;
1258
1259            // Free one allocation
1260            profiler.record_deallocation(alloc_id1)?;
1261
1262            // Give background tasks a moment to process
1263            tokio::time::sleep(Duration::from_millis(1)).await;
1264
1265            let report = profiler.stop().await?;
1266
1267            assert_eq!(report.total_allocations, 2);
1268            assert_eq!(report.total_deallocations, 1);
1269            assert_eq!(report.net_allocations, 1);
1270
1271            Ok::<(), anyhow::Error>(())
1272        })
1273        .await;
1274
1275        match test_result {
1276            Ok(result) => result,
1277            Err(_) => Err(anyhow::anyhow!("Test timed out after 5s")),
1278        }
1279    }
1280
1281    #[tokio::test]
1282    async fn test_leak_detection() -> Result<()> {
1283        let config = MemoryProfilingConfig {
1284            leak_detection_threshold_secs: 1, // 1 second for testing
1285            ..Default::default()
1286        };
1287
1288        let mut profiler = MemoryProfiler::new(config);
1289        profiler.start().await?; // Start the profiler
1290
1291        // Record allocation and wait
1292        profiler.record_allocation(1024, AllocationType::Tensor, vec!["leak_test".to_string()])?;
1293
1294        tokio::time::sleep(Duration::from_secs(2)).await;
1295
1296        let leaks = profiler.detect_leaks()?;
1297        assert!(!leaks.is_empty());
1298
1299        Ok(())
1300    }
1301
1302    #[test]
1303    fn test_size_buckets() {
1304        assert_eq!(size_bucket(512), "0-1KB");
1305        assert_eq!(size_bucket(5120), "1-10KB");
1306        assert_eq!(size_bucket(51200), "10-100KB");
1307        assert_eq!(size_bucket(512000), "100KB-1MB");
1308        assert_eq!(size_bucket(5120000), "1-10MB");
1309        assert_eq!(size_bucket(51200000), ">10MB");
1310    }
1311
1312    #[test]
1313    fn test_leak_severity_classification() {
1314        let config = MemoryProfilingConfig::default();
1315        let profiler = MemoryProfiler::new(config);
1316
1317        // Small, new allocation
1318        assert_eq!(
1319            profiler.classify_leak_severity(1024, 60.0),
1320            LeakSeverity::Low
1321        );
1322
1323        // Large, old allocation
1324        assert_eq!(
1325            profiler.classify_leak_severity(10485760, 3700.0),
1326            LeakSeverity::Critical
1327        );
1328
1329        // Medium size, medium age
1330        assert_eq!(
1331            profiler.classify_leak_severity(524288, 1900.0),
1332            LeakSeverity::Medium
1333        );
1334    }
1335
1336    #[test]
1337    fn test_capture_stack_trace_is_real_not_the_old_hardcoded_placeholder() {
1338        let config = MemoryProfilingConfig::default();
1339        let profiler = MemoryProfiler::new(config);
1340
1341        let frames = profiler.capture_stack_trace();
1342
1343        // The old implementation always returned this exact 3-element vector
1344        // regardless of call site; a real backtrace never matches it.
1345        assert_ne!(
1346            frames,
1347            vec![
1348                "function_a".to_string(),
1349                "function_b".to_string(),
1350                "main".to_string()
1351            ]
1352        );
1353        assert!(
1354            !frames.is_empty(),
1355            "a captured backtrace must contain at least one frame"
1356        );
1357        assert!(
1358            frames.iter().all(|f| !f.trim().is_empty()),
1359            "no frame string should be empty"
1360        );
1361    }
1362
1363    #[test]
1364    fn test_gc_pressure_score_is_computed_from_real_signals_not_a_constant() {
1365        // The old implementation always returned 0.3 regardless of input.
1366        let idle = gc_pressure_score(0.0, 0.0, 0.0);
1367        let busy = gc_pressure_score(2000.0, 2000.0, 0.9);
1368
1369        assert_eq!(
1370            idle, 0.0,
1371            "no churn and no fragmentation must score zero pressure"
1372        );
1373        assert!(
1374            busy > idle,
1375            "high churn + high fragmentation must score higher than idle"
1376        );
1377        assert!((0.0..=1.0).contains(&busy));
1378        assert_ne!(idle, 0.3, "must not be the old fabricated constant");
1379        assert_ne!(busy, 0.3, "must not be the old fabricated constant");
1380    }
1381
1382    #[test]
1383    fn test_allocation_rates_from_timeline_uses_real_deltas() {
1384        let mut timeline = VecDeque::new();
1385        let t0 = SystemTime::now();
1386        timeline.push_back(MemorySnapshot {
1387            timestamp: t0,
1388            total_heap_bytes: 0,
1389            used_heap_bytes: 0,
1390            free_heap_bytes: 0,
1391            peak_heap_bytes: 0,
1392            allocation_count: 0,
1393            free_count: 0,
1394            fragmentation_ratio: 0.0,
1395            gc_pressure_score: 0.0,
1396            allocations_by_type: HashMap::new(),
1397            allocations_by_size: HashMap::new(),
1398        });
1399        timeline.push_back(MemorySnapshot {
1400            timestamp: t0 + Duration::from_secs(2),
1401            total_heap_bytes: 0,
1402            used_heap_bytes: 0,
1403            free_heap_bytes: 0,
1404            peak_heap_bytes: 0,
1405            allocation_count: 20,
1406            free_count: 10,
1407            fragmentation_ratio: 0.0,
1408            gc_pressure_score: 0.0,
1409            allocations_by_type: HashMap::new(),
1410            allocations_by_size: HashMap::new(),
1411        });
1412
1413        let (alloc_rate, dealloc_rate) = allocation_rates_from_timeline(&timeline);
1414        assert!(
1415            (alloc_rate - 10.0).abs() < 1e-9,
1416            "20 allocations over 2s => 10/s, got {alloc_rate}"
1417        );
1418        assert!(
1419            (dealloc_rate - 5.0).abs() < 1e-9,
1420            "10 frees over 2s => 5/s, got {dealloc_rate}"
1421        );
1422    }
1423
1424    #[tokio::test(flavor = "multi_thread")]
1425    async fn test_sampling_accuracy_is_none_when_tracking_disabled() -> Result<()> {
1426        let config = MemoryProfilingConfig {
1427            enable_heap_tracking: false,
1428            ..Default::default()
1429        };
1430        let mut profiler = MemoryProfiler::new(config);
1431        profiler.start().await?;
1432        tokio::time::sleep(Duration::from_millis(20)).await;
1433        let report = profiler.stop().await?;
1434
1435        // The old implementation always reported `0.95` regardless of whether
1436        // sampling ever ran.
1437        assert_eq!(report.sampling_accuracy, None);
1438        Ok(())
1439    }
1440
1441    #[tokio::test(flavor = "multi_thread")]
1442    async fn test_sampling_accuracy_is_real_when_tracking_enabled() -> Result<()> {
1443        let config = MemoryProfilingConfig {
1444            enable_heap_tracking: true,
1445            sampling_interval_ms: 20,
1446            ..Default::default()
1447        };
1448        let mut profiler = MemoryProfiler::new(config);
1449        profiler.start().await?;
1450        // Long enough for several sampler ticks at a 20ms interval.
1451        tokio::time::sleep(Duration::from_millis(220)).await;
1452        let report = profiler.stop().await?;
1453
1454        let accuracy = report.sampling_accuracy.expect("tracking was enabled");
1455        assert!((0.0..=1.0).contains(&accuracy));
1456        assert_ne!(accuracy, 0.95, "must not be the old fabricated constant");
1457        // The periodic sampler should have actually produced timeline entries.
1458        assert!(
1459            !report.memory_timeline.is_empty(),
1460            "background sampler must record real snapshots, not do nothing"
1461        );
1462        Ok(())
1463    }
1464}