Skip to main content

kimetsu_brain/
roi.rs

1//! ROI ledger — v1.5 story "kimetsu pays for itself".
2//!
3//! Estimates the token savings that Kimetsu's memory brain delivered
4//! by surfacing relevant knowledge before a coding session, so the model
5//! didn't have to (re-)discover it through expensive exploration.
6//!
7//! # Design philosophy: deliberate under-claiming
8//!
9//! Every constant in [`SAVED_TOKENS_PER_CITATION`] is a *conservative*
10//! lower-bound estimate of the avoided exploration cost for that memory
11//! kind.  We never inflate the numbers: the goal is that a user who sees
12//! a "net positive" result can trust it.  The methodology document at
13//! `docs/ROI-METHODOLOGY.md` explains the calibration approach and the
14//! Terminal-Bench sanity anchor.
15
16use kimetsu_core::{KimetsuResult, memory::MemoryKind};
17use rusqlite::{OptionalExtension, params};
18use serde::Serialize;
19
20// ---------------------------------------------------------------------------
21// S2.4(b): Output-token accounting
22// ---------------------------------------------------------------------------
23
24/// Conservative ratio of output tokens to input tokens for a typical coding
25/// assistant response.  Calibration: real Claude Code sessions show ~30–40 %
26/// of the context going to output.  We use 0.25 as a deliberate under-claim
27/// to match the project's "never inflate" policy.
28///
29/// **Audited limitation**: this is a ratio-based *estimate* because Claude Code
30/// does not expose per-session output token counts to the Stop hook.  The
31/// estimate will be off for short responses (low ratio) and long code-gen runs
32/// (higher ratio).  We document this in the `--json` `output_token_estimate`
33/// field with an `"estimate_method": "ratio_0.25"` annotation.
34pub const OUTPUT_TOKEN_INPUT_RATIO: f64 = 0.25;
35
36/// Estimate output tokens from the brain-injected input token count.
37///
38/// This is a conservative proxy for sessions where the model is guided by
39/// brain context — more relevant context → fewer wasted generation tokens.
40/// See [`OUTPUT_TOKEN_INPUT_RATIO`] for calibration notes.
41pub fn estimate_output_tokens(input_tokens: u64) -> u64 {
42    (input_tokens as f64 * OUTPUT_TOKEN_INPUT_RATIO).round() as u64
43}
44
45// ---------------------------------------------------------------------------
46// S2.4(c): New event kind savings constants
47// ---------------------------------------------------------------------------
48
49/// Conservative token savings per `digest_served` event.
50///
51/// Calibration: a digest saves the model from re-reading the CLAUDE.md +
52/// searching for the top conventions at session start.  Estimated equivalent:
53/// ~2 search calls × 600 tokens/call = ~1 200 tokens.  We claim 800 as a
54/// conservative lower bound.
55pub const SAVED_TOKENS_PER_DIGEST_SERVED: u64 = 800;
56
57/// Conservative token savings per `resume_served` event.
58///
59/// Calibration: an episodic resume avoids the model asking "what were you
60/// working on?" + 1–2 file reads to reconstruct context.  Estimated
61/// equivalent: ~2 tool calls × 400 tokens/call = ~800 tokens.  We claim 500.
62pub const SAVED_TOKENS_PER_RESUME_SERVED: u64 = 500;
63
64/// Conservative token savings per `skill.served` event (future-proof).
65///
66/// Calibration: a synthesized skill file avoids the model re-deriving the
67/// composite procedure from individual memories.  We claim 300 as a
68/// conservative lower bound.
69pub const SAVED_TOKENS_PER_SKILL_SERVED: u64 = 300;
70
71// ---------------------------------------------------------------------------
72// Per-kind calibrated constants
73// ---------------------------------------------------------------------------
74
75/// Conservative lower-bound estimate of tokens saved per citation, by memory
76/// kind.  These are deliberate *under*-estimates of the exploration cost the
77/// model would have incurred without the brain context.
78///
79/// Calibration methodology (see `docs/ROI-METHODOLOGY.md` for details):
80/// - `failure_pattern`: avoids the "try → fail → diagnose → fix" loop.
81///   Typical loop: ~3 tool calls × ~500 tokens/call = ~1 500 tokens.
82/// - `command`: avoids a web/docs lookup or `--help` trial.  ~1–2 tool
83///   calls = ~400 tokens.
84/// - `convention`: avoids a code-search to find the project pattern.
85///   ~1–2 searches = ~300 tokens.
86/// - `fact`: avoids asking the user or searching docs. ~1 exchange = ~500 t.
87/// - `preference`: avoids one clarifying question. ~1 exchange = ~200 t.
88///
89/// These constants are the source of truth for the methodology doc.
90pub const SAVED_TOKENS_PER_CITATION: &[(MemoryKind, u32)] = &[
91    (MemoryKind::FailurePattern, 1500),
92    (MemoryKind::Command, 400),
93    (MemoryKind::Convention, 300),
94    (MemoryKind::Fact, 500),
95    (MemoryKind::Preference, 200),
96];
97
98// ---------------------------------------------------------------------------
99// Built-in price table (input tokens, conservative single number per family)
100// ---------------------------------------------------------------------------
101
102/// Conservative input-token price in USD per million tokens ($/MTok) for
103/// known model families.  These are approximate and marked as such in the
104/// `--json` output (`usd` field carries them as estimates).
105///
106/// Matched against the project's `[model] model` config value by prefix
107/// (longest match wins).  Unknown model → `usd: None` unless
108/// `[model] price_per_mtok` is set in `project.toml`.
109///
110/// Last updated: 2026-06, approximate retail/API-key pricing.
111const BUILTIN_PRICE_TABLE: &[(&str, f64)] = &[
112    // Anthropic Claude 4 family (Opus > Sonnet > Haiku)
113    ("claude-opus-4", 15.00),
114    ("claude-sonnet-4", 3.00),
115    ("claude-haiku-4", 0.80),
116    // Anthropic Claude 3 family
117    ("claude-3-opus", 15.00),
118    ("claude-3-5-sonnet", 3.00),
119    ("claude-3-5-haiku", 0.80),
120    ("claude-3-sonnet", 3.00),
121    ("claude-3-haiku", 0.25),
122    // Anthropic Bedrock cross-region routing prefixes
123    ("us.anthropic.claude-opus-4", 15.00),
124    ("us.anthropic.claude-sonnet-4", 3.00),
125    ("us.anthropic.claude-haiku-4", 0.80),
126    // OpenAI gpt-5 family
127    ("gpt-5", 2.00),
128    ("gpt-4o", 2.50),
129    ("gpt-4-turbo", 10.00),
130    ("gpt-4", 30.00),
131];
132
133/// Resolve a $/MTok price for the given model id.
134///
135/// Precedence: `price_override` (from `[model] price_per_mtok`) >
136/// longest-prefix match in [`BUILTIN_PRICE_TABLE`].
137/// Returns `None` when neither applies.
138pub fn resolve_price_per_mtok(model: &str, price_override: Option<f64>) -> Option<f64> {
139    if let Some(p) = price_override {
140        return Some(p);
141    }
142    let model_lower = model.to_lowercase();
143    // Longest prefix match wins.
144    let mut best: Option<(&str, f64)> = None;
145    for (prefix, price) in BUILTIN_PRICE_TABLE {
146        if model_lower.starts_with(prefix) && best.is_none_or(|(b, _)| prefix.len() > b.len()) {
147            best = Some((prefix, *price));
148        }
149    }
150    best.map(|(_, p)| p)
151}
152
153// ---------------------------------------------------------------------------
154// Pure savings estimator
155// ---------------------------------------------------------------------------
156
157/// Estimate total tokens saved from a citation summary.
158///
159/// `citations` is a slice of `(kind, count)` pairs — how many times each
160/// memory kind was cited in the window.  The function is intentionally pure
161/// (no I/O) so it can be unit-tested without a DB.
162///
163/// The result is a conservative lower-bound: if a kind has no entry in
164/// [`SAVED_TOKENS_PER_CITATION`] it contributes 0 (fail-safe).
165pub fn estimate_savings(citations: &[(MemoryKind, u32)]) -> u64 {
166    citations
167        .iter()
168        .map(|(kind, count)| {
169            let per = SAVED_TOKENS_PER_CITATION
170                .iter()
171                .find(|(k, _)| k == kind)
172                .map(|(_, v)| *v as u64)
173                .unwrap_or(0);
174            per * (*count as u64)
175        })
176        .sum()
177}
178
179// ---------------------------------------------------------------------------
180// Report types
181// ---------------------------------------------------------------------------
182
183/// USD sub-report — only present when a price is resolvable.
184#[derive(Debug, Clone, Serialize)]
185pub struct RoiUsd {
186    /// Estimated USD value of the tokens saved by the brain.
187    pub saved: f64,
188    /// Estimated USD cost of the brain overhead (injected tokens consumed
189    /// at inference time).  Uses the same $/MTok price as `saved`.
190    pub spent: f64,
191    /// `saved − spent`.  Can be negative when overhead exceeds the
192    /// estimated savings.
193    pub net: f64,
194}
195
196/// Full ROI report for a time window.
197#[derive(Debug, Clone, Serialize)]
198pub struct RoiReport {
199    /// Window length in days, or `None` for "all time".
200    pub window_days: Option<u32>,
201    /// Total tokens injected by the brain (sum of `used_tokens` from
202    /// `context.injected` events in the window).
203    pub injected_tokens: u64,
204    /// S2.4(b): Estimated output tokens generated in the window.
205    ///
206    /// Computed as `injected_tokens × OUTPUT_TOKEN_INPUT_RATIO`.
207    /// **Audited limitation**: ratio-based estimate; Claude Code does not
208    /// expose per-session output token counts.
209    pub estimated_output_tokens: u64,
210    /// Number of `context.served` events in the window.
211    pub served_events: u64,
212    /// S2.4(c): Number of `digest_served` events in the window.
213    pub digest_served_events: u64,
214    /// S2.4(c): Number of `resume_served` events in the window.
215    pub resume_served_events: u64,
216    /// S2.4(c): Tokens saved from warm-start digests and resumes.
217    pub warmstart_saved_tokens: u64,
218    /// Total citation count (rows in `memory_citations` for runs in the
219    /// window).
220    pub citations: u64,
221    /// Estimated tokens saved (conservative lower-bound).
222    pub estimated_saved_tokens: u64,
223    /// `estimated_saved_tokens − injected_tokens`.  Can be negative.
224    pub net_tokens: i64,
225    /// USD sub-report; `None` when price is unknown.
226    pub usd: Option<RoiUsd>,
227}
228
229// ---------------------------------------------------------------------------
230// S2.4(a): Per-memory ROI
231// ---------------------------------------------------------------------------
232
233/// Per-memory ROI entry for `kimetsu brain roi --top`.
234#[derive(Debug, Clone, Serialize)]
235pub struct MemoryRoiEntry {
236    pub memory_id: String,
237    pub kind: String,
238    /// First ~80 chars of the memory text (for human readability).
239    pub text_head: String,
240    /// Total number of times this memory has been cited in the window.
241    pub citation_count: u64,
242    /// Estimated tokens saved by this memory's citations.
243    pub estimated_saved_tokens: u64,
244}
245
246/// Compute per-memory ROI for the top `limit` memories by estimated savings.
247///
248/// Only memories with ≥1 citation in the window are returned.
249pub fn per_memory_roi(
250    conn: &rusqlite::Connection,
251    window: RoiWindow,
252    limit: usize,
253) -> KimetsuResult<Vec<MemoryRoiEntry>> {
254    let window_since: Option<String> = match window {
255        RoiWindow::All => None,
256        RoiWindow::Days(days) => {
257            let secs = days as i64 * 86_400;
258            let now = time::OffsetDateTime::now_utc();
259            let cutoff = now - time::Duration::seconds(secs);
260            let fmt = time::format_description::well_known::Rfc3339;
261            Some(cutoff.format(&fmt).unwrap_or_default())
262        }
263    };
264
265    // Collect (memory_id, citation_count) pairs.
266    struct Row {
267        memory_id: String,
268        count: u64,
269    }
270    let rows: Vec<Row> = match &window_since {
271        Some(ts) => {
272            let mut stmt = conn.prepare(
273                "SELECT mc.memory_id, COUNT(*) \
274                 FROM memory_citations mc \
275                 LEFT JOIN runs r ON mc.run_id = r.run_id \
276                 WHERE r.started_at >= ?1 \
277                    OR (r.run_id IS NULL AND mc.cited_at >= ?1) \
278                 GROUP BY mc.memory_id \
279                 ORDER BY COUNT(*) DESC",
280            )?;
281            let rows = stmt.query_map(params![ts], |r| {
282                Ok(Row {
283                    memory_id: r.get(0)?,
284                    count: r.get(1)?,
285                })
286            })?;
287            rows.collect::<Result<Vec<_>, _>>()?
288        }
289        None => {
290            let mut stmt = conn.prepare(
291                "SELECT memory_id, COUNT(*) FROM memory_citations \
292                 GROUP BY memory_id ORDER BY COUNT(*) DESC",
293            )?;
294            let rows = stmt.query_map([], |r| {
295                Ok(Row {
296                    memory_id: r.get(0)?,
297                    count: r.get(1)?,
298                })
299            })?;
300            rows.collect::<Result<Vec<_>, _>>()?
301        }
302    };
303
304    let mut entries: Vec<MemoryRoiEntry> = Vec::new();
305    for row in rows.into_iter().take(limit) {
306        // Resolve kind and text from the memories table.
307        let memory_row: Option<(String, String)> = conn
308            .query_row(
309                "SELECT kind, text FROM memories WHERE memory_id = ?1",
310                params![row.memory_id],
311                |r| Ok((r.get(0)?, r.get(1)?)),
312            )
313            .optional()?;
314        let (kind_str, text) = memory_row.unwrap_or_else(|| ("fact".to_string(), String::new()));
315        let mk = kind_str.parse::<MemoryKind>().unwrap_or(MemoryKind::Fact);
316        let per_cite = SAVED_TOKENS_PER_CITATION
317            .iter()
318            .find(|(k, _)| k == &mk)
319            .map(|(_, v)| *v as u64)
320            .unwrap_or(0);
321        let estimated_saved = per_cite * row.count;
322        let text_head: String = text.chars().take(80).collect();
323
324        entries.push(MemoryRoiEntry {
325            memory_id: row.memory_id,
326            kind: kind_str,
327            text_head,
328            citation_count: row.count,
329            estimated_saved_tokens: estimated_saved,
330        });
331    }
332
333    // Sort descending by estimated_saved_tokens.
334    entries.sort_by_key(|e| std::cmp::Reverse(e.estimated_saved_tokens));
335    Ok(entries)
336}
337
338// ---------------------------------------------------------------------------
339// Window parsing
340// ---------------------------------------------------------------------------
341
342/// Recognised window strings → days.  Mirrors the CLI arg values.
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum RoiWindow {
345    Days(u32),
346    All,
347}
348
349impl RoiWindow {
350    /// Parse "7d", "30d", or "all".
351    pub fn parse(s: &str) -> Result<Self, String> {
352        match s.trim().to_lowercase().as_str() {
353            "all" => Ok(Self::All),
354            other => {
355                let digits = other.trim_end_matches('d');
356                digits
357                    .parse::<u32>()
358                    .map(Self::Days)
359                    .map_err(|_| format!("invalid window '{s}'; expected '7d', '30d', or 'all'"))
360            }
361        }
362    }
363
364    pub fn days(self) -> Option<u32> {
365        match self {
366            Self::Days(d) => Some(d),
367            Self::All => None,
368        }
369    }
370}
371
372impl Default for RoiWindow {
373    fn default() -> Self {
374        Self::Days(30)
375    }
376}
377
378// ---------------------------------------------------------------------------
379// DB-backed report
380// ---------------------------------------------------------------------------
381
382/// Compute a full ROI report from the project brain.
383///
384/// `window` controls how far back to look.  `price_per_mtok_override`
385/// comes from `[model] price_per_mtok` in `project.toml`; `model_name` is
386/// `[model] model`.
387pub fn roi_report(
388    conn: &rusqlite::Connection,
389    window: RoiWindow,
390    model_name: &str,
391    price_per_mtok_override: Option<f64>,
392) -> KimetsuResult<RoiReport> {
393    // Compute window boundary timestamp (ISO-8601 string).
394    let window_since: Option<String> = match window {
395        RoiWindow::All => None,
396        RoiWindow::Days(days) => {
397            // Compute `now − days` as an ISO string using the `time` crate
398            // that is already a workspace dep.
399            let secs = days as i64 * 86_400;
400            let now = time::OffsetDateTime::now_utc();
401            let cutoff = now - time::Duration::seconds(secs);
402            let fmt = time::format_description::well_known::Rfc3339;
403            Some(cutoff.format(&fmt).unwrap_or_default())
404        }
405    };
406
407    // --- served_events (context.served) ---
408    let served_events: u64 = match &window_since {
409        Some(ts) => conn.query_row(
410            "SELECT COUNT(*) FROM events WHERE kind = 'context.served' AND ts >= ?1",
411            params![ts],
412            |r| r.get(0),
413        )?,
414        None => conn.query_row(
415            "SELECT COUNT(*) FROM events WHERE kind = 'context.served'",
416            [],
417            |r| r.get(0),
418        )?,
419    };
420
421    // S2.4(c): digest_served and resume_served event counts.
422    let digest_served_events: u64 = match &window_since {
423        Some(ts) => conn.query_row(
424            "SELECT COUNT(*) FROM events WHERE kind = 'digest_served' AND ts >= ?1",
425            params![ts],
426            |r| r.get(0),
427        )?,
428        None => conn.query_row(
429            "SELECT COUNT(*) FROM events WHERE kind = 'digest_served'",
430            [],
431            |r| r.get(0),
432        )?,
433    };
434    let resume_served_events: u64 = match &window_since {
435        Some(ts) => conn.query_row(
436            "SELECT COUNT(*) FROM events WHERE kind = 'resume_served' AND ts >= ?1",
437            params![ts],
438            |r| r.get(0),
439        )?,
440        None => conn.query_row(
441            "SELECT COUNT(*) FROM events WHERE kind = 'resume_served'",
442            [],
443            |r| r.get(0),
444        )?,
445    };
446    let warmstart_saved_tokens = digest_served_events * SAVED_TOKENS_PER_DIGEST_SERVED
447        + resume_served_events * SAVED_TOKENS_PER_RESUME_SERVED;
448
449    // --- injected_tokens (sum of used_tokens across context.injected events) ---
450    let injected_tokens: u64 = {
451        let payloads: Vec<String> = match &window_since {
452            Some(ts) => {
453                let mut stmt = conn.prepare(
454                    "SELECT payload_json FROM events WHERE kind = 'context.injected' AND ts >= ?1",
455                )?;
456                let rows = stmt.query_map(params![ts], |r| r.get::<_, String>(0))?;
457                rows.collect::<Result<Vec<_>, _>>()?
458            }
459            None => {
460                let mut stmt = conn
461                    .prepare("SELECT payload_json FROM events WHERE kind = 'context.injected'")?;
462                let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
463                rows.collect::<Result<Vec<_>, _>>()?
464            }
465        };
466        let mut sum: u64 = 0;
467        for p in &payloads {
468            let v: serde_json::Value = serde_json::from_str(p)?;
469            if let Some(t) = v.get("used_tokens").and_then(|x| x.as_u64()) {
470                sum += t;
471            }
472        }
473        sum
474    };
475
476    // --- citations per kind ---
477    // We join memory_citations → memories to get the kind for each citation.
478    // Window applied via run_id → runs.started_at for pipeline runs, or
479    // via the event ts for hook-originated citations.
480    //
481    // Strategy: collect citation rows that belong to runs in the window
482    // (started_at >= window_since) OR, for the sentinel hook run_id
483    // (all-zeroes), filter by the cited_at timestamp.
484    let citations_by_kind: Vec<(MemoryKind, u32)> = {
485        // Collect all (memory_id, count) pairs from citations in window.
486        struct Row {
487            memory_id: String,
488            count: u32,
489        }
490
491        let rows: Vec<Row> = match &window_since {
492            Some(ts) => {
493                let mut stmt = conn.prepare(
494                    "SELECT mc.memory_id, COUNT(*) \
495                     FROM memory_citations mc \
496                     LEFT JOIN runs r ON mc.run_id = r.run_id \
497                     WHERE r.started_at >= ?1 \
498                        OR (r.run_id IS NULL AND mc.cited_at >= ?1) \
499                     GROUP BY mc.memory_id",
500                )?;
501                let rows = stmt.query_map(params![ts], |r| {
502                    Ok(Row {
503                        memory_id: r.get(0)?,
504                        count: r.get(1)?,
505                    })
506                })?;
507                rows.collect::<Result<Vec<_>, _>>()?
508            }
509            None => {
510                let mut stmt = conn.prepare(
511                    "SELECT memory_id, COUNT(*) FROM memory_citations GROUP BY memory_id",
512                )?;
513                let rows = stmt.query_map([], |r| {
514                    Ok(Row {
515                        memory_id: r.get(0)?,
516                        count: r.get(1)?,
517                    })
518                })?;
519                rows.collect::<Result<Vec<_>, _>>()?
520            }
521        };
522
523        // For each memory_id, resolve its kind from the memories table.
524        // Unknown / invalidated memories default to Fact (conservative).
525        let mut by_kind: std::collections::HashMap<MemoryKind, u32> =
526            std::collections::HashMap::new();
527        for row in &rows {
528            let kind: Option<String> = conn
529                .query_row(
530                    "SELECT kind FROM memories WHERE memory_id = ?1",
531                    params![row.memory_id],
532                    |r| r.get(0),
533                )
534                .optional()?;
535            let mk = kind
536                .as_deref()
537                .and_then(|s| s.parse::<MemoryKind>().ok())
538                .unwrap_or(MemoryKind::Fact);
539            *by_kind.entry(mk).or_insert(0) += row.count;
540        }
541        by_kind.into_iter().collect()
542    };
543
544    let total_citations: u64 = citations_by_kind.iter().map(|(_, c)| *c as u64).sum();
545    let citation_saved_tokens = estimate_savings(&citations_by_kind);
546    // S2.4(c): include warm-start savings in the total estimate.
547    let estimated_saved_tokens = citation_saved_tokens + warmstart_saved_tokens;
548    let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
549    // S2.4(b): output token estimate.
550    let estimated_output_tokens = estimate_output_tokens(injected_tokens);
551
552    // --- USD ---
553    let price = resolve_price_per_mtok(model_name, price_per_mtok_override);
554    let usd = price.map(|p_per_mtok| {
555        let saved_usd = estimated_saved_tokens as f64 / 1_000_000.0 * p_per_mtok;
556        let spent_usd = injected_tokens as f64 / 1_000_000.0 * p_per_mtok;
557        RoiUsd {
558            saved: saved_usd,
559            spent: spent_usd,
560            net: saved_usd - spent_usd,
561        }
562    });
563
564    Ok(RoiReport {
565        window_days: window.days(),
566        injected_tokens,
567        estimated_output_tokens,
568        served_events,
569        digest_served_events,
570        resume_served_events,
571        warmstart_saved_tokens,
572        citations: total_citations,
573        estimated_saved_tokens,
574        net_tokens,
575        usd,
576    })
577}
578
579// ---------------------------------------------------------------------------
580// Per-session mini-report (for the Stop hook)
581// ---------------------------------------------------------------------------
582
583/// Compute a per-session ROI mini-report for the Stop hook.
584///
585/// Attribution strategy (conservative):
586/// 1. `context.served` events with matching `session_id` in payload → used
587///    for `served_events` and `injected_tokens`.
588/// 2. Citations: we cannot directly attribute `memory_citations` rows to a
589///    session_id (citations are keyed by run_id, not session_id).  Instead
590///    we fall back to a time-window bounded by the earliest and latest
591///    `context.served` event timestamps for this session.  If session_id
592///    is absent (old hook payload), the time window covers the last 24 hours
593///    as a rough proxy.
594/// 3. ZERO citations → returns `None` (silence; no savings line emitted).
595///
596/// All errors are swallowed and `None` returned — the hook must never fail.
597pub fn session_roi(
598    conn: &rusqlite::Connection,
599    session_id: Option<&str>,
600    model_name: &str,
601    price_per_mtok_override: Option<f64>,
602) -> Option<SessionRoi> {
603    session_roi_inner(conn, session_id, model_name, price_per_mtok_override).unwrap_or(None)
604}
605
606fn session_roi_inner(
607    conn: &rusqlite::Connection,
608    session_id: Option<&str>,
609    model_name: &str,
610    price_per_mtok_override: Option<f64>,
611) -> KimetsuResult<Option<SessionRoi>> {
612    // 1. Find context.served events for this session.
613    let (served_events, injected_tokens, earliest_ts, latest_ts) =
614        session_served_stats(conn, session_id)?;
615
616    // 2. Determine citation time window.
617    let (ts_lo, ts_hi) = match (earliest_ts.as_deref(), latest_ts.as_deref()) {
618        (Some(lo), Some(hi)) => (lo.to_string(), hi.to_string()),
619        _ => {
620            // Fall back to last 24h.
621            let now = time::OffsetDateTime::now_utc();
622            let fmt = time::format_description::well_known::Rfc3339;
623            let lo = (now - time::Duration::seconds(86_400))
624                .format(&fmt)
625                .unwrap_or_default();
626            let hi = now.format(&fmt).unwrap_or_default();
627            (lo, hi)
628        }
629    };
630
631    // 3. Collect citations in the time window.
632    let citations_by_kind = citations_in_window(conn, &ts_lo, &ts_hi)?;
633    let total_citations: u64 = citations_by_kind.iter().map(|(_, c)| *c as u64).sum();
634
635    // Silence when nothing was cited this session.
636    if total_citations == 0 {
637        return Ok(None);
638    }
639
640    let estimated_saved_tokens = estimate_savings(&citations_by_kind);
641    let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
642
643    let price = resolve_price_per_mtok(model_name, price_per_mtok_override);
644    let usd = price.map(|p_per_mtok| {
645        let saved_usd = estimated_saved_tokens as f64 / 1_000_000.0 * p_per_mtok;
646        let spent_usd = injected_tokens as f64 / 1_000_000.0 * p_per_mtok;
647        RoiUsd {
648            saved: saved_usd,
649            spent: spent_usd,
650            net: saved_usd - spent_usd,
651        }
652    });
653
654    Ok(Some(SessionRoi {
655        served_events,
656        injected_tokens,
657        citations: total_citations,
658        estimated_saved_tokens,
659        net_tokens,
660        usd,
661    }))
662}
663
664/// Lightweight per-session ROI summary used by the Stop hook.
665#[derive(Debug, Clone)]
666pub struct SessionRoi {
667    pub served_events: u64,
668    pub injected_tokens: u64,
669    pub citations: u64,
670    pub estimated_saved_tokens: u64,
671    pub net_tokens: i64,
672    pub usd: Option<RoiUsd>,
673}
674
675impl SessionRoi {
676    /// Build a one-line savings sentence for the Stop hook `systemMessage`.
677    /// Returns a human-readable string like:
678    ///   "[Kimetsu] Brain saved ~1 200 tokens (~$0.004) this session."
679    pub fn savings_sentence(&self) -> String {
680        match &self.usd {
681            Some(u) if u.net >= 0.0 => format!(
682                "[Kimetsu] Brain saved ~{} tokens (~${:.4}) this session.",
683                format_tokens(self.estimated_saved_tokens),
684                u.saved,
685            ),
686            Some(u) => format!(
687                "[Kimetsu] Brain used ~{} tokens (net −${:.4}) this session.",
688                format_tokens(self.injected_tokens),
689                u.spent - u.saved,
690            ),
691            None => format!(
692                "[Kimetsu] Brain saved ~{} tokens this session.",
693                format_tokens(self.estimated_saved_tokens),
694            ),
695        }
696    }
697}
698
699fn format_tokens(n: u64) -> String {
700    // Human-friendly: thousands separator via simple manual formatting.
701    if n < 1_000 {
702        return n.to_string();
703    }
704    let s = n.to_string();
705    let mut out = String::new();
706    let rem = s.len() % 3;
707    for (i, ch) in s.chars().enumerate() {
708        if i > 0 && (i % 3 == rem) {
709            out.push(' ');
710        }
711        out.push(ch);
712    }
713    out
714}
715
716// ---------------------------------------------------------------------------
717// Internal helpers
718// ---------------------------------------------------------------------------
719
720/// Returns (served_events, injected_tokens, earliest_ts, latest_ts) for a
721/// given session_id.  When session_id is None the query returns all events.
722fn session_served_stats(
723    conn: &rusqlite::Connection,
724    session_id: Option<&str>,
725) -> KimetsuResult<(u64, u64, Option<String>, Option<String>)> {
726    // context.served events for this session.
727    let served_payloads: Vec<String> = match session_id {
728        Some(sid) => {
729            let mut stmt = conn.prepare(
730                "SELECT payload_json FROM events \
731                 WHERE kind = 'context.served' \
732                   AND json_extract(payload_json, '$.session_id') = ?1",
733            )?;
734            let rows = stmt.query_map(params![sid], |r| r.get::<_, String>(0))?;
735            rows.collect::<Result<Vec<_>, _>>()?
736        }
737        None => {
738            // No session_id available — return empty; caller falls back to 24h window.
739            return Ok((0, 0, None, None));
740        }
741    };
742
743    // Also collect context.injected in the same session window by time.
744    // Derive earliest/latest ts from the served events first.
745    let mut earliest: Option<String> = None;
746    let mut latest: Option<String> = None;
747    let served_count = served_payloads.len() as u64;
748
749    // Parse timestamps from the events table for the served events.
750    if let Some(sid) = session_id {
751        if served_count > 0 {
752            let ts_row: (Option<String>, Option<String>) = conn.query_row(
753                "SELECT MIN(ts), MAX(ts) FROM events \
754                 WHERE kind = 'context.served' \
755                   AND json_extract(payload_json, '$.session_id') = ?1",
756                params![sid],
757                |r| Ok((r.get(0)?, r.get(1)?)),
758            )?;
759            earliest = ts_row.0;
760            latest = ts_row.1;
761        }
762    }
763
764    // Injected tokens: sum from context.injected events in [earliest, latest].
765    let injected_tokens: u64 = match (earliest.as_deref(), latest.as_deref()) {
766        (Some(lo), Some(hi)) => {
767            let payloads: Vec<String> = {
768                let mut stmt = conn.prepare(
769                    "SELECT payload_json FROM events \
770                     WHERE kind = 'context.injected' AND ts >= ?1 AND ts <= ?2",
771                )?;
772                let rows = stmt.query_map(params![lo, hi], |r| r.get::<_, String>(0))?;
773                rows.collect::<Result<Vec<_>, _>>()?
774            };
775            let mut sum: u64 = 0;
776            for p in &payloads {
777                let v: serde_json::Value = serde_json::from_str(p)?;
778                if let Some(t) = v.get("used_tokens").and_then(|x| x.as_u64()) {
779                    sum += t;
780                }
781            }
782            sum
783        }
784        _ => 0,
785    };
786
787    Ok((served_count, injected_tokens, earliest, latest))
788}
789
790/// Collect (MemoryKind, count) citation pairs for citations whose `cited_at`
791/// falls in `[ts_lo, ts_hi]`.
792fn citations_in_window(
793    conn: &rusqlite::Connection,
794    ts_lo: &str,
795    ts_hi: &str,
796) -> KimetsuResult<Vec<(MemoryKind, u32)>> {
797    struct Row {
798        memory_id: String,
799        count: u32,
800    }
801    let mut stmt = conn.prepare(
802        "SELECT memory_id, COUNT(*) FROM memory_citations \
803         WHERE cited_at >= ?1 AND cited_at <= ?2 \
804         GROUP BY memory_id",
805    )?;
806    let rows = stmt.query_map(params![ts_lo, ts_hi], |r| {
807        Ok(Row {
808            memory_id: r.get(0)?,
809            count: r.get(1)?,
810        })
811    })?;
812    let rows: Vec<Row> = rows.collect::<Result<Vec<_>, _>>()?;
813
814    let mut by_kind: std::collections::HashMap<MemoryKind, u32> = std::collections::HashMap::new();
815    for row in &rows {
816        let kind: Option<String> = conn
817            .query_row(
818                "SELECT kind FROM memories WHERE memory_id = ?1",
819                params![row.memory_id],
820                |r| r.get(0),
821            )
822            .optional()?;
823        let mk = kind
824            .as_deref()
825            .and_then(|s| s.parse::<MemoryKind>().ok())
826            .unwrap_or(MemoryKind::Fact);
827        *by_kind.entry(mk).or_insert(0) += row.count;
828    }
829    Ok(by_kind.into_iter().collect())
830}
831
832// ---------------------------------------------------------------------------
833// Tests
834// ---------------------------------------------------------------------------
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use kimetsu_core::memory::MemoryKind;
840
841    // --- Pure function tests ---
842
843    #[test]
844    fn estimate_savings_zero_when_empty() {
845        assert_eq!(estimate_savings(&[]), 0);
846    }
847
848    #[test]
849    fn estimate_savings_single_kind() {
850        // 2 failure_pattern citations × 1500 = 3000
851        assert_eq!(estimate_savings(&[(MemoryKind::FailurePattern, 2)]), 3_000);
852    }
853
854    #[test]
855    fn estimate_savings_multi_kind() {
856        let citations = vec![
857            (MemoryKind::FailurePattern, 1), // 1500
858            (MemoryKind::Command, 2),        // 800
859            (MemoryKind::Convention, 1),     // 300
860            (MemoryKind::Fact, 1),           // 500
861            (MemoryKind::Preference, 3),     // 600
862        ];
863        assert_eq!(estimate_savings(&citations), 1500 + 800 + 300 + 500 + 600);
864    }
865
866    #[test]
867    fn estimate_savings_all_kinds_covered() {
868        // Every kind must appear in SAVED_TOKENS_PER_CITATION.
869        for kind in [
870            MemoryKind::FailurePattern,
871            MemoryKind::Command,
872            MemoryKind::Convention,
873            MemoryKind::Fact,
874            MemoryKind::Preference,
875        ] {
876            let v = SAVED_TOKENS_PER_CITATION
877                .iter()
878                .find(|(k, _)| k == &kind)
879                .map(|(_, v)| *v);
880            assert!(
881                v.is_some(),
882                "kind {:?} missing from SAVED_TOKENS_PER_CITATION",
883                kind
884            );
885            assert!(v.unwrap() > 0, "kind {:?} has zero constant", kind);
886        }
887    }
888
889    #[test]
890    fn resolve_price_override_wins() {
891        assert_eq!(
892            resolve_price_per_mtok("claude-sonnet-4-7", Some(5.0)),
893            Some(5.0)
894        );
895    }
896
897    #[test]
898    fn resolve_price_known_model() {
899        let p = resolve_price_per_mtok("claude-sonnet-4-7", None);
900        assert!(p.is_some(), "claude-sonnet-4 should match");
901        assert!((p.unwrap() - 3.0).abs() < 1e-9);
902    }
903
904    #[test]
905    fn resolve_price_unknown_model_none() {
906        assert!(resolve_price_per_mtok("my-custom-llm-v9", None).is_none());
907    }
908
909    #[test]
910    fn resolve_price_longest_prefix_wins() {
911        // "claude-opus-4" and "claude-opus-4" — make sure haiku doesn't
912        // match opus prefix.
913        let opus_p = resolve_price_per_mtok("claude-opus-4-5", None).unwrap_or(0.0);
914        let haiku_p = resolve_price_per_mtok("claude-haiku-4-5", None).unwrap_or(0.0);
915        assert!(opus_p > haiku_p, "opus should be more expensive than haiku");
916    }
917
918    #[test]
919    fn roi_window_parse() {
920        assert_eq!(RoiWindow::parse("7d").unwrap(), RoiWindow::Days(7));
921        assert_eq!(RoiWindow::parse("30d").unwrap(), RoiWindow::Days(30));
922        assert_eq!(RoiWindow::parse("all").unwrap(), RoiWindow::All);
923        assert_eq!(RoiWindow::parse("ALL").unwrap(), RoiWindow::All);
924        assert!(RoiWindow::parse("bad").is_err());
925    }
926
927    #[test]
928    fn format_tokens_below_1000() {
929        assert_eq!(format_tokens(42), "42");
930        assert_eq!(format_tokens(999), "999");
931    }
932
933    #[test]
934    fn format_tokens_thousands() {
935        assert_eq!(format_tokens(1_000), "1 000");
936        assert_eq!(format_tokens(12_345), "12 345");
937        assert_eq!(format_tokens(1_234_567), "1 234 567");
938    }
939
940    // --- DB-backed tests ---
941
942    use crate::{
943        project::{init_project, load_project},
944        projector,
945        user_brain::with_user_brain_disabled,
946    };
947    use kimetsu_core::{event::Event, ids::RunId, memory::MemoryScope};
948    use ulid::Ulid;
949
950    fn test_root() -> std::path::PathBuf {
951        let root = std::env::temp_dir().join(format!("kimetsu-roi-test-{}", Ulid::new()));
952        kimetsu_core::paths::git_init_boundary(&root);
953        root
954    }
955
956    fn seed_memory(root: &std::path::Path, kind: MemoryKind, text: &str) -> String {
957        crate::project::add_memory(root, MemoryScope::Project, kind, text).expect("add_memory")
958    }
959
960    fn seed_injected_event(conn: &rusqlite::Connection, run_id: RunId, used_tokens: u64) {
961        let ev = Event::new(
962            run_id,
963            "context.injected",
964            serde_json::json!({
965                "stage": "localization",
966                "memory_ids": [],
967                "used_tokens": used_tokens,
968                "capsule_count": 1,
969            }),
970        );
971        projector::apply_events(conn, &[ev]).expect("seed injected");
972    }
973
974    fn seed_citation(conn: &rusqlite::Connection, run_id: RunId, memory_id: &str, turn: i64) {
975        let ev = Event::new(
976            run_id,
977            "memory.cited",
978            serde_json::json!({
979                "memory_id": memory_id,
980                "turn": turn,
981            }),
982        );
983        projector::apply_events(conn, &[ev]).expect("seed citation");
984    }
985
986    #[test]
987    fn roi_report_empty_db_returns_zeros() {
988        with_user_brain_disabled(|| {
989            let root = test_root();
990            init_project(&root, false).expect("init");
991            let (_paths, config, conn) = load_project(&root).expect("load");
992            let report =
993                roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
994            assert_eq!(report.injected_tokens, 0);
995            assert_eq!(report.citations, 0);
996            assert_eq!(report.estimated_saved_tokens, 0);
997            assert_eq!(report.net_tokens, 0);
998        });
999    }
1000
1001    #[test]
1002    fn roi_report_with_citations_computes_savings() {
1003        with_user_brain_disabled(|| {
1004            let root = test_root();
1005            init_project(&root, false).expect("init");
1006            let m1 = seed_memory(&root, MemoryKind::FailurePattern, "fp1");
1007            let (_paths, config, conn) = load_project(&root).expect("load");
1008            let run_id = RunId::new();
1009            seed_injected_event(&conn, run_id, 300);
1010            seed_citation(&conn, run_id, &m1, 1);
1011
1012            let report =
1013                roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
1014            // 1 failure_pattern citation = 1500 saved tokens.
1015            assert_eq!(report.estimated_saved_tokens, 1500);
1016            assert_eq!(report.injected_tokens, 300);
1017            assert_eq!(report.net_tokens, 1500 - 300);
1018            assert_eq!(report.citations, 1);
1019        });
1020    }
1021
1022    #[test]
1023    fn roi_report_negative_net_when_overhead_exceeds_savings() {
1024        with_user_brain_disabled(|| {
1025            let root = test_root();
1026            init_project(&root, false).expect("init");
1027            let m1 = seed_memory(&root, MemoryKind::Preference, "pref1");
1028            let (_paths, config, conn) = load_project(&root).expect("load");
1029            let run_id = RunId::new();
1030            // Inject 500 tokens but cite 1 preference (200 saved) → net = -300.
1031            seed_injected_event(&conn, run_id, 500);
1032            seed_citation(&conn, run_id, &m1, 1);
1033
1034            let report =
1035                roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
1036            assert_eq!(report.estimated_saved_tokens, 200);
1037            assert_eq!(report.net_tokens, 200 - 500); // −300
1038        });
1039    }
1040
1041    #[test]
1042    fn roi_report_usd_with_known_model() {
1043        with_user_brain_disabled(|| {
1044            let root = test_root();
1045            init_project(&root, false).expect("init");
1046            let m1 = seed_memory(&root, MemoryKind::Command, "cmd1");
1047            let (_paths, _config, conn) = load_project(&root).expect("load");
1048            let run_id = RunId::new();
1049            seed_injected_event(&conn, run_id, 200);
1050            seed_citation(&conn, run_id, &m1, 1);
1051
1052            // Use a known model directly.
1053            let report =
1054                roi_report(&conn, RoiWindow::All, "claude-sonnet-4-7", None).expect("roi_report");
1055            let usd = report.usd.expect("usd must be Some for known model");
1056            // 400 saved tokens @ $3/MTok = $0.0012
1057            assert!((usd.saved - 400.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
1058            // 200 injected tokens @ $3/MTok
1059            assert!((usd.spent - 200.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
1060            assert!((usd.net - (usd.saved - usd.spent)).abs() < 1e-12);
1061        });
1062    }
1063
1064    #[test]
1065    fn roi_report_usd_with_override() {
1066        with_user_brain_disabled(|| {
1067            let root = test_root();
1068            init_project(&root, false).expect("init");
1069            let m1 = seed_memory(&root, MemoryKind::Fact, "fact1");
1070            let (_paths, _config, conn) = load_project(&root).expect("load");
1071            let run_id = RunId::new();
1072            seed_injected_event(&conn, run_id, 0);
1073            seed_citation(&conn, run_id, &m1, 1);
1074
1075            let report =
1076                roi_report(&conn, RoiWindow::All, "my-custom-llm", Some(10.0)).expect("roi_report");
1077            // 500 saved tokens @ $10/MTok = $0.005
1078            let usd = report.usd.expect("usd with override");
1079            assert!((usd.saved - 500.0 / 1_000_000.0 * 10.0).abs() < 1e-9);
1080        });
1081    }
1082
1083    #[test]
1084    fn roi_report_unknown_model_no_usd() {
1085        with_user_brain_disabled(|| {
1086            let root = test_root();
1087            init_project(&root, false).expect("init");
1088            let (_paths, _config, conn) = load_project(&root).expect("load");
1089
1090            let report = roi_report(&conn, RoiWindow::All, "totally-unknown-llm-xyz", None)
1091                .expect("roi_report");
1092            assert!(report.usd.is_none(), "usd must be None for unknown model");
1093        });
1094    }
1095
1096    // ── S2.4 tests ────────────────────────────────────────────────────────────
1097
1098    fn seed_event(conn: &rusqlite::Connection, kind: &str, payload: serde_json::Value) {
1099        let ev = Event::new(RunId::new(), kind, payload);
1100        projector::apply_events(conn, &[ev]).expect("seed event");
1101    }
1102
1103    #[test]
1104    fn roi_report_output_token_estimate_is_quarter_of_input() {
1105        with_user_brain_disabled(|| {
1106            let root = test_root();
1107            init_project(&root, false).expect("init");
1108            let (_paths, _config, conn) = load_project(&root).expect("load");
1109            let run_id = RunId::new();
1110            seed_injected_event(&conn, run_id, 4_000);
1111
1112            let report =
1113                roi_report(&conn, RoiWindow::All, "claude-sonnet-4", None).expect("roi_report");
1114            // 4000 * 0.25 = 1000
1115            assert_eq!(
1116                report.estimated_output_tokens, 1_000,
1117                "output token estimate must be 0.25 × input"
1118            );
1119        });
1120    }
1121
1122    #[test]
1123    fn roi_report_digest_served_adds_savings() {
1124        with_user_brain_disabled(|| {
1125            let root = test_root();
1126            init_project(&root, false).expect("init");
1127            let (_paths, _config, conn) = load_project(&root).expect("load");
1128
1129            seed_event(
1130                &conn,
1131                "digest_served",
1132                serde_json::json!({"digest_chars": 800, "approx_tokens": 200}),
1133            );
1134            seed_event(
1135                &conn,
1136                "resume_served",
1137                serde_json::json!({"resume_chars": 400, "approx_tokens": 100}),
1138            );
1139
1140            let report =
1141                roi_report(&conn, RoiWindow::All, "unknown-model", None).expect("roi_report");
1142            assert_eq!(report.digest_served_events, 1);
1143            assert_eq!(report.resume_served_events, 1);
1144            let expected_warmstart =
1145                SAVED_TOKENS_PER_DIGEST_SERVED + SAVED_TOKENS_PER_RESUME_SERVED;
1146            assert_eq!(
1147                report.warmstart_saved_tokens, expected_warmstart,
1148                "warmstart_saved_tokens must sum digest+resume"
1149            );
1150            assert_eq!(
1151                report.estimated_saved_tokens, expected_warmstart,
1152                "total savings must include warmstart (no citations here)"
1153            );
1154        });
1155    }
1156
1157    #[test]
1158    fn per_memory_roi_top_entries_sorted_by_savings() {
1159        with_user_brain_disabled(|| {
1160            let root = test_root();
1161            init_project(&root, false).expect("init");
1162            // Add two memories of different kinds.
1163            let fp_id = seed_memory(&root, MemoryKind::FailurePattern, "fp roi test");
1164            let cmd_id = seed_memory(&root, MemoryKind::Command, "cmd roi test");
1165            let (_paths, _config, conn) = load_project(&root).expect("load");
1166
1167            let run_id = RunId::new();
1168            // 1 failure_pattern cite (1500 saved) + 3 command cites (3×400=1200).
1169            seed_citation(&conn, run_id, &fp_id, 1);
1170            seed_citation(&conn, run_id, &cmd_id, 2);
1171            seed_citation(&conn, run_id, &cmd_id, 3);
1172            seed_citation(&conn, run_id, &cmd_id, 4);
1173
1174            let entries = per_memory_roi(&conn, RoiWindow::All, 10).expect("per_memory_roi");
1175            assert!(!entries.is_empty(), "must have entries");
1176            // FailurePattern (1500) > Command×3 (1200) → fp must come first.
1177            assert_eq!(
1178                entries[0].memory_id, fp_id,
1179                "failure_pattern cite must rank first by savings"
1180            );
1181            assert_eq!(entries[0].estimated_saved_tokens, 1500);
1182            assert_eq!(entries[0].citation_count, 1);
1183
1184            let cmd_entry = entries
1185                .iter()
1186                .find(|e| e.memory_id == cmd_id)
1187                .expect("cmd entry");
1188            assert_eq!(cmd_entry.citation_count, 3);
1189            assert_eq!(cmd_entry.estimated_saved_tokens, 1200);
1190
1191            std::fs::remove_dir_all(&root).ok();
1192        });
1193    }
1194
1195    #[test]
1196    fn per_memory_roi_respects_top_limit() {
1197        with_user_brain_disabled(|| {
1198            let root = test_root();
1199            init_project(&root, false).expect("init");
1200            let m1 = seed_memory(&root, MemoryKind::Fact, "fact1");
1201            let m2 = seed_memory(&root, MemoryKind::Fact, "fact2");
1202            let m3 = seed_memory(&root, MemoryKind::Fact, "fact3");
1203            let (_paths, _config, conn) = load_project(&root).expect("load");
1204            let run_id = RunId::new();
1205            seed_citation(&conn, run_id, &m1, 1);
1206            seed_citation(&conn, run_id, &m2, 2);
1207            seed_citation(&conn, run_id, &m3, 3);
1208
1209            let entries = per_memory_roi(&conn, RoiWindow::All, 2).expect("per_memory_roi limit");
1210            assert_eq!(entries.len(), 2, "must respect top limit");
1211            std::fs::remove_dir_all(&root).ok();
1212        });
1213    }
1214
1215    #[test]
1216    fn estimate_output_tokens_quarter_ratio() {
1217        assert_eq!(estimate_output_tokens(4_000), 1_000);
1218        assert_eq!(estimate_output_tokens(0), 0);
1219        assert_eq!(estimate_output_tokens(1_000), 250);
1220    }
1221
1222    #[test]
1223    fn session_roi_returns_none_when_no_citations() {
1224        with_user_brain_disabled(|| {
1225            let root = test_root();
1226            init_project(&root, false).expect("init");
1227            let (_paths, _config, conn) = load_project(&root).expect("load");
1228
1229            let result = session_roi(&conn, Some("sess-abc"), "claude-sonnet-4", None);
1230            assert!(result.is_none(), "no citations → no session roi");
1231        });
1232    }
1233
1234    #[test]
1235    fn savings_sentence_positive_no_usd() {
1236        let sr = SessionRoi {
1237            served_events: 3,
1238            injected_tokens: 100,
1239            citations: 2,
1240            estimated_saved_tokens: 1200,
1241            net_tokens: 1100,
1242            usd: None,
1243        };
1244        let s = sr.savings_sentence();
1245        assert!(s.contains("1 200"), "expected formatted token count");
1246        assert!(s.contains("[Kimetsu]"), "must have brand prefix");
1247    }
1248
1249    #[test]
1250    fn savings_sentence_positive_with_usd() {
1251        let sr = SessionRoi {
1252            served_events: 3,
1253            injected_tokens: 100,
1254            citations: 2,
1255            estimated_saved_tokens: 1500,
1256            net_tokens: 1400,
1257            usd: Some(RoiUsd {
1258                saved: 0.0045,
1259                spent: 0.0003,
1260                net: 0.0042,
1261            }),
1262        };
1263        let s = sr.savings_sentence();
1264        assert!(s.contains("$"), "must include dollar sign when usd present");
1265    }
1266}