Skip to main content

skiagram_core/analysis/
classify.rs

1//! Heuristic task-type classification (debugging vs feature work vs refactor…).
2//!
3//! Breaks total spend down by *what the user was actually doing*, so a glance at
4//! the report answers "where did my tokens go?" in human terms rather than per
5//! model/day. Each session is bucketed into one [`TaskType`] from two weak
6//! signals only — the prompt text the user typed ([`Event::content_summary`] on
7//! [`EventKind::User`] events) and the histogram of tool *names* the agent ran.
8//! No file contents, paths, or tool inputs are available (see [`ToolCall`]), so
9//! the classifier is deliberately coarse and reports a `confidence` and the
10//! `signals` it fired on rather than pretending to be certain.
11//!
12//! Spend is rolled up the same way as [`super::aggregate`]: deduplicated per
13//! request (CLAUDE.md §8.1) and folded from sub-agent transcripts into the
14//! parent session's row (§8.3), so the per-type totals here reconcile with the
15//! `summary` grand totals. Classification of a folded group is taken from its
16//! representative (non-sidechain) session; spend always sums across the whole
17//! group.
18
19use std::collections::BTreeMap;
20
21use jiff::civil::Date;
22use serde::Serialize;
23
24use crate::analysis::aggregate::{Filter, Rollup};
25use crate::analysis::dedup::{dedup_session, UsageRecord};
26use crate::model::{Event, EventKind, Session};
27use crate::pricing::PricingTable;
28
29/// What the user was doing in a session, inferred from prompt text + tool mix.
30///
31/// `Unknown` is a real bucket: it means *no* signal fired, not "a bit of
32/// everything". The ordering of the non-`Unknown` variants is the deterministic
33/// tie-breaker rank used when two types score or cost the same.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
35pub enum TaskType {
36    Debugging,
37    FeatureWork,
38    Refactoring,
39    Testing,
40    Documentation,
41    Exploration,
42    ConfigOps,
43    Unknown,
44}
45
46impl TaskType {
47    /// All classifiable (non-`Unknown`) types, in tie-break rank order.
48    const SCORABLE: [TaskType; 7] = [
49        TaskType::Debugging,
50        TaskType::FeatureWork,
51        TaskType::Refactoring,
52        TaskType::Testing,
53        TaskType::Documentation,
54        TaskType::Exploration,
55        TaskType::ConfigOps,
56    ];
57
58    /// Stable rank for deterministic sorting (lower sorts first). `Unknown` last.
59    fn rank(self) -> u8 {
60        match self {
61            TaskType::Debugging => 0,
62            TaskType::FeatureWork => 1,
63            TaskType::Refactoring => 2,
64            TaskType::Testing => 3,
65            TaskType::Documentation => 4,
66            TaskType::Exploration => 5,
67            TaskType::ConfigOps => 6,
68            TaskType::Unknown => 7,
69        }
70    }
71
72    /// Lowercased prompt substrings that vote for this type. Each *distinct*
73    /// phrase matched in a session's prompts contributes one keyword hit.
74    fn keywords(self) -> &'static [&'static str] {
75        match self {
76            TaskType::Debugging => &[
77                "fix",
78                "bug",
79                "error",
80                "broken",
81                "crash",
82                "fails",
83                "debug",
84                "stack trace",
85                "exception",
86                "regression",
87            ],
88            TaskType::FeatureWork => &[
89                "add",
90                "implement",
91                "create",
92                "build",
93                "feature",
94                "support",
95                "new",
96                "endpoint",
97            ],
98            TaskType::Refactoring => &[
99                "refactor",
100                "rename",
101                "clean up",
102                "extract",
103                "simplify",
104                "reorganize",
105                "move",
106                "dedupe",
107            ],
108            TaskType::Testing => &[
109                "test",
110                "tests",
111                "coverage",
112                "assert",
113                "unit test",
114                "integration test",
115            ],
116            TaskType::Documentation => &[
117                "document",
118                "docs",
119                "readme",
120                "comment",
121                "changelog",
122                "explain in writing",
123            ],
124            TaskType::Exploration => &[
125                "review",
126                "explain",
127                "understand",
128                "how does",
129                "what does",
130                "look at",
131                "analyze",
132                "investigate",
133                "find",
134                "trace",
135            ],
136            TaskType::ConfigOps => &[
137                "ci",
138                "cd",
139                "deploy",
140                "pipeline",
141                "config",
142                "dependency",
143                "dependencies",
144                "upgrade",
145                "bump",
146                "cargo",
147                "npm",
148                "docker",
149                "workflow",
150                "version",
151            ],
152            // Never scored against; lives here so the match stays exhaustive.
153            TaskType::Unknown => &[],
154        }
155    }
156}
157
158/// Counts of each tool *name* across a session's events (`MultiEdit` folded into
159/// `Edit`). The only structural signal available — there is no tool input text.
160#[derive(Debug, Default, Clone, Copy)]
161struct ToolMix {
162    edit: u64,
163    write: u64,
164    bash: u64,
165    read: u64,
166    grep: u64,
167    glob: u64,
168}
169
170impl ToolMix {
171    /// Tally tool-call names across a session's in-window events.
172    fn from_session(session: &Session, since: Option<Date>) -> ToolMix {
173        let mut mix = ToolMix::default();
174        for event in &session.events {
175            if !in_window(since, event) {
176                continue;
177            }
178            for call in &event.tool_calls {
179                match call.name.as_str() {
180                    "Edit" | "MultiEdit" => mix.edit += 1,
181                    "Write" => mix.write += 1,
182                    "Bash" => mix.bash += 1,
183                    "Read" => mix.read += 1,
184                    "Grep" => mix.grep += 1,
185                    "Glob" => mix.glob += 1,
186                    _ => {}
187                }
188            }
189        }
190        mix
191    }
192
193    /// Reads-and-searches without writes — the Exploration shape.
194    fn is_read_dominant(&self) -> bool {
195        let browse = self.read + self.grep + self.glob;
196        browse > 0 && self.edit == 0 && self.write == 0
197    }
198}
199
200/// Spend for one [`TaskType`], summed over every session classified into it.
201#[derive(Debug, Clone, Serialize)]
202pub struct TaskTypeRollup {
203    pub task_type: TaskType,
204    /// Number of (folded) sessions classified into this type.
205    pub sessions: u64,
206    pub rollup: Rollup,
207    /// This bucket's share of `total_tokens` in `[0,1]` (0.0 when total is 0).
208    pub token_share: f64,
209    /// This bucket's share of `total_cost_usd`, or `None` when nothing was
210    /// priceable (so a 0/0 share is never shown as a real 0.0).
211    pub cost_share: Option<f64>,
212}
213
214/// One classified session row (sub-agent transcript spend folded into it).
215#[derive(Debug, Clone, Serialize)]
216pub struct SessionClass {
217    pub id: String,
218    pub project: Option<String>,
219    pub model: Option<String>,
220    pub task_type: TaskType,
221    /// Winning score / sum of all type scores, clamped to `[0,1]`. `0.0` for
222    /// `Unknown` (no signal); `1.0` when exactly one type scored.
223    pub confidence: f64,
224    /// Small, human-readable, deterministically ordered notes on why this call
225    /// was made (e.g. `prompt keyword: "fix"`, `tool mix: 12 Edit, 8 Bash`).
226    pub signals: Vec<String>,
227    pub rollup: Rollup,
228}
229
230/// Everything the renderers need for the task-type breakdown.
231#[derive(Debug, Serialize)]
232pub struct ClassifyReport {
233    pub agent: String,
234    /// Window applied to the spend (UTC date, inclusive), echoed for the header.
235    pub since: Option<Date>,
236    /// Number of (folded) sessions that had in-window spend and were classified.
237    pub sessions_classified: u64,
238    /// Deduplicated known tokens across all in-window sessions (incl. sub-agents).
239    pub total_tokens: u64,
240    /// USD cost of the priceable share of that spend.
241    pub total_cost_usd: f64,
242    /// True if any in-window request could not be priced (§8.5 / §8.7): the
243    /// cost figures are then a lower bound.
244    pub has_unpriced: bool,
245    /// Per-type rollups, sorted by cost desc, tokens desc, then `TaskType` rank.
246    pub by_task_type: Vec<TaskTypeRollup>,
247    /// Per-session rows, sorted by cost desc, tokens desc, then id asc.
248    pub sessions: Vec<SessionClass>,
249}
250
251/// Accumulates a session group (a parent plus its folded sub-agent transcripts).
252struct GroupAcc {
253    project: Option<String>,
254    model: Option<String>,
255    rollup: Rollup,
256    /// The session used to classify the group: the non-sidechain one if seen,
257    /// else the first session for the row id (an orphan sub-agent).
258    representative: Option<Session>,
259    /// Set once the representative is a real (non-sidechain) parent session, so
260    /// a later orphan ordering never overrides it.
261    has_parent: bool,
262}
263
264impl GroupAcc {
265    fn new() -> GroupAcc {
266        GroupAcc {
267            project: None,
268            model: None,
269            rollup: Rollup::default(),
270            representative: None,
271            has_parent: false,
272        }
273    }
274}
275
276/// Classify each session by task type and roll deduplicated spend up per type.
277///
278/// Spend is folded from sub-agent transcripts into the parent session's row
279/// (§8.3) exactly as [`super::aggregate::aggregate`] does, so the per-type
280/// totals reconcile with the summary's grand totals. Groups with no in-window
281/// spend are dropped; empty input yields a well-formed empty report.
282pub fn classify(
283    sessions: &[Session],
284    filter: &Filter,
285    agent: &str,
286    pricing: &PricingTable,
287) -> ClassifyReport {
288    let mut groups: BTreeMap<String, GroupAcc> = BTreeMap::new();
289
290    for session in sessions {
291        let row_id = session
292            .parent_session
293            .clone()
294            .unwrap_or_else(|| session.id.clone());
295        let group = groups.entry(row_id).or_insert_with(GroupAcc::new);
296
297        // Representative = the non-sidechain parent if present, else the first
298        // session seen for this row id (an orphan sub-agent transcript).
299        let is_parent = session.parent_session.is_none();
300        if is_parent {
301            group.project.clone_from(&session.project);
302            group.model.clone_from(&session.model);
303        } else if group.project.is_none() {
304            group.project.clone_from(&session.project);
305        }
306        if (is_parent && !group.has_parent) || group.representative.is_none() {
307            group.representative = Some(session.clone());
308            group.has_parent |= is_parent;
309        }
310
311        let (records, _) = dedup_session(session, filter.since);
312        for rec in &records {
313            add_record(&mut group.rollup, rec, pricing);
314        }
315    }
316
317    // Drop groups with no in-window spend; they did not happen in this window.
318    let groups: Vec<(String, GroupAcc)> = groups
319        .into_iter()
320        .filter(|(_, acc)| acc.rollup.requests > 0)
321        .collect();
322
323    let mut total_tokens: u64 = 0;
324    let mut total_cost_usd: f64 = 0.0;
325    let mut has_unpriced = false;
326    for (_, acc) in &groups {
327        total_tokens += acc.rollup.total_tokens();
328        total_cost_usd += acc.rollup.cost_usd;
329        has_unpriced |= acc.rollup.unpriced_requests > 0;
330    }
331
332    let mut sessions_out: Vec<SessionClass> = Vec::with_capacity(groups.len());
333    let mut by_type: BTreeMap<u8, (TaskType, u64, Rollup)> = BTreeMap::new();
334
335    for (id, acc) in groups {
336        let (task_type, confidence, signals) = match &acc.representative {
337            Some(rep) => classify_session(rep, filter.since),
338            // A group can only exist with >0 requests, which require events, so a
339            // representative is always present; classify Unknown if it somehow is not.
340            None => (TaskType::Unknown, 0.0, Vec::new()),
341        };
342
343        let entry = by_type
344            .entry(task_type.rank())
345            .or_insert_with(|| (task_type, 0, Rollup::default()));
346        entry.1 += 1;
347        merge_rollup(&mut entry.2, &acc.rollup);
348
349        sessions_out.push(SessionClass {
350            id,
351            project: acc.project,
352            model: acc.model,
353            task_type,
354            confidence,
355            signals,
356            rollup: acc.rollup,
357        });
358    }
359
360    let mut by_task_type: Vec<TaskTypeRollup> = by_type
361        .into_values()
362        .map(|(task_type, sessions, rollup)| {
363            let token_share = if total_tokens == 0 {
364                0.0
365            } else {
366                rollup.total_tokens() as f64 / total_tokens as f64
367            };
368            let cost_share = (total_cost_usd > 0.0).then(|| rollup.cost_usd / total_cost_usd);
369            TaskTypeRollup {
370                task_type,
371                sessions,
372                rollup,
373                token_share,
374                cost_share,
375            }
376        })
377        .collect();
378
379    by_task_type.sort_by(|a, b| {
380        b.rollup
381            .cost_usd
382            .total_cmp(&a.rollup.cost_usd)
383            .then_with(|| b.rollup.total_tokens().cmp(&a.rollup.total_tokens()))
384            .then_with(|| a.task_type.rank().cmp(&b.task_type.rank()))
385    });
386
387    sessions_out.sort_by(|a, b| {
388        b.rollup
389            .cost_usd
390            .total_cmp(&a.rollup.cost_usd)
391            .then_with(|| b.rollup.total_tokens().cmp(&a.rollup.total_tokens()))
392            .then_with(|| a.id.cmp(&b.id))
393    });
394
395    ClassifyReport {
396        agent: agent.to_string(),
397        since: filter.since,
398        sessions_classified: sessions_out.len() as u64,
399        total_tokens,
400        total_cost_usd,
401        has_unpriced,
402        by_task_type,
403        sessions: sessions_out,
404    }
405}
406
407/// Add one deduplicated request into a [`Rollup`].
408///
409/// `Rollup`'s own per-record `add` is private to [`super::aggregate`], so this
410/// mirrors it exactly (same fields, same `pricing::cost_usd` call) over the
411/// already-deduplicated record — keeping per-type totals reconciled with the
412/// summary's grand totals (CLAUDE.md §8.1, §8.4, §8.5).
413fn add_record(into: &mut Rollup, rec: &UsageRecord, pricing: &PricingTable) {
414    let u = &rec.usage;
415    into.requests += 1;
416    into.input += u.input.unwrap_or(0);
417    into.output += u.output.unwrap_or(0);
418    into.cache_creation += u.cache_creation.unwrap_or(0);
419    into.cache_read += u.cache_read.unwrap_or(0);
420    if u.input.is_none()
421        || u.output.is_none()
422        || u.cache_creation.is_none()
423        || u.cache_read.is_none()
424    {
425        into.incomplete_requests += 1;
426    }
427    match pricing.cost_usd(rec.model.as_deref(), u) {
428        Some(cost) => into.cost_usd += cost,
429        None => into.unpriced_requests += 1,
430    }
431}
432
433/// Fold one [`Rollup`]'s totals into another (for per-type aggregation of
434/// already-built per-group rollups).
435fn merge_rollup(into: &mut Rollup, from: &Rollup) {
436    into.requests += from.requests;
437    into.input += from.input;
438    into.output += from.output;
439    into.cache_creation += from.cache_creation;
440    into.cache_read += from.cache_read;
441    into.incomplete_requests += from.incomplete_requests;
442    into.cost_usd += from.cost_usd;
443    into.unpriced_requests += from.unpriced_requests;
444}
445
446/// Classify a single session into a [`TaskType`] with a confidence and the
447/// signals that fired.
448///
449/// Scoring: each distinct prompt keyword matched adds its type's weight; tool-mix
450/// shapes add bonuses (debugging = many Bash+Edit; feature = Write+Edit; refactor
451/// = many Edit with ~no Write; exploration = Read/Grep/Glob with no Edit/Write).
452/// The argmax type wins; `confidence = winning / Σ scores` clamped to `[0,1]`.
453/// No signal at all → `Unknown`, confidence `0.0`, empty signals.
454fn classify_session(session: &Session, since: Option<Date>) -> (TaskType, f64, Vec<String>) {
455    let prompts = lowercased_prompts(session, since);
456    let mix = ToolMix::from_session(session, since);
457
458    // Score every scorable type, remembering which keywords fired for the winner.
459    let mut scores: Vec<(TaskType, f64, Vec<&'static str>)> = Vec::with_capacity(7);
460    for ty in TaskType::SCORABLE {
461        let mut hits: Vec<&'static str> = Vec::new();
462        for kw in ty.keywords() {
463            if prompts.iter().any(|p| p.contains(kw)) {
464                hits.push(kw);
465            }
466        }
467        // Each distinct keyword is worth 2; tool-mix bonuses are scaled below so a
468        // strong structural signal counts roughly like a keyword or two.
469        let keyword_score = hits.len() as f64 * 2.0;
470        let tool_score = tool_mix_bonus(ty, &mix);
471        let total = keyword_score + tool_score;
472        scores.push((ty, total, hits));
473    }
474
475    let sum: f64 = scores.iter().map(|(_, s, _)| *s).sum();
476
477    // Argmax with a deterministic tie-break on rank (SCORABLE is already in rank
478    // order, so the first max wins).
479    let mut best_idx: Option<usize> = None;
480    for (i, (_, score, _)) in scores.iter().enumerate() {
481        if *score <= 0.0 {
482            continue;
483        }
484        match best_idx {
485            Some(b) if scores[b].1 >= *score => {}
486            _ => best_idx = Some(i),
487        }
488    }
489
490    let Some(best_idx) = best_idx else {
491        // No keyword and no tool-mix signal fired.
492        return (TaskType::Unknown, 0.0, Vec::new());
493    };
494
495    let (task_type, best_score, hits) = &scores[best_idx];
496    let confidence = if sum <= 0.0 {
497        0.0
498    } else {
499        (best_score / sum).clamp(0.0, 1.0)
500    };
501
502    let signals = build_signals(hits, *task_type, &mix);
503    (*task_type, confidence, signals)
504}
505
506/// Structural (tool-mix) bonus for a type. Tuned so a clear shape is worth about
507/// one or two keyword hits, never enough to overpower an explicit prompt.
508fn tool_mix_bonus(ty: TaskType, mix: &ToolMix) -> f64 {
509    match ty {
510        // Iterative fix loop: editing and running things repeatedly.
511        TaskType::Debugging => {
512            if mix.bash >= 3 && mix.edit >= 1 {
513                3.0
514            } else if mix.bash >= 1 && mix.edit >= 1 {
515                1.0
516            } else {
517                0.0
518            }
519        }
520        // New code lands via Write; usually edited afterward too.
521        TaskType::FeatureWork => {
522            if mix.write >= 1 && mix.edit >= 1 {
523                3.0
524            } else if mix.write >= 1 {
525                1.5
526            } else {
527                0.0
528            }
529        }
530        // Lots of edits to existing files, (almost) no new files.
531        TaskType::Refactoring => {
532            if mix.edit >= 3 && mix.write == 0 {
533                3.0
534            } else {
535                0.0
536            }
537        }
538        // Reading and searching, not changing anything.
539        TaskType::Exploration => {
540            if mix.is_read_dominant() {
541                3.0
542            } else {
543                0.0
544            }
545        }
546        // No reliable tool-only shape for these; prompt keywords carry them.
547        TaskType::Testing | TaskType::Documentation | TaskType::ConfigOps => 0.0,
548        TaskType::Unknown => 0.0,
549    }
550}
551
552/// Build the deterministic `signals` list: prompt-keyword notes first (in the
553/// type's keyword order), then a single tool-mix note when a shape contributed.
554fn build_signals(hits: &[&'static str], ty: TaskType, mix: &ToolMix) -> Vec<String> {
555    let mut signals: Vec<String> = hits
556        .iter()
557        .map(|kw| format!("prompt keyword: \"{kw}\""))
558        .collect();
559    if tool_mix_bonus(ty, mix) > 0.0 {
560        if let Some(note) = tool_mix_note(mix) {
561            signals.push(note);
562        }
563    }
564    signals
565}
566
567/// Render the counted tools as a stable `tool mix: 12 Edit, 8 Bash` note, in a
568/// fixed column order. `None` when no counted tool ran.
569fn tool_mix_note(mix: &ToolMix) -> Option<String> {
570    let parts: [(u64, &str); 6] = [
571        (mix.edit, "Edit"),
572        (mix.write, "Write"),
573        (mix.bash, "Bash"),
574        (mix.read, "Read"),
575        (mix.grep, "Grep"),
576        (mix.glob, "Glob"),
577    ];
578    let listed: Vec<String> = parts
579        .iter()
580        .filter(|(n, _)| *n > 0)
581        .map(|(n, name)| format!("{n} {name}"))
582        .collect();
583    if listed.is_empty() {
584        None
585    } else {
586        Some(format!("tool mix: {}", listed.join(", ")))
587    }
588}
589
590/// Lowercased visible prompt text from every in-window `User` event's
591/// `content_summary`.
592fn lowercased_prompts(session: &Session, since: Option<Date>) -> Vec<String> {
593    session
594        .events
595        .iter()
596        .filter(|e| e.kind == EventKind::User && in_window(since, e))
597        .filter_map(|e| e.content_summary.as_deref())
598        .map(|s| s.to_ascii_lowercase())
599        .collect()
600}
601
602/// Window predicate matching the spend passes ([`super::dedup`] /
603/// [`super::aggregate`]): with `since` set, an event counts only if dated on/after
604/// it; undated events drop. Applied to the classification signals too, so a
605/// windowed run classifies a session from the same events whose spend it reports —
606/// not from prompts/tools that happened outside the window.
607fn in_window(since: Option<Date>, event: &Event) -> bool {
608    match (since, event.ts) {
609        (None, _) => true,
610        (Some(since), Some(ts)) => super::utc_date(ts) >= since,
611        (Some(_), None) => false,
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::model::{Event, ToolCall, Usage};
619
620    fn priced_usage(input: u64) -> Usage {
621        Usage {
622            input: Some(input),
623            output: Some(10),
624            cache_creation: Some(0),
625            cache_read: Some(0),
626            ..Usage::default()
627        }
628    }
629
630    /// An assistant turn carrying usage (priced model) and optional tool calls.
631    fn turn(request_id: &str, ts: &str, input: u64, tools: &[&str]) -> Event {
632        Event {
633            kind: EventKind::Assistant,
634            ts: Some(ts.parse().unwrap()),
635            request_id: Some(request_id.into()),
636            model: Some("claude-sonnet-4-5".into()),
637            usage: Some(priced_usage(input)),
638            tool_calls: tools.iter().map(|name| tool_call(name)).collect(),
639            sidechain: false,
640            content_summary: None,
641            content_chars: 0,
642            thinking_chars: 0,
643            has_thinking: false,
644            tool_use_id: None,
645            attachment_kind: None,
646            item_count: 0,
647        }
648    }
649
650    /// An assistant turn whose model is not in the pricing snapshot.
651    fn unpriced_turn(request_id: &str, ts: &str, input: u64) -> Event {
652        let mut e = turn(request_id, ts, input, &[]);
653        e.model = Some("claude-opus-5-0".into()); // post-snapshot, unpriceable
654        e
655    }
656
657    /// A `User` event carrying a prompt snippet.
658    fn user(ts: &str, prompt: &str) -> Event {
659        Event {
660            kind: EventKind::User,
661            ts: Some(ts.parse().unwrap()),
662            request_id: None,
663            model: None,
664            usage: None,
665            tool_calls: Vec::new(),
666            sidechain: false,
667            content_summary: Some(prompt.into()),
668            content_chars: prompt.len() as u64,
669            thinking_chars: 0,
670            has_thinking: false,
671            tool_use_id: None,
672            attachment_kind: None,
673            item_count: 0,
674        }
675    }
676
677    fn tool_call(name: &str) -> ToolCall {
678        ToolCall {
679            id: format!("tc_{name}"),
680            name: name.into(),
681            input_bytes: 0,
682            server: ToolCall::server_from_name(name),
683        }
684    }
685
686    fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
687        Session {
688            id: id.into(),
689            agent: "claude-code".into(),
690            project: Some("proj".into()),
691            model: Some("claude-sonnet-4-5".into()),
692            parent_session: parent.map(str::to_string),
693            started_at: None,
694            ended_at: None,
695            events,
696            sub_agents: Vec::new(),
697            skipped_lines: 0,
698        }
699    }
700
701    /// Make N Edit/Bash/etc. tool calls inside a single assistant turn.
702    fn tools_turn(request_id: &str, tools: &[&str]) -> Event {
703        turn(request_id, "2026-06-01T10:00:00Z", 100, tools)
704    }
705
706    fn no_filter() -> Filter {
707        Filter::default()
708    }
709
710    fn class_of<'a>(report: &'a ClassifyReport, id: &str) -> &'a SessionClass {
711        report
712            .sessions
713            .iter()
714            .find(|s| s.id == id)
715            .expect("session present in report")
716    }
717
718    // ---- per-type firing -------------------------------------------------
719
720    #[test]
721    fn debugging_fires_on_prompt_and_fix_loop() {
722        let s = session(
723            "dbg",
724            None,
725            vec![
726                user("2026-06-01T10:00:00Z", "Please fix the crash in the parser"),
727                tools_turn("r1", &["Edit", "Bash", "Bash", "Bash", "Edit"]),
728            ],
729        );
730        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
731        let c = class_of(&report, "dbg");
732        assert_eq!(c.task_type, TaskType::Debugging);
733        assert!(c.signals.iter().any(|s| s.contains("\"fix\"")));
734        assert!(c.signals.iter().any(|s| s.contains("tool mix")));
735    }
736
737    #[test]
738    fn feature_work_fires_on_implement_with_write() {
739        let s = session(
740            "feat",
741            None,
742            vec![
743                user("2026-06-01T10:00:00Z", "Implement a new export endpoint"),
744                tools_turn("r1", &["Write", "Edit"]),
745            ],
746        );
747        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
748        assert_eq!(class_of(&report, "feat").task_type, TaskType::FeatureWork);
749    }
750
751    #[test]
752    fn refactoring_fires_on_many_edits_no_write() {
753        let s = session(
754            "ref",
755            None,
756            vec![
757                user("2026-06-01T10:00:00Z", "Refactor and simplify this module"),
758                tools_turn("r1", &["Edit", "MultiEdit", "Edit", "Edit"]),
759            ],
760        );
761        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
762        let c = class_of(&report, "ref");
763        assert_eq!(c.task_type, TaskType::Refactoring);
764        // MultiEdit folds into the Edit count: 3 Edit + 1 MultiEdit = 4 Edit.
765        assert!(c.signals.iter().any(|s| s.contains("4 Edit")));
766    }
767
768    #[test]
769    fn testing_fires_on_prompt() {
770        let s = session(
771            "tst",
772            None,
773            vec![user(
774                "2026-06-01T10:00:00Z",
775                "Add unit tests to raise coverage",
776            )],
777        );
778        // Provide spend so the group survives.
779        let s = with_spend(s);
780        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
781        assert_eq!(class_of(&report, "tst").task_type, TaskType::Testing);
782    }
783
784    #[test]
785    fn documentation_fires_on_prompt() {
786        let s = with_spend(session(
787            "doc",
788            None,
789            vec![user(
790                "2026-06-01T10:00:00Z",
791                "Update the README and changelog",
792            )],
793        ));
794        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
795        assert_eq!(class_of(&report, "doc").task_type, TaskType::Documentation);
796    }
797
798    #[test]
799    fn exploration_fires_on_read_dominant_mix() {
800        let s = session(
801            "exp",
802            None,
803            vec![
804                user(
805                    "2026-06-01T10:00:00Z",
806                    "Help me understand how the dedup works",
807                ),
808                tools_turn("r1", &["Read", "Grep", "Glob", "Read"]),
809            ],
810        );
811        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
812        let c = class_of(&report, "exp");
813        assert_eq!(c.task_type, TaskType::Exploration);
814        assert!(c.signals.iter().any(|s| s.contains("tool mix")));
815    }
816
817    #[test]
818    fn config_ops_fires_on_prompt() {
819        let s = with_spend(session(
820            "cfg",
821            None,
822            vec![user(
823                "2026-06-01T10:00:00Z",
824                "Bump the cargo dependencies in the CI workflow",
825            )],
826        ));
827        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
828        assert_eq!(class_of(&report, "cfg").task_type, TaskType::ConfigOps);
829    }
830
831    /// Attach a priced assistant turn so a prompt-only session has in-window spend.
832    fn with_spend(mut s: Session) -> Session {
833        s.events
834            .push(turn("spend", "2026-06-01T10:00:00Z", 100, &[]));
835        s
836    }
837
838    // ---- Unknown + confidence -------------------------------------------
839
840    #[test]
841    fn unknown_when_no_signal_fires() {
842        let s = session(
843            "blank",
844            None,
845            vec![
846                user("2026-06-01T10:00:00Z", "hmm okay then proceed please"),
847                turn("r1", "2026-06-01T10:00:00Z", 100, &[]),
848            ],
849        );
850        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
851        let c = class_of(&report, "blank");
852        assert_eq!(c.task_type, TaskType::Unknown);
853        assert_eq!(c.confidence, 0.0);
854        assert!(c.signals.is_empty());
855    }
856
857    #[test]
858    fn confidence_is_one_when_only_one_type_scores() {
859        let s = with_spend(session(
860            "solo",
861            None,
862            vec![user("2026-06-01T10:00:00Z", "Update the readme docs")],
863        ));
864        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
865        let c = class_of(&report, "solo");
866        assert_eq!(c.task_type, TaskType::Documentation);
867        assert!((c.confidence - 1.0).abs() < 1e-9, "got {}", c.confidence);
868    }
869
870    #[test]
871    fn confidence_in_range_and_below_one_when_types_compete() {
872        // "fix" (Debugging) and "add tests" (Testing) both fire.
873        let s = with_spend(session(
874            "mix",
875            None,
876            vec![user(
877                "2026-06-01T10:00:00Z",
878                "fix the failing tests and add coverage",
879            )],
880        ));
881        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
882        let c = class_of(&report, "mix");
883        assert!(
884            c.confidence > 0.0 && c.confidence < 1.0,
885            "got {}",
886            c.confidence
887        );
888        assert!((0.0..=1.0).contains(&c.confidence));
889    }
890
891    // ---- sub-agent folding ----------------------------------------------
892
893    /// §8.3: a sub-agent transcript folds into the parent's bucket and is NOT a
894    /// separate session row; the parent's classification still drives the type.
895    #[test]
896    fn sub_agent_spend_folds_into_parent() {
897        let parent = session(
898            "parent",
899            None,
900            vec![
901                user("2026-06-01T10:00:00Z", "Refactor and simplify the module"),
902                tools_turn("p1", &["Edit", "Edit", "Edit"]),
903            ],
904        );
905        let child = session(
906            "agent-x",
907            Some("parent"),
908            vec![turn("c1", "2026-06-01T10:01:00Z", 500, &[])],
909        );
910        let report = classify(
911            &[parent, child],
912            &no_filter(),
913            "claude-code",
914            &PricingTable::embedded(),
915        );
916
917        // One row only — the child folded in.
918        assert_eq!(report.sessions.len(), 1);
919        let row = &report.sessions[0];
920        assert_eq!(row.id, "parent");
921        assert_eq!(row.task_type, TaskType::Refactoring);
922        // One parent request (input 100, three tool calls on the one turn) +
923        // one child request (input 500) = 600, folded into the parent row.
924        assert_eq!(row.rollup.requests, 2);
925        assert_eq!(row.rollup.input, 600);
926
927        // The Refactoring bucket holds the combined spend.
928        let bucket = report
929            .by_task_type
930            .iter()
931            .find(|b| b.task_type == TaskType::Refactoring)
932            .unwrap();
933        assert_eq!(bucket.sessions, 1);
934        assert_eq!(bucket.rollup.input, 600);
935    }
936
937    /// An orphan sub-agent (parent file absent) is classified from itself.
938    #[test]
939    fn orphan_sub_agent_is_its_own_group() {
940        let child = session(
941            "agent-y",
942            Some("missing-parent"),
943            vec![
944                user("2026-06-01T10:00:00Z", "Investigate and trace the bug"),
945                tools_turn("c1", &["Read", "Grep"]),
946            ],
947        );
948        let report = classify(
949            &[child],
950            &no_filter(),
951            "claude-code",
952            &PricingTable::embedded(),
953        );
954        assert_eq!(report.sessions.len(), 1);
955        let row = &report.sessions[0];
956        assert_eq!(row.id, "missing-parent");
957        // "investigate"/"trace" + read-dominant mix -> Exploration.
958        assert_eq!(row.task_type, TaskType::Exploration);
959    }
960
961    // ---- windowing -------------------------------------------------------
962
963    #[test]
964    fn since_drops_out_of_window_sessions() {
965        let old = session(
966            "old",
967            None,
968            vec![
969                user("2026-06-01T10:00:00Z", "fix the bug"),
970                turn("o1", "2026-06-01T10:00:00Z", 100, &[]),
971            ],
972        );
973        let new = session(
974            "new",
975            None,
976            vec![
977                user("2026-06-03T10:00:00Z", "implement the feature"),
978                turn("n1", "2026-06-03T10:00:00Z", 200, &["Write", "Edit"]),
979            ],
980        );
981        let filter = Filter {
982            since: Some("2026-06-03".parse().unwrap()),
983        };
984        let report = classify(
985            &[old, new],
986            &filter,
987            "claude-code",
988            &PricingTable::embedded(),
989        );
990        assert_eq!(report.sessions.len(), 1, "old dropped (no in-window spend)");
991        assert_eq!(report.sessions[0].id, "new");
992        assert_eq!(report.total_tokens, 200 + 10);
993    }
994
995    /// `--since` scopes the CLASSIFICATION signals, not just the spend: a session
996    /// that debugged before the window but only explored within it is classified
997    /// Exploration, and the out-of-window "fix" prompt is not a signal.
998    #[test]
999    fn since_scopes_classification_signals_not_just_spend() {
1000        let s = session(
1001            "spanning",
1002            None,
1003            vec![
1004                // Out of window: a debugging prompt + a fix-loop (Bash/Edit) turn.
1005                user("2026-06-01T10:00:00Z", "fix the crash"),
1006                tools_turn("dbg", &["Edit", "Bash", "Bash", "Bash"]),
1007                // In window: an exploration prompt + read-dominant mix (the only
1008                // in-window spend, so the group survives).
1009                user("2026-06-03T10:00:00Z", "review and understand the module"),
1010                turn(
1011                    "exp",
1012                    "2026-06-03T10:00:00Z",
1013                    200,
1014                    &["Read", "Grep", "Glob"],
1015                ),
1016            ],
1017        );
1018        let filter = Filter {
1019            since: Some("2026-06-03".parse().unwrap()),
1020        };
1021        let report = classify(&[s], &filter, "claude-code", &PricingTable::embedded());
1022        let c = class_of(&report, "spanning");
1023        // Without windowing the signals, the heavier out-of-window debugging
1024        // evidence would win; scoped to the window it is Exploration.
1025        assert_eq!(c.task_type, TaskType::Exploration);
1026        assert!(
1027            !c.signals.iter().any(|sig| sig.contains("\"fix\"")),
1028            "out-of-window debugging prompt must not be a signal"
1029        );
1030    }
1031
1032    // ---- pricing / shares ------------------------------------------------
1033
1034    #[test]
1035    fn unpriced_model_sets_flag_and_nulls_cost_share() {
1036        let s = session(
1037            "u",
1038            None,
1039            vec![
1040                user("2026-06-01T10:00:00Z", "fix the crash"),
1041                unpriced_turn("u1", "2026-06-01T10:00:00Z", 1000),
1042            ],
1043        );
1044        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
1045        assert!(report.has_unpriced);
1046        assert_eq!(report.total_cost_usd, 0.0);
1047        assert_eq!(report.by_task_type.len(), 1);
1048        // total_cost_usd == 0 -> every cost_share is None (no 0/0 = 0.0 lie).
1049        assert!(report.by_task_type[0].cost_share.is_none());
1050        // Tokens are still counted even when unpriceable.
1051        assert_eq!(report.total_tokens, 1010);
1052    }
1053
1054    #[test]
1055    fn token_shares_sum_to_one_across_buckets() {
1056        let dbg = session(
1057            "d",
1058            None,
1059            vec![
1060                user("2026-06-01T10:00:00Z", "fix the bug"),
1061                turn("d1", "2026-06-01T10:00:00Z", 1000, &[]),
1062            ],
1063        );
1064        let feat = session(
1065            "f",
1066            None,
1067            vec![
1068                user("2026-06-01T10:00:00Z", "implement the feature"),
1069                turn("f1", "2026-06-01T10:00:00Z", 3000, &["Write", "Edit"]),
1070            ],
1071        );
1072        let report = classify(
1073            &[dbg, feat],
1074            &no_filter(),
1075            "claude-code",
1076            &PricingTable::embedded(),
1077        );
1078        assert_eq!(report.by_task_type.len(), 2);
1079        let share_sum: f64 = report.by_task_type.iter().map(|b| b.token_share).sum();
1080        assert!((share_sum - 1.0).abs() < 1e-9, "shares sum to {share_sum}");
1081        let cost_sum: f64 = report
1082            .by_task_type
1083            .iter()
1084            .map(|b| b.cost_share.unwrap_or(0.0))
1085            .sum();
1086        assert!(
1087            (cost_sum - 1.0).abs() < 1e-9,
1088            "cost shares sum to {cost_sum}"
1089        );
1090    }
1091
1092    // ---- ordering + empty -----------------------------------------------
1093
1094    #[test]
1095    fn buckets_and_sessions_sort_by_cost_desc() {
1096        let small = session(
1097            "small",
1098            None,
1099            vec![
1100                user("2026-06-01T10:00:00Z", "fix the bug"),
1101                turn("s1", "2026-06-01T10:00:00Z", 100, &[]),
1102            ],
1103        );
1104        let big = session(
1105            "big",
1106            None,
1107            vec![
1108                user("2026-06-01T10:00:00Z", "implement the feature"),
1109                turn("b1", "2026-06-01T10:00:00Z", 9000, &["Write", "Edit"]),
1110            ],
1111        );
1112        let report = classify(
1113            &[small, big],
1114            &no_filter(),
1115            "claude-code",
1116            &PricingTable::embedded(),
1117        );
1118        // Sessions: higher cost first.
1119        assert_eq!(report.sessions[0].id, "big");
1120        assert_eq!(report.sessions[1].id, "small");
1121        // Buckets: FeatureWork (bigger spend) before Debugging.
1122        assert_eq!(report.by_task_type[0].task_type, TaskType::FeatureWork);
1123        assert_eq!(report.by_task_type[1].task_type, TaskType::Debugging);
1124    }
1125
1126    #[test]
1127    fn empty_input_yields_empty_report() {
1128        let report = classify(&[], &no_filter(), "claude-code", &PricingTable::embedded());
1129        assert_eq!(report.total_tokens, 0);
1130        assert_eq!(report.total_cost_usd, 0.0);
1131        assert!(!report.has_unpriced);
1132        assert!(report.by_task_type.is_empty());
1133        assert!(report.sessions.is_empty());
1134    }
1135
1136    #[test]
1137    fn sessions_without_spend_are_dropped() {
1138        // A prompt but no assistant usage -> no in-window spend -> no row.
1139        let s = session(
1140            "noissue",
1141            None,
1142            vec![user("2026-06-01T10:00:00Z", "fix the bug")],
1143        );
1144        let report = classify(&[s], &no_filter(), "claude-code", &PricingTable::embedded());
1145        assert!(report.sessions.is_empty());
1146        assert!(report.by_task_type.is_empty());
1147    }
1148}