Skip to main content

warden/reports/
compare.rs

1//! `warden report compare` — this period against the one immediately before it.
2//!
3//! The previous period is the same length, ending where this one starts: a
4//! `--since 7d` run compares week against week, `--since 1d` day against day.
5//! That requires a bounded period, so an unbounded run is an error with the fix
6//! in it rather than a comparison against nothing.
7
8use crate::cli::TimeWindow;
9use crate::output::{Cell, Report, Table};
10use crate::store::Scanner;
11
12use crate::config::Pricing;
13
14use super::{count, round_money, scan, Cost, ReportCtx, ReportError, Totals};
15
16pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
17    let length = ctx
18        .window
19        .to_ms
20        .checked_sub(ctx.window.from_ms)
21        .filter(|len| *len > 0)
22        .ok_or(ReportError::NeedsWindow("compare"))?;
23    // `TimeWindow::all` uses saturating sentinels; there is no period before it.
24    let previous_from = ctx
25        .window
26        .from_ms
27        .checked_sub(length)
28        .ok_or(ReportError::NeedsWindow("compare"))?;
29    let previous_window = TimeWindow::new(previous_from, ctx.window.from_ms);
30
31    let current = scan(scanner, ctx)?;
32    let previous = scan(scanner, &ctx.with_window(previous_window))?;
33
34    let now = fold(&current.events, &ctx.pricing);
35    let before = fold(&previous.events, &ctx.pricing);
36
37    let mut table = Table::new(["metric", "this period", "previous", "delta", "change"]);
38    let mut rows = Vec::new();
39
40    // Cost is the only money metric, and it is a floor rather than a total in
41    // either period where a model could not be priced.
42    let cost_partial = now.cost.is_partial() || before.cost.is_partial();
43
44    for metric in METRICS {
45        let (this, prev) = ((metric.value)(&now), (metric.value)(&before));
46        let partial = metric.kind == Kind::Money && cost_partial;
47        let cell = |value: Option<f64>| match (metric.kind, value) {
48            (Kind::Count, Some(v)) => count(v as u64),
49            (Kind::Money, Some(v)) if partial => Cell::money_partial(v),
50            (Kind::Money, Some(v)) => Cell::money_est(v),
51            (_, None) => Cell::Unsupported,
52        };
53
54        let delta = match (this, prev) {
55            (Some(a), Some(b)) => Some(a - b),
56            _ => None,
57        };
58        let change = match (this, prev) {
59            (Some(a), Some(b)) if b != 0.0 => Some((a - b) / b * 100.0),
60            _ => None,
61        };
62
63        table.push(vec![
64            Cell::text(metric.label),
65            cell(this),
66            cell(prev),
67            match (delta, metric.kind) {
68                (Some(d), Kind::Money) if partial => Cell::money_partial(d),
69                (Some(d), Kind::Money) => Cell::money_est(d),
70                (Some(d), Kind::Count) => Cell::Int(d as i64),
71                (None, _) => Cell::Unsupported,
72            },
73            match change {
74                Some(pct) => Cell::text(format!("{pct:+.1}%")),
75                None => Cell::Unsupported,
76            },
77        ]);
78
79        let number = |value: Option<f64>| match (metric.kind, value) {
80            (Kind::Count, Some(v)) => serde_json::json!(v as i64),
81            (Kind::Money, Some(v)) => serde_json::json!(round_money(v)),
82            (_, None) => serde_json::Value::Null,
83        };
84        rows.push(serde_json::json!({
85            "metric": metric.label,
86            "this_period": number(this),
87            "previous_period": number(prev),
88            "delta": number(delta),
89            "change_pct": change.map(|pct| (pct * 10.0).round() / 10.0),
90            "partial": partial,
91        }));
92    }
93
94    let mut notes = current.notes;
95    notes.merge(&previous.notes);
96    notes.push(format!(
97        "previous period is the {} immediately before this one, ending where it begins ({} to {})",
98        super::format_span(length),
99        iso(previous_window.from_ms),
100        iso(previous_window.to_ms),
101    ));
102    notes.push(
103        "change is blank where the previous period had nothing to divide by, and where a figure \
104         could not be derived in both periods",
105    );
106
107    Ok(Report::new("compare", ctx.window, table)
108        .with_json_rows(rows)
109        .with_notes(notes.finish()))
110}
111
112fn iso(ms: i64) -> String {
113    crate::output::iso8601_ms(ms).unwrap_or_else(|| "unbounded".to_string())
114}
115
116fn fold(events: &[crate::store::Event], pricing: &Pricing) -> Totals {
117    let mut totals = Totals::default();
118    for event in events {
119        totals.add(event, pricing);
120    }
121    totals
122}
123
124#[derive(Clone, Copy, PartialEq)]
125enum Kind {
126    Count,
127    Money,
128}
129
130struct Metric {
131    label: &'static str,
132    kind: Kind,
133    /// `None` means "not derivable", which renders `–` rather than `0`.
134    value: fn(&Totals) -> Option<f64>,
135}
136
137/// Cost is `None` when nothing in the period could be priced, so an unpriced
138/// model never reads as "spend went to zero".
139const METRICS: &[Metric] = &[
140    Metric {
141        label: "sessions",
142        kind: Kind::Count,
143        value: |t| Some(t.sessions.len() as f64),
144    },
145    Metric {
146        label: "requests",
147        kind: Kind::Count,
148        value: |t| Some(t.requests as f64),
149    },
150    Metric {
151        label: "input tok",
152        kind: Kind::Count,
153        value: |t| Some(t.input as f64),
154    },
155    Metric {
156        label: "output tok",
157        kind: Kind::Count,
158        value: |t| Some(t.output as f64),
159    },
160    Metric {
161        label: "cache read tok",
162        kind: Kind::Count,
163        value: |t| Some(t.cache_read as f64),
164    },
165    Metric {
166        label: "cache write tok",
167        kind: Kind::Count,
168        value: |t| Some(t.cache_write as f64),
169    },
170    Metric {
171        label: "total tok",
172        kind: Kind::Count,
173        value: |t| Some(t.total_tokens() as f64),
174    },
175    Metric {
176        label: "est. cost",
177        kind: Kind::Money,
178        value: |t| priced_total(t.cost),
179    },
180];
181
182fn priced_total(cost: Cost) -> Option<f64> {
183    (cost.priced > 0).then_some(cost.total)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::super::testkit::*;
189    use super::*;
190    use crate::output::{Style, UNSUPPORTED};
191
192    fn window() -> TimeWindow {
193        TimeWindow::new(ms(2026, 8, 4, 0), ms(2026, 8, 5, 0))
194    }
195
196    fn report(events: &[crate::store::Event]) -> Report {
197        let (_dir, paths) = store(events);
198        build(&Scanner::new(paths), &ReportCtx::new(window(), None, true)).unwrap()
199    }
200
201    fn two_days() -> Vec<crate::store::Event> {
202        vec![
203            priced(used("y", ms(2026, 8, 3, 10), "acme", "m", 100, 10), 1.0),
204            priced(used("t1", ms(2026, 8, 4, 10), "acme", "m", 150, 10), 2.0),
205        ]
206    }
207
208    #[test]
209    fn compares_day_against_the_day_before() {
210        let report = report(&two_days());
211        let input = row(&report, "input tok");
212        assert_eq!(input["this_period"], 150);
213        assert_eq!(input["previous_period"], 100);
214        assert_eq!(input["delta"], 50);
215        assert_eq!(input["change_pct"], 50.0);
216
217        let cost = row(&report, "est. cost");
218        assert_eq!(cost["this_period"], 2.0);
219        assert_eq!(cost["delta"], 1.0);
220        assert!(report.table.render(Style::plain()).contains("+50.0%"));
221    }
222
223    fn row<'a>(report: &'a Report, metric: &str) -> &'a serde_json::Value {
224        report
225            .json_rows
226            .iter()
227            .find(|row| row["metric"] == metric)
228            .expect("metric present")
229    }
230
231    #[test]
232    fn an_unbounded_period_is_an_error_that_names_the_fix() {
233        let (_dir, paths) = store(&two_days());
234        let err = build(
235            &Scanner::new(paths),
236            &ReportCtx::new(TimeWindow::all(), None, true),
237        )
238        .unwrap_err();
239        let msg = err.to_string();
240        assert!(msg.contains("--since"), "{msg}");
241    }
242
243    #[test]
244    fn an_empty_previous_period_blanks_the_change_instead_of_dividing_by_zero() {
245        let report = report(&[priced(
246            used("t", ms(2026, 8, 4, 10), "acme", "m", 150, 10),
247            2.0,
248        )]);
249        let input = row(&report, "input tok");
250        assert_eq!(input["previous_period"], 0);
251        assert!(input["change_pct"].is_null());
252        assert!(report.table.render(Style::plain()).contains(UNSUPPORTED));
253    }
254
255    #[test]
256    fn unpriced_cost_never_reads_as_spend_dropping_to_zero() {
257        let report = report(&[used("t", ms(2026, 8, 4, 10), "acme", "m", 150, 10)]);
258        let cost = row(&report, "est. cost");
259        assert!(cost["this_period"].is_null());
260        assert!(cost["delta"].is_null());
261    }
262}