From fc2f85eff1982f5ea916fd304b742f9a6edfca90 Mon Sep 17 00:00:00 2001 From: Sahil Gandhi Date: Mon, 23 Feb 2026 10:27:14 -0800 Subject: [PATCH] feat: add cache compounding savings to `rtk gain` Show how direct token savings compound through Claude Code's prompt caching. Saved tokens avoid a 1.25x cache write plus 0.1x cache read on every subsequent turn, producing a multiplier based on average session length (scanned from JSONL session files). New section appears after the "By Command" table with multiplier, effective savings, and optional dollar amounts (when ccusage installed). Gracefully degrades: falls back to 20-turn estimate when no session data, omits section entirely on failure. - New module: src/session_stats.rs (8 unit tests) - Export WEIGHT_* constants from cc_economics.rs - Add cache_compounding field to JSON/CSV export - Remove dead BILLION constant, consolidate color helpers --- src/cc_economics.rs | 8 +- src/ccusage.rs | 44 +++++--- src/gain.rs | 153 +++++++++++++++++++------- src/main.rs | 1 + src/session_stats.rs | 257 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 406 insertions(+), 57 deletions(-) create mode 100644 src/session_stats.rs diff --git a/src/cc_economics.rs b/src/cc_economics.rs index b38bba2f..a947d7dc 100644 --- a/src/cc_economics.rs +++ b/src/cc_economics.rs @@ -14,13 +14,11 @@ use crate::utils::{format_cpt, format_tokens, format_usd}; // ── Constants ── -const BILLION: f64 = 1e9; - // API pricing ratios (verified Feb 2026, consistent across Claude models <=200K context) // Source: https://docs.anthropic.com/en/docs/about-claude/models -const WEIGHT_OUTPUT: f64 = 5.0; // Output = 5x input -const WEIGHT_CACHE_CREATE: f64 = 1.25; // Cache write = 1.25x input -const WEIGHT_CACHE_READ: f64 = 0.1; // Cache read = 0.1x input +pub(crate) const WEIGHT_OUTPUT: f64 = 5.0; // Output = 5x input +pub(crate) const WEIGHT_CACHE_CREATE: f64 = 1.25; // Cache write = 1.25x input +pub(crate) const WEIGHT_CACHE_READ: f64 = 0.1; // Cache read = 0.1x input // ── Types ── diff --git a/src/ccusage.rs b/src/ccusage.rs index 822cca15..b2cd4471 100644 --- a/src/ccusage.rs +++ b/src/ccusage.rs @@ -91,24 +91,42 @@ fn binary_exists() -> bool { .unwrap_or(false) } -/// Build the ccusage command, falling back to npx if binary not in PATH +/// Build the ccusage command, falling back to npx/pnpx if binary not in PATH. +/// Tries: ccusage (direct) → npx → pnpx → pnpm dlx fn build_command() -> Option { if binary_exists() { return Some(Command::new("ccusage")); } - // Fallback: try npx - let npx_check = Command::new("npx") - .arg("ccusage") - .arg("--help") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - - if npx_check.map(|s| s.success()).unwrap_or(false) { - let mut cmd = Command::new("npx"); - cmd.arg("ccusage"); - return Some(cmd); + // Try package runner fallbacks (stdin null prevents interactive prompts) + let runners: &[&[&str]] = &[ + &["npx", "ccusage"], + &["pnpx", "ccusage"], + &["pnpm", "dlx", "ccusage"], + ]; + + for runner in runners { + let (bin, args) = runner.split_first()?; + let mut check = Command::new(bin); + for arg in args { + check.arg(arg); + } + let ok = check + .arg("--help") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if ok { + let mut cmd = Command::new(bin); + for arg in args { + cmd.arg(arg); + } + return Some(cmd); + } } None diff --git a/src/gain.rs b/src/gain.rs index f715296d..6413dd2b 100644 --- a/src/gain.rs +++ b/src/gain.rs @@ -1,10 +1,13 @@ +use crate::cc_economics::{WEIGHT_CACHE_CREATE, WEIGHT_CACHE_READ, WEIGHT_OUTPUT}; +use crate::ccusage::{self, Granularity}; use crate::display_helpers::{format_duration, print_period_table}; +use crate::session_stats::{self, CacheCompoundingSavings}; use crate::tracking::{DayStats, MonthStats, Tracker, WeekStats}; -use crate::utils::format_tokens; +use crate::utils::{format_tokens, format_usd}; use anyhow::{Context, Result}; -use colored::Colorize; // added: terminal colors +use colored::Colorize; use serde::Serialize; -use std::io::IsTerminal; // added: TTY detection for graceful degradation +use std::io::IsTerminal; pub fn run( graph: bool, @@ -39,12 +42,10 @@ pub fn run( // Default view (summary) if !daily && !weekly && !monthly && !all { - // added: styled header with bold title println!("{}", styled("RTK Token Savings (Global Scope)", true)); println!("{}", "═".repeat(60)); println!(); - // added: KPI-style aligned output print_kpi("Total commands", summary.total_commands.to_string()); print_kpi("Input tokens", format_tokens(summary.total_input)); print_kpi("Output tokens", format_tokens(summary.total_output)); @@ -64,14 +65,12 @@ pub fn run( format_duration(summary.avg_time_ms) ), ); - print_efficiency_meter(summary.avg_savings_pct); // added: visual meter + print_efficiency_meter(summary.avg_savings_pct); println!(); if !summary.by_command.is_empty() { - // added: styled section header println!("{}", styled("By Command", true)); - // added: dynamic column widths for clean alignment let cmd_width = 24usize; let impact_width = 10usize; let count_width = summary @@ -128,7 +127,7 @@ pub fn run( for (idx, (cmd, count, saved, pct, avg_time)) in summary.by_command.iter().enumerate() { let row_idx = format!("{:>2}.", idx + 1); - let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); // added: colored command + let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); let count_cell = format!("{:>count_width$}", count, count_width = count_width); let saved_cell = format!( "{:>saved_width$}", @@ -136,13 +135,13 @@ pub fn run( saved_width = saved_width ); let pct_plain = format!("{:>6}", format!("{pct:.1}%")); - let pct_cell = colorize_pct_cell(*pct, &pct_plain); // added: color-coded percentage + let pct_cell = colorize_by_savings(*pct, &pct_plain); let time_cell = format!( "{:>time_width$}", format_duration(*avg_time), time_width = time_width ); - let impact = mini_bar(*saved, max_saved, impact_width); // added: impact bar + let impact = mini_bar(*saved, max_saved, impact_width); println!( "{} {} {} {} {} {} {}", row_idx, cmd_cell, count_cell, saved_cell, pct_cell, time_cell, impact @@ -152,8 +151,11 @@ pub fn run( println!(); } + // Cache compounding section + print_cache_compounding(summary.total_saved); + if graph && !summary.by_day.is_empty() { - println!("{}", styled("Daily Savings (last 30 days)", true)); // added: styled header + println!("{}", styled("Daily Savings (last 30 days)", true)); println!("──────────────────────────────────────────────────────────"); print_ascii_graph(&summary.by_day); println!(); @@ -162,7 +164,7 @@ pub fn run( if history { let recent = tracker.get_recent(10)?; if !recent.is_empty() { - println!("{}", styled("Recent Commands", true)); // added: styled header + println!("{}", styled("Recent Commands", true)); println!("──────────────────────────────────────────────────────────"); for rec in recent { let time = rec.timestamp.format("%m-%d %H:%M"); @@ -171,7 +173,6 @@ pub fn run( } else { rec.rtk_cmd.clone() }; - // added: tier indicators by savings level let sign = if rec.savings_pct >= 70.0 { "▲" } else if rec.savings_pct >= 30.0 { @@ -204,9 +205,9 @@ pub fn run( let quota_pct = (summary.total_saved as f64 / quota_tokens as f64) * 100.0; - println!("{}", styled("Monthly Quota Analysis", true)); // added: styled header + println!("{}", styled("Monthly Quota Analysis", true)); println!("──────────────────────────────────────────────────────────"); - print_kpi("Subscription tier", tier_name.to_string()); // added: KPI style + print_kpi("Subscription tier", tier_name.to_string()); print_kpi("Estimated monthly quota", format_tokens(quota_tokens)); print_kpi( "Tokens saved (lifetime)", @@ -237,9 +238,8 @@ pub fn run( Ok(()) } -// ── Display helpers (TTY-aware) ── // added: entire section +// ── Display helpers (TTY-aware) ── -/// Format text with bold styling (TTY-aware). // added fn styled(text: &str, strong: bool) -> String { if !std::io::stdout().is_terminal() { return text.to_string(); @@ -251,26 +251,23 @@ fn styled(text: &str, strong: bool) -> String { } } -/// Print a key-value pair in KPI layout. // added fn print_kpi(label: &str, value: String) { println!("{:<18} {}", format!("{label}:"), value); } -/// Colorize percentage based on savings tier (TTY-aware). // added -fn colorize_pct_cell(pct: f64, padded: &str) -> String { +fn colorize_by_savings(pct: f64, text: &str) -> String { if !std::io::stdout().is_terminal() { - return padded.to_string(); + return text.to_string(); } if pct >= 70.0 { - padded.green().bold().to_string() + text.green().bold().to_string() } else if pct >= 40.0 { - padded.yellow().bold().to_string() + text.yellow().bold().to_string() } else { - padded.red().bold().to_string() + text.red().bold().to_string() } } -/// Truncate text to fit column width with ellipsis. // added fn truncate_for_column(text: &str, width: usize) -> String { if width == 0 { return String::new(); @@ -287,7 +284,6 @@ fn truncate_for_column(text: &str, width: usize) -> String { out } -/// Style command names with cyan+bold (TTY-aware). // added fn style_command_cell(cmd: &str) -> String { if !std::io::stdout().is_terminal() { return cmd.to_string(); @@ -295,7 +291,6 @@ fn style_command_cell(cmd: &str) -> String { cmd.bright_cyan().bold().to_string() } -/// Render a proportional bar chart segment (TTY-aware). // added fn mini_bar(value: usize, max: usize, width: usize) -> String { if max == 0 || width == 0 { return String::new(); @@ -311,26 +306,103 @@ fn mini_bar(value: usize, max: usize, width: usize) -> String { } } -/// Print an efficiency meter with colored progress bar (TTY-aware). // added fn print_efficiency_meter(pct: f64) { let width = 24usize; let filled = (((pct / 100.0) * width as f64).round() as usize).min(width); let meter = format!("{}{}", "█".repeat(filled), "░".repeat(width - filled)); + let pct_str = format!("{pct:.1}%"); if std::io::stdout().is_terminal() { - let pct_str = format!("{pct:.1}%"); - let colored_pct = if pct >= 70.0 { - pct_str.green().bold().to_string() - } else if pct >= 40.0 { - pct_str.yellow().bold().to_string() - } else { - pct_str.red().bold().to_string() - }; - println!("Efficiency meter: {} {}", meter.green(), colored_pct); + println!( + "Efficiency meter: {} {}", + meter.green(), + colorize_by_savings(pct, &pct_str) + ); + } else { + println!("Efficiency meter: {} {}", meter, pct_str); + } +} + +fn get_weighted_input_cpt() -> Option { + let cc_monthly = ccusage::fetch(Granularity::Monthly).ok()??; + let mut total_cost = 0.0f64; + let mut weighted_units = 0.0f64; + for period in &cc_monthly { + total_cost += period.metrics.total_cost; + weighted_units += period.metrics.input_tokens as f64 + + WEIGHT_OUTPUT * period.metrics.output_tokens as f64 + + WEIGHT_CACHE_CREATE * period.metrics.cache_creation_tokens as f64 + + WEIGHT_CACHE_READ * period.metrics.cache_read_tokens as f64; + } + if weighted_units > 0.0 { + Some(total_cost / weighted_units) } else { - println!("Efficiency meter: {} {:.1}%", meter, pct); + None } } +fn compute_cache_compounding(total_saved: usize) -> Option { + let stats = session_stats::compute_session_stats(90).ok()?; + let cpt = get_weighted_input_cpt(); + Some(session_stats::compute_compounding(total_saved, stats, cpt)) +} + +fn print_cache_compounding(total_saved: usize) { + let compounding = match compute_cache_compounding(total_saved) { + Some(c) => c, + None => return, + }; + + println!("{}", styled("Cache Compounding Effect", true)); + println!("──────────────────────────────────────────────────────────────"); + + print_kpi("Direct savings", format_tokens(compounding.direct_saved)); + + let turns_label = if compounding.stats.is_estimated { + format!( + "~{:.0} (model estimate)", + compounding.stats.avg_turns_per_session + ) + } else { + format!( + "{:.0} (from {} sessions)", + compounding.stats.avg_turns_per_session, compounding.stats.sessions_analyzed + ) + }; + print_kpi("Avg session turns", turns_label); + print_kpi( + "Avg remaining", + format!("{:.0}", compounding.stats.avg_remaining_turns), + ); + print_kpi( + "Cache multiplier", + format!( + "{:.2}x (1.25 + 0.1 x {:.0})", + compounding.multiplier, compounding.stats.avg_remaining_turns + ), + ); + + let effective_str = match compounding.dollar_savings { + Some(dollars) => format!( + "{} tokens ({})", + format_tokens(compounding.effective_saved), + format_usd(dollars) + ), + None => format!("{} tokens", format_tokens(compounding.effective_saved)), + }; + + println!(" ┌─────────────────────────────────────────────────────────┐"); + println!(" │ Effective savings: {:<35}│", effective_str); + println!(" └─────────────────────────────────────────────────────────┘"); + + println!("How: Saved tokens avoid 1.25x cache write + 0.1x per"); + println!("subsequent turn. Longer sessions = bigger multiplier."); + + if compounding.dollar_savings.is_none() { + println!("Tip: Install ccusage (npm i -g ccusage) for dollar amounts."); + } + println!(); +} + fn print_ascii_graph(data: &[(String, usize)]) { if data.is_empty() { return; @@ -383,6 +455,8 @@ fn print_monthly(tracker: &Tracker) -> Result<()> { struct ExportData { summary: ExportSummary, #[serde(skip_serializing_if = "Option::is_none")] + cache_compounding: Option, + #[serde(skip_serializing_if = "Option::is_none")] daily: Option>, #[serde(skip_serializing_if = "Option::is_none")] weekly: Option>, @@ -422,6 +496,7 @@ fn export_json( total_time_ms: summary.total_time_ms, avg_time_ms: summary.avg_time_ms, }, + cache_compounding: compute_cache_compounding(summary.total_saved), daily: if all || daily { Some(tracker.get_all_days()?) } else { diff --git a/src/main.rs b/src/main.rs index fcb39303..a7e91fe3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ mod pytest_cmd; mod read; mod ruff_cmd; mod runner; +mod session_stats; mod summary; mod tee; mod tracking; diff --git a/src/session_stats.rs b/src/session_stats.rs new file mode 100644 index 00000000..6260caaa --- /dev/null +++ b/src/session_stats.rs @@ -0,0 +1,257 @@ +//! Session statistics and cache compounding savings calculator. +//! +//! Scans Claude Code JSONL session files to determine average session length, +//! then computes how RTK's direct token savings compound through prompt caching. +//! +//! Cache compounding logic: +//! - Saved tokens avoid a 1.25x cache write on the turn they're generated +//! - On every subsequent turn, saved tokens avoid a 0.1x cache read +//! - Multiplier = 1.25 + 0.1 * avg_remaining_turns + +use crate::cc_economics::{WEIGHT_CACHE_CREATE, WEIGHT_CACHE_READ}; +use crate::discover::provider::{ClaudeProvider, SessionProvider}; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +const DEFAULT_AVG_TURNS: f64 = 20.0; + +#[derive(Debug, Serialize)] +pub struct SessionStats { + pub sessions_analyzed: usize, + pub avg_turns_per_session: f64, + pub avg_remaining_turns: f64, + pub cache_multiplier: f64, + pub is_estimated: bool, +} + +#[derive(Debug, Serialize)] +pub struct CacheCompoundingSavings { + pub direct_saved: usize, + pub effective_saved: usize, + pub multiplier: f64, + pub dollar_savings: Option, + pub stats: SessionStats, +} + +/// Count assistant turns in a single JSONL session file. +/// Uses fast string matching without full JSON parse. +pub fn count_turns_in_session(path: &Path) -> Result { + let file = std::fs::File::open(path) + .with_context(|| format!("failed to open session file: {}", path.display()))?; + let reader = BufReader::new(file); + let mut count = 0; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + if line.contains("\"type\":\"assistant\"") || line.contains("\"type\": \"assistant\"") { + count += 1; + } + } + + Ok(count) +} + +/// Compute session stats from turn counts. +/// If no session data, falls back to DEFAULT_AVG_TURNS. +fn stats_from_turns(turn_counts: &[usize]) -> SessionStats { + if turn_counts.is_empty() { + let avg_remaining = DEFAULT_AVG_TURNS / 2.0; + return SessionStats { + sessions_analyzed: 0, + avg_turns_per_session: DEFAULT_AVG_TURNS, + avg_remaining_turns: avg_remaining, + cache_multiplier: WEIGHT_CACHE_CREATE + WEIGHT_CACHE_READ * avg_remaining, + is_estimated: true, + }; + } + + let total: usize = turn_counts.iter().sum(); + let avg = total as f64 / turn_counts.len() as f64; + let avg_remaining = avg / 2.0; + + SessionStats { + sessions_analyzed: turn_counts.len(), + avg_turns_per_session: avg, + avg_remaining_turns: avg_remaining, + cache_multiplier: WEIGHT_CACHE_CREATE + WEIGHT_CACHE_READ * avg_remaining, + is_estimated: false, + } +} + +/// Scan Claude Code JSONL sessions and compute average turn stats. +/// Excludes subagent sessions (paths containing "/subagents/"). +pub fn compute_session_stats(since_days: u64) -> Result { + let provider = ClaudeProvider; + let sessions = match provider.discover_sessions(None, Some(since_days)) { + Ok(s) => s, + Err(_) => return Ok(stats_from_turns(&[])), + }; + + let mut turn_counts = Vec::new(); + for path in &sessions { + // Skip subagent sessions + if path.to_string_lossy().contains("/subagents/") { + continue; + } + + match count_turns_in_session(path) { + Ok(count) if count > 0 => turn_counts.push(count), + _ => continue, + } + } + + Ok(stats_from_turns(&turn_counts)) +} + +/// Apply cache compounding multiplier to direct savings. +pub fn compute_compounding( + direct_saved: usize, + stats: SessionStats, + weighted_input_cpt: Option, +) -> CacheCompoundingSavings { + let effective = (direct_saved as f64 * stats.cache_multiplier).round() as usize; + let dollar_savings = weighted_input_cpt.map(|cpt| effective as f64 * cpt); + + CacheCompoundingSavings { + direct_saved, + effective_saved: effective, + multiplier: stats.cache_multiplier, + dollar_savings, + stats, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_default_session_stats() { + let stats = stats_from_turns(&[]); + assert_eq!(stats.sessions_analyzed, 0); + assert_eq!(stats.avg_turns_per_session, 20.0); + assert_eq!(stats.avg_remaining_turns, 10.0); + // 1.25 + 0.1 * 10 = 2.25 + assert!((stats.cache_multiplier - 2.25).abs() < 1e-6); + assert!(stats.is_estimated); + } + + #[test] + fn test_session_stats_from_turns() { + let stats = stats_from_turns(&[100, 200, 300]); + assert_eq!(stats.sessions_analyzed, 3); + assert!((stats.avg_turns_per_session - 200.0).abs() < 1e-6); + assert!((stats.avg_remaining_turns - 100.0).abs() < 1e-6); + // 1.25 + 0.1 * 100 = 11.25 + assert!((stats.cache_multiplier - 11.25).abs() < 1e-6); + assert!(!stats.is_estimated); + } + + #[test] + fn test_compute_compounding_tokens_only() { + let stats = SessionStats { + sessions_analyzed: 10, + avg_turns_per_session: 200.0, + avg_remaining_turns: 100.0, + cache_multiplier: 11.25, + is_estimated: false, + }; + + let result = compute_compounding(1_000_000, stats, None); + assert_eq!(result.direct_saved, 1_000_000); + assert_eq!(result.effective_saved, 11_250_000); + assert!((result.multiplier - 11.25).abs() < 1e-6); + assert!(result.dollar_savings.is_none()); + } + + #[test] + fn test_compute_compounding_with_dollars() { + let stats = SessionStats { + sessions_analyzed: 10, + avg_turns_per_session: 200.0, + avg_remaining_turns: 100.0, + cache_multiplier: 11.25, + is_estimated: false, + }; + + // cpt = $3/MTok = 0.000003 per token + let cpt = 0.000003; + let result = compute_compounding(1_000_000, stats, Some(cpt)); + assert_eq!(result.effective_saved, 11_250_000); + let expected_dollars = 11_250_000.0 * 0.000003; // $33.75 + assert!((result.dollar_savings.unwrap() - expected_dollars).abs() < 0.01); + } + + #[test] + fn test_count_turns_from_fixture() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); + writeln!( + tmpfile, + r#"{{"type":"assistant","message":{{"role":"assistant","content":[]}}}}"# + ) + .unwrap(); + writeln!( + tmpfile, + r#"{{"type":"user","message":{{"role":"user","content":[]}}}}"# + ) + .unwrap(); + writeln!( + tmpfile, + r#"{{"type":"assistant","message":{{"role":"assistant","content":[]}}}}"# + ) + .unwrap(); + writeln!( + tmpfile, + r#"{{"type":"user","message":{{"role":"user","content":[]}}}}"# + ) + .unwrap(); + writeln!( + tmpfile, + r#"{{"type":"assistant","message":{{"role":"assistant","content":[]}}}}"# + ) + .unwrap(); + tmpfile.flush().unwrap(); + + let count = count_turns_in_session(tmpfile.path()).unwrap(); + assert_eq!(count, 3); + } + + #[test] + fn test_count_turns_no_assistant() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); + writeln!( + tmpfile, + r#"{{"type":"user","message":{{"role":"user","content":[]}}}}"# + ) + .unwrap(); + writeln!(tmpfile, r#"{{"type":"system","message":{{}}}}"#).unwrap(); + tmpfile.flush().unwrap(); + + let count = count_turns_in_session(tmpfile.path()).unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_single_session_stats() { + let stats = stats_from_turns(&[50]); + assert_eq!(stats.sessions_analyzed, 1); + assert!((stats.avg_turns_per_session - 50.0).abs() < 1e-6); + assert!((stats.avg_remaining_turns - 25.0).abs() < 1e-6); + // 1.25 + 0.1 * 25 = 3.75 + assert!((stats.cache_multiplier - 3.75).abs() < 1e-6); + } + + #[test] + fn test_compute_compounding_zero_saved() { + let stats = stats_from_turns(&[100]); + let result = compute_compounding(0, stats, Some(0.000003)); + assert_eq!(result.effective_saved, 0); + assert!((result.dollar_savings.unwrap() - 0.0).abs() < 1e-6); + } +}