1use kimetsu_core::{KimetsuResult, memory::MemoryKind};
17use rusqlite::{OptionalExtension, params};
18use serde::Serialize;
19
20pub const OUTPUT_TOKEN_INPUT_RATIO: f64 = 0.25;
35
36pub fn estimate_output_tokens(input_tokens: u64) -> u64 {
42 (input_tokens as f64 * OUTPUT_TOKEN_INPUT_RATIO).round() as u64
43}
44
45pub const SAVED_TOKENS_PER_DIGEST_SERVED: u64 = 800;
56
57pub const SAVED_TOKENS_PER_RESUME_SERVED: u64 = 500;
63
64pub const SAVED_TOKENS_PER_SKILL_SERVED: u64 = 300;
70
71pub 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
98const BUILTIN_PRICE_TABLE: &[(&str, f64)] = &[
112 ("claude-opus-4", 15.00),
114 ("claude-sonnet-4", 3.00),
115 ("claude-haiku-4", 0.80),
116 ("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 ("us.anthropic.claude-opus-4", 15.00),
124 ("us.anthropic.claude-sonnet-4", 3.00),
125 ("us.anthropic.claude-haiku-4", 0.80),
126 ("gpt-5", 2.00),
128 ("gpt-4o", 2.50),
129 ("gpt-4-turbo", 10.00),
130 ("gpt-4", 30.00),
131];
132
133pub 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 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
153pub 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#[derive(Debug, Clone, Serialize)]
185pub struct RoiUsd {
186 pub saved: f64,
188 pub spent: f64,
191 pub net: f64,
194}
195
196#[derive(Debug, Clone, Serialize)]
198pub struct RoiReport {
199 pub window_days: Option<u32>,
201 pub injected_tokens: u64,
204 pub estimated_output_tokens: u64,
210 pub served_events: u64,
212 pub digest_served_events: u64,
214 pub resume_served_events: u64,
216 pub warmstart_saved_tokens: u64,
218 pub citations: u64,
221 pub estimated_saved_tokens: u64,
223 pub net_tokens: i64,
225 pub usd: Option<RoiUsd>,
227}
228
229#[derive(Debug, Clone, Serialize)]
235pub struct MemoryRoiEntry {
236 pub memory_id: String,
237 pub kind: String,
238 pub text_head: String,
240 pub citation_count: u64,
242 pub estimated_saved_tokens: u64,
244}
245
246pub 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 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 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 entries.sort_by_key(|e| std::cmp::Reverse(e.estimated_saved_tokens));
335 Ok(entries)
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum RoiWindow {
345 Days(u32),
346 All,
347}
348
349impl RoiWindow {
350 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
378pub 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 let window_since: Option<String> = match window {
395 RoiWindow::All => None,
396 RoiWindow::Days(days) => {
397 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 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 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 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 let citations_by_kind: Vec<(MemoryKind, u32)> = {
485 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 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 let estimated_saved_tokens = citation_saved_tokens + warmstart_saved_tokens;
548 let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
549 let estimated_output_tokens = estimate_output_tokens(injected_tokens);
551
552 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
579pub 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 let (served_events, injected_tokens, earliest_ts, latest_ts) =
614 session_served_stats(conn, session_id)?;
615
616 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 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 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 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#[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 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 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
716fn session_served_stats(
723 conn: &rusqlite::Connection,
724 session_id: Option<&str>,
725) -> KimetsuResult<(u64, u64, Option<String>, Option<String>)> {
726 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 return Ok((0, 0, None, None));
740 }
741 };
742
743 let mut earliest: Option<String> = None;
746 let mut latest: Option<String> = None;
747 let served_count = served_payloads.len() as u64;
748
749 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 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
790fn 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#[cfg(test)]
837mod tests {
838 use super::*;
839 use kimetsu_core::memory::MemoryKind;
840
841 #[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 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), (MemoryKind::Command, 2), (MemoryKind::Convention, 1), (MemoryKind::Fact, 1), (MemoryKind::Preference, 3), ];
863 assert_eq!(estimate_savings(&citations), 1500 + 800 + 300 + 500 + 600);
864 }
865
866 #[test]
867 fn estimate_savings_all_kinds_covered() {
868 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 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 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 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 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); });
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 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 assert!((usd.saved - 400.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
1058 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 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 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 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 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 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 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}