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}