use kimetsu_core::{KimetsuResult, memory::MemoryKind};
use rusqlite::{OptionalExtension, params};
use serde::Serialize;
pub const OUTPUT_TOKEN_INPUT_RATIO: f64 = 0.25;
pub fn estimate_output_tokens(input_tokens: u64) -> u64 {
(input_tokens as f64 * OUTPUT_TOKEN_INPUT_RATIO).round() as u64
}
pub const SAVED_TOKENS_PER_DIGEST_SERVED: u64 = 800;
pub const SAVED_TOKENS_PER_RESUME_SERVED: u64 = 500;
pub const SAVED_TOKENS_PER_SKILL_SERVED: u64 = 300;
pub const SAVED_TOKENS_PER_CITATION: &[(MemoryKind, u32)] = &[
(MemoryKind::FailurePattern, 1500),
(MemoryKind::Command, 400),
(MemoryKind::Convention, 300),
(MemoryKind::Fact, 500),
(MemoryKind::Preference, 200),
];
const BUILTIN_PRICE_TABLE: &[(&str, f64)] = &[
("claude-opus-4", 15.00),
("claude-sonnet-4", 3.00),
("claude-haiku-4", 0.80),
("claude-3-opus", 15.00),
("claude-3-5-sonnet", 3.00),
("claude-3-5-haiku", 0.80),
("claude-3-sonnet", 3.00),
("claude-3-haiku", 0.25),
("us.anthropic.claude-opus-4", 15.00),
("us.anthropic.claude-sonnet-4", 3.00),
("us.anthropic.claude-haiku-4", 0.80),
("gpt-5", 2.00),
("gpt-4o", 2.50),
("gpt-4-turbo", 10.00),
("gpt-4", 30.00),
];
pub fn resolve_price_per_mtok(model: &str, price_override: Option<f64>) -> Option<f64> {
if let Some(p) = price_override {
return Some(p);
}
let model_lower = model.to_lowercase();
let mut best: Option<(&str, f64)> = None;
for (prefix, price) in BUILTIN_PRICE_TABLE {
if model_lower.starts_with(prefix) && best.is_none_or(|(b, _)| prefix.len() > b.len()) {
best = Some((prefix, *price));
}
}
best.map(|(_, p)| p)
}
pub fn estimate_savings(citations: &[(MemoryKind, u32)]) -> u64 {
citations
.iter()
.map(|(kind, count)| {
let per = SAVED_TOKENS_PER_CITATION
.iter()
.find(|(k, _)| k == kind)
.map(|(_, v)| *v as u64)
.unwrap_or(0);
per * (*count as u64)
})
.sum()
}
#[derive(Debug, Clone, Serialize)]
pub struct RoiUsd {
pub saved: f64,
pub spent: f64,
pub net: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RoiReport {
pub estimate_label: &'static str,
pub model: String,
pub assumptions: serde_json::Value,
pub delivered_cost_by_unit: std::collections::BTreeMap<String, u64>,
pub window_days: Option<u32>,
pub injected_tokens: u64,
pub estimated_output_tokens: u64,
pub served_events: u64,
pub digest_served_events: u64,
pub resume_served_events: u64,
pub warmstart_saved_tokens: u64,
pub citations: u64,
pub estimated_saved_tokens: u64,
pub net_tokens: i64,
pub usd: Option<RoiUsd>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MemoryRoiEntry {
pub memory_id: String,
pub kind: String,
pub text_head: String,
pub citation_count: u64,
pub estimated_saved_tokens: u64,
}
pub fn per_memory_roi(
conn: &rusqlite::Connection,
window: RoiWindow,
limit: usize,
) -> KimetsuResult<Vec<MemoryRoiEntry>> {
let window_since: Option<String> = match window {
RoiWindow::All => None,
RoiWindow::Days(days) => {
let secs = days as i64 * 86_400;
let now = time::OffsetDateTime::now_utc();
let cutoff = now - time::Duration::seconds(secs);
let fmt = time::format_description::well_known::Rfc3339;
Some(cutoff.format(&fmt).unwrap_or_default())
}
};
struct Row {
memory_id: String,
count: u64,
}
let rows: Vec<Row> = match &window_since {
Some(ts) => {
let mut stmt = conn.prepare(
"SELECT mc.memory_id, COUNT(*) \
FROM memory_citations mc \
LEFT JOIN runs r ON mc.run_id = r.run_id \
WHERE r.started_at >= ?1 \
OR (r.run_id IS NULL AND mc.cited_at >= ?1) \
GROUP BY mc.memory_id \
ORDER BY COUNT(*) DESC",
)?;
let rows = stmt.query_map(params![ts], |r| {
Ok(Row {
memory_id: r.get(0)?,
count: r.get(1)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()?
}
None => {
let mut stmt = conn.prepare(
"SELECT memory_id, COUNT(*) FROM memory_citations \
GROUP BY memory_id ORDER BY COUNT(*) DESC",
)?;
let rows = stmt.query_map([], |r| {
Ok(Row {
memory_id: r.get(0)?,
count: r.get(1)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()?
}
};
let mut entries: Vec<MemoryRoiEntry> = Vec::new();
for row in rows.into_iter().take(limit) {
let memory_row: Option<(String, String)> = conn
.query_row(
"SELECT kind, text FROM memories WHERE memory_id = ?1",
params![row.memory_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (kind_str, text) = memory_row.unwrap_or_else(|| ("fact".to_string(), String::new()));
let mk = kind_str.parse::<MemoryKind>().unwrap_or(MemoryKind::Fact);
let per_cite = SAVED_TOKENS_PER_CITATION
.iter()
.find(|(k, _)| k == &mk)
.map(|(_, v)| *v as u64)
.unwrap_or(0);
let estimated_saved = per_cite * row.count;
let text_head: String = text.chars().take(80).collect();
entries.push(MemoryRoiEntry {
memory_id: row.memory_id,
kind: kind_str,
text_head,
citation_count: row.count,
estimated_saved_tokens: estimated_saved,
});
}
entries.sort_by_key(|e| std::cmp::Reverse(e.estimated_saved_tokens));
Ok(entries)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoiWindow {
Days(u32),
All,
}
impl RoiWindow {
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim().to_lowercase().as_str() {
"all" => Ok(Self::All),
other => {
let digits = other.trim_end_matches('d');
digits
.parse::<u32>()
.map(Self::Days)
.map_err(|_| format!("invalid window '{s}'; expected '7d', '30d', or 'all'"))
}
}
}
pub fn days(self) -> Option<u32> {
match self {
Self::Days(d) => Some(d),
Self::All => None,
}
}
}
impl Default for RoiWindow {
fn default() -> Self {
Self::Days(30)
}
}
pub fn roi_report(
conn: &rusqlite::Connection,
window: RoiWindow,
model_name: &str,
price_per_mtok_override: Option<f64>,
) -> KimetsuResult<RoiReport> {
let window_since: Option<String> = match window {
RoiWindow::All => None,
RoiWindow::Days(days) => {
let secs = days as i64 * 86_400;
let now = time::OffsetDateTime::now_utc();
let cutoff = now - time::Duration::seconds(secs);
let fmt = time::format_description::well_known::Rfc3339;
Some(cutoff.format(&fmt).unwrap_or_default())
}
};
let served_events: u64 = match &window_since {
Some(ts) => conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 'context.served' AND ts >= ?1",
params![ts],
|r| r.get(0),
)?,
None => conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 'context.served'",
[],
|r| r.get(0),
)?,
};
let digest_served_events: u64 = match &window_since {
Some(ts) => conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 'digest_served' AND ts >= ?1",
params![ts],
|r| r.get(0),
)?,
None => conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 'digest_served'",
[],
|r| r.get(0),
)?,
};
let resume_served_events: u64 = match &window_since {
Some(ts) => conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 'resume_served' AND ts >= ?1",
params![ts],
|r| r.get(0),
)?,
None => conn.query_row(
"SELECT COUNT(*) FROM events WHERE kind = 'resume_served'",
[],
|r| r.get(0),
)?,
};
let warmstart_saved_tokens = digest_served_events * SAVED_TOKENS_PER_DIGEST_SERVED
+ resume_served_events * SAVED_TOKENS_PER_RESUME_SERVED;
let mut delivered_cost_by_unit = std::collections::BTreeMap::<String, u64>::new();
let injected_tokens: u64 = {
let payloads: Vec<String> = match &window_since {
Some(ts) => {
let mut stmt = conn.prepare(
"SELECT payload_json FROM events WHERE kind = 'context.injected' AND ts >= ?1",
)?;
let rows = stmt.query_map(params![ts], |r| r.get::<_, String>(0))?;
rows.collect::<Result<Vec<_>, _>>()?
}
None => {
let mut stmt = conn
.prepare("SELECT payload_json FROM events WHERE kind = 'context.injected'")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.collect::<Result<Vec<_>, _>>()?
}
};
let mut sum: u64 = 0;
for p in &payloads {
let v: serde_json::Value = serde_json::from_str(p)?;
if let Some(t) = v.get("used_tokens").and_then(|x| x.as_u64()) {
*delivered_cost_by_unit
.entry(
v.get("cost_unit")
.and_then(|x| x.as_str())
.unwrap_or("legacy_token_estimate")
.to_string(),
)
.or_default() += t;
sum += t;
}
}
sum
};
let citations_by_kind: Vec<(MemoryKind, u32)> = {
struct Row {
memory_id: String,
count: u32,
}
let rows: Vec<Row> = match &window_since {
Some(ts) => {
let mut stmt = conn.prepare(
"SELECT mc.memory_id, COUNT(*) \
FROM memory_citations mc \
LEFT JOIN runs r ON mc.run_id = r.run_id \
WHERE r.started_at >= ?1 \
OR (r.run_id IS NULL AND mc.cited_at >= ?1) \
GROUP BY mc.memory_id",
)?;
let rows = stmt.query_map(params![ts], |r| {
Ok(Row {
memory_id: r.get(0)?,
count: r.get(1)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()?
}
None => {
let mut stmt = conn.prepare(
"SELECT memory_id, COUNT(*) FROM memory_citations GROUP BY memory_id",
)?;
let rows = stmt.query_map([], |r| {
Ok(Row {
memory_id: r.get(0)?,
count: r.get(1)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()?
}
};
let mut by_kind: std::collections::HashMap<MemoryKind, u32> =
std::collections::HashMap::new();
for row in &rows {
let kind: Option<String> = conn
.query_row(
"SELECT kind FROM memories WHERE memory_id = ?1",
params![row.memory_id],
|r| r.get(0),
)
.optional()?;
let mk = kind
.as_deref()
.and_then(|s| s.parse::<MemoryKind>().ok())
.unwrap_or(MemoryKind::Fact);
*by_kind.entry(mk).or_insert(0) += row.count;
}
by_kind.into_iter().collect()
};
let total_citations: u64 = citations_by_kind.iter().map(|(_, c)| *c as u64).sum();
let citation_saved_tokens = estimate_savings(&citations_by_kind);
let estimated_saved_tokens = citation_saved_tokens + warmstart_saved_tokens;
let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
let estimated_output_tokens = estimate_output_tokens(injected_tokens);
let price = resolve_price_per_mtok(model_name, price_per_mtok_override);
let usd = price.map(|p_per_mtok| {
let saved_usd = estimated_saved_tokens as f64 / 1_000_000.0 * p_per_mtok;
let spent_usd = injected_tokens as f64 / 1_000_000.0 * p_per_mtok;
RoiUsd {
saved: saved_usd,
spent: spent_usd,
net: saved_usd - spent_usd,
}
});
Ok(RoiReport {
estimate_label: "Assumption-based estimate; savings are not measured or guaranteed",
model: model_name.to_string(),
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"}),
delivered_cost_by_unit,
window_days: window.days(),
injected_tokens,
estimated_output_tokens,
served_events,
digest_served_events,
resume_served_events,
warmstart_saved_tokens,
citations: total_citations,
estimated_saved_tokens,
net_tokens,
usd,
})
}
pub fn session_roi(
conn: &rusqlite::Connection,
session_id: Option<&str>,
model_name: &str,
price_per_mtok_override: Option<f64>,
) -> Option<SessionRoi> {
session_roi_inner(conn, session_id, model_name, price_per_mtok_override).unwrap_or(None)
}
fn session_roi_inner(
conn: &rusqlite::Connection,
session_id: Option<&str>,
model_name: &str,
price_per_mtok_override: Option<f64>,
) -> KimetsuResult<Option<SessionRoi>> {
let (served_events, injected_tokens, earliest_ts, latest_ts) =
session_served_stats(conn, session_id)?;
let (ts_lo, ts_hi) = match (earliest_ts.as_deref(), latest_ts.as_deref()) {
(Some(lo), Some(hi)) => (lo.to_string(), hi.to_string()),
_ => {
let now = time::OffsetDateTime::now_utc();
let fmt = time::format_description::well_known::Rfc3339;
let lo = (now - time::Duration::seconds(86_400))
.format(&fmt)
.unwrap_or_default();
let hi = now.format(&fmt).unwrap_or_default();
(lo, hi)
}
};
let citations_by_kind = citations_in_window(conn, &ts_lo, &ts_hi)?;
let total_citations: u64 = citations_by_kind.iter().map(|(_, c)| *c as u64).sum();
if total_citations == 0 {
return Ok(None);
}
let estimated_saved_tokens = estimate_savings(&citations_by_kind);
let net_tokens = estimated_saved_tokens as i64 - injected_tokens as i64;
let price = resolve_price_per_mtok(model_name, price_per_mtok_override);
let usd = price.map(|p_per_mtok| {
let saved_usd = estimated_saved_tokens as f64 / 1_000_000.0 * p_per_mtok;
let spent_usd = injected_tokens as f64 / 1_000_000.0 * p_per_mtok;
RoiUsd {
saved: saved_usd,
spent: spent_usd,
net: saved_usd - spent_usd,
}
});
Ok(Some(SessionRoi {
served_events,
injected_tokens,
citations: total_citations,
estimated_saved_tokens,
net_tokens,
usd,
}))
}
#[derive(Debug, Clone)]
pub struct SessionRoi {
pub served_events: u64,
pub injected_tokens: u64,
pub citations: u64,
pub estimated_saved_tokens: u64,
pub net_tokens: i64,
pub usd: Option<RoiUsd>,
}
impl SessionRoi {
pub fn savings_sentence(&self) -> String {
match &self.usd {
Some(u) if u.net >= 0.0 => format!(
"[Kimetsu] Estimated savings (nominal assumptions): ~{} tokens (~${:.4}) this session.",
format_tokens(self.estimated_saved_tokens),
u.saved,
),
Some(u) => format!(
"[Kimetsu] Estimated overhead (nominal assumptions): ~{} tokens (net −${:.4}) this session.",
format_tokens(self.injected_tokens),
u.spent - u.saved,
),
None => format!(
"[Kimetsu] Estimated savings (nominal assumptions): ~{} tokens this session.",
format_tokens(self.estimated_saved_tokens),
),
}
}
}
fn format_tokens(n: u64) -> String {
if n < 1_000 {
return n.to_string();
}
let s = n.to_string();
let mut out = String::new();
let rem = s.len() % 3;
for (i, ch) in s.chars().enumerate() {
if i > 0 && (i % 3 == rem) {
out.push(' ');
}
out.push(ch);
}
out
}
fn session_served_stats(
conn: &rusqlite::Connection,
session_id: Option<&str>,
) -> KimetsuResult<(u64, u64, Option<String>, Option<String>)> {
let served_payloads: Vec<String> = match session_id {
Some(sid) => {
let mut stmt = conn.prepare(
"SELECT payload_json FROM events \
WHERE kind = 'context.served' \
AND json_extract(payload_json, '$.session_id') = ?1",
)?;
let rows = stmt.query_map(params![sid], |r| r.get::<_, String>(0))?;
rows.collect::<Result<Vec<_>, _>>()?
}
None => {
return Ok((0, 0, None, None));
}
};
let mut earliest: Option<String> = None;
let mut latest: Option<String> = None;
let served_count = served_payloads.len() as u64;
if let Some(sid) = session_id {
if served_count > 0 {
let ts_row: (Option<String>, Option<String>) = conn.query_row(
"SELECT MIN(ts), MAX(ts) FROM events \
WHERE kind = 'context.served' \
AND json_extract(payload_json, '$.session_id') = ?1",
params![sid],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
earliest = ts_row.0;
latest = ts_row.1;
}
}
let injected_tokens: u64 = match (earliest.as_deref(), latest.as_deref()) {
(Some(lo), Some(hi)) => {
let payloads: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT payload_json FROM events \
WHERE kind = 'context.injected' AND ts >= ?1 AND ts <= ?2",
)?;
let rows = stmt.query_map(params![lo, hi], |r| r.get::<_, String>(0))?;
rows.collect::<Result<Vec<_>, _>>()?
};
let mut sum: u64 = 0;
for p in &payloads {
let v: serde_json::Value = serde_json::from_str(p)?;
if let Some(t) = v.get("used_tokens").and_then(|x| x.as_u64()) {
sum += t;
}
}
sum
}
_ => 0,
};
Ok((served_count, injected_tokens, earliest, latest))
}
fn citations_in_window(
conn: &rusqlite::Connection,
ts_lo: &str,
ts_hi: &str,
) -> KimetsuResult<Vec<(MemoryKind, u32)>> {
struct Row {
memory_id: String,
count: u32,
}
let mut stmt = conn.prepare(
"SELECT memory_id, COUNT(*) FROM memory_citations \
WHERE cited_at >= ?1 AND cited_at <= ?2 \
GROUP BY memory_id",
)?;
let rows = stmt.query_map(params![ts_lo, ts_hi], |r| {
Ok(Row {
memory_id: r.get(0)?,
count: r.get(1)?,
})
})?;
let rows: Vec<Row> = rows.collect::<Result<Vec<_>, _>>()?;
let mut by_kind: std::collections::HashMap<MemoryKind, u32> = std::collections::HashMap::new();
for row in &rows {
let kind: Option<String> = conn
.query_row(
"SELECT kind FROM memories WHERE memory_id = ?1",
params![row.memory_id],
|r| r.get(0),
)
.optional()?;
let mk = kind
.as_deref()
.and_then(|s| s.parse::<MemoryKind>().ok())
.unwrap_or(MemoryKind::Fact);
*by_kind.entry(mk).or_insert(0) += row.count;
}
Ok(by_kind.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use kimetsu_core::memory::MemoryKind;
#[test]
fn estimate_savings_zero_when_empty() {
assert_eq!(estimate_savings(&[]), 0);
}
#[test]
fn estimate_savings_single_kind() {
assert_eq!(estimate_savings(&[(MemoryKind::FailurePattern, 2)]), 3_000);
}
#[test]
fn estimate_savings_multi_kind() {
let citations = vec![
(MemoryKind::FailurePattern, 1), (MemoryKind::Command, 2), (MemoryKind::Convention, 1), (MemoryKind::Fact, 1), (MemoryKind::Preference, 3), ];
assert_eq!(estimate_savings(&citations), 1500 + 800 + 300 + 500 + 600);
}
#[test]
fn estimate_savings_all_kinds_covered() {
for kind in [
MemoryKind::FailurePattern,
MemoryKind::Command,
MemoryKind::Convention,
MemoryKind::Fact,
MemoryKind::Preference,
] {
let v = SAVED_TOKENS_PER_CITATION
.iter()
.find(|(k, _)| k == &kind)
.map(|(_, v)| *v);
assert!(
v.is_some(),
"kind {:?} missing from SAVED_TOKENS_PER_CITATION",
kind
);
assert!(v.unwrap() > 0, "kind {:?} has zero constant", kind);
}
}
#[test]
fn resolve_price_override_wins() {
assert_eq!(
resolve_price_per_mtok("claude-sonnet-4-7", Some(5.0)),
Some(5.0)
);
}
#[test]
fn resolve_price_known_model() {
let p = resolve_price_per_mtok("claude-sonnet-4-7", None);
assert!(p.is_some(), "claude-sonnet-4 should match");
assert!((p.unwrap() - 3.0).abs() < 1e-9);
}
#[test]
fn resolve_price_unknown_model_none() {
assert!(resolve_price_per_mtok("my-custom-llm-v9", None).is_none());
}
#[test]
fn resolve_price_longest_prefix_wins() {
let opus_p = resolve_price_per_mtok("claude-opus-4-5", None).unwrap_or(0.0);
let haiku_p = resolve_price_per_mtok("claude-haiku-4-5", None).unwrap_or(0.0);
assert!(opus_p > haiku_p, "opus should be more expensive than haiku");
}
#[test]
fn roi_window_parse() {
assert_eq!(RoiWindow::parse("7d").unwrap(), RoiWindow::Days(7));
assert_eq!(RoiWindow::parse("30d").unwrap(), RoiWindow::Days(30));
assert_eq!(RoiWindow::parse("all").unwrap(), RoiWindow::All);
assert_eq!(RoiWindow::parse("ALL").unwrap(), RoiWindow::All);
assert!(RoiWindow::parse("bad").is_err());
}
#[test]
fn format_tokens_below_1000() {
assert_eq!(format_tokens(42), "42");
assert_eq!(format_tokens(999), "999");
}
#[test]
fn format_tokens_thousands() {
assert_eq!(format_tokens(1_000), "1 000");
assert_eq!(format_tokens(12_345), "12 345");
assert_eq!(format_tokens(1_234_567), "1 234 567");
}
use crate::{
project::{init_project, load_project},
projector,
user_brain::with_user_brain_disabled,
};
use kimetsu_core::{event::Event, ids::RunId, memory::MemoryScope};
use ulid::Ulid;
fn test_root() -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("kimetsu-roi-test-{}", Ulid::new()));
kimetsu_core::paths::git_init_boundary(&root);
root
}
fn seed_memory(root: &std::path::Path, kind: MemoryKind, text: &str) -> String {
crate::project::add_memory(root, MemoryScope::Project, kind, text).expect("add_memory")
}
fn seed_injected_event(conn: &rusqlite::Connection, run_id: RunId, used_tokens: u64) {
let ev = Event::new(
run_id,
"context.injected",
serde_json::json!({
"stage": "localization",
"memory_ids": [],
"used_tokens": used_tokens,
"capsule_count": 1,
}),
);
projector::apply_events(conn, &[ev]).expect("seed injected");
}
fn seed_citation(conn: &rusqlite::Connection, run_id: RunId, memory_id: &str, turn: i64) {
let ev = Event::new(
run_id,
"memory.cited",
serde_json::json!({
"memory_id": memory_id,
"turn": turn,
}),
);
projector::apply_events(conn, &[ev]).expect("seed citation");
}
#[test]
fn roi_report_empty_db_returns_zeros() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let (_paths, config, conn) = load_project(&root).expect("load");
let report =
roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
assert_eq!(report.injected_tokens, 0);
assert_eq!(report.citations, 0);
assert_eq!(report.estimated_saved_tokens, 0);
assert_eq!(report.net_tokens, 0);
});
}
#[test]
fn roi_report_with_citations_computes_savings() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let m1 = seed_memory(&root, MemoryKind::FailurePattern, "fp1");
let (_paths, config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_injected_event(&conn, run_id, 300);
seed_citation(&conn, run_id, &m1, 1);
let report =
roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
assert_eq!(report.estimated_saved_tokens, 1500);
assert_eq!(report.injected_tokens, 300);
assert_eq!(report.net_tokens, 1500 - 300);
assert_eq!(report.citations, 1);
});
}
#[test]
fn roi_report_negative_net_when_overhead_exceeds_savings() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let m1 = seed_memory(&root, MemoryKind::Preference, "pref1");
let (_paths, config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_injected_event(&conn, run_id, 500);
seed_citation(&conn, run_id, &m1, 1);
let report =
roi_report(&conn, RoiWindow::All, &config.model.model, None).expect("roi_report");
assert_eq!(report.estimated_saved_tokens, 200);
assert_eq!(report.net_tokens, 200 - 500); });
}
#[test]
fn roi_report_usd_with_known_model() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let m1 = seed_memory(&root, MemoryKind::Command, "cmd1");
let (_paths, _config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_injected_event(&conn, run_id, 200);
seed_citation(&conn, run_id, &m1, 1);
let report =
roi_report(&conn, RoiWindow::All, "claude-sonnet-4-7", None).expect("roi_report");
let usd = report.usd.expect("usd must be Some for known model");
assert!((usd.saved - 400.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
assert!((usd.spent - 200.0 / 1_000_000.0 * 3.0).abs() < 1e-9);
assert!((usd.net - (usd.saved - usd.spent)).abs() < 1e-12);
});
}
#[test]
fn roi_report_usd_with_override() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let m1 = seed_memory(&root, MemoryKind::Fact, "fact1");
let (_paths, _config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_injected_event(&conn, run_id, 0);
seed_citation(&conn, run_id, &m1, 1);
let report =
roi_report(&conn, RoiWindow::All, "my-custom-llm", Some(10.0)).expect("roi_report");
let usd = report.usd.expect("usd with override");
assert!((usd.saved - 500.0 / 1_000_000.0 * 10.0).abs() < 1e-9);
});
}
#[test]
fn roi_report_unknown_model_no_usd() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let (_paths, _config, conn) = load_project(&root).expect("load");
let report = roi_report(&conn, RoiWindow::All, "totally-unknown-llm-xyz", None)
.expect("roi_report");
assert!(report.usd.is_none(), "usd must be None for unknown model");
});
}
fn seed_event(conn: &rusqlite::Connection, kind: &str, payload: serde_json::Value) {
let ev = Event::new(RunId::new(), kind, payload);
projector::apply_events(conn, &[ev]).expect("seed event");
}
#[test]
fn roi_report_output_token_estimate_is_quarter_of_input() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let (_paths, _config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_injected_event(&conn, run_id, 4_000);
let report =
roi_report(&conn, RoiWindow::All, "claude-sonnet-4", None).expect("roi_report");
assert_eq!(
report.estimated_output_tokens, 1_000,
"output token estimate must be 0.25 × input"
);
});
}
#[test]
fn roi_report_digest_served_adds_savings() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let (_paths, _config, conn) = load_project(&root).expect("load");
seed_event(
&conn,
"digest_served",
serde_json::json!({"digest_chars": 800, "approx_tokens": 200}),
);
seed_event(
&conn,
"resume_served",
serde_json::json!({"resume_chars": 400, "approx_tokens": 100}),
);
let report =
roi_report(&conn, RoiWindow::All, "unknown-model", None).expect("roi_report");
assert_eq!(report.digest_served_events, 1);
assert_eq!(report.resume_served_events, 1);
let expected_warmstart =
SAVED_TOKENS_PER_DIGEST_SERVED + SAVED_TOKENS_PER_RESUME_SERVED;
assert_eq!(
report.warmstart_saved_tokens, expected_warmstart,
"warmstart_saved_tokens must sum digest+resume"
);
assert_eq!(
report.estimated_saved_tokens, expected_warmstart,
"total savings must include warmstart (no citations here)"
);
});
}
#[test]
fn per_memory_roi_top_entries_sorted_by_savings() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let fp_id = seed_memory(&root, MemoryKind::FailurePattern, "fp roi test");
let cmd_id = seed_memory(&root, MemoryKind::Command, "cmd roi test");
let (_paths, _config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_citation(&conn, run_id, &fp_id, 1);
seed_citation(&conn, run_id, &cmd_id, 2);
seed_citation(&conn, run_id, &cmd_id, 3);
seed_citation(&conn, run_id, &cmd_id, 4);
let entries = per_memory_roi(&conn, RoiWindow::All, 10).expect("per_memory_roi");
assert!(!entries.is_empty(), "must have entries");
assert_eq!(
entries[0].memory_id, fp_id,
"failure_pattern cite must rank first by savings"
);
assert_eq!(entries[0].estimated_saved_tokens, 1500);
assert_eq!(entries[0].citation_count, 1);
let cmd_entry = entries
.iter()
.find(|e| e.memory_id == cmd_id)
.expect("cmd entry");
assert_eq!(cmd_entry.citation_count, 3);
assert_eq!(cmd_entry.estimated_saved_tokens, 1200);
std::fs::remove_dir_all(&root).ok();
});
}
#[test]
fn per_memory_roi_respects_top_limit() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let m1 = seed_memory(&root, MemoryKind::Fact, "fact1");
let m2 = seed_memory(&root, MemoryKind::Fact, "fact2");
let m3 = seed_memory(&root, MemoryKind::Fact, "fact3");
let (_paths, _config, conn) = load_project(&root).expect("load");
let run_id = RunId::new();
seed_citation(&conn, run_id, &m1, 1);
seed_citation(&conn, run_id, &m2, 2);
seed_citation(&conn, run_id, &m3, 3);
let entries = per_memory_roi(&conn, RoiWindow::All, 2).expect("per_memory_roi limit");
assert_eq!(entries.len(), 2, "must respect top limit");
std::fs::remove_dir_all(&root).ok();
});
}
#[test]
fn estimate_output_tokens_quarter_ratio() {
assert_eq!(estimate_output_tokens(4_000), 1_000);
assert_eq!(estimate_output_tokens(0), 0);
assert_eq!(estimate_output_tokens(1_000), 250);
}
#[test]
fn session_roi_returns_none_when_no_citations() {
with_user_brain_disabled(|| {
let root = test_root();
init_project(&root, false).expect("init");
let (_paths, _config, conn) = load_project(&root).expect("load");
let result = session_roi(&conn, Some("sess-abc"), "claude-sonnet-4", None);
assert!(result.is_none(), "no citations → no session roi");
});
}
#[test]
fn savings_sentence_positive_no_usd() {
let sr = SessionRoi {
served_events: 3,
injected_tokens: 100,
citations: 2,
estimated_saved_tokens: 1200,
net_tokens: 1100,
usd: None,
};
let s = sr.savings_sentence();
assert!(s.contains("1 200"), "expected formatted token count");
assert!(s.contains("[Kimetsu]"), "must have brand prefix");
}
#[test]
fn savings_sentence_positive_with_usd() {
let sr = SessionRoi {
served_events: 3,
injected_tokens: 100,
citations: 2,
estimated_saved_tokens: 1500,
net_tokens: 1400,
usd: Some(RoiUsd {
saved: 0.0045,
spent: 0.0003,
net: 0.0042,
}),
};
let s = sr.savings_sentence();
assert!(s.contains("$"), "must include dollar sign when usd present");
}
}