Skip to main content

sentinel_core/
diff.rs

1//! Diff stage: compare two analysis reports and produce a delta.
2//!
3//! Primary use case: PR CI integration. Run `analyze` on the base
4//! branch's traces, run it again on the PR branch's traces, then compare
5//! the two reports to surface regressions (new findings, severity
6//! escalations, per-endpoint I/O op increases) and improvements
7//! (resolved findings, severity de-escalations, per-endpoint I/O op
8//! decreases).
9//!
10//! Finding identity for stable comparison is the tuple
11//! `(finding_type, service, source_endpoint, pattern.template)`. The
12//! template is already normalized at detection time so direct equality
13//! suffices, no re-normalization at diff time.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::Serialize;
18
19use crate::detect::{Finding, FindingType, Severity};
20use crate::report::{PerEndpointIoOps, Report, Warning};
21
22/// Stable identity tuple for matching findings between two runs.
23///
24/// Two findings with the same `IdentityKey` are considered "the same
25/// anti-pattern" across runs. If multiple findings in one run share a
26/// key (e.g. the same N+1 template fired in two traces), the diff
27/// engine collapses them to one entry by keeping the worst-severity
28/// finding for that key.
29type IdentityKey = (
30    FindingType,
31    String,
32    Option<(String, String)>,
33    String,
34    String,
35);
36
37fn identity_of(finding: &Finding, with_grouping: bool) -> IdentityKey {
38    (
39        finding.finding_type.clone(),
40        finding.service.clone(),
41        // Without the grouping, the same problem in two deployments folds
42        // into one row with summed occurrences.
43        with_grouping
44            .then(|| {
45                finding
46                    .effective_grouping()
47                    .map(|g| (g.key.to_string(), g.value.to_string()))
48            })
49            .flatten(),
50        finding.source_endpoint.clone(),
51        finding.pattern.template.clone(),
52    )
53}
54
55/// A baseline written before 0.11.0 carries no grouping, so keying on it
56/// would make every current finding new and every baseline one resolved: a
57/// total false regression on the first run after upgrade. Detected rather
58/// than versioned, so an operator who genuinely runs an ungrouped fleet is
59/// unaffected (nothing to separate on either side anyway).
60fn grouping_is_comparable(before: &[Finding], after: &[Finding]) -> bool {
61    before.iter().any(|f| f.effective_grouping().is_some())
62        || after.iter().all(|f| f.effective_grouping().is_none())
63}
64
65/// Delta between two analysis runs.
66///
67/// Stable JSON shape. Field names will not be renamed or removed in a
68/// minor release; new optional fields may be added.
69#[derive(Debug, Clone, Serialize)]
70pub struct DiffReport {
71    /// Findings present in `after` but absent from `before`.
72    pub new_findings: Vec<Finding>,
73    /// Findings present in `before` but absent from `after`.
74    pub resolved_findings: Vec<Finding>,
75    /// Findings present in both runs whose worst severity differs.
76    /// Ordered with regressions (worse-after) first, then improvements.
77    pub severity_changes: Vec<SeverityChange>,
78    /// Per-endpoint I/O op deltas. Ordered with the largest regressions
79    /// first (most positive delta), then improvements last.
80    pub endpoint_metric_deltas: Vec<EndpointDelta>,
81    /// Warnings carried over from the `after` run (e.g.
82    /// `unmatched_acknowledgment`), so a diff-only CI surface does not
83    /// drop them. Additive, absent when empty.
84    #[serde(skip_serializing_if = "Vec::is_empty")]
85    pub warning_details: Vec<Warning>,
86}
87
88/// A finding whose worst severity changed between the two runs.
89#[derive(Debug, Clone, Serialize)]
90pub struct SeverityChange {
91    /// The "after" version of the finding (same identity, latest data).
92    pub finding: Finding,
93    pub before_severity: Severity,
94    pub after_severity: Severity,
95}
96
97impl SeverityChange {
98    /// `true` when the after severity is worse than the before severity.
99    /// Used to sort regressions ahead of improvements in the output and
100    /// reused by the CLI text renderer to color regressions red.
101    ///
102    /// Severity derives `Ord` with declaration order (Critical < Warning
103    /// < Info). "Worse" means numerically lower.
104    #[must_use]
105    pub fn is_regression(&self) -> bool {
106        self.after_severity < self.before_severity
107    }
108}
109
110/// Per-endpoint I/O op count delta between two runs.
111#[derive(Debug, Clone, Serialize)]
112pub struct EndpointDelta {
113    pub service: String,
114    pub endpoint: String,
115    pub before_io_ops: usize,
116    pub after_io_ops: usize,
117    /// `after - before`. Positive = regression, negative = improvement.
118    pub delta: i64,
119}
120
121/// Compare two analysis reports.
122///
123/// Both reports are expected to come from `pipeline::analyze` runs on
124/// their respective trace sets, with the same `Config` (otherwise the
125/// per-endpoint counts and severity assignments may not be comparable).
126///
127/// Pure function: takes references and returns owned data. No I/O.
128#[must_use]
129pub fn diff_runs(before: &Report, after: &Report) -> DiffReport {
130    let with_grouping = grouping_is_comparable(&before.findings, &after.findings);
131    let before_map = build_identity_map(&before.findings, with_grouping);
132    let after_map = build_identity_map(&after.findings, with_grouping);
133
134    let mut new_findings: Vec<Finding> = Vec::new();
135    let mut resolved_findings: Vec<Finding> = Vec::new();
136    let mut severity_changes: Vec<SeverityChange> = Vec::new();
137
138    for (key, after_finding) in &after_map {
139        match before_map.get(key) {
140            None => new_findings.push(after_finding.clone()),
141            Some(before_finding) if before_finding.severity != after_finding.severity => {
142                severity_changes.push(SeverityChange {
143                    finding: after_finding.clone(),
144                    before_severity: before_finding.severity.clone(),
145                    after_severity: after_finding.severity.clone(),
146                });
147            }
148            Some(_) => {}
149        }
150    }
151    for (key, before_finding) in &before_map {
152        if !after_map.contains_key(key) {
153            resolved_findings.push(before_finding.clone());
154        }
155    }
156
157    // Stable, deterministic ordering for the two finding lists. Reuses
158    // the same ordering rule as `analyze` so a reader's mental model
159    // is identical between the two outputs.
160    crate::detect::sort_findings(&mut new_findings);
161    crate::detect::sort_findings(&mut resolved_findings);
162    // Severity changes: regressions (worse-after) first, then
163    // improvements. Within each group, sort by the same finding-order
164    // rule as the regular output for predictability.
165    severity_changes.sort_by(|a, b| {
166        b.is_regression()
167            .cmp(&a.is_regression())
168            .then_with(|| a.finding.finding_type.cmp(&b.finding.finding_type))
169            .then_with(|| a.finding.service.cmp(&b.finding.service))
170            .then_with(|| a.finding.source_endpoint.cmp(&b.finding.source_endpoint))
171            .then_with(|| a.finding.pattern.template.cmp(&b.finding.pattern.template))
172    });
173
174    let endpoint_metric_deltas =
175        diff_per_endpoint_io_ops(&before.per_endpoint_io_ops, &after.per_endpoint_io_ops);
176
177    DiffReport {
178        new_findings,
179        resolved_findings,
180        severity_changes,
181        endpoint_metric_deltas,
182        warning_details: after.warning_details.clone(),
183    }
184}
185
186/// Build an identity-keyed map of findings, collapsing duplicates by
187/// keeping the worst severity AND summing `pattern.occurrences` across
188/// all duplicates. Used as the canonical view of each side before
189/// computing the diff.
190///
191/// Returning owned `Finding` values (not borrowed) lets us aggregate
192/// occurrences without mutating the input slice. The aggregated
193/// occurrences mean the user sees the total amplitude of the pattern
194/// across the trace set, not just one trace's count, so a regression
195/// from "6 occurrences in trace A" to "60 occurrences in trace A AND 60
196/// in trace B" surfaces as a meaningful endpoint-delta plus (when
197/// severity escalates) a `severity_change`.
198///
199/// Tie-break for the kept Finding template: the first finding inserted
200/// at a key wins for `trace_id` / `first_timestamp` / `code_location` /
201/// `suggested_fix`. Since `pipeline::analyze` calls `sort_findings`
202/// before returning, this is deterministic.
203fn build_identity_map(findings: &[Finding], with_grouping: bool) -> BTreeMap<IdentityKey, Finding> {
204    let mut map: BTreeMap<IdentityKey, Finding> = BTreeMap::new();
205    for finding in findings {
206        let key = identity_of(finding, with_grouping);
207        match map.get_mut(&key) {
208            None => {
209                map.insert(key, finding.clone());
210            }
211            Some(existing) => {
212                // Severity derives Ord with declaration order:
213                // Critical < Warning < Info. Worst = min.
214                if finding.severity < existing.severity {
215                    let summed = existing
216                        .pattern
217                        .occurrences
218                        .saturating_add(finding.pattern.occurrences);
219                    *existing = finding.clone();
220                    existing.pattern.occurrences = summed;
221                } else {
222                    existing.pattern.occurrences = existing
223                        .pattern
224                        .occurrences
225                        .saturating_add(finding.pattern.occurrences);
226                }
227            }
228        }
229    }
230    map
231}
232
233/// Pair up `before` and `after` per-endpoint I/O op counts and emit one
234/// `EndpointDelta` per `(service, endpoint)` whose count differs.
235/// Endpoints absent from one side are treated as `0` on that side.
236/// Sorted regressions-first, then improvements (largest absolute delta
237/// inside each group last for the improvements direction).
238fn diff_per_endpoint_io_ops(
239    before: &[PerEndpointIoOps],
240    after: &[PerEndpointIoOps],
241) -> Vec<EndpointDelta> {
242    let mut before_map: BTreeMap<(&str, &str), usize> = BTreeMap::new();
243    for entry in before {
244        before_map.insert((&entry.service, &entry.endpoint), entry.io_ops);
245    }
246    let mut after_map: BTreeMap<(&str, &str), usize> = BTreeMap::new();
247    for entry in after {
248        after_map.insert((&entry.service, &entry.endpoint), entry.io_ops);
249    }
250
251    let mut keys: BTreeSet<(&str, &str)> = BTreeSet::new();
252    keys.extend(before_map.keys().copied());
253    keys.extend(after_map.keys().copied());
254
255    let mut deltas: Vec<EndpointDelta> = keys
256        .iter()
257        .filter_map(|(service, endpoint)| {
258            let before_io = before_map.get(&(*service, *endpoint)).copied().unwrap_or(0);
259            let after_io = after_map.get(&(*service, *endpoint)).copied().unwrap_or(0);
260            if before_io == after_io {
261                return None;
262            }
263            // Cast through i128 to handle the worst case (`usize::MAX`
264            // on either side) without panicking. Any plausible counts
265            // fit comfortably in i64; if a future workload pushes past
266            // `i64::MAX` we clamp and warn rather than overflow silently.
267            let delta = i128::from(after_io as u64) - i128::from(before_io as u64);
268            let delta_i64 = i64::try_from(delta).unwrap_or_else(|_| {
269                tracing::warn!(
270                    target: "perf_sentinel::diff",
271                    service = %service,
272                    endpoint = %endpoint,
273                    before_io = before_io,
274                    after_io = after_io,
275                    "endpoint I/O op delta overflows i64, clamping for output"
276                );
277                if delta > 0 { i64::MAX } else { i64::MIN }
278            });
279            Some(EndpointDelta {
280                service: (*service).to_string(),
281                endpoint: (*endpoint).to_string(),
282                before_io_ops: before_io,
283                after_io_ops: after_io,
284                delta: delta_i64,
285            })
286        })
287        .collect();
288
289    // Regressions first: positive deltas before negative ones, then by
290    // descending magnitude inside each group. Tie-break on `(service,
291    // endpoint)` so the ordering is stable across runs even if
292    // `sort_by`'s internal stability guarantee changes.
293    deltas.sort_by(|a, b| {
294        b.delta
295            .cmp(&a.delta)
296            .then_with(|| a.service.cmp(&b.service))
297            .then_with(|| a.endpoint.cmp(&b.endpoint))
298    });
299    deltas
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::detect::{Confidence, Finding, FindingType, Pattern, Severity};
306    use crate::report::{Analysis, GreenSummary, PerEndpointIoOps, QualityGate, Report};
307
308    fn make_report(findings: Vec<Finding>, per_endpoint: Vec<PerEndpointIoOps>) -> Report {
309        Report {
310            analysis: Analysis {
311                duration_ms: 0,
312                events_processed: 0,
313                traces_analyzed: 0,
314                ingest: None,
315            },
316            findings,
317            green_summary: GreenSummary::disabled(0),
318            quality_gate: QualityGate {
319                passed: true,
320                rules: vec![],
321            },
322            per_endpoint_io_ops: per_endpoint,
323            correlations: vec![],
324            embedded_traces: vec![],
325            warnings: vec![],
326            warning_details: vec![],
327            acknowledged_findings: vec![],
328            binary_version: String::new(),
329            detection_config: None,
330            disclosure_waste: None,
331        }
332    }
333
334    fn finding(
335        ft: FindingType,
336        sev: Severity,
337        service: &str,
338        endpoint: &str,
339        template: &str,
340    ) -> Finding {
341        Finding {
342            finding_type: ft,
343            severity: sev,
344            trace_id: "trace-1".to_string(),
345            service: service.to_string(),
346            grouping: Vec::new(),
347            source_endpoint: endpoint.to_string(),
348            pattern: Pattern {
349                template: template.to_string(),
350                occurrences: 6,
351                window_ms: 200,
352                distinct_params: 6,
353                ..Default::default()
354            },
355            suggestion: "batch".to_string(),
356            first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
357            last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
358            green_impact: None,
359            confidence: Confidence::default(),
360            classification_method: None,
361            code_location: None,
362            instrumentation_scopes: Vec::new(),
363            suggested_fix: None,
364            signature: String::new(),
365        }
366    }
367
368    #[test]
369    fn identity_separates_the_same_problem_in_two_namespaces() {
370        let mut prod = finding(
371            FindingType::NPlusOneSql,
372            Severity::Warning,
373            "order-svc",
374            "GET /api/orders",
375            "SELECT * FROM t WHERE id = ?",
376        );
377        prod.grouping = crate::test_helpers::k8s_grouping("prod-eu");
378        let mut staging = prod.clone();
379        staging.grouping = crate::test_helpers::k8s_grouping("staging");
380
381        let before = make_report(vec![prod.clone()], vec![]);
382        let after = make_report(vec![prod, staging], vec![]);
383        let report = diff_runs(&before, &after);
384
385        assert_eq!(report.new_findings.len(), 1);
386        assert_eq!(
387            report.new_findings[0].grouping_value(),
388            Some("staging"),
389            "the staging deployment must not fold into the prod row"
390        );
391    }
392
393    #[test]
394    fn identity_separates_equal_values_from_different_grouping_keys() {
395        let mut tenant = finding(
396            FindingType::NPlusOneSql,
397            Severity::Warning,
398            "order-svc",
399            "GET /api/orders",
400            "SELECT * FROM t WHERE id = ?",
401        );
402        tenant.grouping = crate::test_helpers::grouping("tenant.id", "prod");
403        let mut namespace = tenant.clone();
404        namespace.grouping = crate::test_helpers::grouping("k8s.namespace.name", "prod");
405
406        let before = make_report(vec![tenant.clone()], vec![]);
407        let after = make_report(vec![tenant, namespace], vec![]);
408        let report = diff_runs(&before, &after);
409
410        assert_eq!(report.new_findings.len(), 1);
411        assert_eq!(
412            report.new_findings[0]
413                .effective_grouping()
414                .map(|g| g.key.as_ref()),
415            Some("k8s.namespace.name")
416        );
417    }
418
419    /// A baseline written before 0.11.0 has no grouping. Keying on it would
420    /// report every finding as new and every baseline one as resolved, a
421    /// total false regression on the first run after upgrade.
422    #[test]
423    fn a_baseline_without_grouping_does_not_diff_as_a_total_regression() {
424        let legacy = finding(
425            FindingType::NPlusOneSql,
426            Severity::Warning,
427            "order-svc",
428            "GET /api/orders",
429            "SELECT * FROM t WHERE id = ?",
430        );
431        let mut current = legacy.clone();
432        current.grouping = crate::test_helpers::k8s_grouping("prod-eu");
433
434        let report = diff_runs(
435            &make_report(vec![legacy], vec![]),
436            &make_report(vec![current], vec![]),
437        );
438
439        assert!(report.new_findings.is_empty(), "{:?}", report.new_findings);
440        assert!(
441            report.resolved_findings.is_empty(),
442            "{:?}",
443            report.resolved_findings
444        );
445    }
446
447    /// Once the baseline itself carries groupings, the separation is back on.
448    #[test]
449    fn a_grouped_baseline_still_separates_deployments() {
450        let mut prod = finding(
451            FindingType::NPlusOneSql,
452            Severity::Warning,
453            "order-svc",
454            "GET /api/orders",
455            "SELECT * FROM t WHERE id = ?",
456        );
457        prod.grouping = crate::test_helpers::k8s_grouping("prod-eu");
458        let mut staging = prod.clone();
459        staging.grouping = crate::test_helpers::k8s_grouping("staging");
460
461        let report = diff_runs(
462            &make_report(vec![prod.clone()], vec![]),
463            &make_report(vec![prod, staging], vec![]),
464        );
465
466        assert_eq!(report.new_findings.len(), 1);
467        assert_eq!(report.new_findings[0].grouping_value(), Some("staging"));
468    }
469
470    fn endpoint(service: &str, ep: &str, ops: usize) -> PerEndpointIoOps {
471        PerEndpointIoOps {
472            service: service.to_string(),
473            endpoint: ep.to_string(),
474            io_ops: ops,
475        }
476    }
477
478    /// The after run's warnings (e.g. `unmatched_acknowledgment`) must
479    /// survive into the diff, which is the only output a diff-only CI
480    /// surface reads. The before run's warnings describe the baseline
481    /// and stay out.
482    #[test]
483    fn diff_carries_the_after_runs_warnings() {
484        let mut before = make_report(vec![], vec![]);
485        before.warning_details = vec![Warning::new("cold_start", "stale baseline warning")];
486        let mut after = make_report(vec![], vec![]);
487        after.warning_details = vec![Warning::new(
488            "unmatched_acknowledgment",
489            "acknowledgment deadbeef matched no finding in this run",
490        )];
491        let diff = diff_runs(&before, &after);
492        assert_eq!(diff.warning_details, after.warning_details);
493    }
494
495    #[test]
496    fn identical_runs_produce_empty_diff() {
497        let f = finding(
498            FindingType::NPlusOneSql,
499            Severity::Warning,
500            "svc",
501            "POST /api",
502            "SELECT *",
503        );
504        let before = make_report(vec![f.clone()], vec![endpoint("svc", "POST /api", 6)]);
505        let after = make_report(vec![f], vec![endpoint("svc", "POST /api", 6)]);
506        let diff = diff_runs(&before, &after);
507        assert!(diff.new_findings.is_empty());
508        assert!(diff.resolved_findings.is_empty());
509        assert!(diff.severity_changes.is_empty());
510        assert!(diff.endpoint_metric_deltas.is_empty());
511    }
512
513    #[test]
514    fn finding_present_only_in_after_is_new() {
515        let before = make_report(vec![], vec![]);
516        let after = make_report(
517            vec![finding(
518                FindingType::NPlusOneSql,
519                Severity::Warning,
520                "svc",
521                "POST /api",
522                "SELECT *",
523            )],
524            vec![],
525        );
526        let diff = diff_runs(&before, &after);
527        assert_eq!(diff.new_findings.len(), 1);
528        assert!(diff.resolved_findings.is_empty());
529        assert!(diff.severity_changes.is_empty());
530    }
531
532    #[test]
533    fn finding_present_only_in_before_is_resolved() {
534        let before = make_report(
535            vec![finding(
536                FindingType::NPlusOneSql,
537                Severity::Warning,
538                "svc",
539                "POST /api",
540                "SELECT *",
541            )],
542            vec![],
543        );
544        let after = make_report(vec![], vec![]);
545        let diff = diff_runs(&before, &after);
546        assert!(diff.new_findings.is_empty());
547        assert_eq!(diff.resolved_findings.len(), 1);
548        assert!(diff.severity_changes.is_empty());
549    }
550
551    #[test]
552    fn same_identity_with_different_severity_is_severity_change() {
553        let f_warn = finding(
554            FindingType::NPlusOneSql,
555            Severity::Warning,
556            "svc",
557            "POST /api",
558            "SELECT *",
559        );
560        let mut f_crit = f_warn.clone();
561        f_crit.severity = Severity::Critical;
562        let before = make_report(vec![f_warn], vec![]);
563        let after = make_report(vec![f_crit], vec![]);
564        let diff = diff_runs(&before, &after);
565        assert!(diff.new_findings.is_empty());
566        assert!(diff.resolved_findings.is_empty());
567        assert_eq!(diff.severity_changes.len(), 1);
568        let change = &diff.severity_changes[0];
569        assert_eq!(change.before_severity, Severity::Warning);
570        assert_eq!(change.after_severity, Severity::Critical);
571        assert!(
572            change.is_regression(),
573            "warning -> critical is a regression"
574        );
575    }
576
577    #[test]
578    fn severity_changes_sorted_regressions_first() {
579        // After: one regression (warning -> critical), one improvement (critical -> warning).
580        let before = make_report(
581            vec![
582                finding(
583                    FindingType::NPlusOneSql,
584                    Severity::Warning,
585                    "svc-a",
586                    "POST /a",
587                    "SELECT a",
588                ),
589                finding(
590                    FindingType::NPlusOneSql,
591                    Severity::Critical,
592                    "svc-b",
593                    "POST /b",
594                    "SELECT b",
595                ),
596            ],
597            vec![],
598        );
599        let after = make_report(
600            vec![
601                finding(
602                    FindingType::NPlusOneSql,
603                    Severity::Critical,
604                    "svc-a",
605                    "POST /a",
606                    "SELECT a",
607                ),
608                finding(
609                    FindingType::NPlusOneSql,
610                    Severity::Warning,
611                    "svc-b",
612                    "POST /b",
613                    "SELECT b",
614                ),
615            ],
616            vec![],
617        );
618        let diff = diff_runs(&before, &after);
619        assert_eq!(diff.severity_changes.len(), 2);
620        assert!(
621            diff.severity_changes[0].is_regression(),
622            "regression must come first"
623        );
624        assert!(
625            !diff.severity_changes[1].is_regression(),
626            "improvement must come last"
627        );
628    }
629
630    #[test]
631    fn duplicate_identity_in_one_run_is_collapsed_to_worst_severity() {
632        // Two findings with the same identity tuple in `after`, one at
633        // Warning and one at Critical. They should collapse to a single
634        // "after" finding at Critical, and the diff should not interpret
635        // the count difference as a severity change.
636        let before = make_report(
637            vec![finding(
638                FindingType::NPlusOneSql,
639                Severity::Critical,
640                "svc",
641                "POST /api",
642                "SELECT *",
643            )],
644            vec![],
645        );
646        let f_warn = finding(
647            FindingType::NPlusOneSql,
648            Severity::Warning,
649            "svc",
650            "POST /api",
651            "SELECT *",
652        );
653        let mut f_crit = f_warn.clone();
654        f_crit.severity = Severity::Critical;
655        let after = make_report(vec![f_warn, f_crit], vec![]);
656        let diff = diff_runs(&before, &after);
657        assert!(
658            diff.new_findings.is_empty(),
659            "no new findings when identity is shared"
660        );
661        assert!(
662            diff.resolved_findings.is_empty(),
663            "no resolved when identity is shared"
664        );
665        assert!(
666            diff.severity_changes.is_empty(),
667            "worst-severity dedupe should make this a no-op (Critical == Critical)"
668        );
669    }
670
671    #[test]
672    fn endpoint_io_ops_increase_is_a_positive_delta() {
673        let before = make_report(vec![], vec![endpoint("svc", "POST /api/users", 10)]);
674        let after = make_report(vec![], vec![endpoint("svc", "POST /api/users", 20)]);
675        let diff = diff_runs(&before, &after);
676        assert_eq!(diff.endpoint_metric_deltas.len(), 1);
677        let d = &diff.endpoint_metric_deltas[0];
678        assert_eq!(d.service, "svc");
679        assert_eq!(d.endpoint, "POST /api/users");
680        assert_eq!(d.before_io_ops, 10);
681        assert_eq!(d.after_io_ops, 20);
682        assert_eq!(d.delta, 10);
683    }
684
685    #[test]
686    fn endpoint_absent_from_before_is_a_full_addition() {
687        let before = make_report(vec![], vec![]);
688        let after = make_report(vec![], vec![endpoint("svc", "POST /api", 7)]);
689        let diff = diff_runs(&before, &after);
690        assert_eq!(diff.endpoint_metric_deltas.len(), 1);
691        let d = &diff.endpoint_metric_deltas[0];
692        assert_eq!(d.before_io_ops, 0);
693        assert_eq!(d.after_io_ops, 7);
694        assert_eq!(d.delta, 7);
695    }
696
697    #[test]
698    fn endpoint_absent_from_after_is_a_full_removal() {
699        let before = make_report(vec![], vec![endpoint("svc", "POST /api", 5)]);
700        let after = make_report(vec![], vec![]);
701        let diff = diff_runs(&before, &after);
702        assert_eq!(diff.endpoint_metric_deltas.len(), 1);
703        let d = &diff.endpoint_metric_deltas[0];
704        assert_eq!(d.before_io_ops, 5);
705        assert_eq!(d.after_io_ops, 0);
706        assert_eq!(d.delta, -5);
707    }
708
709    #[test]
710    fn endpoint_deltas_sorted_regressions_first() {
711        let before = make_report(
712            vec![],
713            vec![
714                endpoint("svc", "POST /improve", 10),
715                endpoint("svc", "POST /regress", 5),
716                endpoint("svc", "POST /steady", 7),
717            ],
718        );
719        let after = make_report(
720            vec![],
721            vec![
722                endpoint("svc", "POST /improve", 2),
723                endpoint("svc", "POST /regress", 50),
724                endpoint("svc", "POST /steady", 7),
725            ],
726        );
727        let diff = diff_runs(&before, &after);
728        assert_eq!(diff.endpoint_metric_deltas.len(), 2);
729        assert_eq!(diff.endpoint_metric_deltas[0].endpoint, "POST /regress");
730        assert_eq!(diff.endpoint_metric_deltas[0].delta, 45);
731        assert_eq!(diff.endpoint_metric_deltas[1].endpoint, "POST /improve");
732        assert_eq!(diff.endpoint_metric_deltas[1].delta, -8);
733    }
734
735    #[test]
736    fn equal_severity_in_both_runs_is_not_a_severity_change() {
737        // Guard against a future refactor that compares with `<` or
738        // `<=` instead of `!=` and silently classifies equal-severity
739        // findings as severity changes.
740        let f = finding(
741            FindingType::NPlusOneSql,
742            Severity::Critical,
743            "svc",
744            "POST /api",
745            "SELECT *",
746        );
747        let before = make_report(vec![f.clone()], vec![]);
748        let after = make_report(vec![f], vec![]);
749        let diff = diff_runs(&before, &after);
750        assert!(diff.severity_changes.is_empty());
751    }
752
753    #[test]
754    fn same_identity_different_trace_id_is_treated_as_one_finding() {
755        // Two findings with identical (type, service, endpoint, template)
756        // but different trace_ids are conceptually "the same anti-pattern"
757        // observed twice. The diff collapses them and sums occurrences.
758        let mut f_a = finding(
759            FindingType::NPlusOneSql,
760            Severity::Warning,
761            "svc",
762            "POST /api",
763            "SELECT *",
764        );
765        f_a.trace_id = "trace-a".to_string();
766        f_a.pattern.occurrences = 6;
767        let mut f_b = f_a.clone();
768        f_b.trace_id = "trace-b".to_string();
769        f_b.pattern.occurrences = 12;
770
771        let before = make_report(vec![], vec![]);
772        let after = make_report(vec![f_a, f_b], vec![]);
773        let diff = diff_runs(&before, &after);
774        assert_eq!(diff.new_findings.len(), 1, "two duplicates collapse to one");
775        assert_eq!(
776            diff.new_findings[0].pattern.occurrences, 18,
777            "occurrences from both findings sum on collapse"
778        );
779    }
780
781    #[test]
782    fn duplicate_identity_collapse_sums_occurrences() {
783        // Direct test of `build_identity_map` summing semantic.
784        let mut f_a = finding(
785            FindingType::NPlusOneSql,
786            Severity::Warning,
787            "svc",
788            "POST /api",
789            "SELECT *",
790        );
791        f_a.pattern.occurrences = 6;
792        let mut f_b = f_a.clone();
793        f_b.pattern.occurrences = 60;
794
795        let before = make_report(vec![], vec![]);
796        let after = make_report(vec![f_a, f_b], vec![]);
797        let diff = diff_runs(&before, &after);
798        assert_eq!(diff.new_findings.len(), 1);
799        assert_eq!(diff.new_findings[0].pattern.occurrences, 66);
800    }
801
802    #[test]
803    fn diff_sarif_emits_one_result_per_new_finding() {
804        // Smoke test for the public `findings_to_sarif` re-use path
805        // exercised by `perf-sentinel diff --format sarif`.
806        let f = finding(
807            FindingType::NPlusOneSql,
808            Severity::Warning,
809            "svc",
810            "POST /api",
811            "SELECT *",
812        );
813        let before = make_report(vec![], vec![]);
814        let after = make_report(vec![f], vec![]);
815        let diff = diff_runs(&before, &after);
816        assert_eq!(diff.new_findings.len(), 1);
817        let sarif = crate::report::sarif::findings_to_sarif(&diff.new_findings);
818        assert_eq!(
819            sarif.runs[0].results.len(),
820            diff.new_findings.len(),
821            "SARIF results count must match new_findings count"
822        );
823    }
824}