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