Skip to main content

semantic/analysis/
hot_spots.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Longitudinal hot-spot aggregation across commit history.
3//!
4//! Per-pair `semantic_diff` answers "what changed between A and B" with
5//! function-level granularity. This module takes that data and asks the
6//! next question: *across the last N states, where is the activity
7//! concentrated?*
8//!
9//! # Why
10//!
11//! - **Reviewer focus** — surface the files and functions that have churned
12//!   recently so a reviewer knows where to look first.
13//! - **Annotation guidance** — multi-author hot spots are exactly the
14//!   places where a context annotation pays for itself; new editors of
15//!   that function shouldn't have to rediscover its constraints.
16//! - **API stability signals** — a `signature_changed` count of 5 over
17//!   the last 200 commits is a flag that the surface area is volatile.
18//!
19//! # Where this sits
20//!
21//! Pure function over `&impl ObjectStore`. Both the CLI (against the FS
22//! store) and the gRPC service (against any server-side store) call the
23//! same entry point — no host-specific glue required. The walker
24//! follows `state.first_parent()` through the imported ancestry,
25//! matching `git log --first-parent` semantics. That's the right
26//! model for "what landed on this branch": a merge commit's diff
27//! against its first parent surfaces *the merge as one batch event*,
28//! not as one event per file the side-branch happened to touch.
29//!
30//! # Cost
31//!
32//! O(N) `semantic_diff` calls plus an in-memory aggregation. Empirically
33//! against the imported ripgrep repo: 500 pairs walked in ~8 s on dev
34//! hardware, ~3 K events aggregated. The semantic-parse cache is
35//! shared across pairs so tree-sitter parses don't get redone.
36
37use std::{
38    collections::BTreeMap,
39    path::{Path, PathBuf},
40    time::Instant,
41};
42
43use objects::{
44    object::{ChangeId, SemanticChange, State},
45    store::ObjectStore,
46};
47
48use crate::{
49    cache::SemanticParseCache,
50    diff::{SemanticDiffOptions, semantic_diff_with_cache},
51};
52
53/// What dimension to aggregate on.
54///
55/// `File` answers "which files churn most." `Function` answers
56/// "which functions churn most." File events that don't carry a
57/// function name (`FileAdded`, `FileDeleted`, etc.) only contribute
58/// to `File` aggregation; under `Function` they're skipped.
59#[derive(Copy, Clone, Debug, Eq, PartialEq)]
60pub enum HotSpotKey {
61    File,
62    Function,
63}
64
65/// Coarse classification of a [`SemanticChange`]. The aggregator can
66/// optionally filter to a subset of these (e.g. "only signature
67/// changes" → API instability signal).
68#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
69pub enum HotEventKind {
70    FileAdded,
71    FileDeleted,
72    FileModified,
73    FileRenamed,
74    FunctionExtracted,
75    FunctionDeleted,
76    FunctionRenamed,
77    FunctionModified,
78    FunctionMoved,
79    SignatureChanged,
80    DependencyChanged,
81}
82
83impl HotEventKind {
84    fn classify(change: &SemanticChange) -> Option<Self> {
85        Some(match change {
86            SemanticChange::FileAdded { .. } => HotEventKind::FileAdded,
87            SemanticChange::FileDeleted { .. } => HotEventKind::FileDeleted,
88            SemanticChange::FileModified { .. } => HotEventKind::FileModified,
89            SemanticChange::FileRenamed { .. } => HotEventKind::FileRenamed,
90            SemanticChange::FunctionAdded { .. } | SemanticChange::FunctionExtracted { .. } => {
91                HotEventKind::FunctionExtracted
92            }
93            SemanticChange::FunctionDeleted { .. } => HotEventKind::FunctionDeleted,
94            SemanticChange::FunctionRenamed { .. } => HotEventKind::FunctionRenamed,
95            SemanticChange::FunctionModified { .. } => HotEventKind::FunctionModified,
96            SemanticChange::FunctionMoved { .. } => HotEventKind::FunctionMoved,
97            SemanticChange::SignatureChanged { .. } => HotEventKind::SignatureChanged,
98            SemanticChange::DependencyAdded { .. } | SemanticChange::DependencyRemoved { .. } => {
99                HotEventKind::DependencyChanged
100            }
101            // Custom events live outside the enum — we don't have a
102            // stable group_by key for them.
103            SemanticChange::Custom { .. } => return None,
104        })
105    }
106}
107
108/// The aggregation key for a single `(file, name?)` slot. Carries the
109/// function name only for `HotSpotKey::Function` aggregation; on `File`
110/// every `name` is `None` and the slot collapses across function
111/// events that share a path.
112#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
113pub enum HotSpotKeyValue {
114    File { path: PathBuf },
115    Function { path: PathBuf, name: String },
116}
117
118impl HotSpotKeyValue {
119    /// Path of the file the event touched.
120    pub fn path(&self) -> &Path {
121        match self {
122            HotSpotKeyValue::File { path } => path,
123            HotSpotKeyValue::Function { path, .. } => path,
124        }
125    }
126
127    /// Function name, if this is a function-keyed slot.
128    pub fn function_name(&self) -> Option<&str> {
129        match self {
130            HotSpotKeyValue::Function { name, .. } => Some(name),
131            HotSpotKeyValue::File { .. } => None,
132        }
133    }
134}
135
136/// One row of hot-spot output. `event_count` is total events;
137/// `state_count` is the number of distinct states the slot appeared
138/// in (cleaner signal — a single state with 50 events on the same
139/// file shouldn't outrank ten states with one event each, since the
140/// latter is real ongoing churn).
141#[derive(Clone, Debug)]
142pub struct HotSpot {
143    pub key: HotSpotKeyValue,
144    pub event_count: usize,
145    pub state_count: usize,
146    pub first_seen: ChangeId,
147    pub last_seen: ChangeId,
148    /// Breakdown of events by kind. Sums to `event_count`.
149    pub by_kind: BTreeMap<HotEventKind, usize>,
150    /// Per-actor histogram. `None` unless `params.include_actors`
151    /// was set. Keys are `Attribution::to_string()` so they include
152    /// agent suffixes when present.
153    pub by_actor: Option<BTreeMap<String, usize>>,
154}
155
156/// Tunable knobs for [`analyze_hot_spots`].
157#[derive(Clone, Debug)]
158pub struct HotSpotParams {
159    /// Stop walking once we've covered this many state pairs. `None`
160    /// = walk to the root (use carefully on large histories — the
161    /// per-pair `semantic_diff` cost scales linearly).
162    pub limit_states: Option<usize>,
163    /// What to bucket on.
164    pub group_by: HotSpotKey,
165    /// Restrict to events whose [`HotEventKind`] is in this list.
166    /// Empty list = no filter (all kinds counted).
167    pub include_kinds: Vec<HotEventKind>,
168    /// Substring filters on the event's path. Empty list = include all.
169    /// A path matches the include filter if any include substring is
170    /// in the path; matches the exclude filter if any exclude
171    /// substring is in the path.
172    pub include_paths: Vec<String>,
173    pub exclude_paths: Vec<String>,
174    /// Number of slots to return at the top of [`HotSpotsReport::spots`].
175    pub top_n: usize,
176    /// If true, populate [`HotSpot::by_actor`] with the per-actor
177    /// histogram. Useful for "this needs context" surfacing — multi-
178    /// actor hot spots are the strongest annotation candidates.
179    pub include_actors: bool,
180    /// Knobs forwarded to each underlying `semantic_diff` call.
181    pub diff_options: SemanticDiffOptions,
182}
183
184impl Default for HotSpotParams {
185    fn default() -> Self {
186        Self {
187            limit_states: Some(200),
188            group_by: HotSpotKey::File,
189            include_kinds: Vec::new(),
190            include_paths: Vec::new(),
191            exclude_paths: Vec::new(),
192            top_n: 20,
193            include_actors: false,
194            diff_options: SemanticDiffOptions::default(),
195        }
196    }
197}
198
199/// Top-of-output bookkeeping plus the ranked slot list.
200#[derive(Clone, Debug, Default)]
201pub struct HotSpotsReport {
202    pub spots: Vec<HotSpot>,
203    /// How many state pairs were actually walked (≤ `limit_states`).
204    pub states_walked: usize,
205    /// How many semantic-change events were observed across the walk.
206    /// `spots` may contain fewer than this since we keep only the
207    /// top `top_n` and may have filtered some kinds out.
208    pub total_events: usize,
209}
210
211/// Walk `walk_from` backwards through `first_parent()` chains and
212/// aggregate semantic-change events into hot-spots according to
213/// `params`.
214///
215/// `walk_from` is the *newest* state to examine; the first pair is
216/// `(walk_from, walk_from.first_parent())`. If `walk_from` has no
217/// parent, the report is empty.
218pub fn analyze_hot_spots(
219    store: &impl ObjectStore,
220    walk_from: ChangeId,
221    params: &HotSpotParams,
222) -> Result<HotSpotsReport, anyhow::Error> {
223    let started = Instant::now();
224    let cache = SemanticParseCache::shared();
225    let limit = params.limit_states.unwrap_or(usize::MAX);
226
227    // Slot bookkeeping. We maintain one map keyed on `HotSpotKeyValue`
228    // and update it for every event we see.
229    let mut slots: BTreeMap<HotSpotKeyValue, SlotAccumulator> = BTreeMap::new();
230    let mut total_events = 0usize;
231    let mut states_walked = 0usize;
232
233    let mut current_id = walk_from;
234    let mut current = match store.get_state(&current_id)? {
235        Some(s) => s,
236        None => return Ok(HotSpotsReport::default()),
237    };
238
239    while states_walked < limit {
240        let Some(parent_id) = current.first_parent().copied() else {
241            break;
242        };
243        let parent = match store.get_state(&parent_id)? {
244            Some(s) => s,
245            None => break,
246        };
247
248        // Per-pair semantic diff. We use the cache-injection variant
249        // so tree-sitter parses are reused across the whole walk —
250        // most files are unchanged across most pairs and the parse
251        // cache eats those calls.
252        let diff = semantic_diff_with_cache(
253            store,
254            &parent.tree,
255            &current.tree,
256            &params.diff_options,
257            cache,
258        )?;
259
260        let actor_label = if params.include_actors {
261            Some(current.attribution.to_string())
262        } else {
263            None
264        };
265
266        // Track which slots were touched by this state, so we increment
267        // `state_count` once per state regardless of how many events
268        // contribute. Event volume is `event_count`; state volume is
269        // the more honest "this thing keeps coming up" signal.
270        let mut touched_this_state: std::collections::BTreeSet<HotSpotKeyValue> =
271            Default::default();
272
273        for change in &diff.changes {
274            let Some(kind) = HotEventKind::classify(change) else {
275                continue;
276            };
277            if !params.include_kinds.is_empty() && !params.include_kinds.contains(&kind) {
278                continue;
279            }
280            // Function-keyed aggregation requires a function-bearing
281            // event; file-only events are silently skipped under
282            // `HotSpotKey::Function`.
283            let key = match (params.group_by, change_to_key(change)) {
284                (HotSpotKey::File, Some((path, _))) => HotSpotKeyValue::File { path },
285                (HotSpotKey::Function, Some((path, Some(name)))) => {
286                    HotSpotKeyValue::Function { path, name }
287                }
288                _ => continue,
289            };
290
291            if !path_passes_filter(key.path(), &params.include_paths, &params.exclude_paths) {
292                continue;
293            }
294
295            total_events += 1;
296
297            let slot = slots
298                .entry(key.clone())
299                .or_insert_with(|| SlotAccumulator::new(current_id));
300            slot.event_count += 1;
301            slot.last_seen = current_id;
302            *slot.by_kind.entry(kind).or_insert(0) += 1;
303            if let Some(actor) = &actor_label {
304                let by_actor = slot.by_actor.get_or_insert_with(BTreeMap::new);
305                *by_actor.entry(actor.clone()).or_insert(0) += 1;
306            }
307            touched_this_state.insert(key);
308        }
309        for key in touched_this_state {
310            if let Some(slot) = slots.get_mut(&key) {
311                slot.state_count += 1;
312            }
313        }
314
315        states_walked += 1;
316        current_id = parent_id;
317        current = parent;
318    }
319
320    let _ = started; // surface elapsed_ms in a future field if needed
321
322    // Rank by event_count desc, then state_count desc, then key for
323    // determinism. Ties on event count broken by "this keeps coming
324    // up across many states" rather than alphabetical.
325    let mut ranked: Vec<(HotSpotKeyValue, SlotAccumulator)> = slots.into_iter().collect();
326    ranked.sort_by(|a, b| {
327        b.1.event_count
328            .cmp(&a.1.event_count)
329            .then(b.1.state_count.cmp(&a.1.state_count))
330            .then(a.0.cmp(&b.0))
331    });
332
333    let spots = ranked
334        .into_iter()
335        .take(params.top_n)
336        .map(|(key, slot)| HotSpot {
337            key,
338            event_count: slot.event_count,
339            state_count: slot.state_count,
340            first_seen: slot.first_seen,
341            last_seen: slot.last_seen,
342            by_kind: slot.by_kind,
343            by_actor: slot.by_actor,
344        })
345        .collect();
346
347    Ok(HotSpotsReport {
348        spots,
349        states_walked,
350        total_events,
351    })
352}
353
354/// Internal accumulator — flattened into [`HotSpot`] at the end.
355struct SlotAccumulator {
356    event_count: usize,
357    state_count: usize,
358    first_seen: ChangeId,
359    last_seen: ChangeId,
360    by_kind: BTreeMap<HotEventKind, usize>,
361    by_actor: Option<BTreeMap<String, usize>>,
362}
363
364impl SlotAccumulator {
365    fn new(seen: ChangeId) -> Self {
366        Self {
367            event_count: 0,
368            state_count: 0,
369            first_seen: seen,
370            last_seen: seen,
371            by_kind: BTreeMap::new(),
372            by_actor: None,
373        }
374    }
375}
376
377/// Extract `(path, optional name)` from a [`SemanticChange`].
378///
379/// `Some((path, None))` = file-level event, no function attached.
380/// `Some((path, Some(name)))` = function-level event.
381/// `None` = no path (e.g. dependency events) — caller decides whether
382/// to count those (we route them to a synthetic `Cargo.toml` slot in
383/// the future, but for now they're dropped under both group_by modes
384/// since the caller usually wants per-file or per-function output).
385fn change_to_key(change: &SemanticChange) -> Option<(PathBuf, Option<String>)> {
386    match change {
387        SemanticChange::FileAdded { path }
388        | SemanticChange::FileDeleted { path }
389        | SemanticChange::FileModified { path, .. } => Some((path.clone(), None)),
390        SemanticChange::FileRenamed { to, .. } => Some((to.clone(), None)),
391        SemanticChange::FunctionAdded { file, name, .. }
392        | SemanticChange::FunctionExtracted { file, name, .. } => {
393            Some((file.clone(), Some(name.clone())))
394        }
395        SemanticChange::FunctionDeleted { file, name, .. } => {
396            Some((file.clone(), Some(name.clone())))
397        }
398        SemanticChange::FunctionRenamed { file, new_name, .. } => {
399            Some((file.clone(), Some(new_name.clone())))
400        }
401        SemanticChange::FunctionModified { file, name, .. } => {
402            Some((file.clone(), Some(name.clone())))
403        }
404        SemanticChange::FunctionMoved { file, name, .. } => {
405            Some((file.clone(), Some(name.clone())))
406        }
407        SemanticChange::SignatureChanged { file, name, .. } => {
408            Some((file.clone(), Some(name.clone())))
409        }
410        SemanticChange::DependencyAdded { .. }
411        | SemanticChange::DependencyRemoved { .. }
412        | SemanticChange::Custom { .. } => None,
413    }
414}
415
416/// Substring-based path filter. Cheap; upgrade to globset if real
417/// users hit limits.
418fn path_passes_filter(path: &Path, includes: &[String], excludes: &[String]) -> bool {
419    let s = path.to_string_lossy();
420    if !includes.is_empty() && !includes.iter().any(|inc| s.contains(inc.as_str())) {
421        return false;
422    }
423    if excludes.iter().any(|exc| s.contains(exc.as_str())) {
424        return false;
425    }
426    true
427}
428
429/// Companion: walk the chain and report the actor histogram only.
430/// Cheaper than `analyze_hot_spots` because it doesn't need per-pair
431/// semantic diff — pulls the answer straight from each state's
432/// `attribution`. Useful for the "who's been working here" panel
433/// that doesn't need file-granularity output.
434pub fn analyze_actor_histogram(
435    store: &impl ObjectStore,
436    walk_from: ChangeId,
437    limit_states: Option<usize>,
438) -> Result<BTreeMap<String, usize>, anyhow::Error> {
439    let limit = limit_states.unwrap_or(usize::MAX);
440    let mut histogram: BTreeMap<String, usize> = BTreeMap::new();
441    let mut steps = 0usize;
442
443    let Some(mut current) = store.get_state(&walk_from)? else {
444        return Ok(histogram);
445    };
446
447    *histogram
448        .entry(current.attribution.to_string())
449        .or_insert(0) += 1;
450    steps += 1;
451
452    while steps < limit {
453        let Some(parent_id) = current.first_parent().copied() else {
454            break;
455        };
456        let Some(parent) = store.get_state(&parent_id)? else {
457            break;
458        };
459        *histogram.entry(parent.attribution.to_string()).or_insert(0) += 1;
460        steps += 1;
461        current = parent;
462    }
463
464    Ok(histogram)
465}
466
467/// State accessor used by the walker; isolated so future tests can
468/// mock the store layer without going through the whole `ObjectStore`
469/// trait. (Currently unused — the walker calls `store.get_state`
470/// directly — but `State` needs to remain reachable for the test
471/// module's helper to compile.)
472#[allow(dead_code)]
473fn _state_anchor(_: &State) {}
474
475#[cfg(test)]
476mod tests {
477    use objects::{
478        object::{Attribution, ChangeId, Principal, State, Tree, TreeEntry},
479        store::InMemoryStore,
480    };
481
482    use super::*;
483
484    fn principal(label: &str) -> Principal {
485        Principal::new(label.to_string(), format!("{label}@example.com"))
486    }
487
488    /// Build a tiny chain `A → B → C` (C is HEAD) with a single file
489    /// `src/lib.rs` whose content differs at every step. Returns the
490    /// HEAD change id plus the in-memory store.
491    fn build_three_state_chain() -> (ChangeId, InMemoryStore) {
492        let store = InMemoryStore::new();
493
494        let blob_a = store
495            .put_blob(&objects::object::Blob::from_slice(
496                b"fn one() {}\nfn two() {}\n",
497            ))
498            .unwrap();
499        let tree_a = store
500            .put_tree(&Tree::from_entries(vec![
501                TreeEntry::file("lib.rs".to_string(), blob_a, false).unwrap(),
502            ]))
503            .unwrap();
504        let attrib_a = Attribution::human(principal("alice"));
505        let state_a = State::new(tree_a, Vec::new(), attrib_a);
506        store.put_state(&state_a).unwrap();
507        let id_a = state_a.change_id;
508
509        let blob_b = store
510            .put_blob(&objects::object::Blob::from_slice(
511                b"fn one() { println!(\"hi\"); }\nfn two() {}\n",
512            ))
513            .unwrap();
514        let tree_b = store
515            .put_tree(&Tree::from_entries(vec![
516                TreeEntry::file("lib.rs".to_string(), blob_b, false).unwrap(),
517            ]))
518            .unwrap();
519        let state_b = State::new(tree_b, vec![id_a], Attribution::human(principal("bob")));
520        store.put_state(&state_b).unwrap();
521        let id_b = state_b.change_id;
522
523        let blob_c = store
524            .put_blob(&objects::object::Blob::from_slice(
525                b"fn one() { println!(\"hello\"); }\nfn two() {}\nfn three() {}\n",
526            ))
527            .unwrap();
528        let tree_c = store
529            .put_tree(&Tree::from_entries(vec![
530                TreeEntry::file("lib.rs".to_string(), blob_c, false).unwrap(),
531            ]))
532            .unwrap();
533        let state_c = State::new(tree_c, vec![id_b], Attribution::human(principal("carol")));
534        store.put_state(&state_c).unwrap();
535        let id_c = state_c.change_id;
536
537        (id_c, store)
538    }
539
540    #[test]
541    fn walks_first_parent_chain_to_root() {
542        let (head, store) = build_three_state_chain();
543        let report = analyze_hot_spots(&store, head, &HotSpotParams::default()).unwrap();
544
545        // Two pairs walked: C→B and B→A. (A has no parent so we stop.)
546        assert_eq!(report.states_walked, 2);
547        // Both pairs touched src/lib.rs at least at the file level.
548        let lib_path: PathBuf = "lib.rs".into();
549        let file_spot = report
550            .spots
551            .iter()
552            .find(|s| matches!(&s.key, HotSpotKeyValue::File { path } if path == &lib_path))
553            .expect("expected lib.rs hot-spot");
554        assert!(file_spot.event_count >= 2);
555        assert_eq!(file_spot.state_count, 2);
556    }
557
558    #[test]
559    fn limit_states_caps_the_walk() {
560        let (head, store) = build_three_state_chain();
561        let params = HotSpotParams {
562            limit_states: Some(1),
563            ..HotSpotParams::default()
564        };
565        let report = analyze_hot_spots(&store, head, &params).unwrap();
566        assert_eq!(
567            report.states_walked, 1,
568            "limit_states=1 should walk one pair"
569        );
570    }
571
572    #[test]
573    fn group_by_function_skips_pure_file_events() {
574        let (head, store) = build_three_state_chain();
575        let params = HotSpotParams {
576            group_by: HotSpotKey::Function,
577            ..HotSpotParams::default()
578        };
579        let report = analyze_hot_spots(&store, head, &params).unwrap();
580
581        // We added `fn three` between B and C; that's a function-level
582        // event under group_by=Function. Some pure-file modifications
583        // (FileModified events without function-level resolution) are
584        // skipped. So we expect at least one Function key and zero File
585        // keys in the output.
586        for spot in &report.spots {
587            assert!(
588                matches!(&spot.key, HotSpotKeyValue::Function { .. }),
589                "group_by=Function should only emit Function keys, got {:?}",
590                spot.key
591            );
592        }
593    }
594
595    #[test]
596    fn include_actors_populates_per_actor_histogram() {
597        let (head, store) = build_three_state_chain();
598        let params = HotSpotParams {
599            include_actors: true,
600            ..HotSpotParams::default()
601        };
602        let report = analyze_hot_spots(&store, head, &params).unwrap();
603
604        let any = report.spots.first().expect("expected at least one spot");
605        let actors = any
606            .by_actor
607            .as_ref()
608            .expect("include_actors=true should populate by_actor");
609        // We saw bob and carol as the authors of the two compared
610        // states (a→b and b→c). Attribution::Display formats as
611        // "name <email>", so we substring-match instead of exact key.
612        assert!(
613            actors
614                .keys()
615                .any(|k| k.contains("bob") || k.contains("carol")),
616            "expected bob or carol in actor histogram, got {:?}",
617            actors.keys().collect::<Vec<_>>()
618        );
619    }
620
621    #[test]
622    fn path_filter_excludes_substring_match() {
623        let (head, store) = build_three_state_chain();
624        let params = HotSpotParams {
625            exclude_paths: vec!["lib.rs".to_string()],
626            ..HotSpotParams::default()
627        };
628        let report = analyze_hot_spots(&store, head, &params).unwrap();
629        assert!(
630            report.spots.is_empty(),
631            "exclude path 'lib.rs' should remove every spot, got {:?}",
632            report.spots
633        );
634    }
635
636    #[test]
637    fn actor_histogram_walks_chain_independently_of_diff_path() {
638        let (head, store) = build_three_state_chain();
639        let hist = analyze_actor_histogram(&store, head, Some(10)).unwrap();
640        // Three states walked (head + 2 ancestors), three actors total
641        // since each commit had a different principal in the fixture.
642        assert_eq!(hist.values().sum::<usize>(), 3);
643        assert_eq!(hist.len(), 3);
644    }
645
646    #[test]
647    fn empty_chain_returns_empty_report() {
648        // A single root state with no parent: nothing to diff.
649        let store = InMemoryStore::new();
650        let blob = store
651            .put_blob(&objects::object::Blob::from_slice(b"fn solo() {}"))
652            .unwrap();
653        let tree = store
654            .put_tree(&Tree::from_entries(vec![
655                TreeEntry::file("solo.rs".to_string(), blob, false).unwrap(),
656            ]))
657            .unwrap();
658        let state = State::new(tree, Vec::new(), Attribution::human(principal("alice")));
659        store.put_state(&state).unwrap();
660
661        let report = analyze_hot_spots(&store, state.change_id, &HotSpotParams::default()).unwrap();
662        assert_eq!(report.states_walked, 0);
663        assert_eq!(report.total_events, 0);
664        assert!(report.spots.is_empty());
665    }
666}