diff --git a/src/contribution_cache/single_message.rs b/src/contribution_cache/single_message.rs index 7ffdb56..e36035b 100644 --- a/src/contribution_cache/single_message.rs +++ b/src/contribution_cache/single_message.rs @@ -20,12 +20,12 @@ use crate::types::{CompactDate, ConversationMessage, TuiStats, intern_model}; // | output_tokens | 31,999 | 26 | 67,108,863 | // | reasoning_tokens | 7,005 | 26 | 67,108,863 | // | cached_tokens | 186,677 | 27 | 134,217,727 | -// | cost_cents | 356 | 16 | 65,535 ($655.35) | +// | cost_micros | 3,560,000 | 30 | $1,073.74 | // | tool_calls | 73 | 14 | 16,383 | // | year_offset | 2025-2026 | 6 | 63 (2020-2083) | // | month | 1-12 | 4 | 15 | // | day | 1-31 | 5 | 31 | -// | duration_ms | — | 25 | 33,554,431 (~9.3h)| +// | duration_ms | — | 11 | 2,047 (~2s) | // // Total: 176 bits = 22 bytes @@ -36,12 +36,12 @@ use crate::types::{CompactDate, ConversationMessage, TuiStats, intern_model}; /// - output_tokens: bits 27-52 (26 bits, max 67,108,863) /// - reasoning_tokens: bits 53-78 (26 bits, max 67,108,863) /// - cached_tokens: bits 79-105 (27 bits, max 134,217,727) -/// - cost_cents: bits 106-121 (16 bits, max 65,535 = $655.35) -/// - tool_calls: bits 122-135 (14 bits, max 16,383) -/// - year_offset: bits 136-141 (6 bits, years 2020-2083) -/// - month: bits 142-145 (4 bits, 1-12) -/// - day: bits 146-150 (5 bits, 1-31) -/// - duration_ms: bits 151-175 (25 bits, max ~9.3 hours) +/// - cost_micros: bits 106-135 (30 bits, max $1,073.74) +/// - tool_calls: bits 136-149 (14 bits, max 16,383) +/// - year_offset: bits 150-155 (6 bits, years 2020-2083) +/// - month: bits 156-159 (4 bits, 1-12) +/// - day: bits 160-164 (5 bits, 1-31) +/// - duration_ms: bits 165-175 (11 bits; reserved for future use) #[repr(C, align(1))] #[derive(BitfieldStruct, Clone, Copy, Default)] pub struct PackedStatsDate { @@ -49,12 +49,12 @@ pub struct PackedStatsDate { #[bitfield(name = "output_tokens", ty = "u32", bits = "27..=52")] #[bitfield(name = "reasoning_tokens", ty = "u32", bits = "53..=78")] #[bitfield(name = "cached_tokens", ty = "u32", bits = "79..=105")] - #[bitfield(name = "cost_cents", ty = "u16", bits = "106..=121")] - #[bitfield(name = "tool_calls", ty = "u16", bits = "122..=135")] - #[bitfield(name = "year_offset", ty = "u8", bits = "136..=141")] - #[bitfield(name = "month", ty = "u8", bits = "142..=145")] - #[bitfield(name = "day", ty = "u8", bits = "146..=150")] - #[bitfield(name = "duration_ms", ty = "u32", bits = "151..=175")] + #[bitfield(name = "cost_micros", ty = "u32", bits = "106..=135")] + #[bitfield(name = "tool_calls", ty = "u16", bits = "136..=149")] + #[bitfield(name = "year_offset", ty = "u8", bits = "150..=155")] + #[bitfield(name = "month", ty = "u8", bits = "156..=159")] + #[bitfield(name = "day", ty = "u8", bits = "160..=164")] + #[bitfield(name = "duration_ms", ty = "u16", bits = "165..=175")] data: [u8; 22], } @@ -65,7 +65,7 @@ impl std::fmt::Debug for PackedStatsDate { .field("output_tokens", &self.output_tokens()) .field("reasoning_tokens", &self.reasoning_tokens()) .field("cached_tokens", &self.cached_tokens()) - .field("cost_cents", &self.cost_cents()) + .field("cost_micros", &self.cost_micros()) .field("tool_calls", &self.tool_calls()) .field("year_offset", &self.year_offset()) .field("month", &self.month()) @@ -89,7 +89,9 @@ impl PackedStatsDate { packed.set_output_tokens(stats.output_tokens.min(0x3FF_FFFF) as u32); packed.set_reasoning_tokens(stats.reasoning_tokens.min(0x3FF_FFFF) as u32); packed.set_cached_tokens(stats.cached_tokens.min(0x7FF_FFFF) as u32); - packed.set_cost_cents((stats.cost * 100.0).round().min(u16::MAX as f64) as u16); + packed.set_cost_micros( + TuiStats::cost_micros_from_dollars(stats.cost).min(0x3FFF_FFFF) as u32, + ); packed.set_tool_calls(stats.tool_calls.min(0x3FFF) as u16); // Pack date @@ -117,14 +119,16 @@ impl PackedStatsDate { /// Convert packed stats to TuiStats for display. #[inline] pub fn to_tui_stats(self) -> TuiStats { - TuiStats { + let mut stats = TuiStats { input_tokens: self.input_tokens() as u64, output_tokens: self.output_tokens() as u64, reasoning_tokens: self.reasoning_tokens() as u64, cached_tokens: self.cached_tokens() as u64, - cost_cents: self.cost_cents() as u32, tool_calls: self.tool_calls() as u32, - } + ..Default::default() + }; + stats.set_cost_micros(u64::from(self.cost_micros())); + stats } } @@ -235,7 +239,7 @@ mod size_tests { assert_eq!(packed.output_tokens(), 31_999); assert_eq!(packed.reasoning_tokens(), 7_005); assert_eq!(packed.cached_tokens(), 186_677); - assert_eq!(packed.cost_cents(), 356); + assert_eq!(packed.cost_micros(), 3_560_000); assert_eq!(packed.tool_calls(), 73); let unpacked_date = packed.unpack_date(); @@ -254,8 +258,8 @@ mod size_tests { output_tokens: 67_108_863, // 26-bit max reasoning_tokens: 67_108_863, cached_tokens: 134_217_727, - cost: 655.35, // u16 max cents - tool_calls: 16_383, // 14-bit max + cost: 1_073.741_823, // 30-bit max microdollars + tool_calls: 16_383, // 14-bit max ..Default::default() }; let date = CompactDate::from_parts(2083, 12, 31); // Max year (2020 + 63) @@ -264,7 +268,7 @@ mod size_tests { assert_eq!(packed.input_tokens(), 134_217_727); assert_eq!(packed.output_tokens(), 67_108_863); - assert_eq!(packed.cost_cents(), 65535); + assert_eq!(packed.cost_micros(), 0x3FFF_FFFF); assert_eq!(packed.tool_calls(), 16383); let unpacked_date = packed.unpack_date(); diff --git a/src/contribution_cache/tests/compact_stats.rs b/src/contribution_cache/tests/compact_stats.rs index 21c0cde..fe4a13f 100644 --- a/src/contribution_cache/tests/compact_stats.rs +++ b/src/contribution_cache/tests/compact_stats.rs @@ -22,7 +22,7 @@ fn test_packed_stats_date_from_stats() { assert_eq!(packed.output_tokens(), 500); assert_eq!(packed.reasoning_tokens(), 100); assert_eq!(packed.cached_tokens(), 200); - assert_eq!(packed.cost_cents(), 5); // 0.05 * 100 = 5 cents + assert_eq!(packed.cost_micros(), 50_000); assert_eq!(packed.tool_calls(), 3); let unpacked_date = packed.unpack_date(); @@ -55,6 +55,20 @@ fn test_packed_stats_date_to_tui_stats() { assert_eq!(tui.tool_calls, 5); } +#[test] +fn test_packed_stats_date_preserves_subcent_cost() { + let stats = Stats { + cost: 0.004, + ..Default::default() + }; + + let packed = PackedStatsDate::pack(&stats, CompactDate::from_parts(2025, 1, 1)); + let tui = packed.to_tui_stats(); + + assert!((tui.cost() - 0.004).abs() < f64::EPSILON); + assert_eq!(tui.cost_cents, 0); +} + #[test] fn test_packed_stats_date_max_values() { // Test maximum values within bit limits (from diagnostic reference) @@ -63,8 +77,8 @@ fn test_packed_stats_date_max_values() { output_tokens: 67_108_863, // 26-bit max reasoning_tokens: 67_108_863, cached_tokens: 134_217_727, - cost: 655.35, // u16 max cents - tool_calls: 16_383, // 14-bit max + cost: 1_073.741_823, // 30-bit max microdollars + tool_calls: 16_383, // 14-bit max ..Default::default() }; let date = CompactDate::from_parts(2083, 12, 31); // Max year (2020 + 63) @@ -75,7 +89,7 @@ fn test_packed_stats_date_max_values() { assert_eq!(packed.output_tokens(), 67_108_863); assert_eq!(packed.reasoning_tokens(), 67_108_863); assert_eq!(packed.cached_tokens(), 134_217_727); - assert_eq!(packed.cost_cents(), 65535); + assert_eq!(packed.cost_micros(), 0x3FFF_FFFF); assert_eq!(packed.tool_calls(), 16383); let unpacked_date = packed.unpack_date(); @@ -92,7 +106,7 @@ fn test_packed_stats_date_saturation() { output_tokens: 100_000_000, // Exceeds 26-bit max reasoning_tokens: 100_000_000, cached_tokens: 200_000_000, - cost: 1000.00, // Exceeds u16 max cents + cost: 2000.00, // Exceeds 30-bit microdollar max tool_calls: 50_000, // Exceeds 14-bit max ..Default::default() }; @@ -103,7 +117,7 @@ fn test_packed_stats_date_saturation() { // Values should be saturated to max assert_eq!(packed.input_tokens(), 0x7FF_FFFF); // 27-bit max assert_eq!(packed.output_tokens(), 0x3FF_FFFF); // 26-bit max - assert_eq!(packed.cost_cents(), 65535); // u16 max + assert_eq!(packed.cost_micros(), 0x3FFF_FFFF); // 30-bit max assert_eq!(packed.tool_calls(), 16383); // 14-bit max let unpacked_date = packed.unpack_date(); @@ -130,7 +144,7 @@ fn test_packed_stats_date_observed_values() { assert_eq!(packed.output_tokens(), 31_999); assert_eq!(packed.reasoning_tokens(), 7_005); assert_eq!(packed.cached_tokens(), 186_677); - assert_eq!(packed.cost_cents(), 356); + assert_eq!(packed.cost_micros(), 3_560_000); assert_eq!(packed.tool_calls(), 73); let unpacked_date = packed.unpack_date(); @@ -150,7 +164,7 @@ fn test_packed_stats_date_zero_values() { assert_eq!(packed.output_tokens(), 0); assert_eq!(packed.reasoning_tokens(), 0); assert_eq!(packed.cached_tokens(), 0); - assert_eq!(packed.cost_cents(), 0); + assert_eq!(packed.cost_micros(), 0); assert_eq!(packed.tool_calls(), 0); let unpacked_date = packed.unpack_date(); diff --git a/src/contribution_cache/tests/single_session.rs b/src/contribution_cache/tests/single_session.rs index 9431cb1..7c30140 100644 --- a/src/contribution_cache/tests/single_session.rs +++ b/src/contribution_cache/tests/single_session.rs @@ -44,6 +44,35 @@ fn test_single_session_contribution_from_messages() { assert_eq!(contrib.date.to_string(), "2025-01-15"); } +#[test] +fn test_single_session_contribution_preserves_subcent_costs() { + let messages = vec![ + make_message( + "session1", + Some("low-cost-model"), + 100, + 10, + 0.004, + 0, + "2025-01-15", + ), + make_message( + "session1", + Some("low-cost-model"), + 100, + 10, + 0.004, + 0, + "2025-01-15", + ), + ]; + + let contribution = SingleSessionContribution::from_messages(&messages); + + assert!((contribution.stats.cost() - 0.008).abs() < f64::EPSILON); + assert_eq!(contribution.stats.cost_cents, 1); +} + #[test] fn test_single_session_contribution_empty_messages() { let messages: Vec<_> = vec![]; diff --git a/src/tui.rs b/src/tui.rs index 4dbc9e1..ee2cb85 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -308,14 +308,16 @@ fn filter_model_counts(models: &ModelCounts, filter: &str) -> ModelCounts { } fn tui_stats_from_model_stats(stats: &ModelStats) -> TuiStats { - TuiStats { + let mut tui_stats = TuiStats { input_tokens: stats.input_tokens, output_tokens: stats.output_tokens, reasoning_tokens: stats.reasoning_tokens, cached_tokens: stats.cached_tokens, - cost_cents: (stats.cost * 100.0).round() as u32, tool_calls: stats.tool_calls, - } + ..Default::default() + }; + tui_stats.set_cost(stats.cost); + tui_stats } fn filter_analyzer_view_by_model( diff --git a/src/tui/tests.rs b/src/tui/tests.rs index d961fa0..0f6fea9 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -589,6 +589,33 @@ fn test_accumulate_tui_stats_multiple_times() { assert_eq!(dst.cost(), 0.02); } +#[test] +fn test_accumulate_tui_stats_preserves_subcent_costs() { + let mut dst = TuiStats::default(); + let src = Stats { + cost: 0.004, + ..Stats::default() + }; + + accumulate_tui_stats(&mut dst, &src); + accumulate_tui_stats(&mut dst, &src); + + assert!((dst.cost() - 0.008).abs() < f64::EPSILON); + assert_eq!(dst.cost_cents, 1); +} + +#[test] +fn test_tui_stats_keeps_cost_json_contract() { + let mut stats = TuiStats::default(); + stats.add_cost(0.008); + + let json = simd_json::to_string(&stats).expect("TUI stats should serialize"); + + assert!(json.contains(r#""costCents":1"#)); + assert!(!json.contains("costMicros")); + assert_eq!(std::mem::size_of::(), 48); +} + #[test] fn test_accumulate_tui_stats_comprehensive() { let mut dst = TuiStats::default(); @@ -976,6 +1003,7 @@ fn model_filter_recalculates_stats_and_sessions() { reasoning_tokens: 5, cached_tokens: 50, cost_cents: 525, + cost_micros: 5_250_000, tool_calls: 10, }, model_stats: BTreeMap::from([ @@ -1038,7 +1066,7 @@ fn model_filter_recalculates_stats_and_sessions() { assert_eq!(day.ai_messages, 2); assert_eq!(day.user_messages, 4); - assert_eq!(day.conversations, 1); + assert_eq!(day.conversations, 2); assert_eq!(day.stats.input_tokens, 100); assert_eq!(day.stats.output_tokens, 20); assert_eq!(day.stats.cost_cents, 125); diff --git a/src/types.rs b/src/types.rs index cfceb64..2dc480f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -483,7 +483,7 @@ impl std::ops::SubAssign for Stats { } } -/// Lightweight stats for TUI display only (40 bytes vs 320 bytes for full Stats). +/// Lightweight stats for TUI display only (48 bytes vs 320 bytes for full Stats). /// Contains only fields actually rendered in the UI. /// Uses u32 for memory efficiency - sufficient for per-session and per-day values. #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] @@ -494,62 +494,105 @@ pub struct TuiStats { pub reasoning_tokens: u64, pub cached_tokens: u64, pub cost_cents: u32, // Store as cents to avoid f32 precision issues + /// Exact internal cost in millionths of a dollar. Kept out of serialized + /// output so the existing `costCents` JSON contract remains unchanged. + #[serde(skip)] + pub(crate) cost_micros: u64, pub tool_calls: u32, } impl TuiStats { + const MICROS_PER_DOLLAR: f64 = 1_000_000.0; + const MICROS_PER_CENT: u64 = 10_000; + + #[inline] + pub(crate) fn cost_micros_from_dollars(dollars: f64) -> u64 { + if !dollars.is_finite() || dollars <= 0.0 { + return 0; + } + + (dollars * Self::MICROS_PER_DOLLAR).round() as u64 + } + + #[inline] + fn exact_cost_micros(&self) -> u64 { + if self.cost_micros == 0 { + u64::from(self.cost_cents).saturating_mul(Self::MICROS_PER_CENT) + } else { + self.cost_micros + } + } + + #[inline] + pub(crate) fn set_cost_micros(&mut self, cost_micros: u64) { + self.cost_micros = cost_micros; + self.cost_cents = cost_micros + .saturating_add(Self::MICROS_PER_CENT / 2) + .saturating_div(Self::MICROS_PER_CENT) + .min(u64::from(u32::MAX)) as u32; + } + /// Get cost as f64 dollars for display #[inline] pub fn cost(&self) -> f64 { - self.cost_cents as f64 / 100.0 + self.exact_cost_micros() as f64 / Self::MICROS_PER_DOLLAR } /// Set cost from f64 dollars #[inline] pub fn set_cost(&mut self, dollars: f64) { - self.cost_cents = (dollars * 100.0).round() as u32; + self.set_cost_micros(Self::cost_micros_from_dollars(dollars)); } /// Add cost from f64 dollars #[inline] pub fn add_cost(&mut self, dollars: f64) { - self.cost_cents = self - .cost_cents - .saturating_add((dollars * 100.0).round() as u32); + let total = self + .exact_cost_micros() + .saturating_add(Self::cost_micros_from_dollars(dollars)); + self.set_cost_micros(total); } } impl From<&Stats> for TuiStats { fn from(s: &Stats) -> Self { - TuiStats { + let mut stats = TuiStats { input_tokens: s.input_tokens, output_tokens: s.output_tokens, reasoning_tokens: s.reasoning_tokens, cached_tokens: s.cached_tokens, - cost_cents: (s.cost * 100.0).round() as u32, tool_calls: s.tool_calls, - } + ..Default::default() + }; + stats.set_cost(s.cost); + stats } } impl std::ops::AddAssign for TuiStats { fn add_assign(&mut self, rhs: Self) { + let cost_micros = self + .exact_cost_micros() + .saturating_add(rhs.exact_cost_micros()); self.input_tokens = self.input_tokens.saturating_add(rhs.input_tokens); self.output_tokens = self.output_tokens.saturating_add(rhs.output_tokens); self.reasoning_tokens = self.reasoning_tokens.saturating_add(rhs.reasoning_tokens); self.cached_tokens = self.cached_tokens.saturating_add(rhs.cached_tokens); - self.cost_cents = self.cost_cents.saturating_add(rhs.cost_cents); + self.set_cost_micros(cost_micros); self.tool_calls = self.tool_calls.saturating_add(rhs.tool_calls); } } impl std::ops::SubAssign for TuiStats { fn sub_assign(&mut self, rhs: Self) { + let cost_micros = self + .exact_cost_micros() + .saturating_sub(rhs.exact_cost_micros()); self.input_tokens = self.input_tokens.saturating_sub(rhs.input_tokens); self.output_tokens = self.output_tokens.saturating_sub(rhs.output_tokens); self.reasoning_tokens = self.reasoning_tokens.saturating_sub(rhs.reasoning_tokens); self.cached_tokens = self.cached_tokens.saturating_sub(rhs.cached_tokens); - self.cost_cents = self.cost_cents.saturating_sub(rhs.cost_cents); + self.set_cost_micros(cost_micros); self.tool_calls = self.tool_calls.saturating_sub(rhs.tool_calls); } } diff --git a/src/utils/tests.rs b/src/utils/tests.rs index fc721be..1c47034 100644 --- a/src/utils/tests.rs +++ b/src/utils/tests.rs @@ -334,6 +334,37 @@ fn test_aggregate_by_date_basic() { assert_eq!(stats.stats.cost(), 0.01); } +#[test] +fn test_aggregate_by_date_preserves_subcent_costs() { + let date = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(); + let make_message = |global_hash: &str| ConversationMessage { + date, + application: crate::types::Application::ClaudeCode, + project_hash: "p".to_string(), + conversation_hash: "c1".to_string(), + local_hash: None, + global_hash: global_hash.to_string(), + model: Some("low-cost-model".to_string()), + stats: Stats { + cost: 0.004, + ..Stats::default() + }, + role: MessageRole::Assistant, + uuid: None, + session_name: None, + }; + + let result = aggregate_by_date(&[make_message("g1"), make_message("g2")]); + let local_date = date + .with_timezone(&chrono::Local) + .format("%Y-%m-%d") + .to_string(); + let stats = &result[&local_date].stats; + + assert!((stats.cost() - 0.008).abs() < f64::EPSILON); + assert_eq!(stats.cost_cents, 1); +} + #[test] fn test_aggregate_by_date_gap_filling() { // Create messages 2 days apart