Skip to main content

sentinel_core/detect/
mod.rs

1//! Detection stage: identifies performance anti-patterns in traces.
2
3pub mod chatty;
4pub mod correlate_cross;
5pub mod fanout;
6#[cfg(test)]
7mod metamorphic;
8pub mod n_plus_one;
9pub mod pool_saturation;
10pub mod redundant;
11pub mod sanitizer_aware;
12pub mod serialized;
13pub mod slow;
14pub mod suggestions;
15
16pub use n_plus_one::DISCLOSURE_N_PLUS_ONE_THRESHOLD;
17
18use std::collections::HashMap;
19
20use crate::correlate::Trace;
21use crate::event::{EventType, GroupingAttribute};
22use serde::{Deserialize, Serialize};
23
24/// Precomputed per-trace indices shared by the fanout and serialized
25/// detectors. Both detectors need `children_by_parent` +
26/// `span_index`; building them once per trace and passing the struct
27/// halves the hot-path `HashMap` cost on traces that trigger both
28/// detectors.
29///
30/// `pub` visibility is required because [`fanout::detect_fanout`] and
31/// [`serialized::detect_serialized`] are public entry points that take
32/// a `&TraceIndices<'_>`. The internal `build` constructor stays
33/// `pub(super)` so external callers cannot bypass the `detect()`
34/// orchestrator to produce an inconsistent indices / trace pair.
35pub struct TraceIndices<'a> {
36    pub children_by_parent: HashMap<&'a str, Vec<usize>>,
37    pub span_index: HashMap<&'a str, usize>,
38}
39
40impl<'a> TraceIndices<'a> {
41    /// Build both indices in a single pass over the trace's spans.
42    #[must_use]
43    pub fn build(trace: &'a Trace) -> Self {
44        let mut children_by_parent: HashMap<&str, Vec<usize>> =
45            HashMap::with_capacity(trace.spans.len() / 4 + 1);
46        let mut span_index: HashMap<&str, usize> = HashMap::with_capacity(trace.spans.len());
47        for (idx, span) in trace.spans.iter().enumerate() {
48            span_index.insert(span.event.span_id.as_str(), idx);
49            if let Some(ref parent_id) = span.event.parent_span_id {
50                children_by_parent
51                    .entry(parent_id.as_str())
52                    .or_default()
53                    .push(idx);
54            }
55        }
56        Self {
57            children_by_parent,
58            span_index,
59        }
60    }
61}
62
63/// A detected performance anti-pattern.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct Finding {
66    /// The anti-pattern category (N+1, redundant, slow, fanout, etc.).
67    #[serde(rename = "type")]
68    pub finding_type: FindingType,
69    /// Severity level: critical, warning or info.
70    pub severity: Severity,
71    /// Trace identifier where the anti-pattern was detected.
72    pub trace_id: String,
73    /// Name of the service emitting the spans involved in the finding.
74    pub service: String,
75    /// Attributes captured for grouping, in configured order. The first
76    /// entry separates this finding from the same problem in another
77    /// deployment, the rest are kept for the operator to read.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub grouping: Vec<GroupingAttribute>,
80    /// Normalized inbound endpoint (route template) hosting the pattern.
81    pub source_endpoint: String,
82    /// Details of the matched pattern: template, occurrences, window, params.
83    pub pattern: Pattern,
84    /// Human-readable remediation hint for this finding.
85    pub suggestion: String,
86    /// Earliest timestamp among spans in the detected group.
87    pub first_timestamp: String,
88    /// Latest timestamp among spans in the detected group.
89    pub last_timestamp: String,
90    /// `GreenOps` impact estimate. Absent when green scoring is disabled.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub green_impact: Option<GreenImpact>,
93    /// Source context of this finding: CI batch run, staging daemon, or
94    /// production daemon. Intended for downstream consumers to
95    /// boost or reduce severity based on how the finding was produced.
96    ///
97    /// **Contract:** detectors always emit [`Confidence::default()`]
98    /// (= `CiBatch`); the real value is stamped by the pipeline caller
99    /// (`pipeline::analyze_with_traces` for batch, `daemon::process_traces`
100    /// for the daemon) after detection returns. This keeps the detector
101    /// layer oblivious to runtime context.
102    #[serde(default)]
103    pub confidence: Confidence,
104    /// How this finding's type was classified.
105    ///
106    /// `None` (default, omitted from JSON) means direct classification
107    /// via the standard pipeline rules (`distinct_params >= threshold`
108    /// for N+1, repeated identical `(template, params)` for redundant).
109    /// `Some(SanitizerHeuristic)` means the type was inferred via the
110    /// sanitizer-aware heuristic because the standard distinct-params signal
111    /// was insufficient. SQL first requires evidence of collapsed literals;
112    /// HTTP uses timing and trace-shape signals without proving sanitization.
113    /// Operators can filter on this field to spot where the heuristic fires.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub classification_method: Option<ClassificationMethod>,
116    /// Source code location from `OTel` `code.*` span attributes.
117    /// `None` when the instrumentation agent does not emit these attributes.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub code_location: Option<crate::event::CodeLocation>,
120    /// OpenTelemetry instrumentation scope chain from the originating
121    /// span and its ancestors (leaf to root, deduplicated). Primary
122    /// framework signal for [`suggestions::enrich`]. Empty when the
123    /// upstream format carries no scope info (Jaeger, Zipkin) or the
124    /// trace is synthetic.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub instrumentation_scopes: Vec<String>,
127    /// Framework-specific actionable fix, populated by
128    /// [`suggestions::enrich`] after the per-trace detectors run. `None`
129    /// when no framework can be inferred or the `(finding_type,
130    /// framework)` pair has no mapping in the fixes table.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub suggested_fix: Option<suggestions::SuggestedFix>,
133    /// Canonical signature for ack matching, e.g.
134    /// `n_plus_one_sql:order-svc:POST_/api/orders:a3f8b2c1`. Always
135    /// present in JSON output so users can copy-paste it into
136    /// `.perf-sentinel-acknowledgments.toml`. Filled by
137    /// [`crate::acknowledgments::enrich_with_signatures`] at end of
138    /// `pipeline::analyze` and after deserializing baselines.
139    #[serde(default)]
140    pub signature: String,
141}
142
143impl Finding {
144    /// Dimension separating deployments, without changing acknowledgments.
145    #[must_use]
146    pub fn effective_grouping(&self) -> Option<&GroupingAttribute> {
147        self.grouping.first()
148    }
149
150    /// Just the value, for the identity keys that do not display it.
151    #[must_use]
152    pub fn grouping_value(&self) -> Option<&str> {
153        self.grouping.first().map(|g| g.value.as_ref())
154    }
155
156    /// Borrowed `(key, value)` for a partition key.
157    #[must_use]
158    pub fn grouping_identity(&self) -> Option<(&str, &str)> {
159        self.grouping.first().map(GroupingAttribute::identity)
160    }
161}
162
163/// Span ids of the spans a finding is built from, for the HTML evidence
164/// highlight. Empty when the caller does not collect them, so batch runs
165/// that never render spans pay nothing.
166pub(crate) fn member_span_ids<'a>(
167    trace: &'a Trace,
168    indices: &[usize],
169    collect_spans: bool,
170) -> Vec<&'a str> {
171    if collect_spans {
172        indices
173            .iter()
174            .map(|&i| trace.spans[i].event.span_id.as_str())
175            .collect()
176    } else {
177        Vec::new()
178    }
179}
180
181/// Types of performance anti-patterns.
182#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum FindingType {
185    NPlusOneSql,
186    NPlusOneHttp,
187    NPlusOneMessaging,
188    RedundantSql,
189    RedundantHttp,
190    SlowSql,
191    SlowHttp,
192    SlowMessaging,
193    ExcessiveFanout,
194    ChattyService,
195    PoolSaturation,
196    SerializedCalls,
197}
198
199/// Severity levels for findings.
200#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum Severity {
203    Critical,
204    Warning,
205    Info,
206}
207
208/// Source context for a [`Finding`]: where and how it was produced.
209///
210/// A future IDE-side consumer would read this field on import and
211/// uses it to adjust severity in the IDE. A `daemon_production` finding
212/// (observed on real production traffic) is a much stronger signal than a
213/// `ci_batch` finding (observed on a controlled integration test run with
214/// limited traffic shapes).
215#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
216#[serde(rename_all = "snake_case")]
217pub enum Confidence {
218    /// Batch `analyze` run on a developer machine, no CI environment
219    /// detected. Lowest confidence: ad-hoc local traces, uncontrolled run.
220    LocalBatch,
221    /// Batch `analyze` run in CI (integration tests). Low confidence:
222    /// limited traffic shapes, controlled environment.
223    ///
224    /// Marked `#[default]` so detectors that emit `Confidence::default()`
225    /// get a safe batch fallback, a forgotten stamp never inflates
226    /// an editor-side severity to a daemon level.
227    #[default]
228    CiBatch,
229    /// Daemon `watch` run on staging traffic. Medium confidence: real
230    /// patterns but not production scale.
231    DaemonStaging,
232    /// Daemon `watch` run on production traffic. Highest confidence:
233    /// real patterns at real scale.
234    DaemonProduction,
235}
236
237impl Confidence {
238    /// Returns the `snake_case` string representation.
239    #[must_use]
240    pub const fn as_str(&self) -> &'static str {
241        match self {
242            Self::LocalBatch => "local_batch",
243            Self::CiBatch => "ci_batch",
244            Self::DaemonStaging => "daemon_staging",
245            Self::DaemonProduction => "daemon_production",
246        }
247    }
248
249    /// `true` for the two batch contexts (local / CI). The terminal report
250    /// stays quiet about confidence for batch runs and only surfaces it for
251    /// the stronger daemon signals.
252    #[must_use]
253    pub const fn is_batch(&self) -> bool {
254        matches!(self, Self::LocalBatch | Self::CiBatch)
255    }
256
257    /// Pick the batch confidence from whether a CI environment was detected.
258    /// Pure so the env detection (impure) stays at the call site.
259    #[must_use]
260    pub const fn batch_for_ci(is_ci: bool) -> Self {
261        if is_ci {
262            Self::CiBatch
263        } else {
264            Self::LocalBatch
265        }
266    }
267
268    /// Map confidence to a SARIF `rank` value (0-100).
269    ///
270    /// Rank is SARIF v2.1.0's standard "how much should this matter"
271    /// signal: 0 = low priority, 100 = highest. Populating it means
272    /// SARIF consumers that ignore the custom `properties` bag still
273    /// get a usable ordering.
274    #[must_use]
275    pub const fn sarif_rank(&self) -> u32 {
276        match self {
277            Self::LocalBatch => 15,
278            Self::CiBatch => 30,
279            Self::DaemonStaging => 60,
280            Self::DaemonProduction => 90,
281        }
282    }
283}
284
285/// How a [`Finding`]'s type was determined.
286///
287/// Orthogonal to [`Confidence`]: confidence describes the runtime context
288/// (CI vs production daemon), `ClassificationMethod` describes which
289/// detection rule produced the type. Stored in
290/// [`Finding::classification_method`] as `Option`; `None` means the
291/// standard direct rule fired.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum ClassificationMethod {
295    /// Standard pipeline classification (e.g. `distinct_params >=
296    /// threshold` for N+1, repeated identical `(template, params)` for
297    /// redundant). Equivalent to the absence of the field; emitted
298    /// explicitly only when a caller wants to be unambiguous.
299    Direct,
300    /// Reclassified via a heuristic path. For SQL: the `OTel` agent's
301    /// sanitizer collapsed parameters to `?`, and the timing/scope
302    /// signals suggest N+1 over redundant. For HTTP: repeated identical
303    /// params with high timing variance suggest N+1 over redundant.
304    SanitizerHeuristic,
305}
306
307/// Pattern details for a finding.
308#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
309pub struct Pattern {
310    /// Normalized query or URL template shared by the matched spans.
311    pub template: String,
312    /// Number of spans that matched this template within the window.
313    pub occurrences: usize,
314    /// Time span, in milliseconds, covering all matched occurrences.
315    pub window_ms: u64,
316    /// Count of distinct parameter sets observed across occurrences.
317    pub distinct_params: usize,
318    /// Median per-span duration in the group (µs). Diagnostic field
319    /// populated by the n+1 and slow detectors. Not used in the
320    /// detection verdict, exposed so downstream consumers can profile
321    /// cache-warm patterns without needing daemon-log access.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub span_duration_us_p50: Option<u64>,
324    /// 99th-percentile per-span duration in the group (µs).
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub span_duration_us_p99: Option<u64>,
327    /// Coefficient of variation of per-span durations, scaled by 1000
328    /// (523 means CV = 0.523). Avoids floating-point fields so
329    /// `Pattern` can keep its `Eq` derive.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub span_duration_cv_x1000: Option<u32>,
332}
333
334/// `GreenOps` impact for a single finding.
335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336pub struct GreenImpact {
337    /// Extra I/O operations caused by this anti-pattern (occurrences - 1).
338    pub estimated_extra_io_ops: usize,
339    /// I/O Intensity Score of the endpoint where this finding occurs.
340    pub io_intensity_score: f64,
341    /// Classification band for `io_intensity_score`
342    /// (`healthy` / `moderate` / `high` / `critical`).
343    ///
344    /// Computed by [`crate::report::interpret::InterpretationLevel::for_iis`].
345    /// The enum values are stable across versions; the thresholds behind
346    /// them are versioned with the binary. See
347    /// [`crate::report::interpret`] for the stability contract.
348    pub io_intensity_band: crate::report::interpret::InterpretationLevel,
349}
350
351impl FindingType {
352    #[must_use]
353    pub const fn from_event_type_n_plus_one(event_type: &EventType) -> Self {
354        match event_type {
355            EventType::Sql => Self::NPlusOneSql,
356            EventType::HttpOut => Self::NPlusOneHttp,
357            EventType::Messaging => Self::NPlusOneMessaging,
358        }
359    }
360
361    #[must_use]
362    /// `None` for messaging: a publish carries no params, so redundancy is
363    /// not observable and `detect_redundant` skips those spans.
364    pub const fn from_event_type_redundant(event_type: &EventType) -> Option<Self> {
365        match event_type {
366            EventType::Sql => Some(Self::RedundantSql),
367            EventType::HttpOut => Some(Self::RedundantHttp),
368            EventType::Messaging => None,
369        }
370    }
371
372    #[must_use]
373    pub const fn from_event_type_slow(event_type: &EventType) -> Self {
374        match event_type {
375            EventType::Sql => Self::SlowSql,
376            EventType::HttpOut => Self::SlowHttp,
377            EventType::Messaging => Self::SlowMessaging,
378        }
379    }
380
381    /// Returns the `snake_case` string representation of this finding type.
382    #[must_use]
383    pub const fn as_str(&self) -> &'static str {
384        match self {
385            Self::NPlusOneSql => "n_plus_one_sql",
386            Self::NPlusOneHttp => "n_plus_one_http",
387            Self::NPlusOneMessaging => "n_plus_one_messaging",
388            Self::RedundantSql => "redundant_sql",
389            Self::RedundantHttp => "redundant_http",
390            Self::SlowSql => "slow_sql",
391            Self::SlowHttp => "slow_http",
392            Self::SlowMessaging => "slow_messaging",
393            Self::ExcessiveFanout => "excessive_fanout",
394            Self::ChattyService => "chatty_service",
395            Self::PoolSaturation => "pool_saturation",
396            Self::SerializedCalls => "serialized_calls",
397        }
398    }
399
400    /// RGESN 2024 criteria (ARCEP/Arcom/ADEME) this finding type relates to.
401    ///
402    /// An interpretive crosswalk, not a compliance certification: the RGESN
403    /// criterion titles do not name "N+1" or "slow query", these are the
404    /// criteria whose intent the anti-pattern bears on. `slow_*` returns an
405    /// empty slice on purpose, RGESN family 9 "Algorithmie" is ML-specific and
406    /// no criterion targets single-operation latency. Rationale and the full
407    /// crosswalk live in `docs/METHODOLOGY.md`.
408    #[must_use]
409    pub const fn rgesn_criteria(&self) -> &'static [&'static str] {
410        match self {
411            Self::NPlusOneSql | Self::NPlusOneHttp | Self::NPlusOneMessaging => &["7.1", "6.1"],
412            Self::RedundantSql | Self::RedundantHttp => &["7.1", "6.5"],
413            Self::ChattyService => &["4.9", "4.10", "6.1"],
414            Self::ExcessiveFanout | Self::PoolSaturation => &["3.2"],
415            Self::SerializedCalls => &["8.10"],
416            Self::SlowSql | Self::SlowHttp | Self::SlowMessaging => &[],
417        }
418    }
419
420    /// Parse a `FindingType` from its `snake_case` string, the inverse of
421    /// [`as_str`](Self::as_str). Returns `None` for an unknown string.
422    #[must_use]
423    pub fn from_kind_str(s: &str) -> Option<Self> {
424        match s {
425            "n_plus_one_sql" => Some(Self::NPlusOneSql),
426            "n_plus_one_http" => Some(Self::NPlusOneHttp),
427            "n_plus_one_messaging" => Some(Self::NPlusOneMessaging),
428            "redundant_sql" => Some(Self::RedundantSql),
429            "redundant_http" => Some(Self::RedundantHttp),
430            "slow_sql" => Some(Self::SlowSql),
431            "slow_http" => Some(Self::SlowHttp),
432            "slow_messaging" => Some(Self::SlowMessaging),
433            "excessive_fanout" => Some(Self::ExcessiveFanout),
434            "chatty_service" => Some(Self::ChattyService),
435            "pool_saturation" => Some(Self::PoolSaturation),
436            "serialized_calls" => Some(Self::SerializedCalls),
437            _ => None,
438        }
439    }
440
441    /// Returns a short human-readable label for CLI and TUI display.
442    #[must_use]
443    pub const fn display_label(&self) -> &'static str {
444        match self {
445            Self::NPlusOneSql => "N+1 SQL",
446            Self::NPlusOneHttp => "N+1 HTTP",
447            Self::NPlusOneMessaging => "N+1 messaging",
448            Self::RedundantSql => "Redundant SQL",
449            Self::RedundantHttp => "Redundant HTTP",
450            Self::SlowSql => "Slow SQL",
451            Self::SlowHttp => "Slow HTTP",
452            Self::SlowMessaging => "Slow messaging",
453            Self::ExcessiveFanout => "Excessive fanout",
454            Self::ChattyService => "Chatty service",
455            Self::PoolSaturation => "Pool saturation",
456            Self::SerializedCalls => "Serialized calls",
457        }
458    }
459
460    /// Whether this finding type represents avoidable I/O operations.
461    ///
462    /// Only N+1 and redundant qualify (batchable or cacheable). Slow,
463    /// fanout, chatty, pool saturation and serialized calls are excluded
464    /// from waste scoring; the per-type rationale is in the "Not part of
465    /// waste ratio" sections of `docs/design/04-DETECTION.md`.
466    #[must_use]
467    pub const fn is_avoidable_io(&self) -> bool {
468        matches!(
469            self,
470            Self::NPlusOneSql
471                | Self::NPlusOneHttp
472                | Self::NPlusOneMessaging
473                | Self::RedundantSql
474                | Self::RedundantHttp
475        )
476    }
477}
478
479impl Severity {
480    /// Returns the `snake_case` string representation of this severity.
481    #[must_use]
482    pub const fn as_str(&self) -> &'static str {
483        match self {
484            Self::Critical => "critical",
485            Self::Warning => "warning",
486            Self::Info => "info",
487        }
488    }
489}
490
491/// Configuration for the detection stage. Serialized into
492/// `Report.detection_config` so consumers see the producing run's values.
493///
494/// Deserialization stays strict on purpose: a partially drifted object
495/// must not silently become this binary's defaults, or the dashboard
496/// would state thresholds that never produced the findings. The
497/// tolerance lives one level up, on `Report::detection_config`, which
498/// degrades a shape it cannot read to `None`.
499#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
500pub struct DetectConfig {
501    pub n_plus_one_threshold: u32,
502    pub window_ms: u64,
503    pub slow_threshold_ms: u64,
504    pub slow_min_occurrences: u32,
505    pub max_fanout: u32,
506    pub chatty_service_min_calls: u32,
507    pub pool_saturation_concurrent_threshold: u32,
508    pub serialized_min_sequential: u32,
509    pub sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode,
510}
511
512impl Default for DetectConfig {
513    fn default() -> Self {
514        Self::from(&crate::config::Config::default())
515    }
516}
517
518impl From<&crate::config::Config> for DetectConfig {
519    fn from(config: &crate::config::Config) -> Self {
520        Self {
521            n_plus_one_threshold: config.detection.n_plus_one_threshold,
522            window_ms: config.detection.window_duration_ms,
523            slow_threshold_ms: config.detection.slow_query_threshold_ms,
524            slow_min_occurrences: config.detection.slow_query_min_occurrences,
525            max_fanout: config.detection.max_fanout,
526            chatty_service_min_calls: config.detection.chatty_service_min_calls,
527            pool_saturation_concurrent_threshold: config
528                .detection
529                .pool_saturation_concurrent_threshold,
530            serialized_min_sequential: config.detection.serialized_min_sequential,
531            sanitizer_aware_classification: config.detection.sanitizer_aware_classification,
532        }
533    }
534}
535
536/// Arguments for [`build_per_trace_finding`], grouped to stay under
537/// clippy's argument-count limit.
538pub(crate) struct PerTraceFindingArgs<'a> {
539    pub finding_type: FindingType,
540    pub severity: Severity,
541    pub trace_id: &'a str,
542    pub first_span: &'a crate::normalize::NormalizedEvent,
543    pub template: &'a str,
544    pub occurrences: usize,
545    pub window_ms: u64,
546    pub distinct_params: usize,
547    pub suggestion: String,
548    pub first_timestamp: &'a str,
549    pub last_timestamp: &'a str,
550    pub code_location: Option<crate::event::CodeLocation>,
551    pub instrumentation_scopes: Vec<String>,
552    pub classification_method: Option<ClassificationMethod>,
553    pub span_durations_us: Option<Vec<u64>>,
554}
555
556// Build a [`Finding`] from the common fields shared by per-trace
557// detectors (N+1, redundant, slow). Avoids duplicating the struct
558// literal across detection modules. (doc kept as non-doc comment to
559// avoid an empty-line-after-doc-comment clippy error with the next fn.)
560
561/// Compute diagnostic timing stats from a mutable slice of per-span
562/// durations (microseconds). Returns `(p50_us, p99_us, cv_x1000)`.
563fn compute_timing_stats(durations: &mut [u64]) -> (u64, u64, u32) {
564    if durations.is_empty() {
565        return (0, 0, 0);
566    }
567    durations.sort_unstable();
568    let n = durations.len();
569    let p50 = durations[slow::percentile_index(n, 50)];
570    let p99 = durations[slow::percentile_index(n, 99)];
571    #[allow(clippy::cast_precision_loss)]
572    let n_f = n as f64;
573    let mut mean = 0.0_f64;
574    let mut m2 = 0.0_f64;
575    let mut count = 0u64;
576    for &d in durations.iter() {
577        count += 1;
578        #[allow(clippy::cast_precision_loss)]
579        let val = d as f64;
580        let delta = val - mean;
581        #[allow(clippy::cast_precision_loss)]
582        let cf = count as f64;
583        mean += delta / cf;
584        m2 += delta * (val - mean);
585    }
586    let cv_x1000 = if mean > 0.0 && n_f > 1.0 {
587        let cv = (m2 / n_f).sqrt() / mean;
588        #[allow(clippy::cast_sign_loss)] // cv * 1000 is always non-negative
589        {
590            (cv * 1000.0).round() as u32
591        }
592    } else {
593        0
594    };
595    (p50, p99, cv_x1000)
596}
597
598pub(crate) fn build_per_trace_finding(args: PerTraceFindingArgs<'_>) -> Finding {
599    let timing = args
600        .span_durations_us
601        .map(|mut d| compute_timing_stats(&mut d));
602    Finding {
603        finding_type: args.finding_type,
604        severity: args.severity,
605        trace_id: args.trace_id.to_string(),
606        service: args.first_span.event.service.to_string(),
607        grouping: args.first_span.event.grouping.clone(),
608        source_endpoint: args.first_span.event.source.endpoint.clone(),
609        pattern: Pattern {
610            template: args.template.to_string(),
611            occurrences: args.occurrences,
612            window_ms: args.window_ms,
613            distinct_params: args.distinct_params,
614            span_duration_us_p50: timing.map(|(p50, _, _)| p50),
615            span_duration_us_p99: timing.map(|(_, p99, _)| p99),
616            span_duration_cv_x1000: timing.map(|(_, _, cv)| cv),
617        },
618        suggestion: args.suggestion,
619        first_timestamp: args.first_timestamp.to_string(),
620        last_timestamp: args.last_timestamp.to_string(),
621        green_impact: None,
622        confidence: Confidence::default(),
623        classification_method: args.classification_method,
624        code_location: args.code_location,
625        instrumentation_scopes: args.instrumentation_scopes,
626        suggested_fix: None,
627        signature: String::new(),
628    }
629}
630
631/// Stamp `confidence` on every finding in the slice.
632///
633/// Detectors emit `Confidence::default()` (= [`Confidence::CiBatch`])
634/// per the contract on [`Finding::confidence`]. Pipeline callers
635/// override the value with the runtime context (`CiBatch` for batch
636/// `analyze`, `DaemonStaging` or `DaemonProduction` for the daemon)
637/// using this helper so neither the batch nor the daemon path has to
638/// duplicate the loop.
639pub fn apply_confidence(findings: &mut [Finding], confidence: Confidence) {
640    for finding in findings.iter_mut() {
641        finding.confidence = confidence;
642    }
643}
644
645/// Run per-trace + cross-trace detection on a set of traces.
646///
647/// Returns the unsorted, unconfidence-stamped `Vec<Finding>`. Callers
648/// stamp confidence via [`apply_confidence`] then sort via
649/// [`sort_findings`] before emission.
650///
651/// Cross-trace detection is gated on `traces.len() >= 2` because the
652/// percentile-based `detect_slow_cross_trace` requires multiple
653/// observations to compute a meaningful p50/p95/p99.
654#[must_use]
655pub fn run_full_detection(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
656    let mut findings = detect(traces, config);
657    if traces.len() >= 2 {
658        let mut cross_trace = slow::detect_slow_cross_trace(
659            traces,
660            config.slow_threshold_ms,
661            config.slow_min_occurrences,
662        );
663        findings.append(&mut cross_trace);
664    }
665    findings
666}
667
668/// Run all per-trace detectors on a set of traces.
669///
670/// Does not include cross-trace analysis; see [`slow::detect_slow_cross_trace`]
671/// or use [`run_full_detection`] for the combined pass.
672#[must_use]
673pub fn detect(traces: &[Trace], config: &DetectConfig) -> Vec<Finding> {
674    let mut findings = Vec::new();
675    for trace in traces {
676        // Span-relationship indices are built once per trace and shared
677        // by the detectors that need them (fanout, serialized).
678        let indices = TraceIndices::build(trace);
679        // append() moves the backing allocation in O(1), no iterator
680        // overhead. n_plus_one must run before redundant: redundant
681        // receives its findings to skip templates already classified
682        // as N+1 (including sanitizer-heuristic reclassifications).
683        let mut n_plus_one_findings = n_plus_one::detect_n_plus_one(
684            trace,
685            config.n_plus_one_threshold,
686            config.window_ms,
687            config.sanitizer_aware_classification,
688        );
689        let mut redundant_findings = redundant::detect_redundant(trace, &n_plus_one_findings);
690        findings.append(&mut n_plus_one_findings);
691        findings.append(&mut redundant_findings);
692        findings.append(&mut slow::detect_slow(
693            trace,
694            config.slow_threshold_ms,
695            config.slow_min_occurrences,
696        ));
697        findings.append(&mut fanout::detect_fanout(
698            trace,
699            &indices,
700            config.max_fanout,
701        ));
702        findings.append(&mut chatty::detect_chatty(
703            trace,
704            config.chatty_service_min_calls,
705        ));
706        findings.append(&mut pool_saturation::detect_pool_saturation(
707            trace,
708            config.pool_saturation_concurrent_threshold,
709        ));
710        findings.append(&mut serialized::detect_serialized(
711            trace,
712            &indices,
713            config.serialized_min_sequential,
714        ));
715    }
716    suggestions::enrich(&mut findings);
717    findings
718}
719
720/// Sort findings deterministically for stable output.
721///
722/// Orders by finding type, severity, trace ID, source endpoint, and template.
723pub(crate) fn sort_findings(findings: &mut [Finding]) {
724    findings.sort_by(|a, b| {
725        a.finding_type
726            .cmp(&b.finding_type)
727            .then_with(|| a.severity.cmp(&b.severity))
728            .then_with(|| a.trace_id.cmp(&b.trace_id))
729            .then_with(|| a.source_endpoint.cmp(&b.source_endpoint))
730            .then_with(|| a.pattern.template.cmp(&b.pattern.template))
731    });
732}
733
734/// Test-only `Finding` factory shared by the `pg_stat` and `mysql_stat`
735/// cross-reference tests (only the pattern template matters to them).
736#[cfg(test)]
737pub(crate) fn test_finding_with_template(template: &str) -> Finding {
738    Finding {
739        finding_type: FindingType::NPlusOneSql,
740        severity: Severity::Warning,
741        trace_id: "trace-1".to_string(),
742        service: "order-svc".to_string(),
743        grouping: Vec::new(),
744        source_endpoint: "POST /api/orders/42/submit".to_string(),
745        pattern: Pattern {
746            template: template.to_string(),
747            occurrences: 6,
748            window_ms: 200,
749            distinct_params: 6,
750            ..Default::default()
751        },
752        suggestion: "batch".to_string(),
753        first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
754        last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
755        green_impact: None,
756        confidence: Confidence::default(),
757        classification_method: None,
758        code_location: None,
759        instrumentation_scopes: Vec::new(),
760        suggested_fix: None,
761        signature: String::new(),
762    }
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    fn default_config() -> DetectConfig {
770        DetectConfig {
771            n_plus_one_threshold: 5,
772            window_ms: 500,
773            slow_threshold_ms: 500,
774            slow_min_occurrences: 3,
775            max_fanout: 20,
776            chatty_service_min_calls: 15,
777            pool_saturation_concurrent_threshold: 10,
778            serialized_min_sequential: 3,
779            sanitizer_aware_classification: sanitizer_aware::SanitizerAwareMode::default(),
780        }
781    }
782
783    #[test]
784    fn empty_traces_produce_no_findings() {
785        let findings = detect(&[], &default_config());
786        assert!(findings.is_empty());
787    }
788
789    #[test]
790    fn finding_type_serializes_to_snake_case() {
791        let json = serde_json::to_string(&FindingType::NPlusOneSql).unwrap();
792        assert_eq!(json, r#""n_plus_one_sql""#);
793
794        let json = serde_json::to_string(&FindingType::RedundantHttp).unwrap();
795        assert_eq!(json, r#""redundant_http""#);
796
797        let json = serde_json::to_string(&FindingType::SlowSql).unwrap();
798        assert_eq!(json, r#""slow_sql""#);
799
800        let json = serde_json::to_string(&FindingType::SlowHttp).unwrap();
801        assert_eq!(json, r#""slow_http""#);
802
803        let json = serde_json::to_string(&FindingType::ExcessiveFanout).unwrap();
804        assert_eq!(json, r#""excessive_fanout""#);
805
806        let json = serde_json::to_string(&FindingType::ChattyService).unwrap();
807        assert_eq!(json, r#""chatty_service""#);
808
809        let json = serde_json::to_string(&FindingType::PoolSaturation).unwrap();
810        assert_eq!(json, r#""pool_saturation""#);
811
812        let json = serde_json::to_string(&FindingType::SerializedCalls).unwrap();
813        assert_eq!(json, r#""serialized_calls""#);
814    }
815
816    #[test]
817    fn severity_serializes_to_snake_case() {
818        let json = serde_json::to_string(&Severity::Critical).unwrap();
819        assert_eq!(json, r#""critical""#);
820    }
821
822    // --- Confidence field tests ---
823
824    #[test]
825    fn confidence_default_is_ci_batch() {
826        assert_eq!(Confidence::default(), Confidence::CiBatch);
827    }
828
829    #[test]
830    fn confidence_serializes_to_snake_case() {
831        assert_eq!(
832            serde_json::to_string(&Confidence::LocalBatch).unwrap(),
833            r#""local_batch""#
834        );
835        assert_eq!(
836            serde_json::to_string(&Confidence::CiBatch).unwrap(),
837            r#""ci_batch""#
838        );
839        assert_eq!(
840            serde_json::to_string(&Confidence::DaemonStaging).unwrap(),
841            r#""daemon_staging""#
842        );
843        assert_eq!(
844            serde_json::to_string(&Confidence::DaemonProduction).unwrap(),
845            r#""daemon_production""#
846        );
847    }
848
849    #[test]
850    fn confidence_deserializes_from_snake_case() {
851        let c: Confidence = serde_json::from_str(r#""local_batch""#).unwrap();
852        assert_eq!(c, Confidence::LocalBatch);
853        let c: Confidence = serde_json::from_str(r#""ci_batch""#).unwrap();
854        assert_eq!(c, Confidence::CiBatch);
855        let c: Confidence = serde_json::from_str(r#""daemon_staging""#).unwrap();
856        assert_eq!(c, Confidence::DaemonStaging);
857        let c: Confidence = serde_json::from_str(r#""daemon_production""#).unwrap();
858        assert_eq!(c, Confidence::DaemonProduction);
859    }
860
861    #[test]
862    fn confidence_as_str_matches_serialization() {
863        assert_eq!(Confidence::LocalBatch.as_str(), "local_batch");
864        assert_eq!(Confidence::CiBatch.as_str(), "ci_batch");
865        assert_eq!(Confidence::DaemonStaging.as_str(), "daemon_staging");
866        assert_eq!(Confidence::DaemonProduction.as_str(), "daemon_production");
867    }
868
869    #[test]
870    fn confidence_sarif_rank_increases_with_confidence() {
871        // Ordering must be strictly ascending so SARIF consumers that sort
872        // by rank produce the expected "production > staging > CI > local" order.
873        assert!(Confidence::LocalBatch.sarif_rank() < Confidence::CiBatch.sarif_rank());
874        assert!(Confidence::CiBatch.sarif_rank() < Confidence::DaemonStaging.sarif_rank());
875        assert!(Confidence::DaemonStaging.sarif_rank() < Confidence::DaemonProduction.sarif_rank());
876        assert_eq!(Confidence::LocalBatch.sarif_rank(), 15);
877        assert_eq!(Confidence::CiBatch.sarif_rank(), 30);
878        assert_eq!(Confidence::DaemonStaging.sarif_rank(), 60);
879        assert_eq!(Confidence::DaemonProduction.sarif_rank(), 90);
880    }
881
882    #[test]
883    fn batch_for_ci_maps_and_is_batch_classifies() {
884        assert_eq!(Confidence::batch_for_ci(true), Confidence::CiBatch);
885        assert_eq!(Confidence::batch_for_ci(false), Confidence::LocalBatch);
886        assert!(Confidence::LocalBatch.is_batch());
887        assert!(Confidence::CiBatch.is_batch());
888        assert!(!Confidence::DaemonStaging.is_batch());
889        assert!(!Confidence::DaemonProduction.is_batch());
890    }
891
892    #[test]
893    fn detector_findings_default_to_ci_batch_confidence() {
894        // Detectors emit `Confidence::default()`, the pipeline/daemon
895        // caller is responsible for stamping the real value. Verify the
896        // default here so a regression that changes Confidence::default()
897        // surfaces loudly.
898        use crate::test_helpers::{make_sql_event, make_trace};
899        let events: Vec<crate::event::SpanEvent> = (1..=6)
900            .map(|i| {
901                make_sql_event(
902                    "trace-1",
903                    &format!("span-{i}"),
904                    &format!("SELECT * FROM order_item WHERE order_id = {i}"),
905                    &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
906                )
907            })
908            .collect();
909        let trace = make_trace(events);
910        let findings = detect(&[trace], &default_config());
911        assert!(!findings.is_empty());
912        for f in &findings {
913            assert_eq!(f.confidence, Confidence::CiBatch);
914        }
915    }
916
917    #[test]
918    fn detect_combines_n_plus_one_and_redundant() {
919        use crate::test_helpers::{make_sql_event, make_trace};
920        // 5 events with different params -> N+1
921        // 3 events with same params -> redundant
922        let mut events = Vec::new();
923        for i in 1..=5 {
924            events.push(make_sql_event(
925                "trace-1",
926                &format!("span-{i}"),
927                &format!("SELECT * FROM order_item WHERE order_id = {i}"),
928                &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
929            ));
930        }
931        for i in 6..=8 {
932            events.push(make_sql_event(
933                "trace-1",
934                &format!("span-{i}"),
935                "SELECT * FROM config WHERE key = 'timeout'",
936                &format!("2025-07-10T14:32:01.{:03}Z", i * 30),
937            ));
938        }
939
940        let trace = make_trace(events);
941        let findings = detect(&[trace], &default_config());
942
943        let has_n_plus_one = findings
944            .iter()
945            .any(|f| f.finding_type == FindingType::NPlusOneSql);
946        let has_redundant = findings
947            .iter()
948            .any(|f| f.finding_type == FindingType::RedundantSql);
949        assert!(has_n_plus_one, "should detect N+1");
950        assert!(has_redundant, "should detect redundant");
951    }
952
953    #[test]
954    fn detect_multiple_traces() {
955        use crate::test_helpers::{make_sql_event, make_trace};
956        // Two separate traces, each with redundant queries
957        let events_t1: Vec<crate::event::SpanEvent> = (1..=3)
958            .map(|i| {
959                make_sql_event(
960                    "trace-A",
961                    &format!("span-a{i}"),
962                    "SELECT * FROM order_item WHERE order_id = 42",
963                    &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
964                )
965            })
966            .collect();
967
968        let events_t2: Vec<crate::event::SpanEvent> = (1..=2)
969            .map(|i| {
970                make_sql_event(
971                    "trace-B",
972                    &format!("span-b{i}"),
973                    "SELECT * FROM orders WHERE user_id = 7",
974                    &format!("2025-07-10T14:32:02.{:03}Z", i * 50),
975                )
976            })
977            .collect();
978
979        let trace_a = make_trace(events_t1);
980        let trace_b = make_trace(events_t2);
981        let findings = detect(&[trace_a, trace_b], &default_config());
982
983        // Both traces have redundant queries
984        assert!(
985            findings.iter().any(|f| f.trace_id == "trace-A"),
986            "trace-A should have findings"
987        );
988        assert!(
989            findings.iter().any(|f| f.trace_id == "trace-B"),
990            "trace-B should have findings"
991        );
992    }
993
994    #[test]
995    fn finding_type_as_str() {
996        assert_eq!(FindingType::NPlusOneSql.as_str(), "n_plus_one_sql");
997        assert_eq!(FindingType::SlowHttp.as_str(), "slow_http");
998        assert_eq!(FindingType::ChattyService.as_str(), "chatty_service");
999        assert_eq!(FindingType::PoolSaturation.as_str(), "pool_saturation");
1000        assert_eq!(FindingType::SerializedCalls.as_str(), "serialized_calls");
1001    }
1002
1003    #[test]
1004    fn severity_as_str() {
1005        assert_eq!(Severity::Critical.as_str(), "critical");
1006        assert_eq!(Severity::Warning.as_str(), "warning");
1007        assert_eq!(Severity::Info.as_str(), "info");
1008    }
1009
1010    #[test]
1011    fn rgesn_criteria_crosswalk() {
1012        // N+1 and redundant relate to server caching (7.1).
1013        assert_eq!(FindingType::NPlusOneSql.rgesn_criteria(), &["7.1", "6.1"]);
1014        assert_eq!(FindingType::RedundantHttp.rgesn_criteria(), &["7.1", "6.5"]);
1015        assert_eq!(
1016            FindingType::ChattyService.rgesn_criteria(),
1017            &["4.9", "4.10", "6.1"]
1018        );
1019        assert_eq!(FindingType::ExcessiveFanout.rgesn_criteria(), &["3.2"]);
1020        assert_eq!(FindingType::PoolSaturation.rgesn_criteria(), &["3.2"]);
1021        assert_eq!(FindingType::SerializedCalls.rgesn_criteria(), &["8.10"]);
1022        // slow_* has no direct RGESN criterion (family 9 is ML-specific).
1023        assert!(FindingType::SlowSql.rgesn_criteria().is_empty());
1024        assert!(FindingType::SlowHttp.rgesn_criteria().is_empty());
1025    }
1026
1027    #[test]
1028    fn from_kind_str_inverts_as_str() {
1029        // Lock from_kind_str against as_str drift. as_str is an exhaustive
1030        // match (the compiler forces an arm for every new variant), but
1031        // from_kind_str matches on `&str` with a `_ => None` fallback, so a
1032        // new variant would silently parse to None and drop its rgesn_criteria
1033        // from disclosure. This round-trip fails if the two ever disagree.
1034        use FindingType::*;
1035        for v in [
1036            NPlusOneSql,
1037            NPlusOneHttp,
1038            RedundantSql,
1039            RedundantHttp,
1040            SlowSql,
1041            SlowHttp,
1042            ExcessiveFanout,
1043            ChattyService,
1044            PoolSaturation,
1045            SerializedCalls,
1046        ] {
1047            assert_eq!(
1048                FindingType::from_kind_str(v.as_str()),
1049                Some(v.clone()),
1050                "{v:?}"
1051            );
1052        }
1053        assert_eq!(FindingType::from_kind_str("unknown_pattern"), None);
1054    }
1055
1056    #[test]
1057    fn finding_type_from_event_type_n_plus_one() {
1058        use crate::event::EventType;
1059        assert_eq!(
1060            FindingType::from_event_type_n_plus_one(&EventType::Sql),
1061            FindingType::NPlusOneSql
1062        );
1063        assert_eq!(
1064            FindingType::from_event_type_n_plus_one(&EventType::HttpOut),
1065            FindingType::NPlusOneHttp
1066        );
1067    }
1068
1069    #[test]
1070    fn finding_type_from_event_type_redundant() {
1071        use crate::event::EventType;
1072        assert_eq!(
1073            FindingType::from_event_type_redundant(&EventType::Sql),
1074            Some(FindingType::RedundantSql)
1075        );
1076        assert_eq!(
1077            FindingType::from_event_type_redundant(&EventType::HttpOut),
1078            Some(FindingType::RedundantHttp)
1079        );
1080        // A publish carries no params, so redundancy is not observable.
1081        assert_eq!(
1082            FindingType::from_event_type_redundant(&EventType::Messaging),
1083            None
1084        );
1085    }
1086
1087    #[test]
1088    fn finding_type_from_event_type_slow() {
1089        use crate::event::EventType;
1090        assert_eq!(
1091            FindingType::from_event_type_slow(&EventType::Sql),
1092            FindingType::SlowSql
1093        );
1094        assert_eq!(
1095            FindingType::from_event_type_slow(&EventType::HttpOut),
1096            FindingType::SlowHttp
1097        );
1098    }
1099
1100    #[test]
1101    fn detect_all_three_types_on_one_trace() {
1102        use crate::test_helpers::{make_sql_event, make_sql_event_with_duration, make_trace};
1103        let mut events = Vec::new();
1104        // 5 different params -> N+1
1105        for i in 1..=5 {
1106            events.push(make_sql_event(
1107                "trace-1",
1108                &format!("span-n{i}"),
1109                &format!("SELECT * FROM order_item WHERE order_id = {i}"),
1110                &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
1111            ));
1112        }
1113        // 3 identical queries -> redundant
1114        for i in 1..=3 {
1115            events.push(make_sql_event(
1116                "trace-1",
1117                &format!("span-r{i}"),
1118                "SELECT * FROM config WHERE key = 'timeout'",
1119                &format!("2025-07-10T14:32:02.{:03}Z", i * 30),
1120            ));
1121        }
1122        // 3 slow queries -> slow
1123        for i in 1..=3 {
1124            events.push(make_sql_event_with_duration(
1125                "trace-1",
1126                &format!("span-s{i}"),
1127                &format!("SELECT * FROM big_table WHERE id = {}", i + 100),
1128                &format!("2025-07-10T14:32:03.{:03}Z", i * 30),
1129                600_000,
1130            ));
1131        }
1132        let trace = make_trace(events);
1133        let findings = detect(&[trace], &default_config());
1134
1135        let has_n1 = findings
1136            .iter()
1137            .any(|f| f.finding_type == FindingType::NPlusOneSql);
1138        let has_redundant = findings
1139            .iter()
1140            .any(|f| f.finding_type == FindingType::RedundantSql);
1141        let has_slow = findings
1142            .iter()
1143            .any(|f| f.finding_type == FindingType::SlowSql);
1144
1145        assert!(has_n1, "should detect N+1");
1146        assert!(has_redundant, "should detect redundant");
1147        assert!(has_slow, "should detect slow");
1148    }
1149
1150    // --- Serde roundtrip for Finding (Deserialize added for query CLI) ---
1151
1152    #[test]
1153    fn finding_serde_roundtrip() {
1154        let finding =
1155            crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1156        let json = serde_json::to_string(&finding).unwrap();
1157        let back: Finding = serde_json::from_str(&json).unwrap();
1158        assert_eq!(finding.finding_type, back.finding_type);
1159        assert_eq!(finding.severity, back.severity);
1160        assert_eq!(finding.trace_id, back.trace_id);
1161        assert_eq!(finding.service, back.service);
1162        assert_eq!(finding.pattern.template, back.pattern.template);
1163        assert_eq!(finding.confidence, back.confidence);
1164    }
1165
1166    #[test]
1167    fn finding_with_code_location_serde_roundtrip() {
1168        let mut finding =
1169            crate::test_helpers::make_finding(FindingType::NPlusOneSql, Severity::Warning);
1170        finding.code_location = Some(crate::event::CodeLocation {
1171            function: Some("processItems".to_string()),
1172            filepath: Some("src/Order.java".to_string()),
1173            lineno: Some(42),
1174            namespace: Some("com.example".to_string()),
1175        });
1176        let json = serde_json::to_string(&finding).unwrap();
1177        let back: Finding = serde_json::from_str(&json).unwrap();
1178        let loc = back.code_location.unwrap();
1179        assert_eq!(loc.function.as_deref(), Some("processItems"));
1180        assert_eq!(loc.lineno, Some(42));
1181    }
1182
1183    #[test]
1184    fn finding_type_deserializes_from_snake_case() {
1185        let ft: FindingType = serde_json::from_str(r#""n_plus_one_sql""#).unwrap();
1186        assert_eq!(ft, FindingType::NPlusOneSql);
1187        let ft: FindingType = serde_json::from_str(r#""chatty_service""#).unwrap();
1188        assert_eq!(ft, FindingType::ChattyService);
1189    }
1190
1191    #[test]
1192    fn severity_deserializes_from_snake_case() {
1193        let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
1194        assert_eq!(s, Severity::Critical);
1195        let s: Severity = serde_json::from_str(r#""warning""#).unwrap();
1196        assert_eq!(s, Severity::Warning);
1197    }
1198
1199    // --- compute_timing_stats ---
1200
1201    #[test]
1202    fn timing_stats_empty_returns_zeroes() {
1203        assert_eq!(compute_timing_stats(&mut []), (0, 0, 0));
1204    }
1205
1206    #[test]
1207    fn timing_stats_single_element() {
1208        let (p50, p99, cv) = compute_timing_stats(&mut [800]);
1209        assert_eq!(p50, 800);
1210        assert_eq!(p99, 800);
1211        assert_eq!(cv, 0);
1212    }
1213
1214    #[test]
1215    fn timing_stats_two_elements_p99_is_max() {
1216        let (p50, p99, _cv) = compute_timing_stats(&mut [100, 900]);
1217        assert_eq!(p50, 100); // n=2, p50 index = 0 (lower value)
1218        assert_eq!(p99, 900); // n=2, p99 index = 1 (max)
1219    }
1220
1221    #[test]
1222    fn timing_stats_five_elements_p99_is_max() {
1223        let (p50, p99, _cv) = compute_timing_stats(&mut [10, 20, 30, 40, 50]);
1224        assert_eq!(p50, 30);
1225        assert_eq!(p99, 50);
1226    }
1227
1228    #[test]
1229    fn timing_stats_identical_durations_cv_zero() {
1230        let mut durations = [100u64; 10];
1231        let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1232        assert_eq!(cv, 0);
1233    }
1234
1235    #[test]
1236    fn timing_stats_dispersed_durations_cv_matches_variance_helper() {
1237        let mut durations = [100u64, 50, 200, 60, 250, 80, 300, 70, 150, 400];
1238        let (_p50, _p99, cv) = compute_timing_stats(&mut durations);
1239        // CV ~ 0.68 on this set → cv_x1000 ~ 680
1240        assert!(cv > 500, "CV should be > 0.5, got {cv}");
1241        assert!(cv < 800, "CV should be < 0.8, got {cv}");
1242    }
1243}