ktstr/ctprof_compare/
compare.rs

1//! End-to-end comparison pipeline: groups, fudges, derives,
2//! sorts, and lifts the [`crate::ctprof::CtprofSnapshot`] pair into a
3//! materialized [`super::CtprofDiff`].
4//!
5//! The pipeline:
6//!
7//! 1. [`compare`] is the public orchestrator. It builds per-side
8//!    thread groups via [`super::build_groups`] (with
9//!    [`super::build_cgroup_key_map`] for cgroup-mode tightening),
10//!    flattens cgroup-stats per [`flatten_cgroup_stats`] when
11//!    grouping by cgroup, fudges baseline-only / candidate-only
12//!    cgroups together via the Jaccard thread-population overlap
13//!    rule, emits one [`super::DiffRow`] per `(matched group,
14//!    metric)` pair, builds derived rows per
15//!    [`super::CTPROF_DERIVED_METRICS`], and finally hands the
16//!    populated [`super::CtprofDiff`] back. Group keys present
17//!    only on one side surface in
18//!    [`super::CtprofDiff::only_baseline`] /
19//!    [`super::CtprofDiff::only_candidate`] AFTER fudging
20//!    consumed any pairs that joined.
21//!
22//! 2. [`emit_fudged_rows`] handles the N:1 fudge merge: every
23//!    candidate group matched to a single baseline group has its
24//!    metrics merged via `aggregate::merge_aggregated_into`; one
25//!    row per metric is then emitted with the merged candidate
26//!    side. Display key carries the `[fudged: <leaf>]` marker
27//!    so the renderer can flag the merged row visually.
28//!
29//! 3. [`build_derived_row`] computes one [`super::DerivedRow`]
30//!    per matched group per derivation. `None` propagates when
31//!    inputs are missing (CONFIG gate not set, jemalloc not
32//!    linked) or denominator is zero.
33//!
34//! 4. [`sort_diff_rows_by_keys`] applies the `--sort-by` multi-key
35//!    sort, ranking groups lexicographically per the
36//!    [`super::SortKey`] tuple before re-ordering the row vec.
37//!    Within a group, registry order is preserved as a stable
38//!    tiebreak.
39//!
40//! 5. [`flatten_cgroup_stats`] collapses cgroup paths via
41//!    glob-pattern flatten + auto-normalize key map; per-
42//!    controller merges live in [`super::cgroup_merge`].
43//!
44//! Pointer-hash identity on [`super::DiffRow`] in downstream
45//! consumers means the entire pipeline passes
46//! `&super::CtprofDiff` by reference — never by value — so row
47//! addresses stay stable across renderer passes.
48
49use std::collections::BTreeMap;
50
51use crate::ctprof::{CgroupStats, CtprofSnapshot};
52
53use super::{
54    Aggregated, CTPROF_DERIVED_METRICS, CTPROF_METRICS, CompareOptions, CtprofDiff,
55    DerivedMetricDef, DerivedRow, DiffRow, FudgedPair, GroupBy, SortKey, ThreadGroup,
56    aggregate::merge_aggregated_into,
57    cgroup_merge::{merge_cgroup_cpu, merge_cgroup_memory, merge_cgroup_pids, merge_psi},
58    format_value_cell,
59    groups::{
60        build_cgroup_key_map, build_groups, build_row, collect_smaps_rollup,
61        collect_smaps_rollup_hierarchical, compile_flatten_patterns, flatten_cgroup_path,
62    },
63    pattern::{pattern_counts_union, pattern_display_label, pattern_key},
64};
65
66/// Apply a multi-key sort to `rows` per `sort_keys`. Computes a
67/// per-group sort tuple by looking up the requested metrics'
68/// deltas from the existing rows, ranks groups lexicographically
69/// (with per-key direction), then orders rows by
70/// `(group_rank, metric_registry_idx)` so rows for a given
71/// group cluster together in registry order. Mirrors the default
72/// sort's stability guarantee (within a group, registry order is
73/// preserved; across groups, deterministic by tuple).
74///
75/// Missing values (a group has no row for the named metric, or
76/// the row's `delta` is `None` because the metric is categorical
77/// — even though `parse_sort_by` now rejects categorical
78/// metrics at the CLI boundary, a programmatic caller can still
79/// construct a [`SortKey`] over a `Mode*` metric directly) are
80/// treated as `f64::NEG_INFINITY` for descending sort and
81/// `f64::INFINITY` for ascending sort — they sink to the bottom
82/// either way.
83///
84/// Caller must supply at least one sort key — an empty slice is a
85/// programming error (the empty-spec case is handled at the
86/// caller via the `sort_by.is_empty()` branch in
87/// [`compare`] / `write_show`).
88pub(super) fn sort_diff_rows_by_keys(
89    rows: &mut [DiffRow],
90    derived_rows: &mut [DerivedRow],
91    sort_keys: &[SortKey],
92) {
93    debug_assert!(
94        !sort_keys.is_empty(),
95        "sort_diff_rows_by_keys called with empty sort_keys; \
96         caller must short-circuit before invoking the multi-key \
97         sort path",
98    );
99    use std::collections::{BTreeMap, BTreeSet};
100    // metric name → registry index (for stable within-group
101    // ordering after sort). Both sides are `&'static str` so this
102    // map is allocation-free at the key layer.
103    let metric_idx: BTreeMap<&'static str, usize> = CTPROF_METRICS
104        .iter()
105        .enumerate()
106        .map(|(i, m)| (m.name, i))
107        .collect();
108    let derived_idx: BTreeMap<&'static str, usize> = CTPROF_DERIVED_METRICS
109        .iter()
110        .enumerate()
111        .map(|(i, m)| (m.name, i))
112        .collect();
113    // group_key → (metric_name → delta). The inner key is
114    // `&'static str` borrowed from `row.metric_name` (itself a
115    // `&'static str` pointing into `CTPROF_METRICS.name` or
116    // `CTPROF_DERIVED_METRICS.name`), so no per-row
117    // allocation is needed for the metric axis. Derived deltas
118    // populate the same map; sort_by treats primary and derived
119    // names uniformly for ranking.
120    let mut group_metrics: BTreeMap<String, BTreeMap<&'static str, f64>> = BTreeMap::new();
121    for row in rows.iter() {
122        if let Some(d) = row.delta {
123            group_metrics
124                .entry(row.group_key.clone())
125                .or_default()
126                .insert(row.metric_name, d);
127        }
128    }
129    for row in derived_rows.iter() {
130        if let Some(d) = row.delta {
131            group_metrics
132                .entry(row.group_key.clone())
133                .or_default()
134                .insert(row.metric_name, d);
135        }
136    }
137    // Unique group set: every key from group_metrics PLUS every
138    // group_key from `rows` that had no numeric delta (every row
139    // was Mode/etc). BTreeSet handles dedup-on-insert without a
140    // separate sort+dedup pass.
141    let mut unique_groups: BTreeSet<String> = group_metrics.keys().cloned().collect();
142    for row in rows.iter() {
143        unique_groups.insert(row.group_key.clone());
144    }
145    for row in derived_rows.iter() {
146        unique_groups.insert(row.group_key.clone());
147    }
148    // Precompute (group_key, sort_tuple) pairs once. Avoids
149    // recomputing the tuple inside the comparator on every
150    // comparison; with N groups and a non-trivial tuple this
151    // saves O(N log N) tuple builds.
152    let mut groups_with_tuples: Vec<(String, Vec<f64>)> = unique_groups
153        .into_iter()
154        .map(|g| {
155            let metrics = group_metrics.get(&g);
156            let tuple: Vec<f64> = sort_keys
157                .iter()
158                .map(|k| {
159                    metrics
160                        .and_then(|m| m.get(k.metric).copied())
161                        .unwrap_or(if k.descending {
162                            f64::NEG_INFINITY
163                        } else {
164                            f64::INFINITY
165                        })
166                })
167                .collect();
168            (g, tuple)
169        })
170        .collect();
171    // Sort with the precomputed tuples: comparator does only
172    // O(sort_keys.len()) f64 comparisons per call, no map
173    // lookups.
174    groups_with_tuples.sort_by(|(ga, ta), (gb, tb)| {
175        for (i, key) in sort_keys.iter().enumerate() {
176            let (va, vb) = (ta[i], tb[i]);
177            let ord = if key.descending {
178                vb.partial_cmp(&va).unwrap_or(std::cmp::Ordering::Equal)
179            } else {
180                va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
181            };
182            if ord != std::cmp::Ordering::Equal {
183                return ord;
184            }
185        }
186        // Final tie-break: ascending group_key for determinism.
187        ga.cmp(gb)
188    });
189    let group_ranks: BTreeMap<String, usize> = groups_with_tuples
190        .into_iter()
191        .enumerate()
192        .map(|(i, (g, _))| (g, i))
193        .collect();
194    rows.sort_by(|a, b| {
195        let ra = group_ranks.get(&a.group_key).copied().unwrap_or(usize::MAX);
196        let rb = group_ranks.get(&b.group_key).copied().unwrap_or(usize::MAX);
197        ra.cmp(&rb).then_with(|| {
198            let ia = metric_idx.get(a.metric_name).copied().unwrap_or(usize::MAX);
199            let ib = metric_idx.get(b.metric_name).copied().unwrap_or(usize::MAX);
200            ia.cmp(&ib)
201        })
202    });
203    derived_rows.sort_by(|a, b| {
204        let ra = group_ranks.get(&a.group_key).copied().unwrap_or(usize::MAX);
205        let rb = group_ranks.get(&b.group_key).copied().unwrap_or(usize::MAX);
206        ra.cmp(&rb).then_with(|| {
207            let ia = derived_idx
208                .get(a.metric_name)
209                .copied()
210                .unwrap_or(usize::MAX);
211            let ib = derived_idx
212                .get(b.metric_name)
213                .copied()
214                .unwrap_or(usize::MAX);
215            ia.cmp(&ib)
216        })
217    });
218}
219
220/// Compute one [`DerivedRow`] for a matched group. Called per
221/// derivation in [`compare`]; the produced row carries `None`
222/// values when the formula's inputs are missing or the
223/// denominator is zero on either side.
224pub(super) fn build_derived_row(
225    key: &str,
226    display_key: &str,
227    n_a: usize,
228    n_b: usize,
229    def: &DerivedMetricDef,
230    metrics_a: &BTreeMap<String, Aggregated>,
231    metrics_b: &BTreeMap<String, Aggregated>,
232) -> DerivedRow {
233    let baseline = (def.compute)(metrics_a);
234    let candidate = (def.compute)(metrics_b);
235    let (delta, delta_pct) = match (baseline, candidate) {
236        (Some(a), Some(b)) => {
237            let va = a.as_f64();
238            let vb = b.as_f64();
239            let d = vb - va;
240            // Suppress delta_pct for ratio rows per the design
241            // call: `+20%` on a `[0, 1]` ratio is misleading.
242            let pct = if def.is_ratio {
243                None
244            } else if va.abs() > f64::EPSILON {
245                Some(d / va)
246            } else {
247                None
248            };
249            (Some(d), pct)
250        }
251        _ => (None, None),
252    };
253    DerivedRow {
254        group_key: key.to_string(),
255        display_key: display_key.to_string(),
256        thread_count_a: n_a,
257        thread_count_b: n_b,
258        metric_name: def.name,
259        metric_ladder: def.ladder,
260        is_ratio: def.is_ratio,
261        baseline,
262        candidate,
263        delta,
264        delta_pct,
265        sort_by_cell: None,
266        sort_by_delta: None,
267    }
268}
269
270/// Emit one DiffRow per CTPROF_METRICS entry (and one DerivedRow
271/// per CTPROF_DERIVED_METRICS entry) for each fudged baseline
272/// key, with candidate values aggregated across the N matched
273/// candidate groups (N:1 merge). The shared display key
274/// `[fudged]` flags the row in the renderer.
275///
276/// `matches` keys are baseline group keys; the values are the
277/// list of candidate group keys that matched the baseline. A
278/// missing baseline group skips the entry; a missing candidate
279/// group is skipped from the merge but does not abort. Values
280/// from each ckey are merged via [`merge_aggregated_into`].
281pub(super) fn emit_fudged_rows(
282    diff: &mut CtprofDiff,
283    matches: &BTreeMap<String, Vec<String>>,
284    groups_a: &BTreeMap<String, ThreadGroup>,
285    groups_b: &BTreeMap<String, ThreadGroup>,
286) {
287    for (bkey, ckeys) in matches {
288        let Some(ga) = groups_a.get(bkey) else {
289            continue;
290        };
291        let mut merged_metrics: BTreeMap<String, Aggregated> = BTreeMap::new();
292        let mut merged_thread_count: usize = 0;
293        for ckey in ckeys {
294            let Some(gb) = groups_b.get(ckey) else {
295                continue;
296            };
297            merged_thread_count += gb.thread_count;
298            for (name, val) in &gb.metrics {
299                let entry = merged_metrics.entry(name.clone());
300                match entry {
301                    std::collections::btree_map::Entry::Vacant(e) => {
302                        e.insert(val.clone());
303                    }
304                    std::collections::btree_map::Entry::Occupied(mut e) => {
305                        let existing = e.get_mut();
306                        merge_aggregated_into(existing, val);
307                    }
308                }
309            }
310        }
311        // Display key for the fudged row: include the
312        // baseline cgroup leaf so multiple fudged pairs in the
313        // same diff stay distinguishable. `[fudged]` alone
314        // would render N rows that all look identical when
315        // viewed by an operator scanning the table; appending
316        // the leaf path component (the bcg's last `/`-segment)
317        // disambiguates without bloating the column.
318        let bcg = bkey.split_once('\x00').map_or(bkey.as_str(), |(cg, _)| cg);
319        let leaf = bcg.rsplit_once('/').map_or(bcg, |(_, l)| l);
320        let display_key = if leaf.is_empty() {
321            "[fudged]".to_string()
322        } else {
323            format!("[fudged: {leaf}]")
324        };
325        for metric in CTPROF_METRICS {
326            let Some(a) = ga.metrics.get(metric.name).cloned() else {
327                continue;
328            };
329            let Some(b) = merged_metrics.get(metric.name).cloned() else {
330                continue;
331            };
332            diff.rows.push(build_row(
333                bkey,
334                &display_key,
335                ga.thread_count,
336                merged_thread_count,
337                metric,
338                a,
339                b,
340                None,
341            ));
342        }
343        for def in CTPROF_DERIVED_METRICS {
344            diff.derived_rows.push(build_derived_row(
345                bkey,
346                &display_key,
347                ga.thread_count,
348                merged_thread_count,
349                def,
350                &ga.metrics,
351                &merged_metrics,
352            ));
353        }
354    }
355}
356
357/// Read-only context threaded through the phase helpers of
358/// [`compare`]. Holds borrows of the snapshot pair, the resolved
359/// options, and the per-side thread groups so each phase signature
360/// stays short. Built once by [`compare`] after
361/// [`build_compare_groups`] returns the owned groups (kept as
362/// orchestrator locals so the borrows in this struct stay valid).
363struct CompareCtx<'a> {
364    baseline: &'a CtprofSnapshot,
365    candidate: &'a CtprofSnapshot,
366    opts: &'a CompareOptions,
367    /// Resolved grouping axis (`opts.group_by.0`), cached so the
368    /// phases don't re-unwrap the default wrapper.
369    group_by: GroupBy,
370    /// Compiled `cgroup_flatten` glob patterns.
371    flatten: &'a [glob::Pattern],
372    /// Auto-normalize cgroup-key map, present only under
373    /// `GroupBy::Cgroup | GroupBy::All` with `no_cg_normalize`
374    /// unset.
375    cgroup_key_map: Option<&'a BTreeMap<String, String>>,
376    groups_a: &'a BTreeMap<String, ThreadGroup>,
377    groups_b: &'a BTreeMap<String, ThreadGroup>,
378    /// Per-snapshot "now" for lifetime calc: newest candidate
379    /// thread's `start_time_clock_ticks`.
380    now_b: u64,
381}
382
383/// Build both sides' thread groups plus the auto-normalize cgroup
384/// key map. Resolves the grouping axis, seeds the per-thread
385/// frequency-count gate (only under `GroupBy::Comm | GroupBy::Pcomm`
386/// with normalization on), and runs [`build_groups`] for each side.
387/// Returns `(group_by, cgroup_key_map, groups_a, groups_b)` by
388/// value so [`compare`] can keep them as locals and borrow them
389/// into [`CompareCtx`]. `flatten` is computed by the caller and
390/// passed in so the patterns outlive the returned groups.
391fn build_compare_groups(
392    baseline: &CtprofSnapshot,
393    candidate: &CtprofSnapshot,
394    opts: &CompareOptions,
395    flatten: &[glob::Pattern],
396) -> CompareGroups {
397    let group_by = opts.group_by.0;
398    // For `GroupBy::Comm` and `GroupBy::Pcomm`, the frequency gate
399    // that promotes a pattern_key from per-thread literal to a
400    // clustered bucket must be evaluated against the UNION of both
401    // snapshots' threads — otherwise a pattern that has 1 thread
402    // in baseline + 3 threads in candidate would join under a
403    // `worker-{N}` key on the candidate side but a literal
404    // `worker-7` key on the baseline side, and the row would
405    // surface as only-in-candidate. Computing the count from
406    // the union ensures the same key is used on both sides.
407    //
408    // The Pcomm path is structurally identical: process names that
409    // share a normalized skeleton across snapshots (e.g. ephemeral
410    // worker pools whose pcomm differs only by a digit suffix)
411    // collapse into one bucket, keyed by the skeleton. The accessor
412    // selects which `ThreadState` field feeds the count — `t.comm`
413    // for Comm, `t.pcomm` for Pcomm — so one helper covers both
414    // axes.
415    //
416    // Skipped when `no_thread_normalize` is set — under literal
417    // grouping, the key IS the comm/pcomm and there is no
418    // promotion gate to evaluate.
419    // Pattern_counts is only consulted by [`build_groups`] under
420    // GroupBy::Pcomm / GroupBy::Comm (as the singleton-revert
421    // gate). GroupBy::All uses the compound `cg\x00pcomm\x00comm`
422    // key and normalizes both pcomm and comm through `pattern_key`
423    // unconditionally — there is no singleton revert under All
424    // because every thread already disambiguates by cgroup +
425    // process. Skipping the seeding under All saves the
426    // baseline+candidate scan when neither side will read it.
427    let pattern_counts: Option<BTreeMap<String, usize>> = match (group_by, opts.no_thread_normalize)
428    {
429        (GroupBy::Comm, false) => Some(pattern_counts_union(baseline, candidate, |t| {
430            t.comm.as_str()
431        })),
432        (GroupBy::Pcomm, false) => Some(pattern_counts_union(baseline, candidate, |t| {
433            t.pcomm.as_str()
434        })),
435        _ => None,
436    };
437    let cgroup_key_map: Option<BTreeMap<String, String>> =
438        if matches!(group_by, GroupBy::Cgroup | GroupBy::All) && !opts.no_cg_normalize {
439            Some(build_cgroup_key_map(baseline, candidate, flatten))
440        } else {
441            None
442        };
443    let groups_a = build_groups(
444        baseline,
445        group_by,
446        flatten,
447        pattern_counts.as_ref(),
448        cgroup_key_map.as_ref(),
449        opts.no_thread_normalize,
450    );
451    let groups_b = build_groups(
452        candidate,
453        group_by,
454        flatten,
455        pattern_counts.as_ref(),
456        cgroup_key_map.as_ref(),
457        opts.no_thread_normalize,
458    );
459    (group_by, cgroup_key_map, groups_a, groups_b)
460}
461
462/// Emit one [`DiffRow`] per `(matched group, metric)` plus one
463/// [`DerivedRow`] per derivation, and populate
464/// [`CtprofDiff::only_baseline`] / [`CtprofDiff::only_candidate`]
465/// with the keys present on exactly one side. Display labels for
466/// pattern-aware groupings union both sides' members and run grex;
467/// every other grouping echoes the join key.
468fn emit_matched_rows(diff: &mut CtprofDiff, ctx: &CompareCtx) {
469    for (key, group_a) in ctx.groups_a {
470        let Some(group_b) = ctx.groups_b.get(key) else {
471            diff.only_baseline.push(key.clone());
472            continue;
473        };
474        // Render label: pattern grouping (Comm or Pcomm under
475        // auto-normalize) unions baseline+candidate members and
476        // runs grex over the result; every other grouping just
477        // echoes the join key. Computed once per matched group,
478        // reused across every metric row built off it.
479        let pattern_axis_active =
480            matches!(ctx.group_by, GroupBy::Comm | GroupBy::Pcomm) && !ctx.opts.no_thread_normalize;
481        let display_key = if pattern_axis_active {
482            let mut union: Vec<String> = group_a.members.clone();
483            union.extend(group_b.members.iter().cloned());
484            union.sort();
485            union.dedup();
486            pattern_display_label(key, &union)
487        } else {
488            key.clone()
489        };
490        // uptime_pct filled in second pass after all groups processed
491
492        for metric in CTPROF_METRICS {
493            let Some(a) = group_a.metrics.get(metric.name).cloned() else {
494                continue;
495            };
496            let Some(b) = group_b.metrics.get(metric.name).cloned() else {
497                continue;
498            };
499            diff.rows.push(build_row(
500                key,
501                &display_key,
502                group_a.thread_count,
503                group_b.thread_count,
504                metric,
505                a,
506                b,
507                None, // uptime_pct filled in second pass
508            ));
509        }
510        // Derived metrics: one row per derivation per matched
511        // group. Each row carries `None`-valued sides when the
512        // formula's inputs are missing or the denominator is
513        // zero — operator sees `-` rather than a synthesized
514        // zero or NaN.
515        for def in CTPROF_DERIVED_METRICS {
516            diff.derived_rows.push(build_derived_row(
517                key,
518                &display_key,
519                group_a.thread_count,
520                group_b.thread_count,
521                def,
522                &group_a.metrics,
523                &group_b.metrics,
524            ));
525        }
526    }
527    for key in ctx.groups_b.keys() {
528        if !ctx.groups_a.contains_key(key) {
529            diff.only_candidate.push(key.clone());
530        }
531    }
532}
533
534/// Extract the cgroup prefix (the `cg` portion) from a compound
535/// `cg\x00pcomm\x00comm` group key. Used throughout the fudge
536/// phase to group one-sided keys by their owning cgroup.
537fn cg_prefix(key: &str) -> &str {
538    key.split_once('\x00').map_or(key, |(cg, _)| cg)
539}
540
541/// `(pcomm, comm)` thread-type set for one cgroup prefix. Fudge
542/// matches cgroups by Jaccard similarity over these sets.
543type TypeSet = std::collections::BTreeSet<(String, String)>;
544
545/// Grouping outcome from [`build_compare_groups`]: the resolved
546/// `GroupBy`, an optional cgroup-fudge map, and the per-side thread
547/// groups (baseline, candidate).
548type CompareGroups = (
549    GroupBy,
550    Option<BTreeMap<String, String>>,
551    BTreeMap<String, ThreadGroup>,
552    BTreeMap<String, ThreadGroup>,
553);
554
555/// Fudge-match outcome from [`fudge_match_prefixes`]: the matched
556/// `(baseline_cg, candidate_cg)` prefix pairs plus the per-prefix
557/// thread-type sets for each side (baseline, candidate).
558type FudgeMatch = (
559    Vec<(String, String)>,
560    BTreeMap<String, TypeSet>,
561    BTreeMap<String, TypeSet>,
562);
563
564/// Match baseline-only cgroup prefixes to candidate-only cgroup
565/// prefixes by Jaccard similarity (≥ 0.90, overlap ≥ 10) over
566/// their `(pcomm, comm)` thread-type sets, and return both the
567/// matched `(baseline_cg, candidate_cg)` pairs and the per-prefix
568/// type sets (reused downstream for the residual report). Excludes
569/// any prefix that already has a key matched on both sides
570/// (`matched_prefixes`).
571fn fudge_match_prefixes(diff: &CtprofDiff, ctx: &CompareCtx) -> FudgeMatch {
572    // Collect thread types per CGROUP PREFIX (not per compound key).
573    let mut cg_types_a: BTreeMap<String, TypeSet> = BTreeMap::new();
574    let mut cg_types_b: BTreeMap<String, TypeSet> = BTreeMap::new();
575
576    // Collect cgroup prefixes that appear in BOTH groups (already matched).
577    // These must be excluded from fudging.
578    let matched_prefixes: std::collections::BTreeSet<String> = ctx
579        .groups_a
580        .keys()
581        .filter(|k| ctx.groups_b.contains_key(*k))
582        .map(|k| cg_prefix(k).to_string())
583        .collect();
584
585    // Collect unique cgroup prefixes from one-sided keys,
586    // skipping any prefix that already has matched keys.
587    let mut cg_prefixes_a: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
588    let mut cg_prefixes_b: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
589    for key in &diff.only_baseline {
590        let pfx = cg_prefix(key).to_string();
591        if !matched_prefixes.contains(&pfx) {
592            cg_prefixes_a.insert(pfx);
593        }
594    }
595    for key in &diff.only_candidate {
596        let pfx = cg_prefix(key).to_string();
597        if !matched_prefixes.contains(&pfx) {
598            cg_prefixes_b.insert(pfx);
599        }
600    }
601
602    // Populate thread types per cgroup prefix from snapshots.
603    for t in &ctx.baseline.threads {
604        let cg = flatten_cgroup_path(&t.cgroup, ctx.flatten);
605        let cg_key = match ctx.cgroup_key_map.and_then(|m| m.get(&cg)) {
606            Some(k) => k.clone(),
607            None => cg,
608        };
609        if cg_prefixes_a.contains(&cg_key) {
610            cg_types_a
611                .entry(cg_key)
612                .or_default()
613                .insert((pattern_key(&t.pcomm), pattern_key(&t.comm)));
614        }
615    }
616    for t in &ctx.candidate.threads {
617        let cg = flatten_cgroup_path(&t.cgroup, ctx.flatten);
618        let cg_key = match ctx.cgroup_key_map.and_then(|m| m.get(&cg)) {
619            Some(k) => k.clone(),
620            None => cg,
621        };
622        if cg_prefixes_b.contains(&cg_key) {
623            cg_types_b
624                .entry(cg_key)
625                .or_default()
626                .insert((pattern_key(&t.pcomm), pattern_key(&t.comm)));
627        }
628    }
629
630    // Match cgroup prefixes by Jaccard similarity. Each
631    // candidate finds its best baseline match independently —
632    // multiple candidates can match the same baseline (N
633    // vs 1 baseline).
634    let mut fudged_cg: Vec<(String, String)> = Vec::new(); // (baseline_cg, candidate_cg)
635
636    for ccg in &cg_prefixes_b {
637        let Some(set_b) = cg_types_b.get(ccg) else {
638            continue;
639        };
640        if set_b.len() < 10 {
641            continue;
642        }
643        let mut best: Option<(&str, f64, usize)> = None;
644        for bcg in &cg_prefixes_a {
645            let Some(set_a) = cg_types_a.get(bcg) else {
646                continue;
647            };
648            let intersection = set_a.intersection(set_b).count();
649            if intersection < 10 {
650                continue;
651            }
652            let union = set_a.union(set_b).count();
653            let jaccard = intersection as f64 / union as f64;
654            if jaccard >= 0.90 && best.is_none_or(|(_, bj, _)| jaccard > bj) {
655                best = Some((bcg.as_str(), jaccard, intersection));
656            }
657        }
658        if let Some((bcg, _jaccard, _overlap)) = best {
659            fudged_cg.push((bcg.to_string(), ccg.clone()));
660        }
661    }
662
663    (fudged_cg, cg_types_a, cg_types_b)
664}
665
666/// Tracks which one-sided keys have been consumed by fudging (so
667/// the cascade pass and the post-fudge `retain` drop them) and
668/// the per-baseline cascade root + child count for the report.
669#[derive(Default)]
670struct FudgeRemap {
671    remove_baseline: std::collections::BTreeSet<String>,
672    remove_candidate: std::collections::BTreeSet<String>,
673    cascade_counts: BTreeMap<String, usize>,
674    cascade_roots: BTreeMap<(String, String), (String, String)>,
675}
676
677/// Apply the direct (suffix-exact) remap and then the cascade
678/// remap for every fudged cgroup pair, emitting aggregated rows
679/// for both. Records consumed keys + cascade metadata in
680/// [`FudgeRemap`] and appends every joined `(bkey, ckey)` to
681/// `fudged_key_pairs`. Mirrors the two `emit_fudged_rows` calls of
682/// the original block: first the direct suffix matches, then the
683/// cascaded children.
684fn fudge_cascade(
685    diff: &mut CtprofDiff,
686    ctx: &CompareCtx,
687    fudged_cg: &[(String, String)],
688    fudged_key_pairs: &mut Vec<(String, String)>,
689) -> FudgeRemap {
690    // For each fudged cgroup pair, remap ALL compound keys sharing
691    // that prefix. Match baseline keys to candidate keys by their
692    // pcomm\x00comm suffix.
693    let mut remap = FudgeRemap::default();
694
695    // Collect all (baseline_key, candidate_key) pairs across
696    // all fudge pairs. Multiple candidate keys can map to the
697    // same baseline key (N:1).
698    let mut fudge_matches: BTreeMap<String, Vec<String>> = BTreeMap::new(); // bkey → [ckeys]
699    for (bcg, ccg) in fudged_cg {
700        let b_keys: Vec<&String> = diff
701            .only_baseline
702            .iter()
703            .filter(|k| cg_prefix(k) == bcg.as_str())
704            .collect();
705        let c_keys: Vec<&String> = diff
706            .only_candidate
707            .iter()
708            .filter(|k| cg_prefix(k) == ccg.as_str())
709            .collect();
710        let c_suffix_map: BTreeMap<&str, &String> = c_keys
711            .iter()
712            .map(|k| {
713                let suffix = k.split_once('\x00').map_or("", |(_, s)| s);
714                (suffix, *k)
715            })
716            .collect();
717        for bkey in &b_keys {
718            let b_suffix = bkey.split_once('\x00').map_or("", |(_, s)| s);
719            if let Some(ckey) = c_suffix_map.get(b_suffix) {
720                remap.remove_baseline.insert((*bkey).clone());
721                remap.remove_candidate.insert((*ckey).clone());
722                fudged_key_pairs.push(((*bkey).clone(), (*ckey).clone()));
723                fudge_matches
724                    .entry((*bkey).clone())
725                    .or_default()
726                    .push((*ckey).clone());
727            }
728        }
729    }
730    // Emit one row per baseline key with candidate values
731    // aggregated across all N matched candidate groups.
732    emit_fudged_rows(diff, &fudge_matches, ctx.groups_a, ctx.groups_b);
733
734    // Cascade: for each fudged cgroup pair, compute cascade
735    // roots by stripping the longest common suffix (by /
736    // segments) from the pair. Use those shorter roots for
737    // starts_with matching, not the full fudged paths.
738    let mut cascade_matches: BTreeMap<String, Vec<String>> = BTreeMap::new();
739    for (bcg, ccg) in fudged_cg {
740        let b_segs: Vec<&str> = bcg.split('/').collect();
741        let c_segs: Vec<&str> = ccg.split('/').collect();
742        let common_suffix_len = b_segs
743            .iter()
744            .rev()
745            .zip(c_segs.iter().rev())
746            .take_while(|(a, b)| a == b)
747            .count();
748        let b_root: String = b_segs[..b_segs.len().saturating_sub(common_suffix_len)].join("/");
749        let c_root: String = c_segs[..c_segs.len().saturating_sub(common_suffix_len)].join("/");
750        let b_root = if b_root.is_empty() {
751            bcg.clone()
752        } else {
753            b_root
754        };
755        let c_root = if c_root.is_empty() {
756            ccg.clone()
757        } else {
758            c_root
759        };
760
761        remap
762            .cascade_roots
763            .insert((bcg.clone(), ccg.clone()), (b_root.clone(), c_root.clone()));
764
765        // Boundary-checked prefix match: accept either an
766        // exact root match (tail.is_empty()) or a child path
767        // (tail starts with '/'). Bare `starts_with` would
768        // false-match siblings — `/svc-extra` would slip
769        // through `b_root=/svc`. The non-matching keys would
770        // ultimately fail downstream lookup (c_by_suffix
771        // applies the same boundary filter), but skipping them
772        // here keeps the cascade scan consistent with the
773        // remap rule and avoids needless work.
774        let is_root_or_child = |cg: &str, root: &str| {
775            let Some(tail) = cg.strip_prefix(root) else {
776                return false;
777            };
778            tail.is_empty() || tail.starts_with('/')
779        };
780        let remaining_b: Vec<String> = diff
781            .only_baseline
782            .iter()
783            .filter(|k| {
784                !remap.remove_baseline.contains(*k)
785                    && is_root_or_child(cg_prefix(k), b_root.as_str())
786            })
787            .cloned()
788            .collect();
789        let remaining_c: Vec<String> = diff
790            .only_candidate
791            .iter()
792            .filter(|k| {
793                !remap.remove_candidate.contains(*k)
794                    && is_root_or_child(cg_prefix(k), c_root.as_str())
795            })
796            .cloned()
797            .collect();
798        // Filter_map (not map) so boundary-rejected entries
799        // drop instead of all colliding at the empty-string
800        // key in the BTreeMap. Combined with the upstream
801        // is_root_or_child filter on remaining_c, every entry
802        // here should already qualify; this stays defensive
803        // against future filter regressions.
804        let c_by_suffix: BTreeMap<String, &String> = remaining_c
805            .iter()
806            .filter_map(|k| {
807                let child_cg = cg_prefix(k);
808                let tail = &child_cg[c_root.len()..];
809                if !tail.is_empty() && !tail.starts_with('/') {
810                    return None;
811                }
812                let rewritten = format!("{b_root}{tail}");
813                let suffix = k.split_once('\x00').map_or("", |(_, s)| s);
814                Some((format!("{rewritten}\x00{suffix}"), k))
815            })
816            .collect();
817        for bkey in &remaining_b {
818            if let Some(ckey) = c_by_suffix.get(bkey) {
819                remap.remove_baseline.insert(bkey.clone());
820                remap.remove_candidate.insert((*ckey).clone());
821                fudged_key_pairs.push((bkey.clone(), (*ckey).clone()));
822                *remap.cascade_counts.entry(bcg.clone()).or_insert(0) += 1;
823                cascade_matches
824                    .entry(bkey.clone())
825                    .or_default()
826                    .push((*ckey).clone());
827            }
828        }
829    }
830
831    // Emit aggregated rows for cascaded children (same N:1 merge).
832    emit_fudged_rows(diff, &cascade_matches, ctx.groups_a, ctx.groups_b);
833
834    remap
835}
836
837/// Build the [`FudgedPair`] report vector for every matched cgroup
838/// pair: overlap / Jaccard over the two thread-type sets, residuals
839/// computed against the per-bcg / per-ccg UNION of counterpart sets
840/// (so a type missing from every counterpart surfaces once), and
841/// the cascade child count + roots from [`FudgeRemap`].
842fn build_fudged_pairs(
843    fudged_cg: &[(String, String)],
844    cg_types_a: &BTreeMap<String, TypeSet>,
845    cg_types_b: &BTreeMap<String, TypeSet>,
846    remap: &FudgeRemap,
847) -> Vec<FudgedPair> {
848    // Store fudge report per cgroup pair.
849    //
850    // Residual deduplication for N:1: when one bcg is matched
851    // against multiple ccgs, the per-pair baseline_residual =
852    // (set_a - set_b_for_this_pair) over-reports thread types
853    // that are missing from EVERY ccg — they appear N times,
854    // once per pair. Same for candidate_residual when one ccg
855    // is matched against multiple bcgs (M:1 in the other
856    // direction). Compute residuals against the UNION of all
857    // counterpart sets so a missing-on-every-side type
858    // surfaces exactly once across the bcg's pairs.
859    let mut union_b_for_bcg: BTreeMap<String, TypeSet> = BTreeMap::new();
860    let mut union_a_for_ccg: BTreeMap<String, TypeSet> = BTreeMap::new();
861    for (bcg, ccg) in fudged_cg {
862        if let Some(sb) = cg_types_b.get(ccg) {
863            union_b_for_bcg
864                .entry(bcg.clone())
865                .or_default()
866                .extend(sb.iter().cloned());
867        }
868        if let Some(sa) = cg_types_a.get(bcg) {
869            union_a_for_ccg
870                .entry(ccg.clone())
871                .or_default()
872                .extend(sa.iter().cloned());
873        }
874    }
875    fudged_cg
876        .iter()
877        .map(|(bcg, ccg)| {
878            let set_a = cg_types_a.get(bcg).cloned().unwrap_or_default();
879            let set_b = cg_types_b.get(ccg).cloned().unwrap_or_default();
880            // Residuals against the per-bcg / per-ccg unions
881            // so a thread type missing from every counterpart
882            // candidate appears exactly once.
883            let union_b = union_b_for_bcg.get(bcg).cloned().unwrap_or_default();
884            let union_a = union_a_for_ccg.get(ccg).cloned().unwrap_or_default();
885            let residual_a: Vec<String> = set_a
886                .difference(&union_b)
887                .map(|(p, c)| format!("{p}:{c}"))
888                .collect();
889            let residual_b: Vec<String> = set_b
890                .difference(&union_a)
891                .map(|(p, c)| format!("{p}:{c}"))
892                .collect();
893            let intersection = set_a.intersection(&set_b).count();
894            let union = set_a.union(&set_b).count();
895            FudgedPair {
896                baseline_cgroup: bcg.clone(),
897                candidate_cgroup: ccg.clone(),
898                overlap: intersection,
899                jaccard: if union > 0 {
900                    intersection as f64 / union as f64
901                } else {
902                    0.0
903                },
904                baseline_residual: residual_a,
905                candidate_residual: residual_b,
906                cascaded_children: remap.cascade_counts.get(bcg).copied().unwrap_or(0),
907                baseline_root: remap
908                    .cascade_roots
909                    .get(&(bcg.clone(), ccg.clone()))
910                    .map(|(b, _)| b.clone())
911                    .unwrap_or_else(|| bcg.clone()),
912                candidate_root: remap
913                    .cascade_roots
914                    .get(&(bcg.clone(), ccg.clone()))
915                    .map(|(_, c)| c.clone())
916                    .unwrap_or_else(|| ccg.clone()),
917            }
918        })
919        .collect()
920}
921
922/// Content-based cgroup fudging: join one-sided groups whose
923/// thread populations overlap (cgroup path changed but workload is
924/// the same). Runs only under `GroupBy::All` with both unmatched
925/// lists non-empty; otherwise returns an empty `fudged_key_pairs`
926/// and leaves `diff` untouched. On the active path it emits the
927/// merged rows (direct + cascade), prunes the joined keys from
928/// `only_baseline` / `only_candidate`, populates
929/// [`CtprofDiff::fudged_pairs`], and returns the `(bkey, ckey)`
930/// pairs consumed downstream by [`fill_uptime_pct`] and
931/// [`attach_enrichment`].
932fn apply_cgroup_fudge(diff: &mut CtprofDiff, ctx: &CompareCtx) -> Vec<(String, String)> {
933    let mut fudged_key_pairs: Vec<(String, String)> = Vec::new();
934    if ctx.group_by != GroupBy::All
935        || diff.only_baseline.is_empty()
936        || diff.only_candidate.is_empty()
937    {
938        return fudged_key_pairs;
939    }
940
941    let (fudged_cg, cg_types_a, cg_types_b) = fudge_match_prefixes(diff, ctx);
942
943    let remap = fudge_cascade(diff, ctx, &fudged_cg, &mut fudged_key_pairs);
944
945    diff.only_baseline
946        .retain(|k| !remap.remove_baseline.contains(k));
947    diff.only_candidate
948        .retain(|k| !remap.remove_candidate.contains(k));
949
950    diff.fudged_pairs = build_fudged_pairs(&fudged_cg, &cg_types_a, &cg_types_b, &remap);
951
952    fudged_key_pairs
953}
954
955/// Second pass: fill [`DiffRow::uptime_pct`]. Computes each
956/// matched group's average candidate-side thread lifetime
957/// (`now_b - avg_start_ticks`), folds the fudged pairs' candidate
958/// lifetimes into their baseline key (averaged across the N
959/// matched candidates), then expresses each as a percentage of the
960/// longest-lived group.
961fn fill_uptime_pct(diff: &mut CtprofDiff, ctx: &CompareCtx, fudged_key_pairs: &[(String, String)]) {
962    let mut group_lifetime: BTreeMap<String, u64> = BTreeMap::new();
963    for (key, group_b) in ctx.groups_b {
964        if ctx.groups_a.contains_key(key) {
965            group_lifetime.insert(
966                key.clone(),
967                ctx.now_b.saturating_sub(group_b.avg_start_ticks),
968            );
969        }
970    }
971    let mut fudge_lt_sum: BTreeMap<String, (u64, u64)> = BTreeMap::new();
972    for (bkey, ckey) in fudged_key_pairs {
973        if let Some(gb) = ctx.groups_b.get(ckey) {
974            let lt = ctx.now_b.saturating_sub(gb.avg_start_ticks);
975            let entry = fudge_lt_sum.entry(bkey.clone()).or_insert((0, 0));
976            entry.0 += lt;
977            entry.1 += 1;
978        }
979    }
980    for (bkey, (sum, count)) in &fudge_lt_sum {
981        if *count > 0 {
982            group_lifetime.insert(bkey.clone(), sum / count);
983        }
984    }
985    let max_lifetime = group_lifetime.values().copied().max().unwrap_or(1).max(1);
986    for row in &mut diff.rows {
987        if let Some(&lt) = group_lifetime.get(&row.group_key) {
988            row.uptime_pct = Some(lt as f64 / max_lifetime as f64 * 100.0);
989        }
990    }
991}
992
993/// Order the diff + derived rows. With an empty `sort_by`, applies
994/// the default `|delta_pct|`-descending sort (ties broken by
995/// ascending group_key) to both row vecs. With a non-empty
996/// `sort_by`, routes through [`sort_diff_rows_by_keys`] for the
997/// multi-key rank, then fills [`DiffRow::sort_by_cell`] /
998/// `sort_by_delta` (mirrored onto the derived rows) from the
999/// primary sort metric's per-group cell.
1000fn sort_diff(diff: &mut CtprofDiff, sort_by: &[SortKey]) {
1001    if sort_by.is_empty() {
1002        // Default: stable-sort by descending |delta_pct|, ties
1003        // broken by ascending group_key + registry order of
1004        // metric. Apply the same shape to derived_rows so the
1005        // `## Derived metrics` section ranks by salient delta
1006        // rather than registry order — matches the operator's
1007        // expectation that the most-changed row sits at the
1008        // top of every section.
1009        diff.rows.sort_by(|a, b| {
1010            b.sort_key()
1011                .partial_cmp(&a.sort_key())
1012                .unwrap_or(std::cmp::Ordering::Equal)
1013                .then_with(|| a.group_key.cmp(&b.group_key))
1014        });
1015        diff.derived_rows.sort_by(|a, b| {
1016            b.sort_key()
1017                .partial_cmp(&a.sort_key())
1018                .unwrap_or(std::cmp::Ordering::Equal)
1019                .then_with(|| a.group_key.cmp(&b.group_key))
1020        });
1021    } else {
1022        // Multi-key sort: rank groups by tuple of named-metric
1023        // deltas, sort rows by (group_rank, metric_registry_idx).
1024        sort_diff_rows_by_keys(&mut diff.rows, &mut diff.derived_rows, sort_by);
1025
1026        // Fill sort_by_cell: for each group, find the sort metric's
1027        // row and format its baseline→candidate (delta%).
1028        let sort_metric = sort_by.first().map(|sk| sk.metric);
1029        diff.sort_metric_name = sort_metric;
1030        if let Some(metric_name) = sort_metric {
1031            let mut group_cells: BTreeMap<String, (String, Option<f64>)> = BTreeMap::new();
1032            for row in &diff.rows {
1033                if row.metric_name == metric_name && !group_cells.contains_key(&row.group_key) {
1034                    let b = format_value_cell(&row.baseline, row.metric_ladder);
1035                    let c = format_value_cell(&row.candidate, row.metric_ladder);
1036                    let pct = match row.delta_pct {
1037                        Some(p) => format!(" ({:+.1}%)", p * 100.0),
1038                        None => String::new(),
1039                    };
1040                    group_cells.insert(
1041                        row.group_key.clone(),
1042                        (format!("{b}\u{2192}{c}{pct}"), row.delta),
1043                    );
1044                }
1045            }
1046            for row in &mut diff.rows {
1047                if let Some((cell, delta)) = group_cells.get(&row.group_key) {
1048                    row.sort_by_cell = Some(cell.clone());
1049                    row.sort_by_delta = *delta;
1050                }
1051            }
1052            // Mirror the SortBy fill onto derived_rows so the
1053            // derived section's SortBy column carries the sort
1054            // metric's per-group cell instead of "-". Same group
1055            // keying (group_cells is keyed by group_key, which
1056            // is the join key shared by primary and derived rows
1057            // for the same bucket).
1058            for row in &mut diff.derived_rows {
1059                if let Some((cell, delta)) = group_cells.get(&row.group_key) {
1060                    row.sort_by_cell = Some(cell.clone());
1061                    row.sort_by_delta = *delta;
1062                }
1063            }
1064        }
1065    }
1066}
1067
1068/// Re-key candidate-side smaps data so fudged pairs join the
1069/// baseline side. Fudge pairs a baseline cgroup with a candidate
1070/// cgroup, but smaps keys are `cg\x00pcomm` — different cg means no
1071/// join. For each fudge pair (processed most-specific-root-first),
1072/// scan candidate smaps keys under the pair's `candidate_root`,
1073/// strip that root to a relative child path, then re-key under the
1074/// SAME pair's `baseline_root`. Restricts to candidate cgroups that
1075/// were actually fudge-matched (`fudged_key_pairs`) and sums values
1076/// when multiple candidates map to one baseline key.
1077fn remap_fudged_smaps(diff: &mut CtprofDiff, fudged_key_pairs: &[(String, String)]) {
1078    // Build a set of candidate cgroup paths that were
1079    // actually fudge-matched (extracted from the candidate
1080    // half of fudged_key_pairs). The smaps remap MUST
1081    // restrict to these paths — without the gate, any smaps
1082    // key that happens to share a prefix with a fudge pair's
1083    // candidate_root is remapped, even if its compound-key
1084    // counterpart was never fudge-matched. Sub-cgroups under
1085    // a cascade root that didn't actually match would have
1086    // their smaps data silently re-keyed under the baseline_root.
1087    let fudged_cg_set: std::collections::BTreeSet<&str> = fudged_key_pairs
1088        .iter()
1089        .map(|(_, ckey)| ckey.split_once('\x00').map_or(ckey.as_str(), |(cg, _)| cg))
1090        .collect();
1091    let mut sorted_pairs: Vec<&FudgedPair> = diff.fudged_pairs.iter().collect();
1092    sorted_pairs.sort_by(|a, b| b.candidate_root.len().cmp(&a.candidate_root.len()));
1093    for fp in sorted_pairs {
1094        let br = &fp.baseline_root;
1095        let cr = &fp.candidate_root;
1096        let cr_slash = format!("{cr}/");
1097        let cr_nul = format!("{cr}\x00");
1098        // (relative_child_path + \x00 + pcomm) → summed values,
1099        // SCOPED to this fudge pair so pairs with different
1100        // baseline_roots don't share a remap accumulator.
1101        let mut summed_by_rel: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
1102        // Fudge runs only under GroupBy::All, which routes
1103        // through `collect_smaps_rollup_hierarchical` and
1104        // produces keys shaped `cgroup\x00pcomm` for every
1105        // entry (see [`collect_smaps_rollup_inner`] under
1106        // `compound_cgroup=true`). A bare-cgroup key
1107        // (no `\x00`) cannot appear here, so the in-root
1108        // filter only needs to consider the `/`-bounded
1109        // (cr_slash) and `\x00`-bounded (cr_nul) prefixes.
1110        let keys: Vec<String> = diff
1111            .smaps_rollup_b
1112            .keys()
1113            .filter(|k| {
1114                let in_root = k.starts_with(&cr_slash) || k.starts_with(&cr_nul);
1115                if !in_root {
1116                    return false;
1117                }
1118                // Gate on actual fudge match: the smaps key's
1119                // cg_path must appear in the fudged_cg_set.
1120                let cg_path = k.split_once('\x00').map_or(k.as_str(), |(cg, _)| cg);
1121                fudged_cg_set.contains(cg_path)
1122            })
1123            .cloned()
1124            .collect();
1125        for k in keys {
1126            if let Some(val) = diff.smaps_rollup_b.remove(&k) {
1127                // Split smaps key: cg_path \x00 pcomm. The
1128                // unwrap_or fallback would only fire on a
1129                // key with no `\x00`, which cannot reach
1130                // here (see filter above) — kept defensive.
1131                let (cg_path, pcomm) = k.split_once('\x00').unwrap_or((&k, ""));
1132                // Strip candidate root to get relative child path.
1133                // `cg_path == cr.as_str()` covers the exact-root
1134                // hit (e.g. `/svc-a\x00pcomm` against root
1135                // `/svc-a`); the strip_prefix branch covers
1136                // child paths.
1137                let child = if cg_path == cr.as_str() {
1138                    ""
1139                } else if let Some(rest) = cg_path.strip_prefix(&cr_slash) {
1140                    rest
1141                } else {
1142                    continue;
1143                };
1144                let rel_key = format!("{child}\x00{pcomm}");
1145                let entry = summed_by_rel.entry(rel_key).or_default();
1146                for (field, v) in &val {
1147                    let slot = entry.entry(field.clone()).or_insert(0);
1148                    *slot = slot.saturating_add(*v);
1149                }
1150            }
1151        }
1152        // Rebuild this pair's baseline-side keys and insert
1153        // summed data under THIS pair's baseline_root.
1154        for (rel_key, summed) in summed_by_rel {
1155            let (child, pcomm) = rel_key.split_once('\x00').unwrap_or((&rel_key, ""));
1156            let base_key = if child.is_empty() {
1157                format!("{br}\x00{pcomm}")
1158            } else {
1159                format!("{br}/{child}\x00{pcomm}")
1160            };
1161            diff.smaps_rollup_b.insert(base_key, summed);
1162        }
1163    }
1164}
1165
1166/// Attach the non-row enrichment data to `diff`: per-cgroup stats
1167/// (only under `GroupBy::Cgroup`), host PSI snapshots,
1168/// per-process smaps rollups (hierarchical under `GroupBy::All`,
1169/// flat otherwise), the fudged-smaps re-key
1170/// ([`remap_fudged_smaps`]), and the global sched_ext sysfs
1171/// snapshot.
1172fn attach_enrichment(
1173    diff: &mut CtprofDiff,
1174    ctx: &CompareCtx,
1175    fudged_key_pairs: &[(String, String)],
1176) {
1177    if ctx.group_by == GroupBy::Cgroup {
1178        diff.cgroup_stats_a =
1179            flatten_cgroup_stats(&ctx.baseline.cgroup_stats, ctx.flatten, ctx.cgroup_key_map);
1180        diff.cgroup_stats_b =
1181            flatten_cgroup_stats(&ctx.candidate.cgroup_stats, ctx.flatten, ctx.cgroup_key_map);
1182    }
1183
1184    diff.host_psi_a = ctx.baseline.psi;
1185    diff.host_psi_b = ctx.candidate.psi;
1186
1187    if ctx.group_by == GroupBy::All {
1188        diff.smaps_rollup_a = collect_smaps_rollup_hierarchical(
1189            ctx.baseline,
1190            ctx.opts.no_thread_normalize,
1191            ctx.flatten,
1192            ctx.cgroup_key_map,
1193        );
1194        diff.smaps_rollup_b = collect_smaps_rollup_hierarchical(
1195            ctx.candidate,
1196            ctx.opts.no_thread_normalize,
1197            ctx.flatten,
1198            ctx.cgroup_key_map,
1199        );
1200    } else {
1201        diff.smaps_rollup_a = collect_smaps_rollup(ctx.baseline, ctx.opts.no_thread_normalize);
1202        diff.smaps_rollup_b = collect_smaps_rollup(ctx.candidate, ctx.opts.no_thread_normalize);
1203    }
1204
1205    remap_fudged_smaps(diff, fudged_key_pairs);
1206
1207    diff.sched_ext_a = ctx.baseline.sched_ext.clone();
1208    diff.sched_ext_b = ctx.candidate.sched_ext.clone();
1209}
1210
1211/// Compare two snapshots and produce a [`CtprofDiff`]. Sequences
1212/// the comparison phases in data-flow order: build the per-side
1213/// thread groups, emit matched + one-sided rows, fudge one-sided
1214/// cgroups together (producing the `fudged_key_pairs` consumed by
1215/// the uptime and enrichment phases), sort the one-sided lists,
1216/// fill uptime%, order the rows, and attach enrichment. The
1217/// fudged-pair `(bkey, ckey)` threading is the only cross-phase
1218/// data dependency: built by `apply_cgroup_fudge`, read by
1219/// `fill_uptime_pct` and `attach_enrichment`.
1220pub fn compare(
1221    baseline: &CtprofSnapshot,
1222    candidate: &CtprofSnapshot,
1223    opts: &CompareOptions,
1224) -> CtprofDiff {
1225    let flatten = compile_flatten_patterns(&opts.cgroup_flatten);
1226    let (group_by, cgroup_key_map, groups_a, groups_b) =
1227        build_compare_groups(baseline, candidate, opts, &flatten);
1228
1229    // Compute per-snapshot "now" for lifetime calculation:
1230    // newest thread's start_time approximates capture time in ticks.
1231    let now_b = candidate
1232        .threads
1233        .iter()
1234        .map(|t| t.start_time_clock_ticks)
1235        .max()
1236        .unwrap_or(0);
1237
1238    let ctx = CompareCtx {
1239        baseline,
1240        candidate,
1241        opts,
1242        group_by,
1243        flatten: &flatten,
1244        cgroup_key_map: cgroup_key_map.as_ref(),
1245        groups_a: &groups_a,
1246        groups_b: &groups_b,
1247        now_b,
1248    };
1249
1250    let mut diff = CtprofDiff::default();
1251
1252    emit_matched_rows(&mut diff, &ctx);
1253
1254    // Content-based cgroup fudging: match one-sided groups by
1255    // thread population overlap when cgroup paths differ but
1256    // the workload is the same (e.g. service re-deployed to a
1257    // new cgroup path between snapshots). The returned pairs are
1258    // read by the uptime and enrichment phases below.
1259    let fudged_key_pairs = apply_cgroup_fudge(&mut diff, &ctx);
1260
1261    diff.only_baseline.sort();
1262    diff.only_candidate.sort();
1263
1264    fill_uptime_pct(&mut diff, &ctx, &fudged_key_pairs);
1265
1266    sort_diff(&mut diff, &opts.sort_by);
1267
1268    attach_enrichment(&mut diff, &ctx, &fudged_key_pairs);
1269
1270    diff
1271}
1272
1273pub fn flatten_cgroup_stats(
1274    stats: &BTreeMap<String, CgroupStats>,
1275    patterns: &[glob::Pattern],
1276    cgroup_key_map: Option<&BTreeMap<String, String>>,
1277) -> BTreeMap<String, CgroupStats> {
1278    // When multiple input paths flatten to the same key, the
1279    // merge is per-controller and per-field-class:
1280    //
1281    // - **Counters** (`usage_usec`, `nr_throttled`,
1282    //   `throttled_usec`, `pids.current`, `memory.events` map
1283    //   values, AND counter-shaped `memory.stat` keys
1284    //   (workingset_*, pgfault, pgmajfault, pgsteal_*, etc.)):
1285    //   saturating_add. Cumulative across the merged bucket.
1286    // - **Instantaneous values / gauges** (`memory.current` AND
1287    //   gauge-shaped `memory.stat` keys per
1288    //   [`MEMORY_STAT_GAUGE_KEYS`]: anon, file, slab,
1289    //   active_anon, etc.): max. Summing point-in-time pool
1290    //   sizes overstates the merged-bucket gauge. Counter vs
1291    //   gauge dispatch lives in [`merge_memory_stat`].
1292    // - **Limits** (`memory.max`, `memory.high`, `pids.max`,
1293    //   `cpu.max` quota, `cpu.weight`, `cpu.weight.nice`):
1294    //   max-for-limits via [`merge_max_option`]. `None` ("no
1295    //   limit") propagates when EITHER side is unbounded — the
1296    //   merged bucket is unbounded if any contributor is, since
1297    //   no synthesized cap reflects the actual kernel-enforced
1298    //   reality.
1299    // - **Floors** (`memory.low`, `memory.min`): min-for-floors
1300    //   via [`merge_min_option`]. `None` ("no floor")
1301    //   propagates when EITHER side has no floor — the merged
1302    //   bucket is only as protected as its weakest contributor,
1303    //   for the same reason. The literal "max" token (full
1304    //   protection) parses to `Some(u64::MAX)` per
1305    //   [`parse_floor_value`] and merges via min-for-floors,
1306    //   correctly yielding the smaller concrete floor when one
1307    //   contributor has full protection and another has a
1308    //   numeric floor.
1309    // - **PSI**: avg fields max-across, total_usec
1310    //   saturating_add (per [`merge_psi`]).
1311    //
1312    // When `cgroup_key_map` is provided (auto-normalize is on),
1313    // each post-flatten path is further mapped to its final
1314    // tightened key — so the enrichment table renders against
1315    // the same labels as thread groups. When absent, the
1316    // post-flatten path itself is the key (matches the legacy
1317    // behavior with glob-only flatten).
1318    // First-iteration-replace semantics: the first contributor
1319    // for a key is inserted verbatim (clone). Subsequent
1320    // contributors are merged in via the per-domain merge fns.
1321    // Using `or_default()` + merge here would synthesize a
1322    // CgroupStats whose `Option<u64>` limits are all None and
1323    // None-poison every `merge_max_option`/`merge_min_option`
1324    // call against the first real contributor — yielding `None`
1325    // for limits/floors even when every contributor has a
1326    // concrete value. The replace-on-first / merge-on-rest split
1327    // ensures None-poisoning fires only when contributors
1328    // genuinely disagree (one None, one Some), never when
1329    // merging the synthetic seed.
1330    let mut out: BTreeMap<String, CgroupStats> = BTreeMap::new();
1331    for (path, cs) in stats {
1332        let post_flatten = flatten_cgroup_path(path, patterns);
1333        let key = match cgroup_key_map.and_then(|m| m.get(&post_flatten)) {
1334            Some(k) => k.clone(),
1335            None => post_flatten,
1336        };
1337        match out.get_mut(&key) {
1338            None => {
1339                out.insert(key, cs.clone());
1340            }
1341            Some(agg) => {
1342                merge_cgroup_cpu(&mut agg.cpu, &cs.cpu);
1343                merge_cgroup_memory(&mut agg.memory, &cs.memory);
1344                merge_cgroup_pids(&mut agg.pids, &cs.pids);
1345                agg.psi = merge_psi(agg.psi, cs.psi);
1346            }
1347        }
1348    }
1349    out
1350}