1use kimetsu_core::{KimetsuResult, memory::MemoryKind};
14use rusqlite::{OptionalExtension, params};
15use serde::Serialize;
16
17pub const OUTPUT_TOKEN_INPUT_RATIO: f64 = 0.25;
32
33pub fn estimate_output_tokens(input_tokens: u64) -> u64 {
39 (input_tokens as f64 * OUTPUT_TOKEN_INPUT_RATIO).round() as u64
40}
41
42pub const SAVED_TOKENS_PER_DIGEST_SERVED: u64 = 800;
53
54pub const SAVED_TOKENS_PER_RESUME_SERVED: u64 = 500;
60
61pub const SAVED_TOKENS_PER_SKILL_SERVED: u64 = 300;
67
68pub const SAVED_TOKENS_PER_CITATION: &[(MemoryKind, u32)] = &[
88 (MemoryKind::FailurePattern, 1500),
89 (MemoryKind::Command, 400),
90 (MemoryKind::Convention, 300),
91 (MemoryKind::Fact, 500),
92 (MemoryKind::Preference, 200),
93];
94
95const BUILTIN_PRICE_TABLE: &[(&str, f64)] = &[
109 ("claude-opus-4", 15.00),
111 ("claude-sonnet-4", 3.00),
112 ("claude-haiku-4", 0.80),
113 ("claude-3-opus", 15.00),
115 ("claude-3-5-sonnet", 3.00),
116 ("claude-3-5-haiku", 0.80),
117 ("claude-3-sonnet", 3.00),
118 ("claude-3-haiku", 0.25),
119 ("us.anthropic.claude-opus-4", 15.00),
121 ("us.anthropic.claude-sonnet-4", 3.00),
122 ("us.anthropic.claude-haiku-4", 0.80),
123 ("gpt-5", 2.00),
125 ("gpt-4o", 2.50),
126 ("gpt-4-turbo", 10.00),
127 ("gpt-4", 30.00),
128];
129
130pub fn resolve_price_per_mtok(model: &str, price_override: Option<f64>) -> Option<f64> {
136 if let Some(p) = price_override {
137 return Some(p);
138 }
139 let model_lower = model.to_lowercase();
140 let mut best: Option<(&str, f64)> = None;
142 for (prefix, price) in BUILTIN_PRICE_TABLE {
143 if model_lower.starts_with(prefix) && best.is_none_or(|(b, _)| prefix.len() > b.len()) {
144 best = Some((prefix, *price));
145 }
146 }
147 best.map(|(_, p)| p)
148}
149
150pub fn estimate_savings(citations: &[(MemoryKind, u32)]) -> u64 {
163 citations
164 .iter()
165 .map(|(kind, count)| {
166 let per = SAVED_TOKENS_PER_CITATION
167 .iter()
168 .find(|(k, _)| k == kind)
169 .map(|(_, v)| *v as u64)
170 .unwrap_or(0);
171 per * (*count as u64)
172 })
173 .sum()
174}
175
176#[derive(Debug, Clone, Serialize)]
182pub struct RoiUsd {
183 pub saved: f64,
185 pub spent: f64,
188 pub net: f64,
191}
192
193#[derive(Debug, Clone, Serialize)]
195pub struct RoiReport {
196 pub estimate_label: &'static str,
197 pub model: String,
198 pub assumptions: serde_json::Value,
199 pub delivered_cost_by_unit: std::collections::BTreeMap<String, u64>,
201 pub window_days: Option<u32>,
203 pub injected_tokens: u64,
206 pub estimated_output_tokens: u64,
212 pub served_events: u64,
214 pub digest_served_events: u64,
216 pub resume_served_events: u64,
218 pub warmstart_saved_tokens: u64,
220 pub citations: u64,
223 pub estimated_saved_tokens: u64,
225 pub net_tokens: i64,
227 pub usd: Option<RoiUsd>,
229}
230
231#[derive(Debug, Clone, Serialize)]
237pub struct MemoryRoiEntry {
238 pub memory_id: String,
239 pub kind: String,
240 pub text_head: String,
242 pub citation_count: u64,
244 pub estimated_saved_tokens: u64,
246}
247
248pub fn per_memory_roi(
252 conn: &rusqlite::Connection,
253 window: RoiWindow,
254 limit: usize,
255) -> KimetsuResult<Vec<MemoryRoiEntry>> {
256 let window_since: Option<String> = match window {
257 RoiWindow::All => None,
258 RoiWindow::Days(days) => {
259 let secs = days as i64 * 86_400;
260 let now = time::OffsetDateTime::now_utc();
261 let cutoff = now - time::Duration::seconds(secs);
262 let fmt = time::format_description::well_known::Rfc3339;
263 Some(cutoff.format(&fmt).unwrap_or_default())
264 }
265 };
266
267 struct Row {
269 memory_id: String,
270 count: u64,
271 }
272 let rows: Vec<Row> = match &window_since {
273 Some(ts) => {
274 let mut stmt = conn.prepare(
275 "SELECT mc.memory_id, COUNT(*) \
276 FROM memory_citations mc \
277 LEFT JOIN runs r ON mc.run_id = r.run_id \
278 WHERE r.started_at >= ?1 \
279 OR (r.run_id IS NULL AND mc.cited_at >= ?1) \
280 GROUP BY mc.memory_id \
281 ORDER BY COUNT(*) DESC",
282 )?;
283 let rows = stmt.query_map(params![ts], |r| {
284 Ok(Row {
285 memory_id: r.get(0)?,
286 count: r.get(1)?,
287 })
288 })?;
289 rows.collect::<Result<Vec<_>, _>>()?
290 }
291 None => {
292 let mut stmt = conn.prepare(
293 "SELECT memory_id, COUNT(*) FROM memory_citations \
294 GROUP BY memory_id ORDER BY COUNT(*) DESC",
295 )?;
296 let rows = stmt.query_map([], |r| {
297 Ok(Row {
298 memory_id: r.get(0)?,
299 count: r.get(1)?,
300 })
301 })?;
302 rows.collect::<Result<Vec<_>, _>>()?
303 }
304 };
305
306 let mut entries: Vec<MemoryRoiEntry> = Vec::new();
307 for row in rows.into_iter().take(limit) {
308 let memory_row: Option<(String, String)> = conn
310 .query_row(
311 "SELECT kind, text FROM memories WHERE memory_id = ?1",
312 params![row.memory_id],
313 |r| Ok((r.get(0)?, r.get(1)?)),
314 )
315 .optional()?;
316 let (kind_str, text) = memory_row.unwrap_or_else(|| ("fact".to_string(), String::new()));
317 let mk = kind_str.parse::<MemoryKind>().unwrap_or(MemoryKind::Fact);
318 let per_cite = SAVED_TOKENS_PER_CITATION
319 .iter()
320 .find(|(k, _)| k == &mk)
321 .map(|(_, v)| *v as u64)
322 .unwrap_or(0);
323 let estimated_saved = per_cite * row.count;
324 let text_head: String = text.chars().take(80).collect();
325
326 entries.push(MemoryRoiEntry {
327 memory_id: row.memory_id,
328 kind: kind_str,
329 text_head,
330 citation_count: row.count,
331 estimated_saved_tokens: estimated_saved,
332 });
333 }
334
335 entries.sort_by_key(|e| std::cmp::Reverse(e.estimated_saved_tokens));
337 Ok(entries)
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum RoiWindow {
347 Days(u32),
348 All,
349}
350
351impl RoiWindow {
352 pub fn parse(s: &str) -> Result<Self, String> {
354 match s.trim().to_lowercase().as_str() {
355 "all" => Ok(Self::All),
356 other => {
357 let digits = other.trim_end_matches('d');
358 digits
359 .parse::<u32>()
360 .map(Self::Days)
361 .map_err(|_| format!("invalid window '{s}'; expected '7d', '30d', or 'all'"))
362 }
363 }
364 }
365
366 pub fn days(self) -> Option<u32> {
367 match self {
368 Self::Days(d) => Some(d),
369 Self::All => None,
370 }
371 }
372}
373
374impl Default for RoiWindow {
375 fn default() -> Self {
376 Self::Days(30)
377 }
378}
379
380pub fn roi_report(
390 conn: &rusqlite::Connection,
391 window: RoiWindow,
392 model_name: &str,
393 price_per_mtok_override: Option<f64>,
394) -> KimetsuResult<RoiReport> {
395 let window_since: Option<String> = match window {
397 RoiWindow::All => None,
398 RoiWindow::Days(days) => {
399 let secs = days as i64 * 86_400;
402 let now = time::OffsetDateTime::now_utc();
403 let cutoff = now - time::Duration::seconds(secs);
404 let fmt = time::format_description::well_known::Rfc3339;
405 Some(cutoff.format(&fmt).unwrap_or_default())
406 }
407 };
408
409 let served_events: u64 = match &window_since {
411 Some(ts) => conn.query_row(
412 "SELECT COUNT(*) FROM events WHERE kind = 'context.served' AND ts >= ?1",
413 params![ts],
414 |r| r.get(0),
415 )?,
416 None => conn.query_row(
417 "SELECT COUNT(*) FROM events WHERE kind = 'context.served'",
418 [],
419 |r| r.get(0),
420 )?,
421 };
422
423 let digest_served_events: u64 = match &window_since {
425 Some(ts) => conn.query_row(
426 "SELECT COUNT(*) FROM events WHERE kind = 'digest_served' AND ts >= ?1",
427 params![ts],
428 |r| r.get(0),
429 )?,
430 None => conn.query_row(
431 "SELECT COUNT(*) FROM events WHERE kind = 'digest_served'",
432 [],
433 |r| r.get(0),
434 )?,
435 };
436 let resume_served_events: u64 = match &window_since {
437 Some(ts) => conn.query_row(
438 "SELECT COUNT(*) FROM events WHERE kind = 'resume_served' AND ts >= ?1",
439 params![ts],
440 |r| r.get(0),
441 )?,
442 None => conn.query_row(
443 "SELECT COUNT(*) FROM events WHERE kind = 'resume_served'",
444 [],
445 |r| r.get(0),
446 )?,
447 };
448 let warmstart_saved_tokens = digest_served_events * SAVED_TOKENS_PER_DIGEST_SERVED
449 + resume_served_events * SAVED_TOKENS_PER_RESUME_SERVED;
450
451 let mut delivered_cost_by_unit = std::collections::BTreeMap::<String, u64>::new();
453 let injected_tokens: u64 = {
454 let payloads: Vec<String> = match &window_since {
455 Some(ts) => {
456 let mut stmt = conn.prepare(
457 "SELECT payload_json FROM events WHERE kind = 'context.injected' AND ts >= ?1",
458 )?;
459 let rows = stmt.query_map(params![ts], |r| r.get::<_, String>(0))?;
460 rows.collect::<Result<Vec<_>, _>>()?
461 }
462 None => {
463 let mut stmt = conn
464 .prepare("SELECT payload_json FROM events WHERE kind = 'context.injected'")?;
465 let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
466 rows.collect::<Result<Vec<_>, _>>()?
467 }
468 };
469 let mut sum: u64 = 0;
470 for p in &payloads {
471 let v: serde_json::Value = serde_json::from_str(p)?;
472 if let Some(t) = v.get("used_tokens").and_then(|x| x.as_u64()) {
473 *delivered_cost_by_unit
474 .entry(
475 v.get("cost_unit")
476 .and_then(|x| x.as_str())
477 .unwrap_or("legacy_token_estimate")
478 .to_string(),
479 )
480 .or_default() += t;
481 sum += t;
482 }
483 }
484 sum
485 };
486
487 let citations_by_kind: Vec<(MemoryKind, u32)> = {
496 struct Row {
498 memory_id: String,
499 count: u32,
500 }
501
502 let rows: Vec<Row> = match &window_since {
503 Some(ts) => {
504 let mut stmt = conn.prepare(
505 "SELECT mc.memory_id, COUNT(*) \
506 FROM memory_citations mc \
507 LEFT JOIN runs r ON mc.run_id = r.run_id \
508 WHERE r.started_at >= ?1 \
509 OR (r.run_id IS NULL AND mc.cited_at >= ?1) \
510 GROUP BY mc.memory_id",
511 )?;
512 let rows = stmt.query_map(params![ts], |r| {
513 Ok(Row {
514 memory_id: r.get(0)?,
515 count: r.get(1)?,
516 })
517 })?;
518 rows.collect::<Result<Vec<_>, _>>()?
519 }
520 None => {
521 let mut stmt = conn.prepare(
522 "SELECT memory_id, COUNT(*) FROM memory_citations GROUP BY memory_id",
523 )?;
524 let rows = stmt.query_map([], |r| {
525 Ok(Row {
526 memory_id: r.get(0)?,
527 count: r.get(1)?,
528 })
529 })?;
530 rows.collect::<Result<Vec<_>, _>>()?
531 }
532 };
533
534 let mut by_kind: std::collections::HashMap<MemoryKind, u32> =
537 std::collections::HashMap::new();
538 for row in &rows {
539 let kind: Option<String> = conn
540 .query_row(
541 "SELECT kind FROM memories WHERE memory_id = ?1",
542 params![row.memory_id],
543 |r| r.get(0),
544 )
545 .optional()?;
546 let mk = kind
547 .as_deref()
548 .and_then(|s| s.parse::<MemoryKind>().ok())
549 .unwrap_or(MemoryKind::Fact);
550 *by_kind.entry(mk).or_insert(0) += row.count;
551 }
552 by_kind.into_iter().collect()
553 };
554
555 let total_citations: u64 = citations_by_kind.iter().map(|(_, c)| *c as u64).sum();
556 let citation_saved_tokens = estimate_savings(&citations_by_kind);
557 let estimated_saved_tokens = citation_saved_tokens + warmstart_saved_tokens;
559 let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
560 let estimated_output_tokens = estimate_output_tokens(injected_tokens);
562
563 let price = resolve_price_per_mtok(model_name, price_per_mtok_override);
565 let usd = price.map(|p_per_mtok| {
566 let saved_usd = estimated_saved_tokens as f64 / 1_000_000.0 * p_per_mtok;
567 let spent_usd = injected_tokens as f64 / 1_000_000.0 * p_per_mtok;
568 RoiUsd {
569 saved: saved_usd,
570 spent: spent_usd,
571 net: saved_usd - spent_usd,
572 }
573 });
574
575 Ok(RoiReport {
576 estimate_label: "Assumption-based estimate; savings are not measured or guaranteed",
577 model: model_name.to_string(),
578 assumptions: serde_json::json!({"tokens_per_citation":SAVED_TOKENS_PER_CITATION.iter().map(|(kind,n)|(kind.to_string(),*n)).collect::<std::collections::BTreeMap<_,_>>(),"digest":SAVED_TOKENS_PER_DIGEST_SERVED,"resume":SAVED_TOKENS_PER_RESUME_SERVED,"output_input_ratio":OUTPUT_TOKEN_INPUT_RATIO,"price_per_mtok":price,"overhead":"legacy estimate combines producer costs; see delivered_cost_by_unit for observed units"}),
579 delivered_cost_by_unit,
580 window_days: window.days(),
581 injected_tokens,
582 estimated_output_tokens,
583 served_events,
584 digest_served_events,
585 resume_served_events,
586 warmstart_saved_tokens,
587 citations: total_citations,
588 estimated_saved_tokens,
589 net_tokens,
590 usd,
591 })
592}
593
594pub fn session_roi(
613 conn: &rusqlite::Connection,
614 session_id: Option<&str>,
615 model_name: &str,
616 price_per_mtok_override: Option<f64>,
617) -> Option<SessionRoi> {
618 session_roi_inner(conn, session_id, model_name, price_per_mtok_override).unwrap_or(None)
619}
620
621fn session_roi_inner(
622 conn: &rusqlite::Connection,
623 session_id: Option<&str>,
624 model_name: &str,
625 price_per_mtok_override: Option<f64>,
626) -> KimetsuResult<Option<SessionRoi>> {
627 let (served_events, injected_tokens, earliest_ts, latest_ts) =
629 session_served_stats(conn, session_id)?;
630
631 let (ts_lo, ts_hi) = match (earliest_ts.as_deref(), latest_ts.as_deref()) {
633 (Some(lo), Some(hi)) => (lo.to_string(), hi.to_string()),
634 _ => {
635 let now = time::OffsetDateTime::now_utc();
637 let fmt = time::format_description::well_known::Rfc3339;
638 let lo = (now - time::Duration::seconds(86_400))
639 .format(&fmt)
640 .unwrap_or_default();
641 let hi = now.format(&fmt).unwrap_or_default();
642 (lo, hi)
643 }
644 };
645
646 let citations_by_kind = citations_in_window(conn, &ts_lo, &ts_hi)?;
648 let total_citations: u64 = citations_by_kind.iter().map(|(_, c)| *c as u64).sum();
649
650 if total_citations == 0 {
652 return Ok(None);
653 }
654
655 let estimated_saved_tokens = estimate_savings(&citations_by_kind);
656 let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
657
658 let price = resolve_price_per_mtok(model_name, price_per_mtok_override);
659 let usd = price.map(|p_per_mtok| {
660 let saved_usd = estimated_saved_tokens as f64 / 1_000_000.0 * p_per_mtok;
661 let spent_usd = injected_tokens as f64 / 1_000_000.0 * p_per_mtok;
662 RoiUsd {
663 saved: saved_usd,
664 spent: spent_usd,
665 net: saved_usd - spent_usd,
666 }
667 });
668
669 Ok(Some(SessionRoi {
670 served_events,
671 injected_tokens,
672 citations: total_citations,
673 estimated_saved_tokens,
674 net_tokens,
675 usd,
676 }))
677}
678
679#[derive(Debug, Clone)]
681pub struct SessionRoi {
682 pub served_events: u64,
683 pub injected_tokens: u64,
684 pub citations: u64,
685 pub estimated_saved_tokens: u64,
686 pub net_tokens: i64,
687 pub usd: Option<RoiUsd>,
688}
689
690impl SessionRoi {
691 pub fn savings_sentence(&self) -> String {
695 match &self.usd {
696 Some(u) if u.net >= 0.0 => format!(
697 "[Kimetsu] Estimated savings (nominal assumptions): ~{} tokens (~${:.4}) this session.",
698 format_tokens(self.estimated_saved_tokens),
699 u.saved,
700 ),
701 Some(u) => format!(
702 "[Kimetsu] Estimated overhead (nominal assumptions): ~{} tokens (net −${:.4}) this session.",
703 format_tokens(self.injected_tokens),
704 u.spent - u.saved,
705 ),
706 None => format!(
707 "[Kimetsu] Estimated savings (nominal assumptions): ~{} tokens this session.",
708 format_tokens(self.estimated_saved_tokens),
709 ),
710 }
711 }
712}
713
714fn format_tokens(n: u64) -> String {
715 if n < 1_000 {
717 return n.to_string();
718 }
719 let s = n.to_string();
720 let mut out = String::new();
721 let rem = s.len() % 3;
722 for (i, ch) in s.chars().enumerate() {
723 if i > 0 && (i % 3 == rem) {
724 out.push(' ');
725 }
726 out.push(ch);
727 }
728 out
729}
730
731fn session_served_stats(
738 conn: &rusqlite::Connection,
739 session_id: Option<&str>,
740) -> KimetsuResult<(u64, u64, Option<String>, Option<String>)> {
741 let served_payloads: Vec<String> = match session_id {
743 Some(sid) => {
744 let mut stmt = conn.prepare(
745 "SELECT payload_json FROM events \
746 WHERE kind = 'context.served' \
747 AND json_extract(payload_json, '$.session_id') = ?1",
748 )?;
749 let rows = stmt.query_map(params![sid], |r| r.get::<_, String>(0))?;
750 rows.collect::<Result<Vec<_>, _>>()?
751 }
752 None => {
753 return Ok((0, 0, None, None));
755 }
756 };
757
758 let mut earliest: Option<String> = None;
761 let mut latest: Option<String> = None;
762 let served_count = served_payloads.len() as u64;
763
764 if let Some(sid) = session_id {
766 if served_count > 0 {
767 let ts_row: (Option<String>, Option<String>) = conn.query_row(
768 "SELECT MIN(ts), MAX(ts) FROM events \
769 WHERE kind = 'context.served' \
770 AND json_extract(payload_json, '$.session_id') = ?1",
771 params![sid],
772 |r| Ok((r.get(0)?, r.get(1)?)),
773 )?;
774 earliest = ts_row.0;
775 latest = ts_row.1;
776 }
777 }
778
779 let injected_tokens: u64 = match (earliest.as_deref(), latest.as_deref()) {
781 (Some(lo), Some(hi)) => {
782 let payloads: Vec<String> = {
783 let mut stmt = conn.prepare(
784 "SELECT payload_json FROM events \
785 WHERE kind = 'context.injected' AND ts >= ?1 AND ts <= ?2",
786 )?;
787 let rows = stmt.query_map(params![lo, hi], |r| r.get::<_, String>(0))?;
788 rows.collect::<Result<Vec<_>, _>>()?
789 };
790 let mut sum: u64 = 0;
791 for p in &payloads {
792 let v: serde_json::Value = serde_json::from_str(p)?;
793 if let Some(t) = v.get("used_tokens").and_then(|x| x.as_u64()) {
794 sum += t;
795 }
796 }
797 sum
798 }
799 _ => 0,
800 };
801
802 Ok((served_count, injected_tokens, earliest, latest))
803}
804
805fn citations_in_window(
808 conn: &rusqlite::Connection,
809 ts_lo: &str,
810 ts_hi: &str,
811) -> KimetsuResult<Vec<(MemoryKind, u32)>> {
812 struct Row {
813 memory_id: String,
814 count: u32,
815 }
816 let mut stmt = conn.prepare(
817 "SELECT memory_id, COUNT(*) FROM memory_citations \
818 WHERE cited_at >= ?1 AND cited_at <= ?2 \
819 GROUP BY memory_id",
820 )?;
821 let rows = stmt.query_map(params![ts_lo, ts_hi], |r| {
822 Ok(Row {
823 memory_id: r.get(0)?,
824 count: r.get(1)?,
825 })
826 })?;
827 let rows: Vec<Row> = rows.collect::<Result<Vec<_>, _>>()?;
828
829 let mut by_kind: std::collections::HashMap<MemoryKind, u32> = std::collections::HashMap::new();
830 for row in &rows {
831 let kind: Option<String> = conn
832 .query_row(
833 "SELECT kind FROM memories WHERE memory_id = ?1",
834 params![row.memory_id],
835 |r| r.get(0),
836 )
837 .optional()?;
838 let mk = kind
839 .as_deref()
840 .and_then(|s| s.parse::<MemoryKind>().ok())
841 .unwrap_or(MemoryKind::Fact);
842 *by_kind.entry(mk).or_insert(0) += row.count;
843 }
844 Ok(by_kind.into_iter().collect())
845}
846
847#[cfg(test)]
852mod tests {
853 use super::*;
854 use kimetsu_core::memory::MemoryKind;
855
856 #[test]
859 fn estimate_savings_zero_when_empty() {
860 assert_eq!(estimate_savings(&[]), 0);
861 }
862
863 #[test]
864 fn estimate_savings_single_kind() {
865 assert_eq!(estimate_savings(&[(MemoryKind::FailurePattern, 2)]), 3_000);
867 }
868
869 #[test]
870 fn estimate_savings_multi_kind() {
871 let citations = vec![
872 (MemoryKind::FailurePattern, 1), (MemoryKind::Command, 2), (MemoryKind::Convention, 1), (MemoryKind::Fact, 1), (MemoryKind::Preference, 3), ];
878 assert_eq!(estimate_savings(&citations), 1500 + 800 + 300 + 500 + 600);
879 }
880
881 #[test]
882 fn estimate_savings_all_kinds_covered() {
883 for kind in [
885 MemoryKind::FailurePattern,
886 MemoryKind::Command,
887 MemoryKind::Convention,
888 MemoryKind::Fact,
889 MemoryKind::Preference,
890 ] {
891 let v = SAVED_TOKENS_PER_CITATION
892 .iter()
893 .find(|(k, _)| k == &kind)
894 .map(|(_, v)| *v);
895 assert!(
896 v.is_some(),
897 "kind {:?} missing from SAVED_TOKENS_PER_CITATION",
898 kind
899 );
900 assert!(v.unwrap() > 0, "kind {:?} has zero constant", kind);
901 }
902 }
903
904 #[test]
905 fn resolve_price_override_wins() {
906 assert_eq!(
907 resolve_price_per_mtok("claude-sonnet-4-7", Some(5.0)),
908 Some(5.0)
909 );
910 }
911
912 #[test]
913 fn resolve_price_known_model() {
914 let p = resolve_price_per_mtok("claude-sonnet-4-7", None);
915 assert!(p.is_some(), "claude-sonnet-4 should match");
916 assert!((p.unwrap() - 3.0).abs() < 1e-9);
917 }
918
919 #[test]
920 fn resolve_price_unknown_model_none() {
921 assert!(resolve_price_per_mtok("my-custom-llm-v9", None).is_none());
922 }
923
924 #[test]
925 fn resolve_price_longest_prefix_wins() {
926 let opus_p = resolve_price_per_mtok("claude-opus-4-5", None).unwrap_or(0.0);
929 let haiku_p = resolve_price_per_mtok("claude-haiku-4-5", None).unwrap_or(0.0);
930 assert!(opus_p > haiku_p, "opus should be more expensive than haiku");
931 }
932
933 #[test]
934 fn roi_window_parse() {
935 assert_eq!(RoiWindow::parse("7d").unwrap(), RoiWindow::Days(7));
936 assert_eq!(RoiWindow::parse("30d").unwrap(), RoiWindow::Days(30));
937 assert_eq!(RoiWindow::parse("all").unwrap(), RoiWindow::All);
938 assert_eq!(RoiWindow::parse("ALL").unwrap(), RoiWindow::All);
939 assert!(RoiWindow::parse("bad").is_err());
940 }
941
942 #[test]
943 fn format_tokens_below_1000() {
944 assert_eq!(format_tokens(42), "42");
945 assert_eq!(format_tokens(999), "999");
946 }
947
948 #[test]
949 fn format_tokens_thousands() {
950 assert_eq!(format_tokens(1_000), "1 000");
951 assert_eq!(format_tokens(12_345), "12 345");
952 assert_eq!(format_tokens(1_234_567), "1 234 567");
953 }
954
955 use crate::{
958 project::{init_project, load_project},
959 projector,
960 user_brain::with_user_brain_disabled,
961 };
962 use kimetsu_core::{event::Event, ids::RunId, memory::MemoryScope};
963 use ulid::Ulid;
964
965 fn test_root() -> std::path::PathBuf {
966 let root = std::env::temp_dir().join(format!("kimetsu-roi-test-{}", Ulid::new()));
967 kimetsu_core::paths::git_init_boundary(&root);
968 root
969 }
970
971 fn seed_memory(root: &std::path::Path, kind: MemoryKind, text: &str) -> String {
972 crate::project::add_memory(root, MemoryScope::Project, kind, text).expect("add_memory")
973 }
974
975 fn seed_injected_event(conn: &rusqlite::Connection, run_id: RunId, used_tokens: u64) {
976 let ev = Event::new(
977 run_id,
978 "context.injected",
979 serde_json::json!({
980 "stage": "localization",
981 "memory_ids": [],
982 "used_tokens": used_tokens,
983 "capsule_count": 1,
984 }),
985 );
986 projector::apply_events(conn, &[ev]).expect("seed injected");
987 }
988
989 fn seed_citation(conn: &rusqlite::Connection, run_id: RunId, memory_id: &str, turn: i64) {
990 let ev = Event::new(
991 run_id,
992 "memory.cited",
993 serde_json::json!({
994 "memory_id": memory_id,
995 "turn": turn,
996 }),
997 );
998 projector::apply_events(conn, &[ev]).expect("seed citation");
999 }
1000
1001 #[test]
1002 fn roi_report_empty_db_returns_zeros() {
1003 with_user_brain_disabled(|| {
1004 let root = test_root();
1005 init_project(&root, false).expect("init");
1006 let (_paths, config, conn) = load_project(&root).expect("load");
1007 let report =
1008 roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
1009 assert_eq!(report.injected_tokens, 0);
1010 assert_eq!(report.citations, 0);
1011 assert_eq!(report.estimated_saved_tokens, 0);
1012 assert_eq!(report.net_tokens, 0);
1013 });
1014 }
1015
1016 #[test]
1017 fn roi_report_with_citations_computes_savings() {
1018 with_user_brain_disabled(|| {
1019 let root = test_root();
1020 init_project(&root, false).expect("init");
1021 let m1 = seed_memory(&root, MemoryKind::FailurePattern, "fp1");
1022 let (_paths, config, conn) = load_project(&root).expect("load");
1023 let run_id = RunId::new();
1024 seed_injected_event(&conn, run_id, 300);
1025 seed_citation(&conn, run_id, &m1, 1);
1026
1027 let report =
1028 roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
1029 assert_eq!(report.estimated_saved_tokens, 1500);
1031 assert_eq!(report.injected_tokens, 300);
1032 assert_eq!(report.net_tokens, 1500 - 300);
1033 assert_eq!(report.citations, 1);
1034 });
1035 }
1036
1037 #[test]
1038 fn roi_report_negative_net_when_overhead_exceeds_savings() {
1039 with_user_brain_disabled(|| {
1040 let root = test_root();
1041 init_project(&root, false).expect("init");
1042 let m1 = seed_memory(&root, MemoryKind::Preference, "pref1");
1043 let (_paths, config, conn) = load_project(&root).expect("load");
1044 let run_id = RunId::new();
1045 seed_injected_event(&conn, run_id, 500);
1047 seed_citation(&conn, run_id, &m1, 1);
1048
1049 let report =
1050 roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
1051 assert_eq!(report.estimated_saved_tokens, 200);
1052 assert_eq!(report.net_tokens, 200 - 500); });
1054 }
1055
1056 #[test]
1057 fn roi_report_usd_with_known_model() {
1058 with_user_brain_disabled(|| {
1059 let root = test_root();
1060 init_project(&root, false).expect("init");
1061 let m1 = seed_memory(&root, MemoryKind::Command, "cmd1");
1062 let (_paths, _config, conn) = load_project(&root).expect("load");
1063 let run_id = RunId::new();
1064 seed_injected_event(&conn, run_id, 200);
1065 seed_citation(&conn, run_id, &m1, 1);
1066
1067 let report =
1069 roi_report(&conn, RoiWindow::All, "claude-sonnet-4-7", None).expect("roi_report");
1070 let usd = report.usd.expect("usd must be Some for known model");
1071 assert!((usd.saved - 400.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
1073 assert!((usd.spent - 200.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
1075 assert!((usd.net - (usd.saved - usd.spent)).abs() < 1e-12);
1076 });
1077 }
1078
1079 #[test]
1080 fn roi_report_usd_with_override() {
1081 with_user_brain_disabled(|| {
1082 let root = test_root();
1083 init_project(&root, false).expect("init");
1084 let m1 = seed_memory(&root, MemoryKind::Fact, "fact1");
1085 let (_paths, _config, conn) = load_project(&root).expect("load");
1086 let run_id = RunId::new();
1087 seed_injected_event(&conn, run_id, 0);
1088 seed_citation(&conn, run_id, &m1, 1);
1089
1090 let report =
1091 roi_report(&conn, RoiWindow::All, "my-custom-llm", Some(10.0)).expect("roi_report");
1092 let usd = report.usd.expect("usd with override");
1094 assert!((usd.saved - 500.0 / 1_000_000.0 * 10.0).abs() < 1e-9);
1095 });
1096 }
1097
1098 #[test]
1099 fn roi_report_unknown_model_no_usd() {
1100 with_user_brain_disabled(|| {
1101 let root = test_root();
1102 init_project(&root, false).expect("init");
1103 let (_paths, _config, conn) = load_project(&root).expect("load");
1104
1105 let report = roi_report(&conn, RoiWindow::All, "totally-unknown-llm-xyz", None)
1106 .expect("roi_report");
1107 assert!(report.usd.is_none(), "usd must be None for unknown model");
1108 });
1109 }
1110
1111 fn seed_event(conn: &rusqlite::Connection, kind: &str, payload: serde_json::Value) {
1114 let ev = Event::new(RunId::new(), kind, payload);
1115 projector::apply_events(conn, &[ev]).expect("seed event");
1116 }
1117
1118 #[test]
1119 fn roi_report_output_token_estimate_is_quarter_of_input() {
1120 with_user_brain_disabled(|| {
1121 let root = test_root();
1122 init_project(&root, false).expect("init");
1123 let (_paths, _config, conn) = load_project(&root).expect("load");
1124 let run_id = RunId::new();
1125 seed_injected_event(&conn, run_id, 4_000);
1126
1127 let report =
1128 roi_report(&conn, RoiWindow::All, "claude-sonnet-4", None).expect("roi_report");
1129 assert_eq!(
1131 report.estimated_output_tokens, 1_000,
1132 "output token estimate must be 0.25 × input"
1133 );
1134 });
1135 }
1136
1137 #[test]
1138 fn roi_report_digest_served_adds_savings() {
1139 with_user_brain_disabled(|| {
1140 let root = test_root();
1141 init_project(&root, false).expect("init");
1142 let (_paths, _config, conn) = load_project(&root).expect("load");
1143
1144 seed_event(
1145 &conn,
1146 "digest_served",
1147 serde_json::json!({"digest_chars": 800, "approx_tokens": 200}),
1148 );
1149 seed_event(
1150 &conn,
1151 "resume_served",
1152 serde_json::json!({"resume_chars": 400, "approx_tokens": 100}),
1153 );
1154
1155 let report =
1156 roi_report(&conn, RoiWindow::All, "unknown-model", None).expect("roi_report");
1157 assert_eq!(report.digest_served_events, 1);
1158 assert_eq!(report.resume_served_events, 1);
1159 let expected_warmstart =
1160 SAVED_TOKENS_PER_DIGEST_SERVED + SAVED_TOKENS_PER_RESUME_SERVED;
1161 assert_eq!(
1162 report.warmstart_saved_tokens, expected_warmstart,
1163 "warmstart_saved_tokens must sum digest+resume"
1164 );
1165 assert_eq!(
1166 report.estimated_saved_tokens, expected_warmstart,
1167 "total savings must include warmstart (no citations here)"
1168 );
1169 });
1170 }
1171
1172 #[test]
1173 fn per_memory_roi_top_entries_sorted_by_savings() {
1174 with_user_brain_disabled(|| {
1175 let root = test_root();
1176 init_project(&root, false).expect("init");
1177 let fp_id = seed_memory(&root, MemoryKind::FailurePattern, "fp roi test");
1179 let cmd_id = seed_memory(&root, MemoryKind::Command, "cmd roi test");
1180 let (_paths, _config, conn) = load_project(&root).expect("load");
1181
1182 let run_id = RunId::new();
1183 seed_citation(&conn, run_id, &fp_id, 1);
1185 seed_citation(&conn, run_id, &cmd_id, 2);
1186 seed_citation(&conn, run_id, &cmd_id, 3);
1187 seed_citation(&conn, run_id, &cmd_id, 4);
1188
1189 let entries = per_memory_roi(&conn, RoiWindow::All, 10).expect("per_memory_roi");
1190 assert!(!entries.is_empty(), "must have entries");
1191 assert_eq!(
1193 entries[0].memory_id, fp_id,
1194 "failure_pattern cite must rank first by savings"
1195 );
1196 assert_eq!(entries[0].estimated_saved_tokens, 1500);
1197 assert_eq!(entries[0].citation_count, 1);
1198
1199 let cmd_entry = entries
1200 .iter()
1201 .find(|e| e.memory_id == cmd_id)
1202 .expect("cmd entry");
1203 assert_eq!(cmd_entry.citation_count, 3);
1204 assert_eq!(cmd_entry.estimated_saved_tokens, 1200);
1205
1206 std::fs::remove_dir_all(&root).ok();
1207 });
1208 }
1209
1210 #[test]
1211 fn per_memory_roi_respects_top_limit() {
1212 with_user_brain_disabled(|| {
1213 let root = test_root();
1214 init_project(&root, false).expect("init");
1215 let m1 = seed_memory(&root, MemoryKind::Fact, "fact1");
1216 let m2 = seed_memory(&root, MemoryKind::Fact, "fact2");
1217 let m3 = seed_memory(&root, MemoryKind::Fact, "fact3");
1218 let (_paths, _config, conn) = load_project(&root).expect("load");
1219 let run_id = RunId::new();
1220 seed_citation(&conn, run_id, &m1, 1);
1221 seed_citation(&conn, run_id, &m2, 2);
1222 seed_citation(&conn, run_id, &m3, 3);
1223
1224 let entries = per_memory_roi(&conn, RoiWindow::All, 2).expect("per_memory_roi limit");
1225 assert_eq!(entries.len(), 2, "must respect top limit");
1226 std::fs::remove_dir_all(&root).ok();
1227 });
1228 }
1229
1230 #[test]
1231 fn estimate_output_tokens_quarter_ratio() {
1232 assert_eq!(estimate_output_tokens(4_000), 1_000);
1233 assert_eq!(estimate_output_tokens(0), 0);
1234 assert_eq!(estimate_output_tokens(1_000), 250);
1235 }
1236
1237 #[test]
1238 fn session_roi_returns_none_when_no_citations() {
1239 with_user_brain_disabled(|| {
1240 let root = test_root();
1241 init_project(&root, false).expect("init");
1242 let (_paths, _config, conn) = load_project(&root).expect("load");
1243
1244 let result = session_roi(&conn, Some("sess-abc"), "claude-sonnet-4", None);
1245 assert!(result.is_none(), "no citations → no session roi");
1246 });
1247 }
1248
1249 #[test]
1250 fn savings_sentence_positive_no_usd() {
1251 let sr = SessionRoi {
1252 served_events: 3,
1253 injected_tokens: 100,
1254 citations: 2,
1255 estimated_saved_tokens: 1200,
1256 net_tokens: 1100,
1257 usd: None,
1258 };
1259 let s = sr.savings_sentence();
1260 assert!(s.contains("1 200"), "expected formatted token count");
1261 assert!(s.contains("[Kimetsu]"), "must have brand prefix");
1262 }
1263
1264 #[test]
1265 fn savings_sentence_positive_with_usd() {
1266 let sr = SessionRoi {
1267 served_events: 3,
1268 injected_tokens: 100,
1269 citations: 2,
1270 estimated_saved_tokens: 1500,
1271 net_tokens: 1400,
1272 usd: Some(RoiUsd {
1273 saved: 0.0045,
1274 spent: 0.0003,
1275 net: 0.0042,
1276 }),
1277 };
1278 let s = sr.savings_sentence();
1279 assert!(s.contains("$"), "must include dollar sign when usd present");
1280 }
1281}