Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-saga/src/analysis.rs

//! **Frustration / stuck detection** — the "SEE where the user got stuck" half of
//! Saga (roadmap §R.d, folding in *PostHog frustration signals*).
//!
//! A recorded session is an ordered [`SagaEvent`] stream. This module reads that
//! stream (never a live app, never a backend — plain data, like the rest of the
//! lean crate) and derives a per-session [`SessionFrustration`]: how many
//! **rage-click** clusters it contains (a user hammering one component with no
//! navigation making progress) and which **routes the user got stuck on** (a route
//! that errored repeatedly). Both are the classic signals that a real user hit a
//! wall — exactly the sessions a dev should triage first.
//!
//! The output is deliberately a **monotone [`SessionFrustration::score`]** plus an
//! [`SessionFrustration::is_stuck`] flag so a caller can *float the painful
//! sessions to the top* of the Sessions list. [`rank_by_frustration`] does that
//! ordering. Detection is by **capture order** (`seq`), not wall-clock, so it is
//! deterministic and independent of timing skew — the same discipline as
//! [`crate::replay`].

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::event::{EventKind, SagaEvent};

/// A run of this many **consecutive** clicks on the SAME component with no
/// intervening navigation counts as one rage-click cluster. Three is the smallest
/// run that reads as "hammering, not just double-clicking".
pub const RAGE_CLICK_RUN: usize = 3;

/// A route that produces this many `error` events (within one session) is flagged
/// as a route the user got **stuck** on.
pub const REPEATED_ERROR_RUN: usize = 2;

/// The frustration signals derived for ONE session. Ordering by
/// [`Self::score`] (desc) floats the painful sessions to the top of the list.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SessionFrustration {
    /// The session these signals were derived for.
    pub session_id: String,
    /// Number of rage-click clusters — runs of `>= RAGE_CLICK_RUN` consecutive
    /// clicks on one component with no navigation in between.
    pub rage_clusters: usize,
    /// The longest run of consecutive same-component clicks seen (context for the
    /// scrubber / hover; `0` if there were no clicks).
    pub max_click_run: usize,
    /// Routes the user got stuck on — each errored `>= REPEATED_ERROR_RUN` times.
    /// Sorted + de-duplicated so the output is stable.
    pub stuck_routes: Vec<String>,
    /// Total `error` events in the session (all routes).
    pub error_count: usize,
    /// A monotone frustration score for ranking (higher = more painful). Not a
    /// unit — only its ordering is meaningful.
    pub score: u32,
    /// Whether the session is flagged **stuck**: it has at least one rage-click
    /// cluster OR at least one stuck route.
    pub is_stuck: bool,
}

impl SessionFrustration {
    /// Derive the frustration signals for `session_id` out of a (possibly
    /// mixed-session, unordered) slice of events: scope to the session, sort by
    /// capture `seq`, then walk once detecting rage clusters + counting errors
    /// per route.
    pub fn for_session(session_id: &str, events: &[SagaEvent]) -> SessionFrustration {
        let mut mine: Vec<&SagaEvent> =
            events.iter().filter(|e| e.session_id == session_id).collect();
        mine.sort_by_key(|e| e.seq);

        // Rage-click clusters: walk in capture order. A click extends the current
        // run when it targets the same (route, component) as the previous click;
        // a NAV resets the run (the user made progress). Logs/errors/state are
        // context and do NOT break a run.
        let mut cur_key: Option<(&str, &str)> = None;
        let mut run: usize = 0;
        let mut max_click_run: usize = 0;
        let mut rage_clusters: usize = 0;
        let mut counted_this_cluster = false;

        // Errors per route (fall back to component when the adapter left route
        // blank, e.g. a bare `SagaEvent::error(component, ...)`).
        let mut errors_by_route: BTreeMap<&str, usize> = BTreeMap::new();
        let mut error_count: usize = 0;

        for e in &mine {
            match e.kind {
                EventKind::Click => {
                    let key = (e.route.as_str(), e.component.as_str());
                    if cur_key == Some(key) {
                        run += 1;
                    } else {
                        cur_key = Some(key);
                        run = 1;
                        counted_this_cluster = false;
                    }
                    if run > max_click_run {
                        max_click_run = run;
                    }
                    if run >= RAGE_CLICK_RUN && !counted_this_cluster {
                        rage_clusters += 1;
                        counted_this_cluster = true;
                    }
                }
                EventKind::Nav => {
                    // Navigation = progress → the click run resets.
                    cur_key = None;
                    run = 0;
                    counted_this_cluster = false;
                }
                EventKind::Error => {
                    error_count += 1;
                    let key = if e.route.is_empty() {
                        e.component.as_str()
                    } else {
                        e.route.as_str()
                    };
                    *errors_by_route.entry(key).or_insert(0) += 1;
                }
                EventKind::Log | EventKind::State => {}
            }
        }

        let mut stuck_routes: Vec<String> = errors_by_route
            .into_iter()
            .filter(|(_, n)| *n >= REPEATED_ERROR_RUN)
            .map(|(route, _)| route.to_string())
            .collect();
        stuck_routes.sort();
        stuck_routes.dedup();

        let is_stuck = rage_clusters > 0 || !stuck_routes.is_empty();
        // Rage clusters and stuck routes each dominate raw error volume; error
        // volume is the tie-breaker so a noisier painful session still outranks a
        // quieter one with the same structure.
        let score = (rage_clusters as u32) * 100
            + (stuck_routes.len() as u32) * 100
            + (error_count as u32);

        SessionFrustration {
            session_id: session_id.to_string(),
            rage_clusters,
            max_click_run,
            stuck_routes,
            error_count,
            score,
            is_stuck,
        }
    }
}

/// Derive [`SessionFrustration`] for EVERY session present in `events`, one per
/// distinct `session_id`.
pub fn frustration_by_session(events: &[SagaEvent]) -> Vec<SessionFrustration> {
    let mut seen: Vec<&str> = Vec::new();
    for e in events {
        if !seen.iter().any(|id| *id == e.session_id) {
            seen.push(&e.session_id);
        }
    }
    seen.into_iter()
        .map(|id| SessionFrustration::for_session(id, events))
        .collect()
}

/// Order a set of frustration signals **painful-first**: highest [`score`] first,
/// ties broken by `session_id` for a stable result. This is the ordering that
/// floats the stuck sessions to the top of the Sessions list.
///
/// [`score`]: SessionFrustration::score
pub fn rank_by_frustration(mut fr: Vec<SessionFrustration>) -> Vec<SessionFrustration> {
    fr.sort_by(|a, b| {
        b.score
            .cmp(&a.score)
            .then_with(|| a.session_id.cmp(&b.session_id))
    });
    fr
}

// ─── session roll-up (the Sessions-list row model, painful-first) ────────────

/// One row of the Saga **Sessions list**: a recorded session rolled up — its id,
/// app, user, git sha, event + step + error counts, whether it is replayable, when
/// it started, and its frustration signals ([`stuck`](Self::stuck) /
/// [`frustration_score`](Self::frustration_score)) so painful sessions float to the
/// top. Pure (plain data over `&[SagaEvent]`) so BOTH the nornir viz Sessions pane
/// and the dwarves tape-recorder pane render the SAME rows — the warehouse-coupled
/// `list_saga_sessions` in nornir simply reads the events back and calls
/// [`summarize_sessions`] (REUSE — one home, consumed by both).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaSessionSummary {
    pub session_id: String,
    pub app: String,
    pub user: String,
    pub git_sha: String,
    /// Total captured events (all kinds).
    pub event_count: usize,
    /// Actionable (replayable) steps — `nav` + `click`.
    pub step_count: usize,
    /// Distinct error events captured in the session.
    pub error_count: usize,
    /// Earliest capture timestamp (micros) — when the session started.
    pub started_micros: u64,
    /// Whether the session has at least one replayable step.
    pub replayable: bool,
    /// Whether the session is flagged **stuck** (a rage-click cluster or a route the
    /// user hit repeated errors on — see [`SessionFrustration`]). Painful sessions
    /// float to the top so a dev triages them first.
    pub stuck: bool,
    /// A monotone frustration score for ranking (higher = more painful). Only its
    /// ordering is meaningful; drives the "float to top" sort.
    pub frustration_score: u32,
}

/// Roll a flat (possibly mixed-session, unordered) event slice up into one
/// [`SagaSessionSummary`] per session, ordered **painful-first**: highest
/// frustration score, then most-recent (`started_micros` desc), then `session_id`
/// for a stable order — so a dev triages the stuck sessions before the calm ones.
///
/// Folds in the [`frustration_by_session`] signals (rage clusters + stuck routes) in
/// one extra pass over the same events. This is the exact roll-up nornir's
/// warehouse-backed `list_saga_sessions` performs after reading `saga_events` back;
/// keeping it here (the lean crate) lets a warehouse-free consumer (a `.jsonl` tape,
/// the dwarves pane) build the identical list without any backend.
pub fn summarize_sessions(events: &[SagaEvent]) -> Vec<SagaSessionSummary> {
    let mut by_session: BTreeMap<String, SagaSessionSummary> = BTreeMap::new();
    for e in events {
        let s = by_session
            .entry(e.session_id.clone())
            .or_insert_with(|| SagaSessionSummary {
                session_id: e.session_id.clone(),
                app: e.app.clone(),
                user: e.user.clone(),
                git_sha: e.git_sha.clone(),
                event_count: 0,
                step_count: 0,
                error_count: 0,
                started_micros: e.ts_micros,
                replayable: false,
                stuck: false,
                frustration_score: 0,
            });
        s.event_count += 1;
        if e.kind.is_actionable() {
            s.step_count += 1;
            s.replayable = true;
        }
        if e.kind == EventKind::Error {
            s.error_count += 1;
        }
        if e.ts_micros > 0 && (s.started_micros == 0 || e.ts_micros < s.started_micros) {
            s.started_micros = e.ts_micros;
        }
        // Keep the first non-empty identity fields seen.
        if s.app.is_empty() {
            s.app = e.app.clone();
        }
        if s.user.is_empty() {
            s.user = e.user.clone();
        }
    }
    // Fold in the frustration signals (rage-click clusters + stuck routes).
    for fr in frustration_by_session(events) {
        if let Some(s) = by_session.get_mut(&fr.session_id) {
            s.stuck = fr.is_stuck;
            s.frustration_score = fr.score;
        }
    }

    let mut out: Vec<SagaSessionSummary> = by_session.into_values().collect();
    out.sort_by(|a, b| {
        b.frustration_score
            .cmp(&a.frustration_score)
            .then(b.started_micros.cmp(&a.started_micros))
            .then(a.session_id.cmp(&b.session_id))
    });
    out
}

// ─── nav-path / funnel aggregation (dev-cockpit `saga path`) ─────────────────

/// One transition out of a funnel step: where sessions went NEXT, and how many.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavTransition {
    /// The next route sessions moved to from this step.
    pub to: String,
    /// How many sessions took this transition.
    pub count: usize,
}

/// One ordered step of a [`NavFunnel`]: the modal route at this position, how many
/// sessions reached it, how many stopped here (drop-off), and where the rest went.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FunnelStep {
    /// Zero-based position in the ordered route funnel.
    pub position: usize,
    /// The dominant (most common, ties broken lexicographically) route at this
    /// position across the session set.
    pub route: String,
    /// Sessions whose nav path was at least this long (reached this step).
    pub reached: usize,
    /// Sessions that reached this step but did NOT proceed to the next one — the
    /// funnel drop-off at this step.
    pub dropped: usize,
    /// Where sessions went next, most-taken first.
    pub next: Vec<NavTransition>,
}

/// An ordered **navigation funnel** folded from the `Nav` events of a session set:
/// route → sessions-reached, per-step drop-off, and next-route transitions. Pure
/// (plain data, no backend) so both the `saga path` CLI verb and the dev-cockpit
/// tab render the same aggregation. Detection is by capture `seq`, deterministic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct NavFunnel {
    /// The ordered funnel steps (position 0 = entry).
    pub steps: Vec<FunnelStep>,
    /// Number of distinct sessions with at least one nav event.
    pub session_count: usize,
}

impl NavFunnel {
    /// Fold the `Nav` events of `events` (any number of sessions) into an ordered
    /// route funnel. Each session contributes its ordered nav route sequence
    /// (consecutive duplicates collapsed — a re-render on the same route is not a
    /// step). An empty / nav-less stream yields an empty funnel.
    pub fn from_events(events: &[SagaEvent]) -> NavFunnel {
        // Per-session ordered nav route paths (by capture seq), consecutive dups
        // collapsed. BTreeMap keeps session iteration deterministic.
        let mut by_session: BTreeMap<&str, Vec<&SagaEvent>> = BTreeMap::new();
        for e in events {
            if e.kind == EventKind::Nav {
                by_session.entry(e.session_id.as_str()).or_default().push(e);
            }
        }
        let mut paths: Vec<Vec<&str>> = Vec::new();
        for (_sid, mut evs) in by_session {
            evs.sort_by_key(|e| e.seq);
            let mut path: Vec<&str> = Vec::new();
            for e in evs {
                if path.last().copied() != Some(e.route.as_str()) {
                    path.push(e.route.as_str());
                }
            }
            if !path.is_empty() {
                paths.push(path);
            }
        }

        let session_count = paths.len();
        let max_len = paths.iter().map(|p| p.len()).max().unwrap_or(0);
        let mut steps = Vec::with_capacity(max_len);
        for i in 0..max_len {
            // reached = sessions whose path is at least this long.
            let reached = paths.iter().filter(|p| p.len() > i).count();
            // Modal route at this position (ties broken lexicographically for a
            // stable, deterministic label).
            let mut tally: BTreeMap<&str, usize> = BTreeMap::new();
            for p in &paths {
                if let Some(r) = p.get(i) {
                    *tally.entry(*r).or_insert(0) += 1;
                }
            }
            let route = tally
                .iter()
                .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
                .map(|(r, _)| r.to_string())
                .unwrap_or_default();
            // Next-route transitions from this position.
            let mut nexts: BTreeMap<&str, usize> = BTreeMap::new();
            for p in &paths {
                if p.len() > i {
                    if let Some(next) = p.get(i + 1) {
                        *nexts.entry(*next).or_insert(0) += 1;
                    }
                }
            }
            let proceeded: usize = nexts.values().sum();
            let dropped = reached - proceeded;
            let mut next: Vec<NavTransition> = nexts
                .into_iter()
                .map(|(to, count)| NavTransition { to: to.to_string(), count })
                .collect();
            // Most-taken first, ties broken by route for stability.
            next.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.to.cmp(&b.to)));
            steps.push(FunnelStep { position: i, route, reached, dropped, next });
        }

        NavFunnel { steps, session_count }
    }

    /// Serialize the funnel to pretty JSON (the `saga path --json` payload).
    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::SessionCtx;

    fn seq(mut e: SagaEvent, n: u64) -> SagaEvent {
        e.seq = n;
        e
    }

    fn ctx() -> SessionCtx {
        SessionCtx::new("s1", "bob", "njord", "1.0", "sha")
    }

    #[test]
    fn three_consecutive_clicks_on_one_component_is_a_rage_cluster() {
        let c = ctx();
        let events = vec![
            seq(SagaEvent::nav("/offert/7", "OffertForm", "open", "{}").in_session(&c), 0),
            seq(SagaEvent::click("/offert/7", "Save", "submit", "{}").in_session(&c), 1),
            seq(SagaEvent::click("/offert/7", "Save", "submit", "{}").in_session(&c), 2),
            seq(SagaEvent::click("/offert/7", "Save", "submit", "{}").in_session(&c), 3),
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.rage_clusters, 1, "three same-component clicks = one cluster");
        assert_eq!(fr.max_click_run, 3);
        assert!(fr.is_stuck, "a rage cluster flags the session stuck");
        assert!(fr.score > 0);
    }

    #[test]
    fn navigation_between_clicks_breaks_the_run() {
        let c = ctx();
        // Two clicks, a nav (progress!), then two more — no run reaches 3.
        let events = vec![
            seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 0),
            seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 1),
            seq(SagaEvent::nav("/b", "Page", "open", "{}").in_session(&c), 2),
            seq(SagaEvent::click("/b", "Btn", "x", "{}").in_session(&c), 3),
            seq(SagaEvent::click("/b", "Btn", "x", "{}").in_session(&c), 4),
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.rage_clusters, 0, "navigation reset the click run");
        assert_eq!(fr.max_click_run, 2);
        assert!(!fr.is_stuck);
    }

    #[test]
    fn context_events_do_not_break_a_rage_run() {
        let c = ctx();
        // A log + an error interleave the clicks but must NOT reset the run.
        let events = vec![
            seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 0),
            seq(SagaEvent::log("info", "noise").in_session(&c), 1),
            seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 2),
            seq(SagaEvent::state("Btn", "{}").in_session(&c), 3),
            seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 4),
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.rage_clusters, 1, "log/state between clicks are context, not progress");
    }

    #[test]
    fn switching_component_resets_the_run() {
        let c = ctx();
        let events = vec![
            seq(SagaEvent::click("/a", "One", "x", "{}").in_session(&c), 0),
            seq(SagaEvent::click("/a", "One", "x", "{}").in_session(&c), 1),
            seq(SagaEvent::click("/a", "Two", "x", "{}").in_session(&c), 2),
            seq(SagaEvent::click("/a", "Two", "x", "{}").in_session(&c), 3),
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.rage_clusters, 0, "no single component hit 3 in a row");
        assert_eq!(fr.max_click_run, 2);
    }

    #[test]
    fn repeated_error_on_a_route_is_a_stuck_route() {
        let c = ctx();
        let mut e1 = SagaEvent::error("OffertForm", "500").in_session(&c);
        e1.route = "/offert/7".into();
        e1.seq = 1;
        let mut e2 = SagaEvent::error("OffertForm", "500 again").in_session(&c);
        e2.route = "/offert/7".into();
        e2.seq = 2;
        let events = vec![
            seq(SagaEvent::nav("/offert/7", "OffertForm", "open", "{}").in_session(&c), 0),
            e1,
            e2,
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.error_count, 2);
        assert_eq!(fr.stuck_routes, vec!["/offert/7".to_string()]);
        assert!(fr.is_stuck, "two errors on one route = stuck");
    }

    #[test]
    fn single_error_is_not_stuck() {
        let c = ctx();
        let mut e1 = SagaEvent::error("Form", "oops").in_session(&c);
        e1.route = "/a".into();
        e1.seq = 1;
        let events = vec![
            seq(SagaEvent::nav("/a", "Form", "open", "{}").in_session(&c), 0),
            e1,
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.error_count, 1);
        assert!(fr.stuck_routes.is_empty());
        assert!(!fr.is_stuck, "one error is not being stuck");
    }

    #[test]
    fn clean_session_scores_zero() {
        let c = ctx();
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
            seq(SagaEvent::click("/a", "Go", "submit", "{}").in_session(&c), 1),
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&c), 2),
        ];
        let fr = SessionFrustration::for_session("s1", &events);
        assert_eq!(fr.score, 0);
        assert!(!fr.is_stuck);
        assert_eq!(fr.rage_clusters, 0);
    }

    #[test]
    fn ranking_floats_the_painful_session_to_the_top() {
        let clean = SessionCtx::new("clean", "u", "njord", "1.0", "sha");
        let rage = SessionCtx::new("rage", "u", "njord", "1.0", "sha");
        let mut events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&clean), 0),
            seq(SagaEvent::click("/a", "Go", "x", "{}").in_session(&clean), 1),
        ];
        for s in 2..6 {
            events.push(seq(
                SagaEvent::click("/x", "Stuck", "retry", "{}").in_session(&rage),
                s,
            ));
        }
        let ranked = rank_by_frustration(frustration_by_session(&events));
        assert_eq!(ranked.len(), 2);
        assert_eq!(ranked[0].session_id, "rage", "the stuck session floats to the top");
        assert!(ranked[0].is_stuck);
        assert!(!ranked[1].is_stuck);
    }

    #[test]
    fn frustration_is_scoped_per_session() {
        let a = SessionCtx::new("sA", "u", "njord", "1.0", "sha");
        let b = SessionCtx::new("sB", "u", "njord", "1.0", "sha");
        // sA rage-clicks; sB is clean. Detection must not bleed across sessions.
        let events = vec![
            seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&a), 0),
            seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&b), 1),
            seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&a), 2),
            seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&b), 3),
            seq(SagaEvent::click("/x", "Btn", "x", "{}").in_session(&a), 4),
        ];
        let fa = SessionFrustration::for_session("sA", &events);
        let fb = SessionFrustration::for_session("sB", &events);
        assert_eq!(fa.rage_clusters, 1, "sA's 3 clicks are consecutive within its own stream");
        assert_eq!(fb.rage_clusters, 0, "sB only clicked twice");
    }

    // ── session roll-up (painful-first Sessions list) ────────────────────────

    #[test]
    fn summarize_rolls_up_counts_and_replayable() {
        let c = ctx();
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
            seq(SagaEvent::log("info", "noise").in_session(&c), 1),
            seq(SagaEvent::click("/a", "Go", "submit", "{}").in_session(&c), 2),
        ];
        let sums = summarize_sessions(&events);
        assert_eq!(sums.len(), 1);
        let s = &sums[0];
        assert_eq!(s.session_id, "s1");
        assert_eq!(s.app, "njord");
        assert_eq!(s.event_count, 3, "all kinds counted");
        assert_eq!(s.step_count, 2, "nav + click are replayable steps");
        assert!(s.replayable);
        assert_eq!(s.error_count, 0);
    }

    #[test]
    fn summarize_floats_the_painful_session_to_the_top() {
        // A calm session started LATER, a rage-click session EARLIER: recency alone
        // would rank the calm one first; the frustration score must override that.
        let calm = SessionCtx::new("calm", "u", "njord", "1.0", "sha");
        let rage = SessionCtx::new("rage", "u", "njord", "1.0", "sha");
        let mut events = Vec::new();
        for s in 0..3u64 {
            let mut e = SagaEvent::click("/x", "Retry", "retry", "{}").in_session(&rage);
            e.seq = s;
            e.ts_micros = 100 + s; // earliest
            events.push(e);
        }
        let mut e = SagaEvent::nav("/home", "Home", "open", "{}").in_session(&calm);
        e.seq = 10;
        e.ts_micros = 9000; // most recent
        events.push(e);

        let sums = summarize_sessions(&events);
        assert_eq!(sums.len(), 2);
        assert_eq!(sums[0].session_id, "rage", "the stuck session floats above the calmer, more-recent one");
        assert!(sums[0].stuck);
        assert!(sums[0].frustration_score > 0);
        assert!(!sums[1].stuck);
        assert_eq!(sums[1].frustration_score, 0);
    }

    // ── nav-path / funnel aggregation ────────────────────────────────────────

    #[test]
    fn nav_funnel_aggregates_ordered_routes_with_dropoff() {
        // Two sessions: full /a→/b→/c, and a drop at /b (/a→/b only).
        let full = SessionCtx::new("full", "u", "njord", "1.0", "sha");
        let drop = SessionCtx::new("drop", "u", "njord", "1.0", "sha");
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&full), 0),
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&full), 1),
            seq(SagaEvent::nav("/c", "C", "open", "{}").in_session(&full), 2),
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&drop), 3),
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&drop), 4),
        ];
        let funnel = NavFunnel::from_events(&events);
        assert_eq!(funnel.session_count, 2);
        assert_eq!(funnel.steps.len(), 3, "ordered /a → /b → /c");

        assert_eq!(funnel.steps[0].route, "/a");
        assert_eq!(funnel.steps[0].reached, 2);
        assert_eq!(funnel.steps[0].dropped, 0, "both proceed past /a");
        assert_eq!(funnel.steps[0].next, vec![NavTransition { to: "/b".into(), count: 2 }]);

        assert_eq!(funnel.steps[1].route, "/b");
        assert_eq!(funnel.steps[1].reached, 2);
        assert_eq!(funnel.steps[1].dropped, 1, "one session dropped at /b");
        assert_eq!(funnel.steps[1].next, vec![NavTransition { to: "/c".into(), count: 1 }]);

        assert_eq!(funnel.steps[2].route, "/c");
        assert_eq!(funnel.steps[2].reached, 1);
        assert_eq!(funnel.steps[2].dropped, 1, "terminal step is a drop-off");
        assert!(funnel.steps[2].next.is_empty());
    }

    #[test]
    fn nav_funnel_empty_stream_is_empty() {
        let funnel = NavFunnel::from_events(&[]);
        assert_eq!(funnel.session_count, 0);
        assert!(funnel.steps.is_empty());
        // A stream with only non-nav events is also an empty funnel.
        let c = ctx();
        let only_clicks =
            vec![seq(SagaEvent::click("/a", "Btn", "x", "{}").in_session(&c), 0)];
        assert!(NavFunnel::from_events(&only_clicks).steps.is_empty());
    }

    #[test]
    fn nav_funnel_collapses_consecutive_duplicate_routes() {
        // A re-render on the same route is not a new funnel step.
        let c = ctx();
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
            seq(SagaEvent::nav("/a", "A", "rerender", "{}").in_session(&c), 1),
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&c), 2),
        ];
        let funnel = NavFunnel::from_events(&events);
        assert_eq!(funnel.session_count, 1);
        assert_eq!(funnel.steps.len(), 2, "the duplicate /a collapses");
        assert_eq!(funnel.steps[0].route, "/a");
        assert_eq!(funnel.steps[1].route, "/b");
    }

    #[test]
    fn nav_funnel_json_roundtrips() {
        let c = ctx();
        let events = vec![
            seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&c), 0),
            seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&c), 1),
        ];
        let funnel = NavFunnel::from_events(&events);
        let json = funnel.to_json();
        let back: NavFunnel = serde_json::from_str(&json).unwrap();
        assert_eq!(back, funnel, "funnel survives a JSON roundtrip");
        assert!(json.contains("\"route\""));
    }
}