Skip to main content

profile_inspect/analysis/
cpu_analysis.rs

1use std::collections::HashMap;
2
3use regex::Regex;
4
5use crate::ir::{FrameCategory, FrameId, FrameKind, ProfileIR};
6
7use super::{CalleeStats, CallerCalleeAnalyzer, CallerStats};
8
9/// Statistics for a single function
10#[derive(Debug, Clone)]
11pub struct FunctionStats {
12    /// Frame ID
13    pub frame_id: FrameId,
14    /// Function name
15    pub name: String,
16    /// Source location
17    pub location: String,
18    /// Category
19    pub category: FrameCategory,
20    /// Self time (time spent directly in this function)
21    pub self_time: u64,
22    /// Total time (including callees)
23    pub total_time: u64,
24    /// Number of samples where this was the leaf
25    pub self_samples: u32,
26    /// Number of samples where this appeared in the stack
27    pub total_samples: u32,
28    /// Maximum recursion depth observed (0 = not recursive)
29    pub max_recursion_depth: u32,
30    /// Samples where this function was called recursively
31    pub recursive_samples: u32,
32    /// Timestamp when this function first appeared in samples (microseconds)
33    pub first_seen_us: u64,
34    /// Timestamp when this function last appeared in samples (microseconds)
35    pub last_seen_us: u64,
36}
37
38impl FunctionStats {
39    /// Calculate self time as percentage of total profile time
40    #[expect(clippy::cast_precision_loss)]
41    pub fn self_percent(&self, total: u64) -> f64 {
42        if total == 0 {
43            0.0
44        } else {
45            (self.self_time as f64 / total as f64) * 100.0
46        }
47    }
48
49    /// Calculate total time as percentage of total profile time
50    #[expect(clippy::cast_precision_loss)]
51    pub fn total_percent(&self, total: u64) -> f64 {
52        if total == 0 {
53            0.0
54        } else {
55            (self.total_time as f64 / total as f64) * 100.0
56        }
57    }
58
59    /// Average self time per sample (microseconds)
60    #[expect(clippy::cast_precision_loss)]
61    pub fn avg_time_per_sample(&self) -> f64 {
62        if self.self_samples == 0 {
63            0.0
64        } else {
65            self.self_time as f64 / self.self_samples as f64
66        }
67    }
68
69    /// Classify the function's performance pattern
70    pub fn performance_pattern(&self, total_samples: usize) -> PerformancePattern {
71        let call_frequency = if total_samples > 0 {
72            self.self_samples as f64 / total_samples as f64
73        } else {
74            0.0
75        };
76        let avg_time = self.avg_time_per_sample();
77
78        // High frequency = appears in >1% of samples
79        // High cost = >1ms average per sample
80        let high_frequency = call_frequency > 0.01;
81        let high_cost = avg_time > 1000.0; // 1ms in microseconds
82
83        match (high_frequency, high_cost) {
84            (true, true) => PerformancePattern::CriticalPath,
85            (false, true) => PerformancePattern::ExpensiveOperation,
86            (true, false) => PerformancePattern::FrequentlyCalled,
87            (false, false) => PerformancePattern::Normal,
88        }
89    }
90
91    /// Check if function shows recursive behavior
92    pub fn is_recursive(&self) -> bool {
93        self.max_recursion_depth > 0
94    }
95
96    /// Wall clock span from first to last appearance (microseconds)
97    pub fn active_span_us(&self) -> u64 {
98        self.last_seen_us.saturating_sub(self.first_seen_us)
99    }
100
101    /// Estimated time spent in async operations (span minus CPU time)
102    pub fn async_wait_us(&self) -> u64 {
103        self.active_span_us().saturating_sub(self.total_time)
104    }
105
106    /// Ratio of active span to CPU time (>2.0 suggests async-heavy)
107    #[expect(clippy::cast_precision_loss)]
108    pub fn async_ratio(&self) -> f64 {
109        if self.total_time == 0 {
110            return 0.0;
111        }
112        self.active_span_us() as f64 / self.total_time as f64
113    }
114
115    /// Whether this function appears to be async-heavy
116    /// Async-heavy if: span > 2x CPU time AND async wait > 100ms
117    pub fn is_async_heavy(&self) -> bool {
118        self.async_ratio() > 2.0 && self.async_wait_us() > 100_000
119    }
120}
121
122/// Classification of function performance patterns
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum PerformancePattern {
125    /// High frequency + high cost per call - critical optimization target
126    CriticalPath,
127    /// Low frequency but expensive per call - optimize the operation itself
128    ExpensiveOperation,
129    /// High frequency but cheap per call - "death by 1000 cuts", reduce call count
130    FrequentlyCalled,
131    /// Neither particularly frequent nor expensive
132    Normal,
133}
134
135/// Time breakdown by category (self time - when category is at leaf)
136#[derive(Debug, Clone, Default)]
137pub struct CategoryBreakdown {
138    pub app: u64,
139    pub deps: u64,
140    pub node_internal: u64,
141    pub v8_internal: u64,
142    pub native: u64,
143}
144
145/// Inclusive time breakdown by category (when category appears anywhere in stack)
146#[derive(Debug, Clone, Default)]
147pub struct CategoryBreakdownInclusive {
148    pub app: u64,
149    pub deps: u64,
150    pub node_internal: u64,
151    pub v8_internal: u64,
152    pub native: u64,
153}
154
155impl CategoryBreakdownInclusive {
156    /// Get total time
157    pub fn total(&self) -> u64 {
158        self.app + self.deps + self.node_internal + self.v8_internal + self.native
159    }
160}
161
162/// Time spent when one category calls another
163#[derive(Debug, Clone, Default)]
164pub struct CategoryCallFlow {
165    /// Map from (caller, callee) -> time spent in callee when called by caller
166    pub calls: std::collections::HashMap<(FrameCategory, FrameCategory), u64>,
167}
168
169impl CategoryCallFlow {
170    /// Get time spent when caller calls callee
171    pub fn get(&self, caller: FrameCategory, callee: FrameCategory) -> u64 {
172        self.calls.get(&(caller, callee)).copied().unwrap_or(0)
173    }
174
175    /// Get all callees for a given caller category, sorted by time descending
176    pub fn callees_for(&self, caller: FrameCategory) -> Vec<(FrameCategory, u64)> {
177        let mut result: Vec<_> = self
178            .calls
179            .iter()
180            .filter(|((c, _), _)| *c == caller)
181            .map(|((_, callee), &time)| (*callee, time))
182            .collect();
183        result.sort_by(|a, b| b.1.cmp(&a.1));
184        result
185    }
186}
187
188impl CategoryBreakdown {
189    /// Get total time
190    pub fn total(&self) -> u64 {
191        self.app + self.deps + self.node_internal + self.v8_internal + self.native
192    }
193
194    /// Get percentage for a category
195    #[expect(clippy::cast_precision_loss)]
196    pub fn percent(&self, category: FrameCategory) -> f64 {
197        let total = self.total();
198        if total == 0 {
199            return 0.0;
200        }
201        let value = match category {
202            FrameCategory::App => self.app,
203            FrameCategory::Deps => self.deps,
204            FrameCategory::NodeInternal => self.node_internal,
205            FrameCategory::V8Internal => self.v8_internal,
206            FrameCategory::Native => self.native,
207        };
208        (value as f64 / total as f64) * 100.0
209    }
210}
211
212/// A hot path (frequently executed call stack)
213#[derive(Debug, Clone)]
214pub struct HotPath {
215    /// Frame IDs from root to leaf
216    pub frames: Vec<FrameId>,
217    /// Total time spent in this exact path
218    pub time: u64,
219    /// Percentage of total time
220    pub percent: f64,
221    /// Number of samples with this exact path
222    pub sample_count: u32,
223    /// First sample timestamp where this path appeared (microseconds)
224    pub first_seen_us: u64,
225    /// Last sample timestamp where this path appeared (microseconds)
226    pub last_seen_us: u64,
227}
228
229impl HotPath {
230    /// Get the wall-clock span from first to last appearance (microseconds)
231    pub fn active_span_us(&self) -> u64 {
232        self.last_seen_us.saturating_sub(self.first_seen_us)
233    }
234
235    /// Get estimated async wait time (span minus CPU time)
236    pub fn async_wait_us(&self) -> u64 {
237        self.active_span_us().saturating_sub(self.time)
238    }
239
240    /// Get ratio of active span to CPU time (>1 suggests async operations)
241    pub fn async_ratio(&self) -> f64 {
242        if self.time == 0 {
243            return 0.0;
244        }
245        self.active_span_us() as f64 / self.time as f64
246    }
247
248    /// Check if this path is async-heavy (span > 2x CPU time AND > 100ms wait)
249    pub fn is_async_heavy(&self) -> bool {
250        self.async_ratio() > 2.0 && self.async_wait_us() > 100_000
251    }
252}
253
254/// Statistics aggregated by source file
255#[derive(Debug, Clone)]
256pub struct FileStats {
257    /// Source file path
258    pub file: String,
259    /// Self time in this file
260    pub self_time: u64,
261    /// Total time (inclusive) in this file
262    pub total_time: u64,
263    /// Number of calls (sample appearances)
264    pub call_count: u32,
265    /// Primary category of frames in this file
266    pub category: FrameCategory,
267}
268
269/// Statistics aggregated by npm package
270#[derive(Debug, Clone)]
271pub struct PackageStats {
272    /// Package name (e.g., "vitest@4.0.15")
273    pub package: String,
274    /// Total time spent in this package
275    pub time: u64,
276    /// Percentage of all dependency time
277    pub percent_of_deps: f64,
278    /// Top function by self time in this package
279    pub top_function: String,
280    /// Top function location
281    pub top_function_location: String,
282}
283
284/// Detailed analysis for a hot function (caller/callee attribution)
285#[derive(Debug, Clone)]
286pub struct HotFunctionDetail {
287    /// Frame ID of the hot function
288    pub frame_id: FrameId,
289    /// Function name
290    pub name: String,
291    /// Source location
292    pub location: String,
293    /// Self time of this function
294    pub self_time: u64,
295    /// Total (inclusive) time of this function
296    pub total_time: u64,
297    /// Top callers (functions that call this one)
298    pub callers: Vec<CallerStats>,
299    /// Top callees (functions called by this one)
300    pub callees: Vec<CalleeStats>,
301    /// First sample timestamp where this function appeared (microseconds)
302    pub first_seen_us: u64,
303    /// Last sample timestamp where this function appeared (microseconds)
304    pub last_seen_us: u64,
305}
306
307impl HotFunctionDetail {
308    /// Get the wall-clock span from first to last appearance (microseconds)
309    pub fn active_span_us(&self) -> u64 {
310        self.last_seen_us.saturating_sub(self.first_seen_us)
311    }
312
313    /// Check if this function is async-heavy (span > 2x CPU time AND > 100ms wait)
314    pub fn is_async_heavy(&self) -> bool {
315        let async_wait = self.active_span_us().saturating_sub(self.total_time);
316        let ratio = if self.total_time > 0 {
317            self.active_span_us() as f64 / self.total_time as f64
318        } else {
319            0.0
320        };
321        ratio > 2.0 && async_wait > 100_000
322    }
323}
324
325/// Profile metadata for the report header
326#[derive(Debug, Clone, Default)]
327pub struct ProfileMetadata {
328    /// Source profile file name
329    pub source_file: Option<String>,
330    /// Profile duration in milliseconds (CPU time from samples)
331    pub duration_ms: f64,
332    /// Wall-clock duration in milliseconds (actual elapsed time)
333    pub wall_time_ms: Option<f64>,
334    /// Total sample count
335    pub sample_count: usize,
336    /// Average sample interval in milliseconds
337    pub sample_interval_ms: f64,
338    /// Whether internal frames are filtered
339    pub internals_filtered: bool,
340    /// Number of external sourcemaps loaded
341    pub sourcemaps_loaded: usize,
342    /// Number of inline sourcemaps found
343    pub sourcemaps_inline: usize,
344    /// Focused package name (if package filter is active)
345    pub focus_package: Option<String>,
346    /// Number of profiles merged (1 = single, >1 = merged from multiple processes)
347    pub profiles_merged: usize,
348    /// Categories being filtered to (empty = all categories)
349    pub filter_categories: Vec<FrameCategory>,
350}
351
352impl ProfileMetadata {
353    /// Calculate CPU utilization (CPU time / wall time)
354    /// Returns a value between 0.0 and 1.0+ (can exceed 1.0 for merged profiles)
355    #[expect(clippy::cast_precision_loss)]
356    pub fn cpu_utilization(&self) -> Option<f64> {
357        self.wall_time_ms.map(|wall| {
358            if wall > 0.0 {
359                self.duration_ms / wall
360            } else {
361                0.0
362            }
363        })
364    }
365}
366
367/// Time-based phase of profile execution
368#[derive(Debug, Clone)]
369pub struct PhaseAnalysis {
370    /// Startup phase (first 10% of samples or first 500ms, whichever is smaller)
371    pub startup: PhaseStats,
372    /// Steady state (middle portion)
373    pub steady_state: PhaseStats,
374    /// Total profile duration in microseconds
375    pub total_duration_us: u64,
376}
377
378/// Statistics for a single phase
379#[derive(Debug, Clone)]
380pub struct PhaseStats {
381    /// Phase name
382    pub name: String,
383    /// Start time (microseconds from profile start)
384    pub start_us: u64,
385    /// End time (microseconds from profile start)
386    pub end_us: u64,
387    /// Number of samples in this phase
388    pub sample_count: usize,
389    /// Top functions by self time in this phase
390    pub top_functions: Vec<PhaseFunctionStats>,
391    /// Category breakdown for this phase
392    pub category_breakdown: CategoryBreakdown,
393}
394
395/// Function stats within a specific phase
396#[derive(Debug, Clone)]
397pub struct PhaseFunctionStats {
398    /// Function name
399    pub name: String,
400    /// Location
401    pub location: String,
402    /// Self time in this phase
403    pub self_time: u64,
404    /// Percentage of phase time
405    pub percent: f64,
406    /// Category
407    pub category: FrameCategory,
408}
409
410/// Functions detected with recursive call patterns
411#[derive(Debug, Clone)]
412pub struct RecursiveFunctionStats {
413    /// Function name
414    pub name: String,
415    /// Location
416    pub location: String,
417    /// Maximum recursion depth observed
418    pub max_depth: u32,
419    /// Number of samples with recursion
420    pub recursive_samples: u32,
421    /// Total samples for this function
422    pub total_samples: u32,
423    /// Time spent in recursive calls
424    pub recursive_time: u64,
425    /// Total self time
426    pub total_self_time: u64,
427}
428
429/// GC analysis with allocation hotspots
430#[derive(Debug, Clone)]
431pub struct GcAnalysis {
432    /// Total GC time in microseconds
433    pub total_time: u64,
434    /// Number of samples where GC was active
435    pub sample_count: u32,
436    /// Average GC pause duration (time / samples)
437    pub avg_pause_us: u64,
438    /// Functions frequently on stack during GC (likely allocators)
439    pub allocation_hotspots: Vec<AllocationHotspot>,
440    /// GC time during startup phase
441    pub startup_gc_time: u64,
442    /// GC time during steady state
443    pub steady_gc_time: u64,
444}
445
446/// Function that appears frequently during GC (likely allocating)
447#[derive(Debug, Clone)]
448pub struct AllocationHotspot {
449    /// Function name
450    pub name: String,
451    /// Location
452    pub location: String,
453    /// Category
454    pub category: FrameCategory,
455    /// Times this function was on stack during GC
456    pub gc_samples: u32,
457    /// Total samples for this function
458    pub total_samples: u32,
459    /// Percentage of GC samples where this function appeared
460    pub gc_correlation: f64,
461}
462
463/// Result of CPU profile analysis
464#[derive(Debug)]
465pub struct CpuAnalysis {
466    /// Total profile time in microseconds
467    pub total_time: u64,
468    /// Total number of samples
469    pub total_samples: usize,
470    /// Per-function statistics (sorted by self time)
471    pub functions: Vec<FunctionStats>,
472    /// Per-function statistics sorted by total (inclusive) time
473    pub functions_by_total: Vec<FunctionStats>,
474    /// Time breakdown by category (self time)
475    pub category_breakdown: CategoryBreakdown,
476    /// Time breakdown by category (inclusive time)
477    pub category_breakdown_inclusive: CategoryBreakdownInclusive,
478    /// Call flow between categories
479    pub category_call_flow: CategoryCallFlow,
480    /// Hot paths (top call stacks by time)
481    pub hot_paths: Vec<HotPath>,
482    /// Statistics aggregated by source file
483    pub file_stats: Vec<FileStats>,
484    /// Statistics aggregated by npm package
485    pub package_stats: Vec<PackageStats>,
486    /// Detailed caller/callee analysis for top hot functions
487    pub hot_function_details: Vec<HotFunctionDetail>,
488    /// Time spent in GC frames (microseconds)
489    pub gc_time: u64,
490    /// Enhanced GC analysis with allocation hotspots
491    pub gc_analysis: Option<GcAnalysis>,
492    /// Time spent in native addon frames (microseconds)
493    pub native_time: u64,
494    /// Profile metadata
495    pub metadata: ProfileMetadata,
496    /// Phase analysis (startup vs steady state)
497    pub phase_analysis: Option<PhaseAnalysis>,
498    /// Functions with recursive call patterns
499    pub recursive_functions: Vec<RecursiveFunctionStats>,
500}
501
502/// Analyzer for CPU profiles
503pub struct CpuAnalyzer {
504    /// Minimum percentage to include in results
505    min_percent: f64,
506    /// Maximum number of functions to return
507    top_n: usize,
508    /// Whether to include internal frames
509    include_internals: bool,
510    /// Categories to include (empty = all)
511    filter_categories: Vec<FrameCategory>,
512    /// Focus on a specific npm package
513    filter_package: Option<String>,
514    /// Focus on functions matching this pattern (regex)
515    focus_pattern: Option<Regex>,
516    /// Exclude functions matching this pattern (regex)
517    exclude_pattern: Option<Regex>,
518}
519
520impl CpuAnalyzer {
521    /// Create a new analyzer with default settings
522    pub fn new() -> Self {
523        Self {
524            min_percent: 0.0,
525            top_n: 50,
526            include_internals: false,
527            filter_categories: vec![],
528            filter_package: None,
529            focus_pattern: None,
530            exclude_pattern: None,
531        }
532    }
533
534    /// Set minimum percentage threshold
535    pub fn min_percent(mut self, percent: f64) -> Self {
536        self.min_percent = percent;
537        self
538    }
539
540    /// Set maximum number of results
541    pub fn top_n(mut self, n: usize) -> Self {
542        self.top_n = n;
543        self
544    }
545
546    /// Include internal frames (Node.js, V8, Native)
547    pub fn include_internals(mut self, include: bool) -> Self {
548        self.include_internals = include;
549        self
550    }
551
552    /// Filter to specific categories
553    pub fn filter_categories(mut self, categories: Vec<FrameCategory>) -> Self {
554        self.filter_categories = categories;
555        self
556    }
557
558    /// Focus analysis on a specific npm package
559    pub fn filter_package(mut self, package: String) -> Self {
560        self.filter_package = Some(package);
561        self
562    }
563
564    /// Focus on functions matching this regex pattern
565    pub fn focus(mut self, pattern: Regex) -> Self {
566        self.focus_pattern = Some(pattern);
567        self
568    }
569
570    /// Exclude functions matching this regex pattern
571    pub fn exclude(mut self, pattern: Regex) -> Self {
572        self.exclude_pattern = Some(pattern);
573        self
574    }
575
576    /// Check if a file path belongs to the target package
577    fn is_in_package(file: &str, package: &str) -> bool {
578        // Handle various node_modules path formats:
579        // - node_modules/package-name/
580        // - node_modules/@scope/package-name/
581        // - .pnpm/package-name@version/node_modules/package-name/
582        // - .pnpm/@scope+package-name@version/node_modules/@scope/package-name/
583
584        // Direct match in path
585        if file.contains(&format!("/{package}/"))
586            || file.contains(&format!("/{package}:"))
587            || file.ends_with(&format!("/{package}"))
588        {
589            return true;
590        }
591
592        // For pnpm, also check the .pnpm directory format
593        // e.g., .pnpm/prettier-plugin-tailwindcss@0.5.0/
594        if file.contains(".pnpm/") {
595            let pnpm_pattern = format!(".pnpm/{package}@");
596            let pnpm_scoped = format!(".pnpm/{}+", package.replace('/', "+"));
597            if file.contains(&pnpm_pattern) || file.contains(&pnpm_scoped) {
598                return true;
599            }
600        }
601
602        false
603    }
604
605    /// Check if a frame should be included based on all filter settings.
606    /// This is the single source of truth for filtering logic.
607    fn should_include_frame(&self, frame: &crate::ir::Frame) -> bool {
608        // Apply category filter
609        if !self.filter_categories.is_empty() && !self.filter_categories.contains(&frame.category) {
610            return false;
611        }
612
613        // Apply internals filter (unless package filter is active)
614        if self.filter_package.is_none() && !self.include_internals && frame.category.is_internal()
615        {
616            return false;
617        }
618
619        // Apply package filter
620        if let Some(ref pkg) = self.filter_package {
621            if let Some(ref file) = frame.file {
622                if !Self::is_in_package(file, pkg) {
623                    return false;
624                }
625            } else {
626                return false;
627            }
628        }
629
630        let name = frame.display_name();
631        let location = frame.location();
632
633        // Apply focus pattern (must match name or location)
634        if let Some(ref pattern) = self.focus_pattern {
635            if !pattern.is_match(&name) && !pattern.is_match(&location) {
636                return false;
637            }
638        }
639
640        // Apply exclude pattern (must not match name or location)
641        if let Some(ref pattern) = self.exclude_pattern {
642            if pattern.is_match(&name) || pattern.is_match(&location) {
643                return false;
644            }
645        }
646
647        true
648    }
649
650    /// Analyze a profile
651    #[expect(clippy::cast_precision_loss)]
652    #[expect(clippy::too_many_lines)]
653    pub fn analyze(&self, profile: &ProfileIR) -> CpuAnalysis {
654        let total_time = profile.total_weight();
655        let total_samples = profile.sample_count();
656
657        // Aggregate times per frame
658        let mut self_times: HashMap<FrameId, u64> = HashMap::new();
659        let mut total_times: HashMap<FrameId, u64> = HashMap::new();
660        let mut self_counts: HashMap<FrameId, u32> = HashMap::new();
661        let mut total_counts: HashMap<FrameId, u32> = HashMap::new();
662        let mut category_breakdown = CategoryBreakdown::default();
663        let mut category_breakdown_inclusive = CategoryBreakdownInclusive::default();
664        let mut category_call_flow = CategoryCallFlow::default();
665        let mut stack_times: HashMap<Vec<FrameId>, (u64, u32, u64, u64)> = HashMap::new(); // (time, sample_count, first_seen, last_seen)
666
667        // Track GC and native time
668        let mut gc_time: u64 = 0;
669        let mut gc_samples: u32 = 0;
670        let mut gc_stack_frames: HashMap<FrameId, u32> = HashMap::new(); // frame_id -> gc_sample_count
671        let mut gc_sample_timestamps: Vec<u64> = Vec::new(); // timestamps of GC samples
672        let mut native_time: u64 = 0;
673
674        // Track recursion: frame_id -> (max_depth, recursive_sample_count, recursive_time)
675        let mut recursion_stats: HashMap<FrameId, (u32, u32, u64)> = HashMap::new();
676
677        // Track phase analysis data: (timestamp, sample_index)
678        let mut sample_timestamps: Vec<(u64, usize)> = Vec::new();
679
680        // Track file-level aggregations
681        let mut file_self_times: HashMap<String, u64> = HashMap::new();
682        let mut file_total_times: HashMap<String, u64> = HashMap::new();
683        let mut file_call_counts: HashMap<String, u32> = HashMap::new();
684        let mut file_categories: HashMap<String, FrameCategory> = HashMap::new();
685
686        // Track package-level aggregations (for deps only)
687        let mut package_times: HashMap<String, u64> = HashMap::new();
688        let mut package_top_funcs: HashMap<String, (String, String, u64)> = HashMap::new(); // (name, location, self_time)
689
690        // Track temporal bounds for async analysis: frame_id -> timestamp
691        let mut first_seen: HashMap<FrameId, u64> = HashMap::new();
692        let mut last_seen: HashMap<FrameId, u64> = HashMap::new();
693
694        for (sample_idx, sample) in profile.samples.iter().enumerate() {
695            let weight = sample.weight;
696
697            // Track sample timestamp for phase analysis
698            sample_timestamps.push((sample.timestamp_us, sample_idx));
699
700            if let Some(stack) = profile.get_stack(sample.stack_id) {
701                // Detect recursion: count occurrences of each frame in this stack
702                let mut frame_counts: HashMap<FrameId, u32> = HashMap::new();
703                for &frame_id in &stack.frames {
704                    *frame_counts.entry(frame_id).or_default() += 1;
705                }
706                // Update recursion stats for frames that appear multiple times
707                for (&frame_id, &count) in &frame_counts {
708                    if count > 1 {
709                        let depth = count - 1; // Recursion depth is count - 1
710                        let entry = recursion_stats.entry(frame_id).or_insert((0, 0, 0));
711                        entry.0 = entry.0.max(depth); // Max depth
712                        entry.1 += 1; // Recursive sample count
713                        entry.2 += weight; // Recursive time
714                    }
715                }
716
717                // Track temporal bounds for each frame in the stack (for async analysis)
718                let timestamp = sample.timestamp_us;
719                for &frame_id in &stack.frames {
720                    // Update first_seen (only if not already set)
721                    first_seen.entry(frame_id).or_insert(timestamp);
722                    // Always update last_seen to track the latest appearance
723                    last_seen.insert(frame_id, timestamp);
724                }
725
726                // Leaf frame gets self time
727                if let Some(&leaf_frame) = stack.frames.last() {
728                    *self_times.entry(leaf_frame).or_default() += weight;
729                    *self_counts.entry(leaf_frame).or_default() += 1;
730
731                    // Attribute to category based on leaf frame
732                    if let Some(frame) = profile.get_frame(leaf_frame) {
733                        match frame.category {
734                            FrameCategory::App => category_breakdown.app += weight,
735                            FrameCategory::Deps => category_breakdown.deps += weight,
736                            FrameCategory::NodeInternal => {
737                                category_breakdown.node_internal += weight;
738                            }
739                            FrameCategory::V8Internal => category_breakdown.v8_internal += weight,
740                            FrameCategory::Native => category_breakdown.native += weight,
741                        }
742
743                        // Track GC and native time based on FrameKind
744                        match frame.kind {
745                            FrameKind::GC => {
746                                gc_time += weight;
747                                gc_samples += 1;
748                                gc_sample_timestamps.push(sample.timestamp_us);
749                                // Track all frames on stack during GC (potential allocators)
750                                for &frame_id in &stack.frames {
751                                    if frame_id != leaf_frame {
752                                        // Exclude the GC frame itself
753                                        *gc_stack_frames.entry(frame_id).or_default() += 1;
754                                    }
755                                }
756                            }
757                            FrameKind::Native => native_time += weight,
758                            _ => {}
759                        }
760
761                        // File-level self time
762                        if let Some(file) = frame.clean_file() {
763                            *file_self_times.entry(file.clone()).or_default() += weight;
764                            file_categories.entry(file).or_insert(frame.category);
765                        }
766
767                        // Package-level aggregation for deps
768                        if frame.category == FrameCategory::Deps {
769                            if let Some(file) = frame.clean_file() {
770                                if let Some(pkg) = Self::extract_package_name(&file) {
771                                    *package_times.entry(pkg.clone()).or_default() += weight;
772
773                                    // Track top function by self time for this package
774                                    let current_self =
775                                        self_times.get(&frame.id).copied().unwrap_or(0);
776                                    package_top_funcs
777                                        .entry(pkg)
778                                        .and_modify(|(name, loc, time)| {
779                                            if weight > *time {
780                                                *name = frame.display_name().to_string();
781                                                *loc = frame.location();
782                                                *time = weight;
783                                            }
784                                        })
785                                        .or_insert((
786                                            frame.display_name().to_string(),
787                                            frame.location(),
788                                            current_self,
789                                        ));
790                                }
791                            }
792                        }
793                    }
794                }
795
796                // All frames get total time
797                // Track which categories appear in this stack (for inclusive breakdown)
798                let mut stack_has_app = false;
799                let mut stack_has_deps = false;
800                let mut stack_has_node = false;
801                let mut stack_has_v8 = false;
802                let mut stack_has_native = false;
803
804                // Track category transitions for call flow
805                let mut prev_category: Option<FrameCategory> = None;
806
807                for &frame_id in &stack.frames {
808                    *total_times.entry(frame_id).or_default() += weight;
809                    *total_counts.entry(frame_id).or_default() += 1;
810
811                    // File-level total time
812                    if let Some(frame) = profile.get_frame(frame_id) {
813                        if let Some(file) = frame.clean_file() {
814                            *file_total_times.entry(file.clone()).or_default() += weight;
815                            *file_call_counts.entry(file.clone()).or_default() += 1;
816                            file_categories.entry(file).or_insert(frame.category);
817                        }
818
819                        // Track categories in this stack
820                        match frame.category {
821                            FrameCategory::App => stack_has_app = true,
822                            FrameCategory::Deps => stack_has_deps = true,
823                            FrameCategory::NodeInternal => stack_has_node = true,
824                            FrameCategory::V8Internal => stack_has_v8 = true,
825                            FrameCategory::Native => stack_has_native = true,
826                        }
827
828                        // Track call flow: when category changes, record the transition
829                        if let Some(prev) = prev_category {
830                            if prev != frame.category {
831                                *category_call_flow
832                                    .calls
833                                    .entry((prev, frame.category))
834                                    .or_default() += weight;
835                            }
836                        }
837                        prev_category = Some(frame.category);
838                    }
839                }
840
841                // Attribute inclusive time to categories that appear in this stack
842                if stack_has_app {
843                    category_breakdown_inclusive.app += weight;
844                }
845                if stack_has_deps {
846                    category_breakdown_inclusive.deps += weight;
847                }
848                if stack_has_node {
849                    category_breakdown_inclusive.node_internal += weight;
850                }
851                if stack_has_v8 {
852                    category_breakdown_inclusive.v8_internal += weight;
853                }
854                if stack_has_native {
855                    category_breakdown_inclusive.native += weight;
856                }
857
858                // Track stack times for hot paths (time, sample_count, first_seen, last_seen)
859                let timestamp = sample.timestamp_us;
860                let entry = stack_times
861                    .entry(stack.frames.clone())
862                    .or_insert((0, 0, timestamp, timestamp));
863                entry.0 += weight;
864                entry.1 += 1;
865                // Update last_seen to track the latest appearance
866                entry.3 = timestamp;
867            }
868        }
869
870        // Build function stats (unfiltered for internal use)
871        let all_functions: Vec<FunctionStats> = profile
872            .frames
873            .iter()
874            .filter_map(|frame| {
875                let self_time = self_times.get(&frame.id).copied().unwrap_or(0);
876                let frame_total_time = total_times.get(&frame.id).copied().unwrap_or(0);
877
878                // Skip if no time
879                if self_time == 0 && frame_total_time == 0 {
880                    return None;
881                }
882
883                let (max_depth, rec_samples, _) =
884                    recursion_stats.get(&frame.id).copied().unwrap_or((0, 0, 0));
885
886                Some(FunctionStats {
887                    frame_id: frame.id,
888                    name: frame.display_name().to_string(),
889                    location: frame.location(),
890                    category: frame.category,
891                    self_time,
892                    total_time: frame_total_time,
893                    self_samples: self_counts.get(&frame.id).copied().unwrap_or(0),
894                    total_samples: total_counts.get(&frame.id).copied().unwrap_or(0),
895                    max_recursion_depth: max_depth,
896                    recursive_samples: rec_samples,
897                    first_seen_us: first_seen.get(&frame.id).copied().unwrap_or(0),
898                    last_seen_us: last_seen.get(&frame.id).copied().unwrap_or(0),
899                })
900            })
901            .collect();
902
903        // Apply filters for the user-facing list (using unified filter)
904        let mut functions: Vec<FunctionStats> = all_functions
905            .iter()
906            .filter(|func| {
907                // Look up original frame to use unified filter
908                if let Some(frame) = profile.get_frame(func.frame_id) {
909                    if !self.should_include_frame(frame) {
910                        return false;
911                    }
912                } else {
913                    return false;
914                }
915
916                // Apply min percent filter (separate from frame-based filters)
917                let self_pct = if total_time > 0 {
918                    (func.self_time as f64 / total_time as f64) * 100.0
919                } else {
920                    0.0
921                };
922                if self_pct < self.min_percent && self.min_percent > 0.0 {
923                    return false;
924                }
925
926                true
927            })
928            .cloned()
929            .collect();
930
931        // Sort by self time descending
932        functions.sort_by(|a, b| {
933            b.self_time
934                .cmp(&a.self_time)
935                .then_with(|| a.name.cmp(&b.name))
936        });
937        functions.truncate(self.top_n);
938
939        // Build functions_by_total (sorted by inclusive time)
940        let mut functions_by_total = functions.clone();
941        functions_by_total.sort_by(|a, b| {
942            b.total_time
943                .cmp(&a.total_time)
944                .then_with(|| a.name.cmp(&b.name))
945        });
946
947        // Build hot paths
948        let mut hot_paths: Vec<HotPath> = stack_times
949            .into_iter()
950            .filter(|(frames, _)| {
951                // Filter out stacks with only internal frames
952                if !self.include_internals
953                    && !frames.iter().any(|&fid| {
954                        profile
955                            .get_frame(fid)
956                            .is_some_and(|f| !f.category.is_internal())
957                    })
958                {
959                    return false;
960                }
961
962                // Apply category filter: require the first NON-INTERNAL frame to match
963                // This shows "hot paths originating from App code" rather than
964                // "hot paths that touch App code somewhere"
965                // We skip internal frames (root, program, etc.) at the start
966                if !self.filter_categories.is_empty() {
967                    let first_visible_frame = frames.iter().find_map(|&fid| {
968                        profile.get_frame(fid).filter(|f| !f.category.is_internal())
969                    });
970                    let matches = first_visible_frame
971                        .is_some_and(|f| self.filter_categories.contains(&f.category));
972                    if !matches {
973                        return false;
974                    }
975                }
976
977                true
978            })
979            .map(
980                |(frames, (time, sample_count, first_seen_us, last_seen_us))| {
981                    let percent = if total_time > 0 {
982                        (time as f64 / total_time as f64) * 100.0
983                    } else {
984                        0.0
985                    };
986                    HotPath {
987                        frames,
988                        time,
989                        percent,
990                        sample_count,
991                        first_seen_us,
992                        last_seen_us,
993                    }
994                },
995            )
996            .collect();
997
998        // Sort by CPU time descending
999        hot_paths.sort_by(|a, b| b.time.cmp(&a.time));
1000
1001        // Deduplicate: remove paths that are prefixes of other paths
1002        // (e.g., A->B->C and A->B are duplicates, keep the longer one)
1003        let hot_paths = Self::deduplicate_prefix_paths(hot_paths);
1004
1005        let hot_paths: Vec<_> = hot_paths.into_iter().take(10).collect();
1006
1007        // Build file stats
1008        let mut file_stats: Vec<FileStats> = file_total_times
1009            .iter()
1010            .map(|(file, &file_total_time)| {
1011                let self_time = file_self_times.get(file).copied().unwrap_or(0);
1012                let call_count = file_call_counts.get(file).copied().unwrap_or(0);
1013                let category = file_categories
1014                    .get(file)
1015                    .copied()
1016                    .unwrap_or(FrameCategory::App);
1017                FileStats {
1018                    file: file.clone(),
1019                    self_time,
1020                    total_time: file_total_time,
1021                    call_count,
1022                    category,
1023                }
1024            })
1025            .filter(|fs| {
1026                // Filter internal files if not including internals
1027                if !self.include_internals && fs.category.is_internal() {
1028                    return false;
1029                }
1030                true
1031            })
1032            .collect();
1033        file_stats.sort_by(|a, b| {
1034            b.self_time
1035                .cmp(&a.self_time)
1036                .then_with(|| a.file.cmp(&b.file))
1037        });
1038        file_stats.truncate(20); // Top 20 files
1039
1040        // Build package stats
1041        let total_deps_time = category_breakdown.deps;
1042        let mut package_stats: Vec<PackageStats> = package_times
1043            .iter()
1044            .map(|(pkg, &time)| {
1045                let (top_func, top_loc, _) = package_top_funcs
1046                    .get(pkg)
1047                    .cloned()
1048                    .unwrap_or_else(|| ("(unknown)".to_string(), "(unknown)".to_string(), 0));
1049                let percent_of_deps = if total_deps_time > 0 {
1050                    (time as f64 / total_deps_time as f64) * 100.0
1051                } else {
1052                    0.0
1053                };
1054                PackageStats {
1055                    package: pkg.clone(),
1056                    time,
1057                    percent_of_deps,
1058                    top_function: top_func,
1059                    top_function_location: top_loc,
1060                }
1061            })
1062            .collect();
1063        package_stats.sort_by(|a, b| b.time.cmp(&a.time).then_with(|| a.package.cmp(&b.package)));
1064        package_stats.truncate(15); // Top 15 packages
1065
1066        // Build hot function details (caller/callee for top N functions)
1067        let caller_callee_analyzer = CallerCalleeAnalyzer::new().top_n(5);
1068        let hot_function_details: Vec<HotFunctionDetail> = functions
1069            .iter()
1070            .take(5) // Top 5 hot functions
1071            .filter_map(|func| {
1072                caller_callee_analyzer
1073                    .analyze(profile, func.frame_id)
1074                    .map(|analysis| HotFunctionDetail {
1075                        frame_id: func.frame_id,
1076                        name: func.name.clone(),
1077                        location: func.location.clone(),
1078                        self_time: func.self_time,
1079                        total_time: func.total_time,
1080                        callers: analysis.callers,
1081                        callees: analysis.callees,
1082                        first_seen_us: func.first_seen_us,
1083                        last_seen_us: func.last_seen_us,
1084                    })
1085            })
1086            .collect();
1087
1088        // Build recursive functions list (using unified filter)
1089        let mut recursive_functions: Vec<RecursiveFunctionStats> = recursion_stats
1090            .iter()
1091            .filter_map(|(&frame_id, &(max_depth, rec_samples, rec_time))| {
1092                if max_depth == 0 {
1093                    return None;
1094                }
1095                let frame = profile.get_frame(frame_id)?;
1096
1097                // Use unified filter method
1098                if !self.should_include_frame(frame) {
1099                    return None;
1100                }
1101
1102                let total_self = self_times.get(&frame_id).copied().unwrap_or(0);
1103                // Use total_counts (samples where function appears anywhere in stack)
1104                // not self_counts (samples where function is at leaf)
1105                let total_samp = total_counts.get(&frame_id).copied().unwrap_or(0);
1106
1107                Some(RecursiveFunctionStats {
1108                    name: frame.display_name().to_string(),
1109                    location: frame.location(),
1110                    max_depth,
1111                    recursive_samples: rec_samples,
1112                    total_samples: total_samp,
1113                    recursive_time: rec_time,
1114                    total_self_time: total_self,
1115                })
1116            })
1117            .collect();
1118        recursive_functions.sort_by(|a, b| {
1119            b.recursive_time
1120                .cmp(&a.recursive_time)
1121                .then_with(|| a.name.cmp(&b.name))
1122        });
1123        recursive_functions.truncate(10); // Top 10 recursive functions
1124
1125        // Build phase analysis (startup vs steady state)
1126        let phase_analysis = if !sample_timestamps.is_empty() && total_time > 0 {
1127            // Define startup as first 500ms or first 10% of samples, whichever is smaller
1128            let startup_threshold_us = 500_000u64; // 500ms
1129            let startup_sample_threshold = total_samples / 10;
1130
1131            let startup_end_idx = sample_timestamps
1132                .iter()
1133                .position(|(ts, _)| *ts > startup_threshold_us)
1134                .unwrap_or(sample_timestamps.len())
1135                .min(startup_sample_threshold.max(1));
1136
1137            let startup_end_time = if startup_end_idx < sample_timestamps.len() {
1138                sample_timestamps[startup_end_idx].0
1139            } else {
1140                total_time
1141            };
1142
1143            // Compute stats for each phase (using unified filter)
1144            let startup_stats = self.compute_phase_stats(
1145                profile,
1146                &profile.samples[..startup_end_idx.min(profile.samples.len())],
1147                "Startup",
1148                0,
1149                startup_end_time,
1150            );
1151
1152            let steady_stats = self.compute_phase_stats(
1153                profile,
1154                &profile.samples[startup_end_idx.min(profile.samples.len())..],
1155                "Steady State",
1156                startup_end_time,
1157                total_time,
1158            );
1159
1160            Some(PhaseAnalysis {
1161                startup: startup_stats,
1162                steady_state: steady_stats,
1163                total_duration_us: total_time,
1164            })
1165        } else {
1166            None
1167        };
1168
1169        // Build GC analysis if GC samples exist
1170        let gc_analysis = if gc_samples > 0 {
1171            // Calculate average pause
1172            let avg_pause_us = gc_time / u64::from(gc_samples);
1173
1174            // Build allocation hotspots (functions frequently on stack during GC)
1175            let mut hotspots: Vec<AllocationHotspot> = gc_stack_frames
1176                .iter()
1177                .filter_map(|(&frame_id, &gc_count)| {
1178                    let frame = profile.get_frame(frame_id)?;
1179                    let total_count = total_counts.get(&frame_id).copied().unwrap_or(0);
1180
1181                    // Only include if function appears in significant portion of GC samples
1182                    // and is user code (App or Deps), not internals
1183                    if gc_count < 2
1184                        || (frame.category != FrameCategory::App
1185                            && frame.category != FrameCategory::Deps)
1186                    {
1187                        return None;
1188                    }
1189
1190                    let gc_correlation = f64::from(gc_count) / f64::from(gc_samples) * 100.0;
1191
1192                    Some(AllocationHotspot {
1193                        name: frame.display_name().to_string(),
1194                        location: frame.location(),
1195                        category: frame.category,
1196                        gc_samples: gc_count,
1197                        total_samples: total_count,
1198                        gc_correlation,
1199                    })
1200                })
1201                .collect();
1202
1203            // Sort by GC correlation (most correlated first), then by name for stable ordering
1204            hotspots.sort_by(|a, b| {
1205                b.gc_correlation
1206                    .partial_cmp(&a.gc_correlation)
1207                    .unwrap_or(std::cmp::Ordering::Equal)
1208                    .then_with(|| a.name.cmp(&b.name))
1209            });
1210            hotspots.truncate(10);
1211
1212            // Calculate GC time in startup vs steady state
1213            let startup_end_time = phase_analysis.as_ref().map_or(0, |p| p.startup.end_us);
1214            let startup_gc_time: u64 = gc_sample_timestamps
1215                .iter()
1216                .filter(|&&ts| ts <= startup_end_time)
1217                .count() as u64
1218                * (gc_time / u64::from(gc_samples).max(1));
1219            let steady_gc_time = gc_time.saturating_sub(startup_gc_time);
1220
1221            Some(GcAnalysis {
1222                total_time: gc_time,
1223                sample_count: gc_samples,
1224                avg_pause_us,
1225                allocation_hotspots: hotspots,
1226                startup_gc_time,
1227                steady_gc_time,
1228            })
1229        } else {
1230            None
1231        };
1232
1233        // Build metadata
1234        let duration_ms = total_time as f64 / 1000.0;
1235        let wall_time_ms = profile.duration_us.map(|us| us as f64 / 1000.0);
1236        let sample_interval_ms = if total_samples > 0 {
1237            duration_ms / total_samples as f64
1238        } else {
1239            0.0
1240        };
1241        let metadata = ProfileMetadata {
1242            source_file: profile.source_file.clone(),
1243            duration_ms,
1244            wall_time_ms,
1245            sample_count: total_samples,
1246            sample_interval_ms,
1247            internals_filtered: !self.include_internals,
1248            sourcemaps_loaded: profile.sourcemaps_resolved,
1249            sourcemaps_inline: 0,
1250            focus_package: self.filter_package.clone(),
1251            profiles_merged: profile.profiles_merged,
1252            filter_categories: self.filter_categories.clone(),
1253        };
1254
1255        CpuAnalysis {
1256            total_time,
1257            total_samples,
1258            functions,
1259            functions_by_total,
1260            category_breakdown,
1261            category_breakdown_inclusive,
1262            category_call_flow,
1263            hot_paths,
1264            file_stats,
1265            package_stats,
1266            hot_function_details,
1267            gc_time,
1268            gc_analysis,
1269            native_time,
1270            metadata,
1271            phase_analysis,
1272            recursive_functions,
1273        }
1274    }
1275
1276    /// Compute statistics for a phase of the profile
1277    #[expect(clippy::cast_precision_loss)]
1278    fn compute_phase_stats(
1279        &self,
1280        profile: &ProfileIR,
1281        samples: &[crate::ir::Sample],
1282        name: &str,
1283        start_us: u64,
1284        end_us: u64,
1285    ) -> PhaseStats {
1286        let mut self_times: HashMap<FrameId, u64> = HashMap::new();
1287        let mut category_breakdown = CategoryBreakdown::default();
1288
1289        for sample in samples {
1290            let weight = sample.weight;
1291
1292            if let Some(stack) = profile.get_stack(sample.stack_id) {
1293                if let Some(&leaf_frame) = stack.frames.last() {
1294                    *self_times.entry(leaf_frame).or_default() += weight;
1295
1296                    if let Some(frame) = profile.get_frame(leaf_frame) {
1297                        match frame.category {
1298                            FrameCategory::App => category_breakdown.app += weight,
1299                            FrameCategory::Deps => category_breakdown.deps += weight,
1300                            FrameCategory::NodeInternal => {
1301                                category_breakdown.node_internal += weight;
1302                            }
1303                            FrameCategory::V8Internal => category_breakdown.v8_internal += weight,
1304                            FrameCategory::Native => category_breakdown.native += weight,
1305                        }
1306                    }
1307                }
1308            }
1309        }
1310
1311        let total_phase_time = category_breakdown.total();
1312
1313        // Get top functions for this phase (using unified filter)
1314        let mut top_functions: Vec<PhaseFunctionStats> = self_times
1315            .iter()
1316            .filter_map(|(&frame_id, &self_time)| {
1317                let frame = profile.get_frame(frame_id)?;
1318
1319                // Use unified filter method
1320                if !self.should_include_frame(frame) {
1321                    return None;
1322                }
1323
1324                let percent = if total_phase_time > 0 {
1325                    (self_time as f64 / total_phase_time as f64) * 100.0
1326                } else {
1327                    0.0
1328                };
1329                Some(PhaseFunctionStats {
1330                    name: frame.display_name().to_string(),
1331                    location: frame.location(),
1332                    self_time,
1333                    percent,
1334                    category: frame.category,
1335                })
1336            })
1337            .collect();
1338
1339        top_functions.sort_by(|a, b| b.self_time.cmp(&a.self_time));
1340        top_functions.truncate(5); // Top 5 functions per phase
1341
1342        PhaseStats {
1343            name: name.to_string(),
1344            start_us,
1345            end_us,
1346            sample_count: samples.len(),
1347            top_functions,
1348            category_breakdown,
1349        }
1350    }
1351
1352    /// Extract package name from a node_modules path
1353    /// Handles both regular packages (lodash) and scoped packages (@vitest/runner)
1354    fn extract_package_name(path: &str) -> Option<String> {
1355        // Look for node_modules in the path
1356        let parts: Vec<&str> = path.split("node_modules/").collect();
1357        if parts.len() < 2 {
1358            return None;
1359        }
1360
1361        let after_node_modules = parts.last()?;
1362        let path_parts: Vec<&str> = after_node_modules.split('/').collect();
1363
1364        if path_parts.is_empty() {
1365            return None;
1366        }
1367
1368        // Handle scoped packages (@org/package)
1369        if path_parts[0].starts_with('@') && path_parts.len() >= 2 {
1370            // For pnpm paths like ".pnpm/@vitest+runner@4.0.15/node_modules/@vitest/runner"
1371            // We want the final package name
1372            let scoped_name = format!("{}/{}", path_parts[0], path_parts[1]);
1373            Some(scoped_name)
1374        } else {
1375            // Regular package, extract up to first @ for version or / for path
1376            let pkg = path_parts[0];
1377            // Handle pnpm format: package@version
1378            if let Some(at_pos) = pkg.find('@') {
1379                if at_pos > 0 {
1380                    Some(pkg[..at_pos].to_string())
1381                } else {
1382                    Some(pkg.to_string())
1383                }
1384            } else {
1385                Some(pkg.to_string())
1386            }
1387        }
1388    }
1389
1390    /// Deduplicate hot paths by removing paths that are:
1391    /// 1. Exact prefixes of other paths (A->B is prefix of A->B->C)
1392    /// 2. Share a significant common prefix with a higher-time path
1393    ///
1394    /// This merges paths like:
1395    /// - A->B and A->B->C (exact prefix)
1396    /// - A->B->C->D->X and A->B->C->D->Y (same entry, diverge at end)
1397    fn deduplicate_prefix_paths(mut paths: Vec<HotPath>) -> Vec<HotPath> {
1398        if paths.len() <= 1 {
1399            return paths;
1400        }
1401
1402        // Sort by CPU time descending - keep highest-time paths first
1403        paths.sort_by(|a, b| b.time.cmp(&a.time));
1404
1405        let mut result: Vec<HotPath> = Vec::new();
1406
1407        for path in paths {
1408            // Check if this path should be deduplicated against any kept path
1409            let is_duplicate = result
1410                .iter()
1411                .any(|kept| Self::is_duplicate_path(&path.frames, &kept.frames));
1412
1413            if !is_duplicate {
1414                result.push(path);
1415            }
1416        }
1417
1418        result
1419    }
1420
1421    /// Check if path `a` is a duplicate of path `b` (which has higher time).
1422    /// Returns true if:
1423    /// 1. They share ≥2 frames at the start (same entry point + context)
1424    /// 2. Path `a` can be found by skipping 1-3 frames from the start of path `b`
1425    ///    (handles sub-path case: A→B→C→... vs B→C→...)
1426    fn is_duplicate_path(a: &[FrameId], b: &[FrameId]) -> bool {
1427        if a.is_empty() || b.is_empty() {
1428            return false;
1429        }
1430
1431        // Case 1: They share ≥2 frames at the start
1432        let common_prefix_len = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
1433        if common_prefix_len >= 2 {
1434            return true;
1435        }
1436
1437        // Case 2: Check if `a` matches `b` with 1-3 frames skipped from start
1438        // This catches: loadPlugins→loadThirdParty→... vs loadThirdParty→...
1439        for skip in 1..=3 {
1440            if b.len() <= skip {
1441                break;
1442            }
1443            // Check if a matches b starting from position `skip`
1444            let matches = a
1445                .iter()
1446                .zip(b.iter().skip(skip))
1447                .take(3)
1448                .all(|(x, y)| x == y);
1449            if matches && a.len() >= 3 {
1450                // They share at least 3 frames with offset
1451                return true;
1452            }
1453        }
1454
1455        false
1456    }
1457}
1458
1459impl Default for CpuAnalyzer {
1460    fn default() -> Self {
1461        Self::new()
1462    }
1463}