Extract three cohesive modules from the monolithic lib.rs (3188 -> 2785 lines): - metrics.rs (147 lines): Turn metrics tracking and histogram generation - TurnMetrics struct - format_elapsed_time() for human-readable durations - generate_turn_histogram() for performance visualization - Added unit tests for core functions - project_files.rs (181 lines): Project file reading utilities - read_agents_config() for AGENTS.md loading - read_project_readme() for README detection - read_project_memory() for .g3/memory.md - extract_readme_heading() for display - Added unit tests - coach_feedback.rs (129 lines): Coach feedback extraction from session logs - extract_from_logs() main entry point - Helper functions for log parsing and text extraction All modules have clear single responsibilities, improved documentation, and maintain identical behavior to the original inline functions. Agent: carmack
148 lines
5.3 KiB
Rust
148 lines
5.3 KiB
Rust
//! Turn metrics and histogram generation for performance visualization.
|
|
|
|
use std::time::Duration;
|
|
|
|
/// Metrics captured for a single turn of interaction.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TurnMetrics {
|
|
pub turn_number: usize,
|
|
pub tokens_used: u32,
|
|
pub wall_clock_time: Duration,
|
|
}
|
|
|
|
/// Format a Duration as human-readable elapsed time (e.g., "1h 23m 45s").
|
|
pub fn format_elapsed_time(duration: Duration) -> String {
|
|
let total_secs = duration.as_secs();
|
|
let hours = total_secs / 3600;
|
|
let minutes = (total_secs % 3600) / 60;
|
|
let seconds = total_secs % 60;
|
|
|
|
match (hours, minutes, seconds) {
|
|
(h, m, s) if h > 0 => format!("{}h {}m {}s", h, m, s),
|
|
(_, m, s) if m > 0 => format!("{}m {}s", m, s),
|
|
(_, _, s) if s > 0 => format!("{}s", s),
|
|
_ => format!("{}ms", duration.as_millis()),
|
|
}
|
|
}
|
|
|
|
/// Generate a histogram showing tokens used and wall clock time per turn.
|
|
pub fn generate_turn_histogram(turn_metrics: &[TurnMetrics]) -> String {
|
|
if turn_metrics.is_empty() {
|
|
return " No turn data available".to_string();
|
|
}
|
|
|
|
const MAX_BAR_WIDTH: usize = 40;
|
|
const TOKEN_CHAR: char = '█';
|
|
const TIME_CHAR: char = '▓';
|
|
|
|
let max_tokens = turn_metrics.iter().map(|t| t.tokens_used).max().unwrap_or(1);
|
|
let max_time_ms = turn_metrics
|
|
.iter()
|
|
.map(|t| t.wall_clock_time.as_millis().min(u32::MAX as u128) as u32)
|
|
.max()
|
|
.unwrap_or(1);
|
|
|
|
let mut histogram = String::new();
|
|
histogram.push_str("\n📊 Per-Turn Performance Histogram:\n");
|
|
histogram.push_str(&format!(" {} = Tokens Used (max: {})\n", TOKEN_CHAR, max_tokens));
|
|
histogram.push_str(&format!(
|
|
" {} = Wall Clock Time (max: {:.1}s)\n\n",
|
|
TIME_CHAR,
|
|
max_time_ms as f64 / 1000.0
|
|
));
|
|
|
|
for metrics in turn_metrics {
|
|
let turn_time_ms = metrics.wall_clock_time.as_millis().min(u32::MAX as u128) as u32;
|
|
|
|
let token_bar_len = scale_bar(metrics.tokens_used, max_tokens, MAX_BAR_WIDTH);
|
|
let time_bar_len = scale_bar(turn_time_ms, max_time_ms, MAX_BAR_WIDTH);
|
|
|
|
let time_str = format_duration_ms(turn_time_ms);
|
|
let token_bar = TOKEN_CHAR.to_string().repeat(token_bar_len);
|
|
let time_bar = TIME_CHAR.to_string().repeat(time_bar_len);
|
|
|
|
histogram.push_str(&format!(
|
|
" Turn {:2}: {:>6} tokens │{:<40}│\n",
|
|
metrics.turn_number, metrics.tokens_used, token_bar
|
|
));
|
|
histogram.push_str(&format!(" {:>6} │{:<40}│\n", time_str, time_bar));
|
|
|
|
// Separator between turns (except for last)
|
|
if metrics.turn_number != turn_metrics.last().unwrap().turn_number {
|
|
histogram.push_str(
|
|
" ────────────┼────────────────────────────────────────┤\n",
|
|
);
|
|
}
|
|
}
|
|
|
|
append_summary_statistics(&mut histogram, turn_metrics);
|
|
histogram
|
|
}
|
|
|
|
/// Scale a value to a bar length proportional to max.
|
|
fn scale_bar(value: u32, max: u32, max_width: usize) -> usize {
|
|
if max == 0 {
|
|
0
|
|
} else {
|
|
((value as f64 / max as f64) * max_width as f64) as usize
|
|
}
|
|
}
|
|
|
|
/// Format milliseconds as a human-readable duration string.
|
|
fn format_duration_ms(ms: u32) -> String {
|
|
match ms {
|
|
ms if ms < 1000 => format!("{}ms", ms),
|
|
ms if ms < 60_000 => format!("{:.1}s", ms as f64 / 1000.0),
|
|
ms => {
|
|
let minutes = ms / 60_000;
|
|
let seconds = (ms % 60_000) as f64 / 1000.0;
|
|
format!("{}m{:.1}s", minutes, seconds)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Append summary statistics to the histogram output.
|
|
fn append_summary_statistics(histogram: &mut String, turn_metrics: &[TurnMetrics]) {
|
|
let total_tokens: u32 = turn_metrics.iter().map(|t| t.tokens_used).sum();
|
|
let total_time: Duration = turn_metrics.iter().map(|t| t.wall_clock_time).sum();
|
|
let avg_tokens = total_tokens as f64 / turn_metrics.len() as f64;
|
|
let avg_time_ms = total_time.as_millis() as f64 / turn_metrics.len() as f64;
|
|
|
|
histogram.push_str("\n📈 Summary Statistics:\n");
|
|
histogram.push_str(&format!(
|
|
" • Total Tokens: {} across {} turns\n",
|
|
total_tokens,
|
|
turn_metrics.len()
|
|
));
|
|
histogram.push_str(&format!(" • Average Tokens/Turn: {:.1}\n", avg_tokens));
|
|
histogram.push_str(&format!(" • Total Time: {:.1}s\n", total_time.as_secs_f64()));
|
|
histogram.push_str(&format!(" • Average Time/Turn: {:.1}s\n", avg_time_ms / 1000.0));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_format_elapsed_time() {
|
|
assert_eq!(format_elapsed_time(Duration::from_millis(500)), "500ms");
|
|
assert_eq!(format_elapsed_time(Duration::from_secs(45)), "45s");
|
|
assert_eq!(format_elapsed_time(Duration::from_secs(90)), "1m 30s");
|
|
assert_eq!(format_elapsed_time(Duration::from_secs(3661)), "1h 1m 1s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_histogram() {
|
|
let result = generate_turn_histogram(&[]);
|
|
assert!(result.contains("No turn data available"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scale_bar() {
|
|
assert_eq!(scale_bar(50, 100, 40), 20);
|
|
assert_eq!(scale_bar(100, 100, 40), 40);
|
|
assert_eq!(scale_bar(0, 100, 40), 0);
|
|
assert_eq!(scale_bar(50, 0, 40), 0);
|
|
}
|
|
}
|