Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 27 additions & 23 deletions src/contribution_cache/single_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -36,25 +36,25 @@ 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 {
#[bitfield(name = "input_tokens", ty = "u32", bits = "0..=26")]
#[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],
}

Expand All @@ -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())
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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();
Expand All @@ -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)
Expand All @@ -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();
Expand Down
30 changes: 22 additions & 8 deletions src/contribution_cache/tests/compact_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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();
Expand All @@ -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()
};
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions src/contribution_cache/tests/single_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![];
Expand Down
8 changes: 5 additions & 3 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 29 additions & 1 deletion src/tui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<TuiStats>(), 48);
}

#[test]
fn test_accumulate_tui_stats_comprehensive() {
let mut dst = TuiStats::default();
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading