Skip to main content

skiagram_core/analysis/
anomaly.rs

1//! Retry-storm and fat-tail detection (CLAUDE.md §6 "Fat tail", roadmap v0.3).
2//!
3//! Answers "*which few requests dominated my spend, and did the agent loop?*"
4//! Two findings, both computed over DEDUPLICATED requests — never raw JSONL
5//! lines, since request-level dedup is THE accounting step (CLAUDE.md §8.1).
6//! This pass reuses [`super::dedup::dedup_session`] exactly like
7//! [`super::aggregate`] / [`super::drilldown`], so the numbers agree with the
8//! `summary` and `context` commands.
9//!
10//! 1. **Fat tail** — the small fraction of requests that dominate token spend,
11//!    surfaced two ways: a *concentration* table (the top 1 / 5 / 10 / 25 % of
12//!    requests and the share of all tokens they account for) and a ranked list
13//!    of the *heaviest individual requests*. Ranking and concentration are by
14//!    TOKENS, which are always known and never a pricing guess (§8.7); USD cost
15//!    is shown alongside where the model is priced, and totals are a lower bound
16//!    when any request is unpriced (§8.5).
17//!
18//! 2. **Retry storms** — bursts of requests bunched together in time within one
19//!    session: a candidate retry/loop episode (each retry re-sends the whole
20//!    window, re-paying cache-read, often for little new output). Detected
21//!    purely from timestamps as a run of at least [`STORM_MIN_REQUESTS`]
22//!    requests whose every consecutive gap is at most [`STORM_MAX_GAP_SECONDS`].
23//!    These thresholds are a coarse heuristic and are surfaced in the report so
24//!    the output is self-describing.
25//!
26//! Note on scope: API-error lines (`isApiErrorMessage`) never reach this pass —
27//! the adapter records them with `usage: None`, so dedup drops them (absence ≠
28//! zero, §8.5). Storm detection therefore reflects only requests that actually
29//! hit the API and cost money. Folding error-burst counts in is future work.
30
31use std::collections::BTreeMap;
32
33use jiff::civil::Date;
34use jiff::Timestamp;
35use serde::Serialize;
36
37use crate::analysis::aggregate::Filter;
38use crate::analysis::dedup::dedup_session;
39use crate::model::Session;
40use crate::pricing::PricingTable;
41
42/// Minimum number of rapid requests for a run to count as a retry storm.
43/// Heuristic — surfaced in [`AnomalyReport::storm_min_requests`].
44pub const STORM_MIN_REQUESTS: u64 = 5;
45
46/// Maximum allowed gap (seconds) between consecutive requests in a storm run.
47/// Heuristic — surfaced in [`AnomalyReport::storm_max_gap_seconds`].
48pub const STORM_MAX_GAP_SECONDS: i64 = 15;
49
50/// How many heaviest individual requests to keep in the ranked list.
51const HEAVIEST_N: usize = 15;
52
53/// Top-of-distribution request fractions reported in the concentration table.
54const CONCENTRATION_FRACTIONS: [f64; 4] = [0.01, 0.05, 0.10, 0.25];
55
56/// One deduplicated request flagged as a heavy contributor (a fat-tail member).
57#[derive(Debug, Clone, Serialize)]
58pub struct HeavyRequest {
59    pub session_id: String,
60    pub request_id: Option<String>,
61    pub model: Option<String>,
62    pub ts: Option<Timestamp>,
63    /// Known tokens for this request (`Usage::known_total`).
64    pub total_tokens: u64,
65    /// USD cost when the model is priced, else `None` (§8.7 — never guessed).
66    pub cost_usd: Option<f64>,
67    /// Share of total priced cost (`0.0..=1.0`), or `None` when this request is
68    /// unpriced or total cost is zero.
69    pub cost_share: Option<f64>,
70    /// Share of total tokens (`0.0..=1.0`).
71    pub token_share: f64,
72    /// The request came from a sub-agent (sidechain) transcript.
73    pub sidechain: bool,
74    /// This request used extended thinking (its `output` already includes the
75    /// thinking tokens — §8.2).
76    pub has_thinking: bool,
77}
78
79/// Cumulative concentration: the top `request_fraction` of requests (by tokens)
80/// account for `token_share` of all tokens — the classic "fat tail" statement.
81#[derive(Debug, Clone, Serialize)]
82pub struct ConcentrationBucket {
83    /// Top fraction of requests by count (e.g. `0.05` = the heaviest 5 %).
84    pub request_fraction: f64,
85    /// Requests in this top slice: `ceil(request_fraction * n)`, clamped to
86    /// `1..=n`.
87    pub requests: u64,
88    /// Their share of total tokens (`0.0..=1.0`).
89    pub token_share: f64,
90    /// Their share of total priced cost, or `None` when total cost is zero.
91    pub cost_share: Option<f64>,
92}
93
94/// A temporal burst of requests within one session — a candidate retry/loop
95/// episode. See the module docs for the detection rule and its caveats.
96#[derive(Debug, Clone, Serialize)]
97pub struct RetryStorm {
98    pub session_id: String,
99    pub project: Option<String>,
100    pub started_at: Timestamp,
101    pub ended_at: Timestamp,
102    /// Requests in the burst (post-dedup).
103    pub requests: u64,
104    pub total_tokens: u64,
105    /// Summed cost of the burst's priced requests; `None` when none are priced.
106    /// A lower bound when the burst mixes priced and unpriced requests (§8.5).
107    pub cost_usd: Option<f64>,
108    /// Wall-clock span of the burst, in seconds (`ended_at - started_at`).
109    pub span_seconds: i64,
110    /// Requests in the burst that used extended thinking (§8.2).
111    pub thinking_requests: u64,
112}
113
114/// Retry-storm + fat-tail findings over a set of deduplicated requests.
115#[derive(Debug, Clone, Serialize)]
116pub struct AnomalyReport {
117    pub agent: String,
118    pub since: Option<Date>,
119    /// Deduplicated requests considered (the denominator for every share).
120    pub requests_analyzed: u64,
121    pub total_tokens: u64,
122    /// Total priced cost across analyzed requests; a lower bound when
123    /// `has_unpriced` is true.
124    pub total_cost_usd: f64,
125    /// At least one analyzed request had no price (model unknown / not in the
126    /// snapshot) — cost figures are lower bounds (§8.5/§8.7).
127    pub has_unpriced: bool,
128    /// Top-fraction concentration, ascending by `request_fraction`.
129    pub concentration: Vec<ConcentrationBucket>,
130    /// Heaviest individual requests, by tokens desc (then cost, ts, ids).
131    pub heaviest: Vec<HeavyRequest>,
132    /// Detected retry storms, by total tokens desc (then start, session).
133    pub retry_storms: Vec<RetryStorm>,
134    /// Heuristic params, echoed so the output is self-describing.
135    pub storm_min_requests: u64,
136    pub storm_max_gap_seconds: i64,
137}
138
139/// A deduplicated request reduced to just what anomaly detection needs.
140struct Req {
141    session_id: String,
142    project: Option<String>,
143    request_id: Option<String>,
144    model: Option<String>,
145    ts: Option<Timestamp>,
146    tokens: u64,
147    cost: Option<f64>,
148    sidechain: bool,
149    has_thinking: bool,
150}
151
152/// `part / whole` as a fraction, with `whole == 0` yielding `0.0` (never NaN).
153fn share(part: u64, whole: u64) -> f64 {
154    if whole == 0 {
155        0.0
156    } else {
157        part as f64 / whole as f64
158    }
159}
160
161/// Detect fat-tail concentration and retry storms across the given sessions.
162///
163/// Sub-agent transcripts are deduplicated like any other file; each request
164/// keeps its own session id (storms are per-transcript timelines), while the
165/// fat-tail rollups span every request in the window. `filter.since` is applied
166/// inside [`dedup_session`], so the report covers the same window as `summary`.
167pub fn detect(
168    sessions: &[Session],
169    filter: &Filter,
170    agent: &str,
171    pricing: &PricingTable,
172) -> AnomalyReport {
173    // 1. Flatten every session to deduplicated requests (§8.1).
174    let mut reqs: Vec<Req> = Vec::new();
175    for session in sessions {
176        let (records, _stats) = dedup_session(session, filter.since);
177        for rec in records {
178            let tokens = rec.usage.known_total();
179            let cost = pricing.cost_usd(rec.model.as_deref(), &rec.usage);
180            reqs.push(Req {
181                session_id: rec.session_id,
182                project: rec.project,
183                request_id: rec.request_id,
184                model: rec.model,
185                ts: rec.ts,
186                tokens,
187                cost,
188                sidechain: rec.sidechain,
189                has_thinking: rec.has_thinking,
190            });
191        }
192    }
193
194    let n = reqs.len();
195    let total_tokens: u64 = reqs.iter().map(|r| r.tokens).sum();
196    let total_cost_usd: f64 = reqs.iter().filter_map(|r| r.cost).sum();
197    let has_unpriced = reqs.iter().any(|r| r.cost.is_none());
198
199    // Empty window: an honest empty report (still echo the heuristic params).
200    if n == 0 {
201        return AnomalyReport {
202            agent: agent.to_string(),
203            since: filter.since,
204            requests_analyzed: 0,
205            total_tokens: 0,
206            total_cost_usd: 0.0,
207            has_unpriced: false,
208            concentration: Vec::new(),
209            heaviest: Vec::new(),
210            retry_storms: Vec::new(),
211            storm_min_requests: STORM_MIN_REQUESTS,
212            storm_max_gap_seconds: STORM_MAX_GAP_SECONDS,
213        };
214    }
215
216    // 2. Rank requests by tokens desc for the fat-tail views. The order is a
217    //    strict total order so the output is deterministic across runs.
218    let mut order: Vec<usize> = (0..n).collect();
219    order.sort_by(|&a, &b| {
220        let (ra, rb) = (&reqs[a], &reqs[b]);
221        rb.tokens
222            .cmp(&ra.tokens)
223            .then_with(|| rb.cost.unwrap_or(0.0).total_cmp(&ra.cost.unwrap_or(0.0)))
224            .then_with(|| ra.session_id.cmp(&rb.session_id))
225            .then_with(|| ra.request_id.cmp(&rb.request_id))
226    });
227
228    // 3. Concentration: top ceil(frac * n) requests' share of tokens / cost.
229    let concentration = CONCENTRATION_FRACTIONS
230        .iter()
231        .map(|&frac| {
232            let k = ((frac * n as f64).ceil() as usize).clamp(1, n);
233            let slice = &order[..k];
234            let tok: u64 = slice.iter().map(|&i| reqs[i].tokens).sum();
235            let cost: f64 = slice.iter().filter_map(|&i| reqs[i].cost).sum();
236            ConcentrationBucket {
237                request_fraction: frac,
238                requests: k as u64,
239                token_share: share(tok, total_tokens),
240                cost_share: (total_cost_usd > 0.0).then_some(cost / total_cost_usd),
241            }
242        })
243        .collect();
244
245    // 4. Heaviest individual requests (the named fat-tail members).
246    let heaviest = order
247        .iter()
248        .take(HEAVIEST_N)
249        .map(|&i| {
250            let r = &reqs[i];
251            HeavyRequest {
252                session_id: r.session_id.clone(),
253                request_id: r.request_id.clone(),
254                model: r.model.clone(),
255                ts: r.ts,
256                total_tokens: r.tokens,
257                cost_usd: r.cost,
258                cost_share: match (r.cost, total_cost_usd > 0.0) {
259                    (Some(c), true) => Some(c / total_cost_usd),
260                    _ => None,
261                },
262                token_share: share(r.tokens, total_tokens),
263                sidechain: r.sidechain,
264                has_thinking: r.has_thinking,
265            }
266        })
267        .collect();
268
269    // 5. Retry storms: per-session temporal bursts over dated requests.
270    let retry_storms = detect_storms(&reqs);
271
272    AnomalyReport {
273        agent: agent.to_string(),
274        since: filter.since,
275        requests_analyzed: n as u64,
276        total_tokens,
277        total_cost_usd,
278        has_unpriced,
279        concentration,
280        heaviest,
281        retry_storms,
282        storm_min_requests: STORM_MIN_REQUESTS,
283        storm_max_gap_seconds: STORM_MAX_GAP_SECONDS,
284    }
285}
286
287/// Scan each session's dated requests in time order and emit a [`RetryStorm`]
288/// for every run of `>= STORM_MIN_REQUESTS` requests whose consecutive gaps are
289/// all `<= STORM_MAX_GAP_SECONDS`. Undated requests cannot be sequenced and are
290/// excluded from storms (they still count toward the fat-tail totals).
291fn detect_storms(reqs: &[Req]) -> Vec<RetryStorm> {
292    let mut by_session: BTreeMap<&str, Vec<(Timestamp, usize)>> = BTreeMap::new();
293    for (i, r) in reqs.iter().enumerate() {
294        if let Some(ts) = r.ts {
295            by_session
296                .entry(r.session_id.as_str())
297                .or_default()
298                .push((ts, i));
299        }
300    }
301
302    let mut storms: Vec<RetryStorm> = Vec::new();
303    for (_session, mut timeline) in by_session {
304        timeline.sort();
305        let mut run: Vec<(Timestamp, usize)> = Vec::new();
306        let mut last: Option<Timestamp> = None;
307        for &(ts, i) in &timeline {
308            let continues = matches!(
309                last,
310                Some(prev) if ts.as_second() - prev.as_second() <= STORM_MAX_GAP_SECONDS
311            );
312            if continues {
313                run.push((ts, i));
314            } else {
315                push_storm(&mut storms, reqs, &run);
316                run = vec![(ts, i)];
317            }
318            last = Some(ts);
319        }
320        push_storm(&mut storms, reqs, &run);
321    }
322
323    // Heaviest bursts first; stable, deterministic tiebreak.
324    storms.sort_by(|a, b| {
325        b.total_tokens
326            .cmp(&a.total_tokens)
327            .then_with(|| a.started_at.cmp(&b.started_at))
328            .then_with(|| a.session_id.cmp(&b.session_id))
329    });
330    storms
331}
332
333/// Emit a [`RetryStorm`] for `run` when it is long enough; otherwise a no-op.
334fn push_storm(out: &mut Vec<RetryStorm>, reqs: &[Req], run: &[(Timestamp, usize)]) {
335    if (run.len() as u64) < STORM_MIN_REQUESTS {
336        return;
337    }
338    let (started_at, first_idx) = run[0];
339    let ended_at = run[run.len() - 1].0;
340
341    let mut total_tokens = 0u64;
342    let mut cost_sum = 0.0f64;
343    let mut any_priced = false;
344    let mut thinking_requests = 0u64;
345    for &(_, i) in run {
346        total_tokens += reqs[i].tokens;
347        if let Some(c) = reqs[i].cost {
348            cost_sum += c;
349            any_priced = true;
350        }
351        if reqs[i].has_thinking {
352            thinking_requests += 1;
353        }
354    }
355
356    out.push(RetryStorm {
357        session_id: reqs[first_idx].session_id.clone(),
358        project: reqs[first_idx].project.clone(),
359        started_at,
360        ended_at,
361        requests: run.len() as u64,
362        total_tokens,
363        cost_usd: any_priced.then_some(cost_sum),
364        span_seconds: ended_at.as_second() - started_at.as_second(),
365        thinking_requests,
366    });
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::model::{Event, EventKind, Usage};
373
374    /// Assistant event at `ts` with one request id and the given token counts.
375    fn turn(rid: &str, ts: &str, input: u64, output: u64) -> Event {
376        Event {
377            kind: EventKind::Assistant,
378            ts: Some(ts.parse().unwrap()),
379            request_id: Some(rid.into()),
380            model: Some("claude-sonnet-4-5".into()),
381            usage: Some(Usage {
382                input: Some(input),
383                output: Some(output),
384                cache_creation: Some(0),
385                cache_read: Some(0),
386                ..Usage::default()
387            }),
388            tool_calls: Vec::new(),
389            sidechain: false,
390            content_summary: None,
391            content_chars: 0,
392            thinking_chars: 0,
393            has_thinking: false,
394            tool_use_id: None,
395            attachment_kind: None,
396            item_count: 0,
397        }
398    }
399
400    fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
401        Session {
402            id: id.into(),
403            agent: "claude-code".into(),
404            project: Some("proj".into()),
405            model: Some("claude-sonnet-4-5".into()),
406            parent_session: parent.map(str::to_string),
407            started_at: None,
408            ended_at: None,
409            events,
410            sub_agents: Vec::new(),
411            skipped_lines: 0,
412        }
413    }
414
415    fn bucket(r: &AnomalyReport, frac: f64) -> &ConcentrationBucket {
416        r.concentration
417            .iter()
418            .find(|b| (b.request_fraction - frac).abs() < 1e-9)
419            .expect("bucket present")
420    }
421
422    /// One giant request among nine small ones: the heaviest 10 % (1 request)
423    /// holds the lion's share of tokens — the fat-tail headline.
424    #[test]
425    fn concentration_reports_token_share_of_top_requests() {
426        let mut events = vec![turn("big", "2026-06-02T10:00:00Z", 100_000, 0)];
427        // Nine small requests, each 1,000 input, spread far apart in time so they
428        // form no storm.
429        for i in 0..9 {
430            events.push(turn(
431                &format!("small{i}"),
432                &format!("2026-06-02T1{i}:00:00Z"),
433                1_000,
434                0,
435            ));
436        }
437        let s = session("s", None, events);
438        let r = detect(
439            &[s],
440            &Filter::default(),
441            "claude-code",
442            &PricingTable::embedded(),
443        );
444
445        assert_eq!(r.requests_analyzed, 10);
446        assert_eq!(r.total_tokens, 109_000);
447
448        // top 10 % of 10 = 1 request = the 100k one.
449        let b10 = bucket(&r, 0.10);
450        assert_eq!(b10.requests, 1);
451        assert!((b10.token_share - 100_000.0 / 109_000.0).abs() < 1e-9);
452        // top 25 % of 10 = ceil(2.5) = 3 requests = 100k + 1k + 1k.
453        let b25 = bucket(&r, 0.25);
454        assert_eq!(b25.requests, 3);
455        assert!((b25.token_share - 102_000.0 / 109_000.0).abs() < 1e-9);
456
457        // Heaviest list ranks the giant first with the right share.
458        assert_eq!(r.heaviest[0].request_id.as_deref(), Some("big"));
459        assert_eq!(r.heaviest[0].total_tokens, 100_000);
460        assert!((r.heaviest[0].token_share - 100_000.0 / 109_000.0).abs() < 1e-9);
461        // sonnet-4-5 is priced, so cost + cost_share are present.
462        assert!(r.heaviest[0].cost_usd.is_some());
463        assert!(r.heaviest[0].cost_share.is_some());
464        assert!(!r.has_unpriced);
465    }
466
467    /// Five requests within 15 s gaps form one storm; a far-later sixth does not
468    /// extend it (gap too large) and is too short on its own.
469    #[test]
470    fn retry_storm_detects_a_temporal_burst() {
471        let s = session(
472            "s",
473            None,
474            vec![
475                turn("r0", "2026-06-02T10:00:00Z", 1_000, 10),
476                turn("r1", "2026-06-02T10:00:10Z", 1_000, 10),
477                turn("r2", "2026-06-02T10:00:20Z", 1_000, 10),
478                turn("r3", "2026-06-02T10:00:30Z", 1_000, 10),
479                turn("r4", "2026-06-02T10:00:40Z", 1_000, 10),
480                // One hour later — breaks the run.
481                turn("late", "2026-06-02T11:00:00Z", 1_000, 10),
482            ],
483        );
484        let r = detect(
485            &[s],
486            &Filter::default(),
487            "claude-code",
488            &PricingTable::embedded(),
489        );
490        assert_eq!(r.retry_storms.len(), 1, "one burst of five");
491        let storm = &r.retry_storms[0];
492        assert_eq!(storm.requests, 5);
493        assert_eq!(storm.total_tokens, 5 * 1_010);
494        assert_eq!(storm.span_seconds, 40);
495        assert_eq!(storm.session_id, "s");
496        assert!(storm.cost_usd.is_some());
497    }
498
499    /// Four rapid requests are below the storm floor; one gap over the limit
500    /// splits an otherwise-rapid sequence so neither half qualifies.
501    #[test]
502    fn no_storm_below_min_or_across_a_large_gap() {
503        // Only four rapid requests.
504        let four = session(
505            "four",
506            None,
507            vec![
508                turn("a", "2026-06-02T10:00:00Z", 1, 1),
509                turn("b", "2026-06-02T10:00:05Z", 1, 1),
510                turn("c", "2026-06-02T10:00:10Z", 1, 1),
511                turn("d", "2026-06-02T10:00:15Z", 1, 1),
512            ],
513        );
514        // Six requests but a 60 s gap in the middle splits them 3 + 3.
515        let split = session(
516            "split",
517            None,
518            vec![
519                turn("a", "2026-06-02T10:00:00Z", 1, 1),
520                turn("b", "2026-06-02T10:00:05Z", 1, 1),
521                turn("c", "2026-06-02T10:00:10Z", 1, 1),
522                turn("d", "2026-06-02T10:01:10Z", 1, 1),
523                turn("e", "2026-06-02T10:01:15Z", 1, 1),
524                turn("f", "2026-06-02T10:01:20Z", 1, 1),
525            ],
526        );
527        let r = detect(
528            &[four, split],
529            &Filter::default(),
530            "claude-code",
531            &PricingTable::embedded(),
532        );
533        assert!(r.retry_storms.is_empty());
534    }
535
536    /// Dedup runs first: three lines of one request collapse to a single
537    /// analyzed request, so neither tokens nor the storm count are inflated.
538    #[test]
539    fn dedup_is_applied_before_counting() {
540        let s = session(
541            "s",
542            None,
543            vec![
544                turn("req", "2026-06-02T10:00:00Z", 1_000, 200),
545                turn("req", "2026-06-02T10:00:00Z", 1_000, 200),
546                turn("req", "2026-06-02T10:00:00Z", 1_000, 200),
547            ],
548        );
549        let r = detect(
550            &[s],
551            &Filter::default(),
552            "claude-code",
553            &PricingTable::embedded(),
554        );
555        assert_eq!(r.requests_analyzed, 1, "3 lines -> 1 request");
556        assert_eq!(r.total_tokens, 1_200);
557        assert!(r.retry_storms.is_empty(), "one request is not a storm");
558    }
559
560    /// Unpriced models surface as such: cost is a lower bound, per-request cost
561    /// is `None`, but tokens (and the fat-tail ranking) are unaffected (§8.7).
562    #[test]
563    fn unpriced_requests_are_flagged_not_guessed() {
564        let mut unp = turn("u", "2026-06-02T10:00:00Z", 5_000, 0);
565        unp.model = Some("claude-opus-5-0".into()); // post-snapshot, unpriced
566        let priced = turn("p", "2026-06-02T11:00:00Z", 1_000, 0);
567        let s = session("s", None, vec![unp, priced]);
568        let r = detect(
569            &[s],
570            &Filter::default(),
571            "claude-code",
572            &PricingTable::embedded(),
573        );
574
575        assert!(r.has_unpriced);
576        // Heaviest is the 5k unpriced request — ranking is by tokens, not cost.
577        assert_eq!(r.heaviest[0].request_id.as_deref(), Some("u"));
578        assert_eq!(r.heaviest[0].cost_usd, None);
579        assert_eq!(r.heaviest[0].cost_share, None, "no cost -> no share");
580        // total cost reflects only the priced request (a lower bound).
581        assert!(r.total_cost_usd > 0.0);
582    }
583
584    /// `since` flows through to dedup: out-of-window requests drop entirely.
585    #[test]
586    fn since_filter_restricts_the_window() {
587        let s = session(
588            "s",
589            None,
590            vec![
591                turn("old", "2026-06-01T10:00:00Z", 9_999, 0),
592                turn("new", "2026-06-02T10:00:00Z", 100, 0),
593            ],
594        );
595        let filter = Filter {
596            since: Some("2026-06-02".parse().unwrap()),
597        };
598        let r = detect(&[s], &filter, "claude-code", &PricingTable::embedded());
599        assert_eq!(r.requests_analyzed, 1);
600        assert_eq!(r.total_tokens, 100);
601        assert_eq!(r.heaviest[0].request_id.as_deref(), Some("new"));
602        assert_eq!(r.since, filter.since);
603    }
604
605    /// No requests in the window -> an empty but well-formed report.
606    #[test]
607    fn empty_window_is_an_empty_report() {
608        let r = detect(
609            &[],
610            &Filter::default(),
611            "claude-code",
612            &PricingTable::embedded(),
613        );
614        assert_eq!(r.requests_analyzed, 0);
615        assert!(r.concentration.is_empty());
616        assert!(r.heaviest.is_empty());
617        assert!(r.retry_storms.is_empty());
618        assert_eq!(r.storm_min_requests, STORM_MIN_REQUESTS);
619    }
620
621    /// A storm inside a sub-agent transcript is detected on its own timeline and
622    /// the burst is marked with that transcript's session id.
623    #[test]
624    fn storm_within_a_subagent_transcript() {
625        let child = session(
626            "agent-x",
627            Some("parent"),
628            vec![
629                turn("c0", "2026-06-02T10:00:00Z", 100, 1),
630                turn("c1", "2026-06-02T10:00:05Z", 100, 1),
631                turn("c2", "2026-06-02T10:00:10Z", 100, 1),
632                turn("c3", "2026-06-02T10:00:14Z", 100, 1),
633                turn("c4", "2026-06-02T10:00:18Z", 100, 1),
634            ],
635        );
636        let r = detect(
637            &[child],
638            &Filter::default(),
639            "claude-code",
640            &PricingTable::embedded(),
641        );
642        assert_eq!(r.retry_storms.len(), 1);
643        assert_eq!(r.retry_storms[0].session_id, "agent-x");
644        assert!(r.heaviest.iter().all(|h| h.sidechain));
645    }
646}