Skip to main content

ic_testkit/benchmark/
mod.rs

1//! Parse, pair, aggregate, compare, and report compact benchmark markers.
2//!
3//! The normal pipeline is [`parse_benchmark_events`],
4//! [`pair_benchmark_spans`], [`aggregate_benchmark_spans`], and optionally
5//! [`compare_benchmark_aggregates`]. Native report output lives in
6//! the repository's `benchmark_report` example. Marker
7//! producers can use [`format_marker`] on the host or
8//! `ic_testkit::performance` in canister code.
9//!
10//! Totals and run counts remain exact integers. Means use Testkit's exact
11//! scaled integer projection, rounded down. Callers choose the scale and handle
12//! projection errors; reports use scale one in the original counter units.
13//! Comparisons retain current and previous means instead of percentage estimates.
14
15use std::collections::{BTreeMap, btree_map::Entry};
16
17/// Why a benchmark's scaled average cannot be represented.
18#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
19pub enum BenchmarkAverageError {
20    /// A mean is undefined when the run count is zero, even at scale zero.
21    #[error("benchmark run count is zero")]
22    ZeroDenominator,
23    /// The scaled average exceeds `u128::MAX`.
24    #[error("scaled benchmark average exceeds u128")]
25    Overflow,
26}
27
28fn checked_scaled_average(
29    total: u128,
30    runs: u64,
31    scale: u64,
32) -> Result<u128, BenchmarkAverageError> {
33    if runs == 0 {
34        return Err(BenchmarkAverageError::ZeroDenominator);
35    }
36    let runs = u128::from(runs);
37    let scale = u128::from(scale);
38    // Split before multiplying: only the final average must fit. The remainder
39    // and scale are at most u64::MAX, so their product always fits in u128.
40    let fraction = (total % runs) * scale / runs;
41    (total / runs)
42        .checked_mul(scale)
43        .and_then(|whole| whole.checked_add(fraction))
44        .ok_or(BenchmarkAverageError::Overflow)
45}
46
47/// A comparison refuses ambiguous row identities or unrepresentable means.
48#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
49pub enum BenchmarkComparisonError {
50    /// More than one current row has the same scope and span.
51    #[error("duplicate current benchmark row: {scope:?}/{span_label}")]
52    DuplicateCurrent {
53        /// Repeated scope.
54        scope: BenchmarkScope,
55        /// Repeated span label.
56        span_label: String,
57    },
58    /// More than one previous row has the same scope and span.
59    #[error("duplicate previous benchmark row: {scope:?}/{span_label}")]
60    DuplicatePrevious {
61        /// Repeated scope.
62        scope: BenchmarkScope,
63        /// Repeated span label.
64        span_label: String,
65    },
66    /// A row's selected mean cannot be represented.
67    #[error("{0}")]
68    Average(#[from] BenchmarkAverageError),
69}
70
71/// Default prefix for benchmark marker lines.
72pub const DEFAULT_PREFIX: &str = "ICTK";
73
74/// Structural identity of one suite or the cross-suite aggregate.
75#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
76pub enum BenchmarkScope {
77    /// One caller-authored suite, including a suite literally named `ALL`.
78    Suite(String),
79    /// Aggregate across every suite.
80    All,
81}
82
83/// Marker prefixes, suite naming and noise handling selected by the caller.
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct BenchmarkParserConfig {
86    /// Accepted marker prefixes.
87    pub prefixes: Vec<String>,
88    /// Rule used to derive each event’s suite.
89    pub suite_derivation: SuiteDerivation,
90    /// When enabled, non-empty lines without a configured marker prefix are
91    /// reported as malformed markers instead of ignored log noise.
92    pub strict: bool,
93}
94
95impl Default for BenchmarkParserConfig {
96    fn default() -> Self {
97        Self {
98            prefixes: vec![DEFAULT_PREFIX.to_string()],
99            suite_derivation: SuiteDerivation::FirstPathSegment,
100            strict: false,
101        }
102    }
103}
104
105/// How a marker label determines its suite.
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub enum SuiteDerivation {
108    /// Use the first slash-delimited label segment, falling back to the whole label.
109    FirstPathSegment,
110    /// Use the supplied suite for every marker.
111    Fixed(String),
112}
113
114impl SuiteDerivation {
115    /// Derive the suite name for a marker label.
116    #[must_use]
117    pub fn derive_suite(&self, span_label: &str) -> String {
118        match self {
119            Self::FirstPathSegment => span_label
120                .split('/')
121                .next()
122                .filter(|part| !part.is_empty())
123                .unwrap_or(span_label)
124                .to_string(),
125            Self::Fixed(suite) => suite.clone(),
126        }
127    }
128}
129
130/// Whether a marker opens or closes a measured span.
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub enum BenchmarkEventKind {
133    /// Opening counter sample.
134    Start,
135    /// Closing counter sample.
136    End,
137}
138
139/// Stream from which a marker was collected.
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum BenchmarkEventSource {
142    /// Stream identity was not supplied.
143    Unknown,
144    /// Captured standard output.
145    Stdout,
146    /// Captured standard error.
147    Stderr,
148    /// Fetched canister log.
149    FetchedLog,
150}
151
152impl BenchmarkEventSource {
153    /// Return the stable stream label used in reports.
154    #[must_use]
155    pub const fn as_str(self) -> &'static str {
156        match self {
157            Self::Unknown => "unknown",
158            Self::Stdout => "stdout",
159            Self::Stderr => "stderr",
160            Self::FetchedLog => "fetched_log",
161        }
162    }
163}
164
165/// Exact instruction and byte counters reported by a marker.
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167pub struct BenchmarkCounters {
168    /// Instruction counter in the representation described by this type.
169    pub instructions: u128,
170    /// Heap byte counter in the representation described by this type.
171    pub heap_bytes: u128,
172    /// Memory byte counter in the representation described by this type.
173    pub memory_bytes: u128,
174}
175
176impl BenchmarkCounters {
177    fn checked_delta(self, start: Self) -> Option<Self> {
178        Some(Self {
179            instructions: self.instructions.checked_sub(start.instructions)?,
180            heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
181            memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
182        })
183    }
184
185    fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
186        Ok(Self {
187            instructions: self
188                .instructions
189                .checked_add(other.instructions)
190                .ok_or(BenchmarkCounter::Instructions)?,
191            heap_bytes: self
192                .heap_bytes
193                .checked_add(other.heap_bytes)
194                .ok_or(BenchmarkCounter::HeapBytes)?,
195            memory_bytes: self
196                .memory_bytes
197                .checked_add(other.memory_bytes)
198                .ok_or(BenchmarkCounter::MemoryBytes)?,
199        })
200    }
201
202    fn min_assign(&mut self, other: Self) {
203        self.instructions = self.instructions.min(other.instructions);
204        self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
205        self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
206    }
207
208    fn max_assign(&mut self, other: Self) {
209        self.instructions = self.instructions.max(other.instructions);
210        self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
211        self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
212    }
213}
214
215/// Parsed marker with its original stream and line provenance.
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct RawBenchmarkEvent {
218    /// Matched marker prefix.
219    pub prefix: String,
220    /// Derived suite name.
221    pub suite: String,
222    /// Marker label used to group matching spans.
223    pub span_label: String,
224    /// Opening or closing marker.
225    pub kind: BenchmarkEventKind,
226    /// Counters observed at this marker.
227    pub counters: BenchmarkCounters,
228    /// One-based line number in the original input stream.
229    pub source_line: usize,
230    /// Originating stream.
231    pub source: BenchmarkEventSource,
232}
233
234/// Rejected marker and its parse diagnostic.
235#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct MalformedBenchmarkMarker {
237    /// One-based line number in the original input stream.
238    pub source_line: usize,
239    /// Originating stream.
240    pub source: BenchmarkEventSource,
241    /// Original rejected line.
242    pub line: String,
243    /// Explanation of the rejected marker.
244    pub reason: String,
245}
246
247/// Parsed events, rejected markers and ignored log noise.
248#[derive(Clone, Debug, Default, Eq, PartialEq)]
249pub struct BenchmarkParseReport {
250    /// Successfully parsed markers in input order.
251    pub events: Vec<RawBenchmarkEvent>,
252    /// Rejected marker lines with diagnostics.
253    pub malformed_markers: Vec<MalformedBenchmarkMarker>,
254    /// Lines ignored as non-marker noise.
255    pub ignored_line_count: usize,
256}
257
258/// Matched start/end markers with nonnegative counter differences.
259#[derive(Clone, Debug, Eq, PartialEq)]
260pub struct BenchmarkSpan {
261    /// Derived suite name.
262    pub suite: String,
263    /// Marker label used to group matching spans.
264    pub span_label: String,
265    /// One-based line of the opening marker.
266    pub start_line: usize,
267    /// One-based line of the closing marker.
268    pub end_line: usize,
269    /// Counter values observed at the closing marker.
270    pub end: BenchmarkCounters,
271    /// Closing counters minus opening counters.
272    pub delta: BenchmarkCounters,
273}
274
275/// Matched markers whose counters decreased.
276#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct InvalidBenchmarkSpan {
278    /// Opening marker.
279    pub start: RawBenchmarkEvent,
280    /// Closing marker.
281    pub end: RawBenchmarkEvent,
282}
283
284/// Pairing result retaining unmatched and invalid markers.
285#[derive(Clone, Debug, Default, Eq, PartialEq)]
286pub struct BenchmarkSpanReport {
287    /// Successfully paired spans.
288    pub spans: Vec<BenchmarkSpan>,
289    /// Markers without a matching start or end.
290    pub unpaired_markers: Vec<RawBenchmarkEvent>,
291    /// Matched pairs whose counters decreased.
292    pub invalid_spans: Vec<InvalidBenchmarkSpan>,
293}
294
295/// Exact totals and extrema for one scope and span label.
296#[derive(Clone, Debug, Eq, PartialEq)]
297pub struct BenchmarkAggregateRow {
298    /// Marker label used to group matching spans.
299    pub span_label: String,
300    /// Number of measured spans.
301    pub runs: u64,
302    /// Sum of the per-span counter differences.
303    pub total: BenchmarkCounters,
304    /// Smallest per-counter span difference.
305    pub min: BenchmarkCounters,
306    /// Largest per-counter span difference.
307    pub max: BenchmarkCounters,
308    /// Largest per-counter closing value.
309    pub peak_end: BenchmarkCounters,
310    /// Suite identity or the cross-suite aggregate.
311    pub scope: BenchmarkScope,
312}
313
314/// Floor-rounded scaled means of exact aggregate totals and run counts.
315///
316/// Fields are `floor(total * scale / runs)`; choose a positive scale to
317/// interpret them in the original counter unit. Scale zero projects zero.
318#[derive(Clone, Copy, Debug, Eq, PartialEq)]
319pub struct BenchmarkAverages {
320    /// Multiplier applied before dividing by the run count.
321    pub scale: u64,
322    /// Instruction counter in the representation described by this type.
323    pub instructions: u128,
324    /// Heap byte counter in the representation described by this type.
325    pub heap_bytes: u128,
326    /// Memory byte counter in the representation described by this type.
327    pub memory_bytes: u128,
328}
329
330/// Counter whose aggregate cannot be represented.
331#[derive(Clone, Copy, Debug, Eq, PartialEq)]
332pub enum BenchmarkCounter {
333    /// Measured span count.
334    Runs,
335    /// Instruction total.
336    Instructions,
337    /// Heap byte total.
338    HeapBytes,
339    /// Memory byte total.
340    MemoryBytes,
341}
342
343/// Overflow rejects the complete aggregation, without returning partial totals.
344#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
345#[error("benchmark aggregate overflow: scope={scope:?}, span={span_label}, counter={counter:?}")]
346pub struct BenchmarkAggregateError {
347    /// Marker label used to group matching spans.
348    pub span_label: String,
349    /// Counter that overflowed.
350    pub counter: BenchmarkCounter,
351    /// Suite identity or the cross-suite aggregate.
352    pub scope: BenchmarkScope,
353}
354
355/// Run count and scaled means for one present comparison side.
356#[derive(Clone, Copy, Debug, Eq, PartialEq)]
357pub struct BenchmarkMeasurement {
358    /// Number of measured spans.
359    pub runs: u64,
360    /// Scaled, floor-rounded means.
361    pub average: BenchmarkAverages,
362}
363
364/// Current and previous measurements, preserving missing rows and measured zero.
365#[derive(Clone, Debug, Eq, PartialEq)]
366pub struct BenchmarkComparisonRow {
367    /// Marker label used to group matching spans.
368    pub span_label: String,
369    /// Current measurement, absent when that side has no row.
370    pub current: Option<BenchmarkMeasurement>,
371    /// Previous measurement, absent when that side has no row.
372    pub previous: Option<BenchmarkMeasurement>,
373    /// Suite identity or the cross-suite aggregate.
374    pub scope: BenchmarkScope,
375}
376
377impl BenchmarkAggregateRow {
378    /// Project exact totals at the caller's scale, rounding down.
379    ///
380    /// # Errors
381    /// Zero runs and unrepresentable scaled results return [`BenchmarkAverageError`].
382    pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, BenchmarkAverageError> {
383        Ok(BenchmarkAverages {
384            scale,
385            instructions: checked_scaled_average(self.total.instructions, self.runs, scale)?,
386            heap_bytes: checked_scaled_average(self.total.heap_bytes, self.runs, scale)?,
387            memory_bytes: checked_scaled_average(self.total.memory_bytes, self.runs, scale)?,
388        })
389    }
390}
391
392/// Format one marker using the supplied prefix, label and counters.
393///
394/// This raw formatter does not admit marker grammar. Use [`try_format_marker`]
395/// to refuse invalid fields before emission.
396#[must_use]
397pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
398    format!(
399        "{}|{}|{}|{}|{}",
400        prefix, label, counters.instructions, counters.heap_bytes, counters.memory_bytes
401    )
402}
403
404/// A marker field cannot be represented in the line-oriented marker grammar.
405#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
406pub enum BenchmarkMarkerError {
407    /// Prefix is empty or contains a pipe, CR or LF.
408    #[error("invalid benchmark marker prefix")]
409    InvalidPrefix,
410    /// Label contains a pipe/line break or lacks a nonempty span and start/end suffix.
411    #[error("invalid benchmark marker label")]
412    InvalidLabel,
413}
414
415/// Format one complete marker after admitting its prefix and start/end label.
416///
417/// Printable Unicode and spaces remain literal. No fields are escaped or trimmed.
418/// The parser must separately be configured to accept this prefix.
419///
420/// # Errors
421/// Refuses empty prefixes, pipe/CR/LF delimiters and labels without a nonempty
422/// span followed by `:start` or `:end`. Errors do not include the rejected text.
423pub fn try_format_marker(
424    prefix: &str,
425    label: &str,
426    counters: BenchmarkCounters,
427) -> Result<String, BenchmarkMarkerError> {
428    if prefix.is_empty() || prefix.contains(['|', '\r', '\n']) {
429        return Err(BenchmarkMarkerError::InvalidPrefix);
430    }
431    if label.contains(['|', '\r', '\n']) || split_label_kind(label).is_none() {
432        return Err(BenchmarkMarkerError::InvalidLabel);
433    }
434    Ok(format_marker(prefix, label, counters))
435}
436
437/// Parse markers without assigning a stream identity.
438#[must_use]
439pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
440    parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
441}
442
443/// Parse markers while retaining the supplied stream identity.
444#[must_use]
445pub fn parse_benchmark_events_from_source(
446    input: &str,
447    config: &BenchmarkParserConfig,
448    source: BenchmarkEventSource,
449) -> BenchmarkParseReport {
450    let mut report = BenchmarkParseReport::default();
451    append_benchmark_events(&mut report, input, config, source);
452    report
453}
454
455fn append_benchmark_events(
456    report: &mut BenchmarkParseReport,
457    input: &str,
458    config: &BenchmarkParserConfig,
459    source: BenchmarkEventSource,
460) {
461    for (index, line) in input.lines().enumerate() {
462        let source_line = index + 1;
463        let Some(prefix) = configured_prefix(line, &config.prefixes) else {
464            if config.strict && !line.trim().is_empty() {
465                report.malformed_markers.push(malformed(
466                    source_line,
467                    source,
468                    line,
469                    "line does not use a configured marker prefix",
470                ));
471            } else {
472                report.ignored_line_count += 1;
473            }
474            continue;
475        };
476
477        match parse_marker_line(line, prefix, source_line, source, &config.suite_derivation) {
478            Ok(event) => report.events.push(event),
479            Err(marker) => report.malformed_markers.push(marker),
480        }
481    }
482}
483
484/// Parse separately captured stdout and stderr.
485///
486/// Separate streams do not carry global ordering, so events are returned in
487/// stdout-then-stderr order. If a benchmark span can start on one stream and end
488/// on the other, capture combined process output and use [`parse_benchmark_events`].
489#[must_use]
490pub fn parse_benchmark_events_from_captured_output(
491    stdout: &str,
492    stderr: &str,
493    config: &BenchmarkParserConfig,
494) -> BenchmarkParseReport {
495    let mut report = BenchmarkParseReport::default();
496    append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
497    append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
498    report
499}
500
501/// Pair markers by suite and label, using the most recent unmatched start.
502#[must_use]
503pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
504    pair_spans(events, |event| (&event.suite, &event.span_label))
505}
506
507/// Pair only markers with the same prefix, stream, suite and span label.
508///
509/// This prevents a start from one prefix/stream consuming another's end. Order
510/// within each origin remains caller-supplied and nesting remains last-start-first.
511/// Prefix/stream are grouping facts, not authenticated producer identities;
512/// callers must keep independent producers with otherwise equal fields separate.
513/// Returned spans and subsequent aggregation retain their existing suite/label shape.
514#[must_use]
515pub fn pair_benchmark_spans_by_origin(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
516    pair_spans(events, |event| {
517        (
518            &event.prefix,
519            event.source.as_str(),
520            &event.suite,
521            &event.span_label,
522        )
523    })
524}
525
526fn pair_spans<'a, K: Ord>(
527    events: &'a [RawBenchmarkEvent],
528    key: impl Fn(&'a RawBenchmarkEvent) -> K,
529) -> BenchmarkSpanReport {
530    let mut report = BenchmarkSpanReport::default();
531    let mut open_starts = BTreeMap::<_, Vec<&RawBenchmarkEvent>>::new();
532
533    for event in events {
534        let key = key(event);
535        match event.kind {
536            BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
537            BenchmarkEventKind::End => match open_starts.entry(key) {
538                Entry::Occupied(mut entry) => {
539                    let start = entry
540                        .get_mut()
541                        .pop()
542                        .expect("open marker stacks must remain nonempty");
543                    if entry.get().is_empty() {
544                        entry.remove();
545                    }
546                    push_paired_span(&mut report, start, event);
547                }
548                Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
549            },
550        }
551    }
552
553    for starts in open_starts.into_values() {
554        for event in starts {
555            report.unpaired_markers.push(event.clone());
556        }
557    }
558
559    report
560}
561
562/// Aggregate spans by named suite and across all suites.
563///
564/// # Errors
565/// Returns an error if any counter total or run count overflows. No partial
566/// report is returned, and totals are never wrapped or saturated.
567pub fn aggregate_benchmark_spans(
568    spans: &[BenchmarkSpan],
569) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
570    let mut rows = BTreeMap::new();
571
572    for span in spans {
573        add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
574        add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
575    }
576
577    Ok(rows
578        .into_iter()
579        .map(|((scope, span_label), builder)| {
580            builder.finish(scope.into_owned(), span_label.to_owned())
581        })
582        .collect())
583}
584
585/// Pair current and previous means at the same caller-selected scale.
586///
587/// # Errors
588/// Returns a typed error for duplicate scope/span rows on either side or an invalid mean.
589pub fn compare_benchmark_aggregates(
590    current: &[BenchmarkAggregateRow],
591    previous: &[BenchmarkAggregateRow],
592    scale: u64,
593) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
594    let mut rows = BTreeMap::new();
595    for row in current {
596        if rows
597            .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
598            .is_some()
599        {
600            return Err(BenchmarkComparisonError::DuplicateCurrent {
601                scope: row.scope.clone(),
602                span_label: row.span_label.clone(),
603            });
604        }
605    }
606    for row in previous {
607        let previous = &mut rows
608            .entry((&row.scope, row.span_label.as_str()))
609            .or_insert((None, None))
610            .1;
611        if previous.replace(row).is_some() {
612            return Err(BenchmarkComparisonError::DuplicatePrevious {
613                scope: row.scope.clone(),
614                span_label: row.span_label.clone(),
615            });
616        }
617    }
618    let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
619        Ok(BenchmarkMeasurement {
620            runs: row.runs,
621            average: row.average(scale)?,
622        })
623    };
624
625    rows.into_iter()
626        .map(|((scope, span_label), (current_row, previous_row))| {
627            Ok(BenchmarkComparisonRow {
628                span_label: span_label.to_owned(),
629                current: current_row.map(measurement).transpose()?,
630                previous: previous_row.map(measurement).transpose()?,
631                scope: scope.clone(),
632            })
633        })
634        .collect()
635}
636
637fn parse_marker_line(
638    line: &str,
639    admitted_prefix: &str,
640    source_line: usize,
641    source: BenchmarkEventSource,
642    suite_derivation: &SuiteDerivation,
643) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
644    let invalid = |reason: String| malformed(source_line, source, line, reason);
645    let parse_counter = |value: &str, name: &str| {
646        if value.is_empty() {
647            return Err(invalid(format!("{name} counter is empty")));
648        }
649        value
650            .parse::<u128>()
651            .map_err(|_| invalid(format!("{name} counter is not an unsigned integer")))
652    };
653    let mut parts = line.split('|');
654    // Five fields and an absent sixth establish the complete marker shape
655    // without collecting an arbitrary number of columns from malformed logs.
656    let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
657    let [
658        Some(prefix),
659        Some(label),
660        Some(instructions),
661        Some(heap_bytes),
662        Some(memory_bytes),
663        None,
664    ] = columns
665    else {
666        return Err(invalid("expected five pipe-separated columns".into()));
667    };
668
669    // Prefixes containing delimiters still earn the existing malformed-marker
670    // diagnostic after shape admission; they never become valid field prefixes.
671    if prefix != admitted_prefix {
672        return Err(invalid("prefix is not configured".into()));
673    }
674
675    if label.is_empty() {
676        return Err(invalid("label is empty".into()));
677    }
678
679    let (span_label, kind) = split_label_kind(label)
680        .ok_or_else(|| invalid("label must end in :start or :end".into()))?;
681
682    let counters = BenchmarkCounters {
683        instructions: parse_counter(instructions, "instructions")?,
684        heap_bytes: parse_counter(heap_bytes, "heap_bytes")?,
685        memory_bytes: parse_counter(memory_bytes, "memory_bytes")?,
686    };
687    let suite = suite_derivation.derive_suite(span_label);
688
689    Ok(RawBenchmarkEvent {
690        prefix: prefix.to_string(),
691        suite,
692        span_label: span_label.to_string(),
693        kind,
694        counters,
695        source_line,
696        source,
697    })
698}
699
700fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
701    label
702        .strip_suffix(":start")
703        .map(|span| (span, BenchmarkEventKind::Start))
704        .or_else(|| {
705            label
706                .strip_suffix(":end")
707                .map(|span| (span, BenchmarkEventKind::End))
708        })
709        .filter(|(span, _)| !span.is_empty())
710}
711
712fn configured_prefix<'a>(line: &str, prefixes: &'a [String]) -> Option<&'a str> {
713    let (field, _) = line.split_once('|')?;
714    prefixes
715        .iter()
716        .find(|known| known.as_str() == field)
717        .or_else(|| {
718            prefixes.iter().find(|known| {
719                line.strip_prefix(known.as_str())
720                    .is_some_and(|rest| rest.starts_with('|'))
721            })
722        })
723        .map(String::as_str)
724}
725
726fn malformed(
727    source_line: usize,
728    source: BenchmarkEventSource,
729    line: &str,
730    reason: impl Into<String>,
731) -> MalformedBenchmarkMarker {
732    MalformedBenchmarkMarker {
733        source_line,
734        source,
735        line: line.to_string(),
736        reason: reason.into(),
737    }
738}
739
740fn push_paired_span(
741    report: &mut BenchmarkSpanReport,
742    start: &RawBenchmarkEvent,
743    end: &RawBenchmarkEvent,
744) {
745    if let Some(delta) = end.counters.checked_delta(start.counters) {
746        report.spans.push(BenchmarkSpan {
747            suite: start.suite.clone(),
748            span_label: start.span_label.clone(),
749            start_line: start.source_line,
750            end_line: end.source_line,
751            end: end.counters,
752            delta,
753        });
754    } else {
755        report.invalid_spans.push(InvalidBenchmarkSpan {
756            start: start.clone(),
757            end: end.clone(),
758        });
759    }
760}
761
762// Borrow grouping identities until a row or error must own them. Keep the same
763// suite-before-all ordering as the public report scope.
764#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
765enum AggregateScope<'a> {
766    Suite(&'a str),
767    All,
768}
769
770impl AggregateScope<'_> {
771    fn into_owned(self) -> BenchmarkScope {
772        match self {
773            Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
774            Self::All => BenchmarkScope::All,
775        }
776    }
777}
778
779#[derive(Clone, Debug)]
780struct AggregateBuilder {
781    runs: u64,
782    total: BenchmarkCounters,
783    min: BenchmarkCounters,
784    max: BenchmarkCounters,
785    peak_end: BenchmarkCounters,
786}
787
788impl AggregateBuilder {
789    const fn new(span: &BenchmarkSpan) -> Self {
790        Self {
791            runs: 1,
792            total: span.delta,
793            min: span.delta,
794            max: span.delta,
795            peak_end: span.end,
796        }
797    }
798
799    fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
800        let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
801        let total = self.total.checked_add(span.delta)?;
802        self.runs = runs;
803        self.total = total;
804        self.min.min_assign(span.delta);
805        self.max.max_assign(span.delta);
806        self.peak_end.max_assign(span.end);
807        Ok(())
808    }
809
810    const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
811        BenchmarkAggregateRow {
812            span_label,
813            runs: self.runs,
814            total: self.total,
815            min: self.min,
816            max: self.max,
817            peak_end: self.peak_end,
818            scope,
819        }
820    }
821}
822
823fn add_span_to_aggregate<'a>(
824    rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
825    scope: AggregateScope<'a>,
826    span: &'a BenchmarkSpan,
827) -> Result<(), BenchmarkAggregateError> {
828    match rows.entry((scope, span.span_label.as_str())) {
829        Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
830            let (scope, span_label) = entry.key();
831            BenchmarkAggregateError {
832                span_label: (*span_label).to_owned(),
833                counter,
834                scope: scope.into_owned(),
835            }
836        }),
837        Entry::Vacant(entry) => {
838            entry.insert(AggregateBuilder::new(span));
839            Ok(())
840        }
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
847
848    #[test]
849    fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
850        let span = BenchmarkSpan {
851            suite: "probe".into(),
852            span_label: "probe/run".into(),
853            start_line: 1,
854            end_line: 2,
855            end: BenchmarkCounters::default(),
856            delta: BenchmarkCounters::default(),
857        };
858        let mut builder = AggregateBuilder::new(&span);
859        builder.runs = u64::MAX;
860        assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
861        assert_eq!(builder.runs, u64::MAX);
862        assert_eq!(builder.total, BenchmarkCounters::default());
863    }
864}