Skip to main content

skiagram_core/analysis/
flame.rs

1//! Folded-stack data for a token-spend flamegraph (CLAUDE.md §2.4, roadmap v0.3).
2//!
3//! Answers "*where did the tokens (or dollars) go?*" as a hierarchy the binary
4//! can hand straight to inferno: each leaf is one path with an integer weight.
5//! The path's levels are chosen by [`Dim`], defaulting to `project → session →
6//! model → token-type` ([`Dim::DEFAULT`]) but reorderable/droppable via the
7//! binary's `--group-by`. This is the `-core` half only — it produces the
8//! weighted "folded stacks"; the binary owns the SVG rendering and has the
9//! inferno dependency (this crate must never gain one).
10//!
11//! Like every accounting pass here, weights are computed over DEDUPLICATED
12//! requests, never raw JSONL lines — request-level dedup is THE accounting step
13//! (CLAUDE.md §8.1). This pass reuses [`super::dedup::dedup_session`] and
14//! [`crate::pricing`] exactly like [`super::aggregate`] / [`super::anomaly`], so
15//! a flamegraph's widths agree with the `summary` numbers.
16//!
17//! The `session` frame uses the request's parent-session id when present, which
18//! FOLDS sub-agent (sidechain) transcripts into their parent session's slice —
19//! spawned work is attributed, never dropped (§8.3), matching `aggregate` and
20//! `drilldown` — then shortens it to a readable prefix ([`short_session`]).
21//!
22//! Two metrics (see [`FlameMetric`]):
23//!
24//! - **Tokens** — deduplicated token counts. Always known, never a pricing guess
25//!   (§8.7); unknown fields contribute 0 (absence ≠ zero, §8.5).
26//! - **Cost** — estimated USD in MICRO-dollars (1e-6 USD) so the weight stays an
27//!   integer. Priced from the embedded snapshot; a request on an unpriced model
28//!   contributes nothing and is counted in [`FlameData::unpriced_requests`]
29//!   rather than guessed at (§8.7). Per-token-type cost mirrors
30//!   [`crate::pricing::cost_usd`] exactly so the two never drift.
31
32use std::collections::BTreeMap;
33
34use jiff::civil::Date;
35use serde::Serialize;
36
37use crate::analysis::aggregate::Filter;
38use crate::analysis::dedup::dedup_session;
39use crate::model::{Session, Usage};
40use crate::pricing::{self, PricingTable};
41
42/// Fallback `project` frame when a record has no project label.
43const UNKNOWN_PROJECT: &str = "(unknown project)";
44/// Fallback `model` frame when a record has no model.
45const UNKNOWN_MODEL: &str = "(unknown model)";
46
47/// What the width of a flamegraph frame measures.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
49pub enum FlameMetric {
50    /// Deduplicated token counts. Always known; never a pricing guess (§8.7).
51    #[default]
52    Tokens,
53    /// Estimated USD cost, in MICRO-dollars (1e-6 USD) so it stays an integer
54    /// weight. Priced from the embedded snapshot; unpriced requests contribute
55    /// nothing and are counted in `unpriced_requests` (§8.5/§8.7).
56    Cost,
57}
58
59/// One level (frame "row") of the flamegraph hierarchy, outermost first. The
60/// default is `Project → Session → Model → Type`; the binary's `--group-by`
61/// lets a user reorder or drop levels — e.g. dropping [`Dim::Session`] (the
62/// opaque session-id row) gives a cleaner `project → model → token-type` view.
63///
64/// [`Dim::Type`] is the per-request token-type split (input / output /
65/// cache-read / cache-write / thinking). When it is omitted, those five are
66/// SUMMED into the innermost structural frame instead of broken out — totals
67/// are unchanged either way (regrouping never drops spend).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69pub enum Dim {
70    Project,
71    Session,
72    Model,
73    Type,
74}
75
76impl Dim {
77    /// The default hierarchy: `Project → Session → Model → Type`.
78    pub const DEFAULT: [Dim; 4] = [Dim::Project, Dim::Session, Dim::Model, Dim::Type];
79}
80
81/// Human-readable session-frame label: the first 8 characters of the id — for a
82/// UUID, the first hyphen-delimited group (e.g. `3e9d2c41`). Full session UUIDs
83/// are unreadable in narrow frames and add nothing at a glance; 8 hex chars tell
84/// sessions apart. A prefix collision would only merge two slices *visually* —
85/// every leaf weight is still counted, so totals are unaffected (and a clash is
86/// astronomically unlikely for realistic session counts anyway).
87fn short_session(id: &str) -> String {
88    id.chars().take(8).collect()
89}
90
91/// One folded-stack line: a base→leaf frame path and its integer weight.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93pub struct FoldedStack {
94    /// Frames from base (root) to leaf, e.g.
95    /// ["-home-dev-acme-app", "<session-id>", "claude-sonnet-4-5", "cache-read"].
96    pub frames: Vec<String>,
97    /// Weight in the chosen metric's unit (tokens, or micro-USD for cost).
98    pub value: u64,
99}
100
101impl FoldedStack {
102    /// Render as an inferno folded line: `frame;frame;… value`.
103    /// (Frame labels are sanitized so `;`/whitespace can't corrupt the format.)
104    pub fn to_folded_line(&self) -> String {
105        let mut line = String::new();
106        for (i, frame) in self.frames.iter().enumerate() {
107            if i > 0 {
108                line.push(';');
109            }
110            line.push_str(&sanitize_frame(frame));
111        }
112        line.push(' ');
113        line.push_str(&self.value.to_string());
114        line
115    }
116}
117
118/// Aggregated, deduplicated flamegraph data.
119#[derive(Debug, Clone, Serialize)]
120pub struct FlameData {
121    pub agent: String,
122    pub since: Option<Date>,
123    pub metric: FlameMetric,
124    /// Count-name label for the unit, e.g. "tokens" or "µ$". (`&'static str`.)
125    pub unit: &'static str,
126    /// Folded stacks, accumulated per unique frame-path, sorted deterministically.
127    pub stacks: Vec<FoldedStack>,
128    /// Sum of all `stacks[].value` (matches the emitted stacks exactly).
129    pub total_value: u64,
130    /// Requests dropped from a Cost flamegraph because their model is unpriced.
131    /// Always 0 for the Tokens metric.
132    pub unpriced_requests: u64,
133}
134
135/// The five token-type leaves, paired with how each is weighted under a metric.
136///
137/// Tuple is `(leaf label, tokens, USD-per-million rate)`. The rate is only used
138/// by the Cost metric; for Tokens the weight is the token count itself. The
139/// cache-write leaf is handled separately because its rate depends on the
140/// reported 5m/1h TTL split (see [`cache_write_micro_usd`]).
141struct Leaf {
142    label: &'static str,
143    /// Weight under the Tokens metric (the raw token count, unknown → 0).
144    tokens: u64,
145    /// Weight under the Cost metric, in micro-USD. Pre-computed so the caller
146    /// need not re-branch on the metric per leaf.
147    micro_usd: f64,
148}
149
150/// Replace inferno's frame separator (`;`) and any ASCII whitespace in a label
151/// so a stray character can't corrupt the `frame;frame value` line format. Real
152/// labels (url-encoded project dirs, uuids, model ids, the fixed token-type
153/// words) contain none of these; this is defensive per the house "lenient" style.
154fn sanitize_frame(frame: &str) -> String {
155    frame
156        .chars()
157        .map(|c| match c {
158            ';' => ':',
159            c if c.is_ascii_whitespace() => '_',
160            c => c,
161        })
162        .collect()
163}
164
165/// Cache-write cost in micro-USD, mirroring [`crate::pricing::cost_usd`] exactly:
166/// use the per-TTL split when the agent reports it, else price the whole total at
167/// the (cheaper) 5m rate so an unsplit figure stays a lower bound.
168fn cache_write_micro_usd(usage: &Usage, p: &pricing::ModelPricing) -> f64 {
169    match (usage.cache_creation_5m, usage.cache_creation_1h) {
170        (None, None) => usage.cache_creation.unwrap_or(0) as f64 * p.cache_write_5m,
171        (m5, h1) => {
172            m5.unwrap_or(0) as f64 * p.cache_write_5m + h1.unwrap_or(0) as f64 * p.cache_write_1h
173        }
174    }
175}
176
177/// The five token-type leaves for one request's usage under both metrics.
178///
179/// Cost weights are in micro-USD; because the snapshot rates are USD per million,
180/// `tokens * rate` already yields micro-USD. Thinking bills at the output rate,
181/// mirroring [`crate::pricing::cost_usd`]. `cache-write` uses the request total
182/// (the 5m/1h fields are a breakdown of it, not added again — §8.4).
183fn leaves(usage: &Usage, pricing: Option<&pricing::ModelPricing>) -> [Leaf; 5] {
184    let input = usage.input.unwrap_or(0);
185    let output = usage.output.unwrap_or(0);
186    let cache_read = usage.cache_read.unwrap_or(0);
187    let cache_write = usage.cache_creation.unwrap_or(0);
188    let thinking = usage.thinking.unwrap_or(0);
189
190    // Cost weights are 0 unless the model is priced; callers skip whole records
191    // for unpriced models, so these are only consulted for priced ones.
192    let (c_in, c_out, c_read, c_write, c_think) = match pricing {
193        Some(p) => (
194            input as f64 * p.input,
195            output as f64 * p.output,
196            cache_read as f64 * p.cache_read,
197            cache_write_micro_usd(usage, p),
198            thinking as f64 * p.output,
199        ),
200        None => (0.0, 0.0, 0.0, 0.0, 0.0),
201    };
202
203    [
204        Leaf {
205            label: "input",
206            tokens: input,
207            micro_usd: c_in,
208        },
209        Leaf {
210            label: "output",
211            tokens: output,
212            micro_usd: c_out,
213        },
214        Leaf {
215            label: "cache-read",
216            tokens: cache_read,
217            micro_usd: c_read,
218        },
219        Leaf {
220            label: "cache-write",
221            tokens: cache_write,
222            micro_usd: c_write,
223        },
224        Leaf {
225            label: "thinking",
226            tokens: thinking,
227            micro_usd: c_think,
228        },
229    ]
230}
231
232/// Fold sessions into flamegraph data. See module docs for the rules.
233///
234/// Each session is reduced to deduplicated requests via [`dedup_session`] (THE
235/// accounting step, §8.1 — `filter.since` filtering happens inside it). Every
236/// request contributes up to five leaves; `dims` chooses the frame path each
237/// leaf lands on (outermost first), defaulting to
238/// `project → session → model → token-type` ([`Dim::DEFAULT`]). The `Session`
239/// frame folds sub-agent transcripts into their parent (§8.3) and is shortened
240/// to a readable prefix ([`short_session`]); omitting [`Dim::Type`] sums the
241/// token-types into the innermost structural frame. Under the Cost metric, a
242/// request whose model is unpriced is skipped entirely and counted in
243/// `unpriced_requests` — prices are never guessed (§8.7).
244pub fn fold(
245    sessions: &[Session],
246    filter: &Filter,
247    agent: &str,
248    metric: FlameMetric,
249    dims: &[Dim],
250    pricing_table: &PricingTable,
251) -> FlameData {
252    // Accumulate weights per unique 4-frame path. A BTreeMap keeps the emitted
253    // stacks in deterministic sorted order for free. f64 keeps cross-request
254    // cost accumulation exact (we round only on emit); for tokens the values are
255    // integers well below 2^53, so f64 is exact there too.
256    let mut paths: BTreeMap<Vec<String>, f64> = BTreeMap::new();
257    let mut unpriced_requests = 0u64;
258
259    for session in sessions {
260        let (records, _stats) = dedup_session(session, filter.since);
261        for rec in records {
262            // Cost: refuse to price an unknown model — count it and skip (§8.7).
263            let pricing = match metric {
264                FlameMetric::Tokens => None,
265                FlameMetric::Cost => {
266                    match rec.model.as_deref().and_then(|m| pricing_table.lookup(m)) {
267                        Some(p) => Some(p),
268                        None => {
269                            unpriced_requests += 1;
270                            continue;
271                        }
272                    }
273                }
274            };
275
276            // Structural frame values for this request. `session` folds sub-agent
277            // transcripts into their parent (§8.3) and is shortened to a readable
278            // prefix; project/model fall back to explicit "unknown" labels.
279            let project = rec
280                .project
281                .clone()
282                .unwrap_or_else(|| UNKNOWN_PROJECT.into());
283            let session_label =
284                short_session(rec.parent_session.as_deref().unwrap_or(&rec.session_id));
285            let model = rec.model.clone().unwrap_or_else(|| UNKNOWN_MODEL.into());
286
287            for leaf in leaves(&rec.usage, pricing) {
288                let weight = match metric {
289                    FlameMetric::Tokens => leaf.tokens as f64,
290                    FlameMetric::Cost => leaf.micro_usd,
291                };
292                // Only non-empty leaves matter; zeros are dropped on emit anyway.
293                if weight <= 0.0 {
294                    continue;
295                }
296                // Build the frame path from the selected dimensions, in order.
297                // Omitting `Type` makes every leaf of a request share one path,
298                // so the five token-types sum into the innermost structural frame.
299                let frames: Vec<String> = dims
300                    .iter()
301                    .map(|dim| match dim {
302                        Dim::Project => project.clone(),
303                        Dim::Session => session_label.clone(),
304                        Dim::Model => model.clone(),
305                        Dim::Type => leaf.label.to_string(),
306                    })
307                    .collect();
308                *paths.entry(frames).or_insert(0.0) += weight;
309            }
310        }
311    }
312
313    // Emit in sorted (ascending frame-path) order; round each weight to an
314    // integer here so cross-request accumulation never compounds rounding error.
315    // total_value sums the EMITTED values, so it equals the stacks exactly.
316    let mut total_value = 0u64;
317    let mut stacks = Vec::with_capacity(paths.len());
318    for (frames, weight) in paths {
319        let value = weight.round() as u64;
320        if value == 0 {
321            continue;
322        }
323        total_value += value;
324        stacks.push(FoldedStack { frames, value });
325    }
326
327    FlameData {
328        agent: agent.to_string(),
329        since: filter.since,
330        metric,
331        unit: match metric {
332            FlameMetric::Tokens => "tokens",
333            FlameMetric::Cost => "µ$",
334        },
335        stacks,
336        total_value,
337        unpriced_requests,
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::model::{Event, EventKind, Usage};
345
346    /// The default `project → session → model → token-type` hierarchy, as the CLI
347    /// passes it when `--group-by` is omitted.
348    const DIMS: &[Dim] = &Dim::DEFAULT;
349
350    /// Assistant event with one request id and the given full usage breakdown.
351    /// Model is the priced `claude-sonnet-4-5` unless a test overrides it.
352    fn turn(rid: &str, usage: Usage) -> Event {
353        Event {
354            kind: EventKind::Assistant,
355            ts: Some("2026-06-02T10:00:00Z".parse().unwrap()),
356            request_id: Some(rid.into()),
357            model: Some("claude-sonnet-4-5".into()),
358            usage: Some(usage),
359            tool_calls: Vec::new(),
360            sidechain: false,
361            content_summary: None,
362            content_chars: 0,
363            thinking_chars: 0,
364            has_thinking: false,
365            tool_use_id: None,
366            attachment_kind: None,
367            item_count: 0,
368        }
369    }
370
371    fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
372        Session {
373            id: id.into(),
374            agent: "claude-code".into(),
375            project: Some("proj".into()),
376            model: Some("claude-sonnet-4-5".into()),
377            parent_session: parent.map(str::to_string),
378            started_at: None,
379            ended_at: None,
380            events,
381            sub_agents: Vec::new(),
382            skipped_lines: 0,
383        }
384    }
385
386    fn usage(input: u64, output: u64, cache_read: u64, cache_write: u64) -> Usage {
387        Usage {
388            input: Some(input),
389            output: Some(output),
390            cache_read: Some(cache_read),
391            cache_creation: Some(cache_write),
392            ..Usage::default()
393        }
394    }
395
396    /// Look up the weight of one full frame-path in the emitted stacks.
397    fn leaf_value(
398        d: &FlameData,
399        project: &str,
400        session: &str,
401        model: &str,
402        ty: &str,
403    ) -> Option<u64> {
404        let want = [project, session, model, ty];
405        d.stacks.iter().find(|s| s.frames == want).map(|s| s.value)
406    }
407
408    /// Basic tokens fold: one request's four non-zero leaves appear under
409    /// `proj;s;model;type` with the right values, zero leaves are omitted, and
410    /// `total_value` sums the emitted stacks.
411    #[test]
412    fn basic_tokens_fold_produces_per_type_leaves() {
413        let s = session("s", None, vec![turn("r", usage(1_000, 200, 5_000, 300))]);
414        let d = fold(
415            &[s],
416            &Filter::default(),
417            "claude-code",
418            FlameMetric::Tokens,
419            DIMS,
420            &PricingTable::embedded(),
421        );
422
423        assert_eq!(d.unit, "tokens");
424        assert_eq!(d.unpriced_requests, 0);
425        let m = "claude-sonnet-4-5";
426        assert_eq!(leaf_value(&d, "proj", "s", m, "input"), Some(1_000));
427        assert_eq!(leaf_value(&d, "proj", "s", m, "output"), Some(200));
428        assert_eq!(leaf_value(&d, "proj", "s", m, "cache-read"), Some(5_000));
429        assert_eq!(leaf_value(&d, "proj", "s", m, "cache-write"), Some(300));
430        // No thinking tokens -> that leaf is dropped (only four stacks).
431        assert_eq!(d.stacks.len(), 4);
432        assert!(leaf_value(&d, "proj", "s", m, "thinking").is_none());
433        assert_eq!(d.total_value, 1_000 + 200 + 5_000 + 300);
434        let stack_sum: u64 = d.stacks.iter().map(|s| s.value).sum();
435        assert_eq!(d.total_value, stack_sum);
436    }
437
438    /// Going through `dedup_session`: three lines sharing one requestId collapse
439    /// to one request, so the leaf values are NOT multiplied by three.
440    #[test]
441    fn dedup_collapses_repeated_lines() {
442        let u = usage(1_000, 200, 0, 0);
443        let s = session(
444            "s",
445            None,
446            vec![turn("req", u), turn("req", u), turn("req", u)],
447        );
448        let d = fold(
449            &[s],
450            &Filter::default(),
451            "claude-code",
452            FlameMetric::Tokens,
453            DIMS,
454            &PricingTable::embedded(),
455        );
456        let m = "claude-sonnet-4-5";
457        assert_eq!(leaf_value(&d, "proj", "s", m, "input"), Some(1_000));
458        assert_eq!(leaf_value(&d, "proj", "s", m, "output"), Some(200));
459        assert_eq!(d.total_value, 1_200, "3 lines -> 1 request, not 3x");
460    }
461
462    /// §8.3: a sub-agent (sidechain) transcript folds into its PARENT's session
463    /// frame, so the parent and child contributions sum under the same leaf.
464    #[test]
465    fn subagent_folds_into_parent_session_frame() {
466        let parent = session("parent", None, vec![turn("p", usage(1_000, 0, 0, 0))]);
467        let child = session(
468            "agent-x",
469            Some("parent"),
470            vec![turn("c", usage(500, 0, 0, 0))],
471        );
472        let d = fold(
473            &[parent, child],
474            &Filter::default(),
475            "claude-code",
476            FlameMetric::Tokens,
477            DIMS,
478            &PricingTable::embedded(),
479        );
480        let m = "claude-sonnet-4-5";
481        // Both land on session frame "parent"; the child's own id never appears.
482        assert_eq!(
483            leaf_value(&d, "proj", "parent", m, "input"),
484            Some(1_500),
485            "parent + child fold into one leaf"
486        );
487        assert!(leaf_value(&d, "proj", "agent-x", m, "input").is_none());
488        assert_eq!(d.total_value, 1_500);
489    }
490
491    /// Cost metric on a priced model: each leaf is micro-USD = tokens * rate, and
492    /// the SUM of the request's cost leaves matches `pricing::cost_usd * 1e6`
493    /// (guards against drift from the pricing module).
494    #[test]
495    fn cost_metric_prices_each_leaf() {
496        let u = usage(1_000, 100, 2_000, 400);
497        let s = session("s", None, vec![turn("r", u)]);
498        let d = fold(
499            &[s],
500            &Filter::default(),
501            "claude-code",
502            FlameMetric::Cost,
503            DIMS,
504            &PricingTable::embedded(),
505        );
506
507        assert_eq!(d.unit, "µ$");
508        assert_eq!(d.unpriced_requests, 0);
509        let m = "claude-sonnet-4-5";
510        // sonnet-4-5: input 3.0, output 15.0, cache_read 0.30, cache_write_5m 3.75
511        // (USD per million == micro-USD per token).
512        assert_eq!(leaf_value(&d, "proj", "s", m, "input"), Some(3_000)); // 1000 * 3.0
513        assert_eq!(leaf_value(&d, "proj", "s", m, "output"), Some(1_500)); // 100 * 15.0
514        assert_eq!(leaf_value(&d, "proj", "s", m, "cache-read"), Some(600)); // 2000 * 0.30
515        assert_eq!(leaf_value(&d, "proj", "s", m, "cache-write"), Some(1_500)); // 400 * 3.75
516
517        // Cross-check the per-request total against the authoritative pricer.
518        let expected = pricing::cost_usd(Some("claude-sonnet-4-5"), &u).unwrap() * 1e6;
519        let diff = (d.total_value as f64 - expected).abs();
520        assert!(
521            diff <= 3.0,
522            "flame cost {} vs pricer {expected}",
523            d.total_value
524        );
525    }
526
527    /// Thinking tokens bill at the OUTPUT rate under the Cost metric (mirrors
528    /// `pricing::cost_usd`), and the leaf is labeled `thinking`.
529    #[test]
530    fn cost_metric_thinking_bills_at_output_rate() {
531        let u = Usage {
532            thinking: Some(1_000),
533            ..Usage::default()
534        };
535        let s = session("s", None, vec![turn("r", u)]);
536        let d = fold(
537            &[s],
538            &Filter::default(),
539            "claude-code",
540            FlameMetric::Cost,
541            DIMS,
542            &PricingTable::embedded(),
543        );
544        // 1000 thinking tokens * output rate 15.0 = 15_000 micro-USD.
545        assert_eq!(
546            leaf_value(&d, "proj", "s", "claude-sonnet-4-5", "thinking"),
547            Some(15_000)
548        );
549    }
550
551    /// §8.7: a request on an unpriced model produces NO stacks under the Cost
552    /// metric and is counted; a mixed session keeps only the priced request.
553    #[test]
554    fn cost_metric_skips_and_counts_unpriced() {
555        // Pure unpriced session: no stacks, one unpriced request.
556        let mut unp = turn("u", usage(5_000, 100, 0, 0));
557        unp.model = Some("claude-opus-5-0".into()); // post-snapshot, unpriced
558        let only_unpriced = session("u", None, vec![unp.clone()]);
559        let d = fold(
560            &[only_unpriced],
561            &Filter::default(),
562            "claude-code",
563            FlameMetric::Cost,
564            DIMS,
565            &PricingTable::embedded(),
566        );
567        assert!(d.stacks.is_empty());
568        assert_eq!(d.total_value, 0);
569        assert_eq!(d.unpriced_requests, 1);
570
571        // Mixed: the priced request appears, the unpriced one is only counted.
572        let priced = turn("p", usage(1_000, 0, 0, 0)); // sonnet-4-5
573        let mixed = session("mix", None, vec![unp, priced]);
574        let d = fold(
575            &[mixed],
576            &Filter::default(),
577            "claude-code",
578            FlameMetric::Cost,
579            DIMS,
580            &PricingTable::embedded(),
581        );
582        assert_eq!(d.unpriced_requests, 1);
583        assert_eq!(d.stacks.len(), 1, "only the priced request contributes");
584        assert_eq!(
585            leaf_value(&d, "proj", "mix", "claude-sonnet-4-5", "input"),
586            Some(3_000)
587        );
588    }
589
590    /// Unpriced never affects the Tokens metric: `unpriced_requests` stays 0 and
591    /// the tokens are still counted (§8.7 — tokens are never a pricing guess).
592    #[test]
593    fn tokens_metric_ignores_pricing() {
594        let mut unp = turn("u", usage(5_000, 100, 0, 0));
595        unp.model = Some("claude-opus-5-0".into());
596        let s = session("s", None, vec![unp]);
597        let d = fold(
598            &[s],
599            &Filter::default(),
600            "claude-code",
601            FlameMetric::Tokens,
602            DIMS,
603            &PricingTable::embedded(),
604        );
605        assert_eq!(d.unpriced_requests, 0);
606        assert_eq!(
607            leaf_value(&d, "proj", "s", "claude-opus-5-0", "input"),
608            Some(5_000)
609        );
610    }
611
612    /// `since` flows through to `dedup_session`: out-of-window requests drop.
613    #[test]
614    fn since_filter_excludes_out_of_window_requests() {
615        let mut old = turn("old", usage(9_999, 0, 0, 0));
616        old.ts = Some("2026-06-01T10:00:00Z".parse().unwrap());
617        let mut new = turn("new", usage(100, 0, 0, 0));
618        new.ts = Some("2026-06-02T10:00:00Z".parse().unwrap());
619        let s = session("s", None, vec![old, new]);
620        let filter = Filter {
621            since: Some("2026-06-02".parse().unwrap()),
622        };
623        let d = fold(
624            &[s],
625            &filter,
626            "claude-code",
627            FlameMetric::Tokens,
628            DIMS,
629            &PricingTable::embedded(),
630        );
631        assert_eq!(d.since, filter.since);
632        assert_eq!(d.total_value, 100, "only the in-window request remains");
633        assert_eq!(
634            leaf_value(&d, "proj", "s", "claude-sonnet-4-5", "input"),
635            Some(100)
636        );
637    }
638
639    /// Empty input -> an empty but well-formed result.
640    #[test]
641    fn empty_input_is_an_empty_result() {
642        let d = fold(
643            &[],
644            &Filter::default(),
645            "claude-code",
646            FlameMetric::Tokens,
647            DIMS,
648            &PricingTable::embedded(),
649        );
650        assert!(d.stacks.is_empty());
651        assert_eq!(d.total_value, 0);
652        assert_eq!(d.unpriced_requests, 0);
653    }
654
655    /// `to_folded_line` yields `a;b;c;d value`, and sanitizes any `;` (→ `:`) or
656    /// ASCII whitespace (→ `_`) so it cannot corrupt inferno's line format.
657    #[test]
658    fn to_folded_line_formats_and_sanitizes() {
659        let clean = FoldedStack {
660            frames: vec!["a".into(), "b".into(), "c".into(), "input".into()],
661            value: 1_234,
662        };
663        assert_eq!(clean.to_folded_line(), "a;b;c;input 1234");
664
665        let dirty = FoldedStack {
666            frames: vec!["a;b".into(), "c d".into()],
667            value: 7,
668        };
669        assert_eq!(dirty.to_folded_line(), "a:b;c_d 7");
670    }
671
672    /// The emitted stacks come out in ascending frame-path order (BTreeMap),
673    /// giving deterministic output across runs.
674    #[test]
675    fn stacks_are_emitted_in_sorted_order() {
676        // Two projects, two sessions, multiple leaves -> several stacks.
677        let mut a = session("sa", None, vec![turn("ra", usage(10, 20, 30, 40))]);
678        a.project = Some("zeta".into());
679        let mut b = session("sb", None, vec![turn("rb", usage(1, 2, 3, 4))]);
680        b.project = Some("alpha".into());
681        let d = fold(
682            &[a, b],
683            &Filter::default(),
684            "claude-code",
685            FlameMetric::Tokens,
686            DIMS,
687            &PricingTable::embedded(),
688        );
689
690        assert!(d.stacks.len() >= 2);
691        for w in d.stacks.windows(2) {
692            assert!(w[0].frames <= w[1].frames, "stacks must be sorted");
693        }
694        // "alpha" sorts before "zeta" at the project frame.
695        assert_eq!(d.stacks[0].frames[0], "alpha");
696    }
697
698    /// The session frame is shortened to a readable 8-char prefix; the full UUID
699    /// never appears as a frame, and the total is unchanged.
700    #[test]
701    fn session_frame_is_shortened_to_prefix() {
702        let id = "3e9d2c41-7b5a-4f2e-9c1d-2f6b8a1c0e11";
703        let s = session(id, None, vec![turn("r", usage(1_000, 0, 0, 0))]);
704        let d = fold(
705            &[s],
706            &Filter::default(),
707            "claude-code",
708            FlameMetric::Tokens,
709            DIMS,
710            &PricingTable::embedded(),
711        );
712        let m = "claude-sonnet-4-5";
713        assert_eq!(leaf_value(&d, "proj", "3e9d2c41", m, "input"), Some(1_000));
714        assert!(
715            !d.stacks.iter().any(|s| s.frames.iter().any(|f| f == id)),
716            "the full UUID must never appear as a frame"
717        );
718        assert_eq!(d.total_value, 1_000);
719    }
720
721    /// `--group-by project,model,type` drops the session level: every path is a
722    /// 3-frame `project;model;type`, no session prefix appears, and the grand
723    /// total is unchanged (regrouping never drops spend).
724    #[test]
725    fn dims_drop_session_level() {
726        let id = "3e9d2c41-7b5a-4f2e-9c1d-2f6b8a1c0e11";
727        let s = session(id, None, vec![turn("r", usage(1_000, 200, 0, 0))]);
728        let dims = &[Dim::Project, Dim::Model, Dim::Type];
729        let d = fold(
730            &[s],
731            &Filter::default(),
732            "claude-code",
733            FlameMetric::Tokens,
734            dims,
735            &PricingTable::embedded(),
736        );
737        assert!(d.stacks.iter().all(|s| s.frames.len() == 3));
738        assert!(
739            !d.stacks
740                .iter()
741                .any(|s| s.frames.iter().any(|f| f == "3e9d2c41")),
742            "session level dropped"
743        );
744        let m = "claude-sonnet-4-5";
745        let input = d.stacks.iter().find(|s| s.frames == ["proj", m, "input"]);
746        assert_eq!(input.map(|s| s.value), Some(1_000));
747        assert_eq!(d.total_value, 1_200);
748    }
749
750    /// Omitting `Type` sums the token-types into the innermost structural frame:
751    /// a single `project;model` stack whose weight is every type added together.
752    #[test]
753    fn omitting_type_sums_token_types() {
754        let s = session("s", None, vec![turn("r", usage(1_000, 200, 50, 0))]);
755        let dims = &[Dim::Project, Dim::Model];
756        let d = fold(
757            &[s],
758            &Filter::default(),
759            "claude-code",
760            FlameMetric::Tokens,
761            dims,
762            &PricingTable::embedded(),
763        );
764        let m = "claude-sonnet-4-5";
765        assert_eq!(d.stacks.len(), 1);
766        assert_eq!(d.stacks[0].frames, ["proj", m]);
767        assert_eq!(d.stacks[0].value, 1_250, "1000 + 200 + 50 summed");
768        assert_eq!(d.total_value, 1_250);
769    }
770}