Skip to main content

kui_trace/
query.rs

1//! Shared, windowless perf exploration (ADR 0078). All times are µs.
2//! Selection chooses a frame population; span filters only affect attribution.
3//! The module has no dependency on Lua, channels, a window, or serialization.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6
7use crate::analysis::{self, Burn, InputPath, Phases, Verdict};
8use crate::{Frame, NameId, Origin, Placement, Span, Timeline, Trace};
9
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
11pub enum Selection {
12    #[default]
13    Whole,
14    /// Recorded frame numbers, inclusive at both ends.
15    Frames { first: u64, last: u64 },
16    /// Half-open recording-clock range, expanded to complete intersecting frames.
17    Time { from_us: u64, to_us: u64 },
18}
19
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct Filter {
22    pub name: Option<String>,
23    pub origins: Option<Vec<String>>,
24    pub hide_names: Vec<String>,
25    pub min_us: u64,
26    pub verdicts: Option<Vec<String>>,
27    pub hide_unchanged: bool,
28    /// Optional scripting predicate results: original IDs, never filtered rows.
29    pub span_ids: Option<Vec<u32>>,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Operation {
34    Summary,
35    Frames,
36    Frame(u64),
37    Spans,
38    Span(u32),
39    Counters,
40    Marks,
41}
42
43impl Operation {
44    pub fn name(self) -> &'static str {
45        match self {
46            Self::Summary => "summary",
47            Self::Frames => "frames",
48            Self::Frame(_) => "frame",
49            Self::Spans => "spans",
50            Self::Span(_) => "span",
51            Self::Counters => "counters",
52            Self::Marks => "marks",
53        }
54    }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Query {
59    pub operation: Operation,
60    pub selection: Selection,
61    pub filter: Filter,
62    pub sort: String,
63    pub descending: bool,
64    /// None requests all matches. Details are always complete.
65    pub limit: Option<usize>,
66}
67
68impl Query {
69    pub fn new(operation: Operation) -> Self {
70        Self {
71            operation,
72            selection: Selection::Whole,
73            filter: Filter::default(),
74            sort: match operation {
75                Operation::Frames => "work",
76                Operation::Spans => "self",
77                Operation::Marks => "start",
78                _ => "name",
79            }
80            .into(),
81            descending: matches!(operation, Operation::Frames | Operation::Spans),
82            limit: matches!(
83                operation,
84                Operation::Frames | Operation::Spans | Operation::Counters | Operation::Marks
85            )
86            .then_some(20),
87        }
88    }
89
90    /// Costs descend; names, frame numbers and timestamps ascend. Adapters
91    /// use this before applying an explicitly requested direction.
92    pub fn set_sort(&mut self, sort: impl Into<String>) {
93        self.sort = sort.into();
94        self.descending = matches!(
95            self.sort.as_str(),
96            "work" | "self" | "total" | "worst" | "calls"
97        );
98    }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct Scope {
103    pub requested: Selection,
104    /// Original timeline indices, in recording order, after frame filters.
105    pub indices: Vec<usize>,
106    /// Effective accounting intervals, half-open, including preceding gaps.
107    /// More than one interval can remain when a frame filter removes frames.
108    pub intervals: Vec<(u64, u64)>,
109}
110
111impl Scope {
112    pub fn contains(&self, t: u64) -> bool {
113        let i = self.intervals.partition_point(|(a, _)| *a <= t);
114        i > 0 && t < self.intervals[i - 1].1
115    }
116    pub fn duration_us(&self) -> u64 {
117        self.intervals
118            .iter()
119            .fold(0u64, |s, (a, b)| s.saturating_add(b.saturating_sub(*a)))
120    }
121}
122
123#[derive(Clone, Debug, Default, PartialEq)]
124pub struct Distribution {
125    pub samples: usize,
126    pub avg_us: Option<f64>,
127    pub p50_us: Option<u64>,
128    pub p95_us: Option<u64>,
129    pub p99_us: Option<u64>,
130    pub max_us: Option<u64>,
131}
132
133impl Distribution {
134    fn of(values: &[u64]) -> Self {
135        if values.is_empty() {
136            return Self::default();
137        }
138        let mut sorted = values.to_vec();
139        sorted.sort_unstable();
140        // Retain the estimator used by the existing p95 reading.
141        let percentile = |q: f64| sorted[((sorted.len() - 1) as f64 * q).round() as usize];
142        let max = *sorted.last().unwrap();
143        Self {
144            samples: values.len(),
145            avg_us: Some(values.iter().map(|&v| v as f64).sum::<f64>() / values.len() as f64),
146            p50_us: Some(percentile(0.50)),
147            p95_us: Some(percentile(0.95)),
148            p99_us: Some(percentile(0.99)),
149            max_us: Some(max),
150        }
151    }
152}
153
154#[derive(Clone, Debug, PartialEq)]
155pub struct Summary {
156    pub frames: usize,
157    pub complete_frames: usize,
158    pub duration_us: u64,
159    pub budget_us: Option<u64>,
160    pub work: Distribution,
161    pub pacing: Distribution,
162    pub phases: Vec<(&'static str, Distribution)>,
163    pub over_budget: Option<usize>,
164    pub unchanged: usize,
165}
166
167impl Summary {
168    /// Only frames with complete work measurements enter the denominator.
169    pub fn over_budget_percent(&self) -> Option<f64> {
170        self.over_budget
171            .filter(|_| self.complete_frames > 0)
172            .map(|n| 100.0 * n as f64 / self.complete_frames as f64)
173    }
174}
175
176#[derive(Clone, Debug, PartialEq)]
177pub struct FrameDetail {
178    pub index: usize,
179    pub frame: Frame,
180    pub phases: Phases,
181    pub verdict: Option<Verdict>,
182    pub path: Option<InputPath>,
183    pub fresh: Vec<Placement>,
184    pub notes: Vec<String>,
185}
186
187#[derive(Clone, Debug, PartialEq)]
188pub struct SpanDetail {
189    pub span: Span,
190    pub name: NameId,
191    pub self_us: u64,
192    pub frame: Option<u64>,
193    pub depth: u32,
194    pub placement: Option<Placement>,
195    pub children: Vec<u32>,
196}
197
198#[derive(Clone, Debug, PartialEq)]
199pub struct SpanGroup {
200    pub name: NameId,
201    pub origin: Origin,
202    pub calls: u64,
203    pub total_us: u64,
204    pub self_us: u64,
205    pub worst_us: u64,
206    pub cpu_us: Option<u64>,
207    pub example_id: u32,
208}
209
210#[derive(Clone, Debug, PartialEq)]
211pub struct Counter {
212    pub name: NameId,
213    pub avg: f64,
214    pub max: i64,
215}
216
217#[derive(Clone, Debug, PartialEq)]
218pub struct Gauge {
219    pub name: NameId,
220    pub last: f64,
221    pub least: f64,
222    pub most: f64,
223}
224
225#[derive(Clone, Debug, PartialEq)]
226pub enum Data {
227    Summary(Summary),
228    Frames(Vec<FrameDetail>),
229    Frame(Box<FrameDetail>),
230    Spans(Vec<SpanGroup>),
231    Span(SpanDetail),
232    Counters(Vec<Counter>, Vec<Gauge>),
233    Marks(Vec<crate::Mark>),
234}
235
236#[derive(Clone, Debug, PartialEq)]
237pub struct ResultSet {
238    pub query: Query,
239    pub scope: Scope,
240    pub matched: usize,
241    pub returned: usize,
242    pub data: Data,
243}
244
245#[derive(Clone, Debug, PartialEq)]
246pub struct Readings {
247    pub summary: Summary,
248    pub spans: Vec<SpanGroup>,
249    pub origins: Vec<(Origin, u64, u64)>,
250    pub components: Vec<analysis::PlacementStat>,
251    pub counters: Vec<Counter>,
252    pub gauges: Vec<Gauge>,
253    /// Original timeline indices, so a range cannot renumber a reference.
254    pub burns: Vec<Burn>,
255    pub gaps: analysis::Gaps,
256}
257
258#[derive(Clone, Debug, PartialEq)]
259pub struct Delta {
260    pub metric: String,
261    pub unit: &'static str,
262    pub before: Option<f64>,
263    pub after: Option<f64>,
264    pub delta: Option<f64>,
265    pub percent: Option<f64>,
266}
267
268#[derive(Clone, Debug, PartialEq)]
269pub struct Comparison {
270    pub before: ResultSet,
271    pub after: ResultSet,
272    pub rows: Vec<Delta>,
273    pub matched: usize,
274    pub limit: Option<usize>,
275}
276
277/// Match semantic metric names, never recording-local IDs. Missing groups and
278/// empty populations have no measurement, and a zero baseline has no percent.
279pub fn compare(
280    before: &Explorer<'_>,
281    bq: &Query,
282    after: &Explorer<'_>,
283    aq: &Query,
284    limit: Option<usize>,
285) -> Result<Comparison, String> {
286    if bq.operation != Operation::Summary || aq.operation != Operation::Summary {
287        return Err("comparison inputs must be summary queries".into());
288    }
289    fn metrics(ex: &Explorer<'_>, r: &ResultSet) -> Vec<(String, &'static str, Option<f64>)> {
290        let Data::Summary(s) = &r.data else {
291            unreachable!()
292        };
293        let mut v = vec![
294            ("duration".into(), "ms", Some(s.duration_us as f64 / 1000.0)),
295            ("frames".into(), "count", Some(s.frames as f64)),
296            (
297                "complete_frames".into(),
298                "count",
299                Some(s.complete_frames as f64),
300            ),
301            ("unchanged".into(), "count", Some(s.unchanged as f64)),
302            ("over_budget_percent".into(), "%", s.over_budget_percent()),
303            (
304                "over_budget".into(),
305                "count",
306                s.over_budget.map(|n| n as f64),
307            ),
308        ];
309        for (n, d) in [("work", &s.work), ("pacing", &s.pacing)]
310            .into_iter()
311            .chain(s.phases.iter().map(|(n, d)| (*n, d)))
312        {
313            v.push((format!("{n}.avg"), "ms", d.avg_us.map(|t| t / 1000.0)));
314            for (field, value) in [
315                ("p50", d.p50_us),
316                ("p95", d.p95_us),
317                ("p99", d.p99_us),
318                ("max", d.max_us),
319            ] {
320                v.push((
321                    format!("{n}.{field}"),
322                    "ms",
323                    value.map(|t| t as f64 / 1000.0),
324                ));
325            }
326        }
327        let frames = s.frames as f64;
328        let mut groups = ex.span_groups(&r.scope, &r.query.filter);
329        groups.sort_by(|a, b| {
330            ex.trace
331                .name(a.name)
332                .cmp(ex.trace.name(b.name))
333                .then_with(|| origin_name(ex.trace, a.origin).cmp(origin_name(ex.trace, b.origin)))
334        });
335        for g in groups {
336            // Quoting makes names containing separators unambiguous.
337            let name = ex.trace.name(g.name);
338            let origin = origin_name(ex.trace, g.origin);
339            let n = format!("span[{name:?},{origin:?}]");
340            for (field, unit, value) in [
341                ("calls", "count", Some(g.calls as f64)),
342                ("total", "ms", Some(g.total_us as f64 / 1000.0)),
343                ("self", "ms", Some(g.self_us as f64 / 1000.0)),
344                ("worst", "ms", Some(g.worst_us as f64 / 1000.0)),
345                (
346                    "calls_per_frame",
347                    "count/frame",
348                    (frames > 0.0).then_some(g.calls as f64 / frames),
349                ),
350                (
351                    "total_per_frame",
352                    "ms/frame",
353                    (frames > 0.0).then_some(g.total_us as f64 / 1000.0 / frames),
354                ),
355                (
356                    "self_per_frame",
357                    "ms/frame",
358                    (frames > 0.0).then_some(g.self_us as f64 / 1000.0 / frames),
359                ),
360            ] {
361                v.push((format!("{n}.{field}"), unit, value));
362            }
363        }
364        let (cs, gs) = ex.counters(&r.scope);
365        for c in cs {
366            v.push((
367                format!("counter[{}].avg", ex.trace.name(c.name)),
368                "count/frame",
369                Some(c.avg),
370            ));
371        }
372        for g in gs {
373            v.push((
374                format!("gauge[{}].last", ex.trace.name(g.name)),
375                "value",
376                Some(g.last),
377            ));
378        }
379        v
380    }
381    let b = before.execute(bq)?;
382    let a = after.execute(aq)?;
383    let bm = metrics(before, &b);
384    let am = metrics(after, &a);
385    let mut order: Vec<_> = bm.iter().map(|(n, _, _)| n.clone()).collect();
386    let seen: HashSet<_> = order.iter().cloned().collect();
387    order.extend(
388        am.iter()
389            .filter(|(n, _, _)| !seen.contains(n))
390            .map(|(n, _, _)| n.clone()),
391    );
392    let bm: HashMap<_, _> = bm.into_iter().map(|(n, u, v)| (n, (u, v))).collect();
393    let am: HashMap<_, _> = am.into_iter().map(|(n, u, v)| (n, (u, v))).collect();
394    let matched = order.len();
395    order.truncate(limit.unwrap_or(usize::MAX));
396    let rows = order
397        .into_iter()
398        .map(|metric| {
399            let bv = bm.get(&metric);
400            let av = am.get(&metric);
401            let unit = bv.or(av).unwrap().0;
402            let before = bv.and_then(|x| x.1);
403            let after = av.and_then(|x| x.1);
404            let delta = before.zip(after).map(|(b, a)| a - b);
405            let percent = delta
406                .zip(before)
407                .and_then(|(d, b)| (b != 0.0).then_some(d / b.abs() * 100.0));
408            Delta {
409                metric,
410                unit,
411                before,
412                after,
413                delta,
414                percent,
415            }
416        })
417        .collect();
418    Ok(Comparison {
419        before: b,
420        after: a,
421        rows,
422        matched,
423        limit,
424    })
425}
426
427pub fn budget_us(trace: &Trace) -> Option<u64> {
428    trace
429        .session
430        .as_ref()
431        .and_then(|s| {
432            (s.budget_ms.is_finite() && s.budget_ms > 0.0)
433                .then_some((s.budget_ms as f64 * 1000.0) as u64)
434        })
435        .filter(|b| *b > 0)
436}
437
438pub fn origin_name(trace: &Trace, origin: Origin) -> &str {
439    match origin {
440        Origin::Kui => "kui",
441        Origin::Host => "host",
442        Origin::Extension(n) => trace.name(n),
443    }
444}
445
446pub fn verdict_kind(v: &Verdict) -> &'static str {
447    match v {
448        Verdict::Late { .. } => "late",
449        Verdict::Blocked { .. } => "blocked",
450        Verdict::Slow { .. } => "slow",
451        Verdict::Waited { .. } => "waited",
452        Verdict::Wasted => "wasted",
453        Verdict::Burn { .. } => "burn",
454        Verdict::Fine { .. } => "fine",
455    }
456}
457
458pub fn placement_of<'t>(tl: &'t Timeline, trace: &Trace, s: &Span) -> Option<&'t Placement> {
459    let ns = trace.name(s.name).strip_prefix("fill ")?;
460    let at = tl.placements.partition_point(|p| p.t < s.end());
461    tl.placements
462        .get(at)
463        .filter(|p| p.t <= s.end().saturating_add(50))
464        .filter(|p| {
465            trace
466                .name(p.component)
467                .strip_prefix(ns)
468                .is_some_and(|s| s.starts_with('/'))
469        })
470}
471
472/// Reuse a recording's already-built timeline; constructing an explorer is
473/// linear in spans, with self costs computed against the unfiltered tree.
474pub struct Explorer<'a> {
475    pub trace: &'a Trace,
476    pub timeline: &'a Timeline,
477    index: std::borrow::Cow<'a, Index>,
478}
479
480/// Owned analysis for one immutable recording revision. Reuse it for detail
481/// reads/redraws; invalidate it whenever the source timeline or session changes.
482#[derive(Clone)]
483pub struct Index {
484    budget: Option<u64>,
485    burns: Vec<Burn>,
486    spans: HashMap<u32, usize>,
487    frames: HashMap<u64, usize>,
488    children: Vec<(u32, u32)>,
489    self_times: Vec<u64>,
490    depths: Vec<u32>,
491}
492
493impl std::ops::Deref for Explorer<'_> {
494    type Target = Index;
495    fn deref(&self) -> &Index {
496        &self.index
497    }
498}
499
500impl<'a> Explorer<'a> {
501    pub fn new(trace: &'a Trace, timeline: &'a Timeline) -> Self {
502        let spans: HashMap<u32, usize> = timeline
503            .spans
504            .iter()
505            .enumerate()
506            .map(|(i, s)| (s.id, i))
507            .collect();
508        let mut children = vec![0u64; timeline.spans.len()];
509        for s in &timeline.spans {
510            if s.id != s.parent
511                && let Some(&p) = spans.get(&s.parent)
512            {
513                let parent = &timeline.spans[p];
514                if s.track == parent.track {
515                    let length = s
516                        .end()
517                        .min(parent.end())
518                        .saturating_sub(s.start.max(parent.start));
519                    children[p] = children[p].saturating_add(length);
520                }
521            }
522        }
523        let self_times = timeline
524            .spans
525            .iter()
526            .enumerate()
527            .map(|(i, s)| s.dur.saturating_sub(children[i]))
528            .collect();
529        // Resolve depth by parent ID with memoization; corrupt cycles terminate.
530        let mut depths = vec![None; timeline.spans.len()];
531        let mut visited = vec![0usize; timeline.spans.len()];
532        let mut path = Vec::new();
533        for i in 0..timeline.spans.len() {
534            if depths[i].is_some() {
535                continue;
536            }
537            path.clear();
538            let mut at = i;
539            let depth = loop {
540                if let Some(d) = depths[at] {
541                    break d + 1;
542                }
543                if visited[at] == i + 1 {
544                    break 0u32;
545                }
546                visited[at] = i + 1;
547                path.push(at);
548                let s = &timeline.spans[at];
549                match spans.get(&s.parent) {
550                    Some(&p) if p != at && timeline.spans[p].track == s.track => at = p,
551                    _ => break 0,
552                }
553            };
554            let mut d = depth;
555            for &p in path.iter().rev() {
556                depths[p] = Some(d);
557                d = d.saturating_add(1);
558            }
559        }
560        let budget = budget_us(trace);
561        let frames = timeline
562            .frames
563            .iter()
564            .enumerate()
565            .map(|(i, f)| (f.frame, i))
566            .collect();
567        let mut children: Vec<_> = timeline
568            .spans
569            .iter()
570            .filter(|s| s.parent != 0 && s.parent != s.id)
571            .map(|s| (s.parent, s.id))
572            .collect();
573        children.sort_unstable();
574        Self {
575            trace,
576            timeline,
577            index: std::borrow::Cow::Owned(Index {
578                budget,
579                burns: analysis::burns(timeline, trace),
580                spans,
581                frames,
582                children,
583                self_times,
584                depths: depths.into_iter().map(|d| d.unwrap_or(0)).collect(),
585            }),
586        }
587    }
588
589    pub fn with_index(trace: &'a Trace, timeline: &'a Timeline, index: &'a Index) -> Self {
590        Self {
591            trace,
592            timeline,
593            index: std::borrow::Cow::Borrowed(index),
594        }
595    }
596
597    pub fn into_index(self) -> Index {
598        self.index.into_owned()
599    }
600
601    pub fn frame_index(&self, id: u64) -> Option<usize> {
602        self.index.frames.get(&id).copied()
603    }
604
605    pub fn frame_detail(&self, index: usize) -> FrameDetail {
606        let tl = self.timeline;
607        let f = &tl.frames[index];
608        let end = f.end_or_begin().saturating_add(1);
609        FrameDetail {
610            index,
611            frame: f.clone(),
612            phases: analysis::phases(tl, index, self.trace),
613            verdict: self
614                .budget
615                .filter(|_| frame_work_us(f).is_some())
616                .map(|b| analysis::verdict(tl, index, b, &self.burns, self.trace)),
617            path: analysis::input_path(tl, index, self.trace),
618            fresh: tl
619                .placements_in(f.begin, end)
620                .iter()
621                .filter(|p| self.trace.name(p.answer) != analysis::REPLAYED)
622                .copied()
623                .collect(),
624            notes: tl
625                .notes_in(f.gap_from, end)
626                .iter()
627                .map(|n| n.1.clone())
628                .collect(),
629        }
630    }
631
632    pub fn span_detail(&self, id: u32) -> Option<SpanDetail> {
633        let &i = self.spans.get(&id)?;
634        let s = &self.timeline.spans[i];
635        let placement = placement_of(self.timeline, self.trace, s).copied();
636        Some(SpanDetail {
637            span: s.clone(),
638            name: placement.map_or(s.name, |p| p.component),
639            self_us: self.self_times[i],
640            frame: self
641                .frame_for_time(s.start)
642                .map(|i| self.timeline.frames[i].frame),
643            depth: self.depths[i],
644            placement,
645            children: {
646                let a = self
647                    .index
648                    .children
649                    .partition_point(|(parent, _)| *parent < id);
650                let b = self
651                    .index
652                    .children
653                    .partition_point(|(parent, _)| *parent <= id);
654                self.index.children[a..b]
655                    .iter()
656                    .map(|(_, child)| *child)
657                    .collect()
658            },
659        })
660    }
661
662    pub fn frame_for_time(&self, t: u64) -> Option<usize> {
663        frame_for_time(self.timeline, t)
664    }
665
666    fn effective_end(&self, i: usize) -> u64 {
667        let f = &self.timeline.frames[i];
668        if f.end.is_none() {
669            frame_end(f).max(
670                self.timeline
671                    .frames
672                    .get(i + 1)
673                    .map_or(self.timeline.end, |f| f.begin)
674                    .saturating_add(1),
675            )
676        } else {
677            frame_end(f)
678        }
679    }
680
681    pub fn resolve(&self, selection: &Selection, filter: &Filter) -> Result<Scope, String> {
682        match selection {
683            Selection::Frames { first, last } if first > last => {
684                return Err("frame range must be increasing".into());
685            }
686            Selection::Time { from_us, to_us } if from_us >= to_us => {
687                return Err("time range must have positive length".into());
688            }
689            _ => {}
690        }
691        let mut indices = Vec::new();
692        let mut intervals: Vec<(u64, u64)> = Vec::new();
693        for (i, f) in self.timeline.frames.iter().enumerate() {
694            let selected = match selection {
695                Selection::Whole => true,
696                Selection::Frames { first, last } => f.frame >= *first && f.frame <= *last,
697                Selection::Time { from_us, to_us } => {
698                    f.gap_from < *to_us && self.effective_end(i) > *from_us
699                }
700            };
701            if !selected || !self.keeps_frame(i, filter) {
702                continue;
703            }
704            indices.push(i);
705            let end = self.effective_end(i);
706            if let Some((_, b)) = intervals.last_mut()
707                && f.gap_from <= *b
708            {
709                *b = (*b).max(end);
710            } else {
711                intervals.push((f.gap_from, end));
712            }
713        }
714        // Whole-recording queries also include unframed records at either end.
715        if matches!(selection, Selection::Whole)
716            && filter.verdicts.is_none()
717            && !filter.hide_unchanged
718        {
719            intervals =
720                if self.timeline.end >= self.timeline.start && (!self.trace.records.is_empty()) {
721                    vec![(self.timeline.start, self.timeline.end.saturating_add(1))]
722                } else {
723                    Vec::new()
724                };
725        }
726        Ok(Scope {
727            requested: selection.clone(),
728            indices,
729            intervals,
730        })
731    }
732
733    pub fn keeps_frame(&self, i: usize, filter: &Filter) -> bool {
734        let f = &self.timeline.frames[i];
735        let verdict = filter
736            .verdicts
737            .as_ref()
738            .and_then(|_| self.budget.filter(|_| frame_work_us(f).is_some()))
739            .map(|b| analysis::verdict(self.timeline, i, b, &self.burns, self.trace));
740        matches_frame(f.unchanged, verdict.as_ref().map(verdict_kind), filter)
741    }
742
743    pub fn keeps_span(&self, s: &Span, filter: &Filter) -> bool {
744        matches_span(self.trace, self.timeline, s, filter)
745    }
746
747    pub fn selected_spans<'b>(
748        &'b self,
749        scope: &'b Scope,
750        filter: &'b Filter,
751    ) -> impl Iterator<Item = (usize, &'a Span)> + 'b {
752        let ids = filter
753            .span_ids
754            .as_ref()
755            .map(|v| v.iter().copied().collect::<HashSet<_>>());
756        // Present intervals can overlap the following frame's gap. Their
757        // union describes the clock extent, not ownership of span costs.
758        let whole_population = scope.requested == Selection::Whole
759            && filter.verdicts.is_none()
760            && !filter.hide_unchanged;
761        self.timeline
762            .spans
763            .iter()
764            .enumerate()
765            .filter(move |(_, s)| {
766                scope.contains(s.start)
767                    && (whole_population
768                        || self
769                            .frame_for_time(s.start)
770                            .is_some_and(|i| scope.indices.binary_search(&i).is_ok()))
771                    && self.keeps_span(s, filter)
772                    && ids.as_ref().is_none_or(|ids| ids.contains(&s.id))
773            })
774    }
775
776    pub fn summary(&self, scope: &Scope) -> Summary {
777        let frames: Vec<_> = scope
778            .indices
779            .iter()
780            .map(|&i| &self.timeline.frames[i])
781            .collect();
782        let works: Vec<_> = frames.iter().filter_map(|f| frame_work_us(f)).collect();
783        let pacing: Vec<_> = scope
784            .indices
785            .windows(2)
786            .filter(|w| w[1] == w[0] + 1)
787            .map(|w| {
788                self.timeline.frames[w[1]]
789                    .begin
790                    .saturating_sub(self.timeline.frames[w[0]].begin)
791            })
792            .collect();
793        let phases: Vec<_> = scope
794            .indices
795            .iter()
796            .filter(|&&i| self.timeline.frames[i].end.is_some())
797            .map(|&i| {
798                (
799                    analysis::phases(self.timeline, i, self.trace),
800                    self.timeline.frames[i].present.is_some(),
801                )
802            })
803            .collect();
804        let phases = Phases::default()
805            .named()
806            .into_iter()
807            .enumerate()
808            .map(|(j, (name, _))| {
809                let values: Vec<_> = phases
810                    .iter()
811                    .filter(|(_, present)| j < 5 || *present)
812                    .map(|(p, _)| p.named()[j].1)
813                    .collect();
814                (name, Distribution::of(&values))
815            })
816            .collect();
817        Summary {
818            frames: frames.len(),
819            complete_frames: works.len(),
820            duration_us: scope.duration_us(),
821            budget_us: self.budget,
822            work: Distribution::of(&works),
823            pacing: Distribution::of(&pacing),
824            phases,
825            over_budget: self
826                .budget
827                .filter(|_| !works.is_empty())
828                .map(|b| works.iter().filter(|&&w| w > b).count()),
829            unchanged: frames.iter().filter(|f| f.unchanged == Some(true)).count(),
830        }
831    }
832
833    pub fn span_groups(&self, scope: &Scope, filter: &Filter) -> Vec<SpanGroup> {
834        let mut groups: HashMap<(NameId, Origin), SpanGroup> = HashMap::new();
835        for (i, s) in self.selected_spans(scope, filter) {
836            let name = placement_of(self.timeline, self.trace, s).map_or(s.name, |p| p.component);
837            let g = groups.entry((name, s.origin)).or_insert(SpanGroup {
838                name,
839                origin: s.origin,
840                calls: 0,
841                total_us: 0,
842                self_us: 0,
843                worst_us: 0,
844                cpu_us: Some(0),
845                example_id: s.id,
846            });
847            g.calls += 1;
848            g.total_us = g.total_us.saturating_add(s.dur);
849            g.self_us = g.self_us.saturating_add(self.self_times[i]);
850            if s.dur > g.worst_us || (s.dur == g.worst_us && s.id < g.example_id) {
851                g.example_id = s.id;
852            }
853            g.worst_us = g.worst_us.max(s.dur);
854            g.cpu_us = g.cpu_us.zip(s.cpu).map(|(a, b)| a.saturating_add(b));
855        }
856        groups.into_values().collect()
857    }
858
859    pub fn origins(&self, scope: &Scope, filter: &Filter) -> Vec<(Origin, u64, u64)> {
860        let mut groups: HashMap<Origin, (u64, u64)> = HashMap::new();
861        for (i, s) in self.selected_spans(scope, filter) {
862            let g = groups.entry(s.origin).or_default();
863            g.0 = g.0.saturating_add(self.self_times[i]);
864            g.1 += 1;
865        }
866        let mut out: Vec<_> = groups.into_iter().map(|(o, (us, n))| (o, us, n)).collect();
867        out.sort_by(|a, b| {
868            b.1.cmp(&a.1)
869                .then_with(|| origin_name(self.trace, a.0).cmp(origin_name(self.trace, b.0)))
870        });
871        out
872    }
873
874    pub fn counters(&self, scope: &Scope) -> (Vec<Counter>, Vec<Gauge>) {
875        let mut counters: BTreeMap<NameId, (f64, i64)> = BTreeMap::new();
876        for &i in &scope.indices {
877            for &(n, v) in &self.timeline.frames[i].counters {
878                let g = counters.entry(n).or_insert((0.0, i64::MIN));
879                g.0 += v as f64;
880                g.1 = g.1.max(v);
881            }
882        }
883        let counters = counters
884            .into_iter()
885            .map(|(name, (sum, max))| Counter {
886                name,
887                avg: sum / scope.indices.len().max(1) as f64,
888                max,
889            })
890            .collect();
891        let mut gauges: BTreeMap<NameId, Gauge> = BTreeMap::new();
892        for g in self
893            .timeline
894            .gauges
895            .iter()
896            .filter(|g| scope.contains(g.t) && g.value.is_finite())
897        {
898            let v = gauges.entry(g.name).or_insert(Gauge {
899                name: g.name,
900                last: g.value,
901                least: g.value,
902                most: g.value,
903            });
904            v.last = g.value;
905            v.least = v.least.min(g.value);
906            v.most = v.most.max(g.value);
907        }
908        (counters, gauges.into_values().collect())
909    }
910
911    /// The viewer's complete tables, without CLI result limits. Attribution
912    /// uses the original tree; placements/gaps describe the frame population.
913    pub fn readings(&self, scope: &Scope, filter: &Filter) -> Readings {
914        let mut spans = self.span_groups(scope, filter);
915        spans.sort_by(|a, b| {
916            b.self_us
917                .cmp(&a.self_us)
918                .then_with(|| self.trace.name(a.name).cmp(self.trace.name(b.name)))
919                .then_with(|| {
920                    origin_name(self.trace, a.origin).cmp(origin_name(self.trace, b.origin))
921                })
922                .then(a.name.cmp(&b.name))
923                .then(a.origin.cmp(&b.origin))
924        });
925        let mut selected = Timeline::default();
926        selected.placements = self
927            .timeline
928            .placements
929            .iter()
930            .filter(|p| scope.contains(p.t))
931            .copied()
932            .collect();
933        let (counters, gauges) = self.counters(scope);
934        let indices: HashSet<_> = scope.indices.iter().copied().collect();
935        let mut burns = Vec::new();
936        for b in &self.burns {
937            let mut first = None;
938            for i in b.first..=b.last {
939                if indices.contains(&i) {
940                    first.get_or_insert(i);
941                } else if let Some(start) = first.take() {
942                    burns.push(Burn {
943                        first: start,
944                        last: i - 1,
945                    });
946                }
947            }
948            if let Some(first) = first {
949                burns.push(Burn {
950                    first,
951                    last: b.last,
952                });
953            }
954        }
955        Readings {
956            summary: self.summary(scope),
957            spans,
958            origins: self.origins(scope, filter),
959            components: analysis::placements(&selected, self.trace),
960            counters,
961            gauges,
962            burns,
963            gaps: analysis::gaps_in(self.timeline, self.trace, scope.indices.iter().copied()),
964        }
965    }
966
967    pub fn execute(&self, query: &Query) -> Result<ResultSet, String> {
968        let mut selection = query.selection.clone();
969        let mut filter = query.filter.clone();
970        match query.operation {
971            Operation::Frame(id) => {
972                selection = Selection::Frames {
973                    first: id,
974                    last: id,
975                };
976                filter = Filter::default();
977            }
978            Operation::Span(id) => {
979                if let Some(s) = self.span_detail(id)
980                    && let Some(id) = s.frame
981                {
982                    selection = Selection::Frames {
983                        first: id,
984                        last: id,
985                    };
986                }
987                filter = Filter::default();
988            }
989            _ => {}
990        }
991        let scope = self.resolve(&selection, &filter)?;
992        let limit = query.limit.unwrap_or(usize::MAX);
993        let flip = |o: std::cmp::Ordering| if query.descending { o.reverse() } else { o };
994        let (data, matched, returned) = match query.operation {
995            Operation::Summary => (Data::Summary(self.summary(&scope)), 1, 1),
996            Operation::Frame(id) => {
997                let i = self
998                    .frame_index(id)
999                    .ok_or_else(|| format!("no frame {id} in this recording"))?;
1000                (Data::Frame(Box::new(self.frame_detail(i))), 1, 1)
1001            }
1002            Operation::Span(id) => (
1003                Data::Span(
1004                    self.span_detail(id)
1005                        .ok_or_else(|| format!("no span {id} in this recording"))?,
1006                ),
1007                1,
1008                1,
1009            ),
1010            Operation::Frames => {
1011                let mut indices = scope.indices.clone();
1012                if !["frame", "start", "work"].contains(&query.sort.as_str()) {
1013                    return Err("frames --sort needs frame, start or work".into());
1014                }
1015                indices.sort_by(|&a, &b| {
1016                    let a = &self.timeline.frames[a];
1017                    let b = &self.timeline.frames[b];
1018                    match query.sort.as_str() {
1019                        "frame" => flip(a.frame.cmp(&b.frame)),
1020                        "start" => flip(a.begin.cmp(&b.begin)),
1021                        _ => match (frame_work_us(a), frame_work_us(b)) {
1022                            (Some(a), Some(b)) => flip(a.cmp(&b)),
1023                            (None, Some(_)) => std::cmp::Ordering::Greater,
1024                            (Some(_), None) => std::cmp::Ordering::Less,
1025                            (None, None) => std::cmp::Ordering::Equal,
1026                        },
1027                    }
1028                    .then(a.frame.cmp(&b.frame))
1029                });
1030                let matched = indices.len();
1031                indices.truncate(limit);
1032                let returned = indices.len();
1033                (
1034                    Data::Frames(indices.into_iter().map(|i| self.frame_detail(i)).collect()),
1035                    matched,
1036                    returned,
1037                )
1038            }
1039            Operation::Spans => {
1040                let mut groups = self.span_groups(&scope, &filter);
1041                if !["self", "total", "calls", "worst", "name"].contains(&query.sort.as_str()) {
1042                    return Err("spans --sort needs self, total, calls, worst or name".into());
1043                }
1044                groups.sort_by(|a, b| {
1045                    flip(match query.sort.as_str() {
1046                        "total" => a.total_us.cmp(&b.total_us),
1047                        "calls" => a.calls.cmp(&b.calls),
1048                        "worst" => a.worst_us.cmp(&b.worst_us),
1049                        "name" => self.trace.name(a.name).cmp(self.trace.name(b.name)),
1050                        _ => a.self_us.cmp(&b.self_us),
1051                    })
1052                    .then_with(|| self.trace.name(a.name).cmp(self.trace.name(b.name)))
1053                    .then_with(|| {
1054                        origin_name(self.trace, a.origin).cmp(origin_name(self.trace, b.origin))
1055                    })
1056                    .then(a.name.cmp(&b.name))
1057                    .then(a.origin.cmp(&b.origin))
1058                });
1059                let matched = groups.len();
1060                groups.truncate(limit);
1061                let returned = groups.len();
1062                (Data::Spans(groups), matched, returned)
1063            }
1064            Operation::Counters => {
1065                if query.sort != "name" {
1066                    return Err("counters --sort needs name".into());
1067                }
1068                let (mut counters, mut gauges) = self.counters(&scope);
1069                // One shared ordered list/limit for both kinds, not a hidden cap per kind.
1070                let mut names: Vec<_> = counters
1071                    .iter()
1072                    .map(|c| (self.trace.name(c.name), 0, c.name))
1073                    .chain(gauges.iter().map(|g| (self.trace.name(g.name), 1, g.name)))
1074                    .collect();
1075                names.sort_by(|a, b| flip(a.0.cmp(b.0)).then(a.1.cmp(&b.1)));
1076                let matched = names.len();
1077                names.truncate(limit);
1078                let returned = names.len();
1079                counters.retain(|c| names.iter().any(|n| n.1 == 0 && n.2 == c.name));
1080                gauges.retain(|g| names.iter().any(|n| n.1 == 1 && n.2 == g.name));
1081                counters.sort_by(|a, b| flip(self.trace.name(a.name).cmp(self.trace.name(b.name))));
1082                gauges.sort_by(|a, b| flip(self.trace.name(a.name).cmp(self.trace.name(b.name))));
1083                (Data::Counters(counters, gauges), matched, returned)
1084            }
1085            Operation::Marks => {
1086                if !["name", "start"].contains(&query.sort.as_str()) {
1087                    return Err("marks --sort needs name or start".into());
1088                }
1089                let mut marks: Vec<_> = self
1090                    .timeline
1091                    .marks
1092                    .iter()
1093                    .filter(|m| scope.contains(m.t))
1094                    .copied()
1095                    .collect();
1096                marks.sort_by(|a, b| {
1097                    flip(if query.sort == "start" {
1098                        a.t.cmp(&b.t)
1099                    } else {
1100                        self.trace.name(a.name).cmp(self.trace.name(b.name))
1101                    })
1102                    .then(a.t.cmp(&b.t))
1103                    .then(a.track.0.cmp(&b.track.0))
1104                });
1105                let matched = marks.len();
1106                marks.truncate(limit);
1107                let returned = marks.len();
1108                (Data::Marks(marks), matched, returned)
1109            }
1110        };
1111        let mut query = query.clone();
1112        query.selection = selection;
1113        query.filter = filter;
1114        Ok(ResultSet {
1115            query,
1116            scope,
1117            matched,
1118            returned,
1119            data,
1120        })
1121    }
1122}
1123
1124/// Total work needs both the core's end and the driver's rendering cost.
1125/// Core-only recordings still expose their known core/phase timings, but
1126/// absence of a present is not evidence of zero rendering work.
1127pub fn frame_work_us(frame: &Frame) -> Option<u64> {
1128    let end = frame.end?;
1129    let present = frame.present?;
1130    Some(
1131        end.saturating_sub(frame.begin)
1132            .saturating_add(present.render),
1133    )
1134}
1135
1136/// A span's frame, including its preceding gap. Ownership is half-open at
1137/// the core end, independent of the driver's possibly overlapping present.
1138/// A zero-length frame occupies one clock tick; an unfinished last frame
1139/// owns the observed tail until its end is known.
1140pub fn frame_for_time(timeline: &Timeline, t: u64) -> Option<usize> {
1141    let i = timeline.frames.partition_point(|f| {
1142        f.end
1143            .map_or(f.begin, |end| end.max(f.begin.saturating_add(1)))
1144            <= t
1145    });
1146    if i == timeline.frames.len()
1147        && let Some(f) = timeline.frames.last()
1148        && f.end.is_none()
1149        && t >= f.gap_from
1150        && t <= timeline.end
1151    {
1152        return Some(i - 1);
1153    }
1154    timeline
1155        .frames
1156        .get(i)
1157        .filter(|f| f.gap_from <= t)
1158        .map(|_| i)
1159}
1160
1161fn frame_end(f: &Frame) -> u64 {
1162    f.present
1163        .map_or(f.end_or_begin(), |p| p.t.max(f.end_or_begin()))
1164        .max(f.begin.saturating_add(1))
1165}
1166
1167pub fn milliseconds(ms: f64) -> Result<u64, String> {
1168    if !ms.is_finite() || ms < 0.0 || ms * 1000.0 >= u64::MAX as f64 {
1169        return Err(
1170            "time must be finite, nonnegative milliseconds within the recording clock".into(),
1171        );
1172    }
1173    Ok((ms * 1000.0).round() as u64)
1174}
1175
1176pub fn frame_range(s: &str) -> Result<Selection, String> {
1177    let (a, b) = s.split_once(':').ok_or("frame range needs FIRST:LAST")?;
1178    let first = a.parse().map_err(|_| "invalid first frame number")?;
1179    let last = b.parse().map_err(|_| "invalid last frame number")?;
1180    if first > last {
1181        return Err("frame range must be increasing".into());
1182    }
1183    Ok(Selection::Frames { first, last })
1184}
1185
1186pub fn time_range(s: &str) -> Result<Selection, String> {
1187    fn endpoint(s: &str) -> Result<u64, String> {
1188        let (n, ms) = if let Some(n) = s.strip_suffix("us") {
1189            (n, 0.001)
1190        } else if let Some(n) = s.strip_suffix("ms") {
1191            (n, 1.0)
1192        } else if let Some(n) = s.strip_suffix('s') {
1193            (n, 1000.0)
1194        } else {
1195            return Err("time endpoints need us, ms or s units".into());
1196        };
1197        let n = n.parse::<f64>().map_err(|_| format!("invalid time {s}"))?;
1198        milliseconds(n * ms)
1199    }
1200    let (a, b) = s
1201        .split_once(':')
1202        .ok_or("time range needs FROM:TO with units, as 1s:1500ms")?;
1203    let from_us = endpoint(a)?;
1204    let to_us = endpoint(b)?;
1205    if from_us >= to_us {
1206        return Err("time range must have positive length".into());
1207    }
1208    Ok(Selection::Time { from_us, to_us })
1209}
1210
1211/// Shared built-in span filtering, also used by the incremental Lua channels.
1212pub fn matches_span(trace: &Trace, timeline: &Timeline, s: &Span, filter: &Filter) -> bool {
1213    s.dur >= filter.min_us
1214        && filter
1215            .origins
1216            .as_ref()
1217            .is_none_or(|os| os.iter().any(|o| o == origin_name(trace, s.origin)))
1218        && !filter.hide_names.iter().any(|n| n == trace.name(s.name))
1219        && filter.name.as_ref().is_none_or(|n| {
1220            let n = n.to_lowercase();
1221            trace.name(s.name).to_lowercase().contains(&n)
1222                || placement_of(timeline, trace, s)
1223                    .is_some_and(|p| trace.name(p.component).to_lowercase().contains(&n))
1224        })
1225}
1226
1227pub fn matches_frame(unchanged: Option<bool>, verdict: Option<&str>, filter: &Filter) -> bool {
1228    !(filter.hide_unchanged && unchanged == Some(true))
1229        && filter
1230            .verdicts
1231            .as_ref()
1232            .is_none_or(|vs| verdict.is_some_and(|v| vs.iter().any(|k| k == v)))
1233}
1234
1235#[cfg(test)]
1236mod tests;