use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct TurnMetrics {
pub started_at: Instant,
pub tool_calls: usize,
pub distinct_tools: HashSet<String>,
pub tool_errors: usize,
pub same_call_streak_max: usize,
pub final_text_len: usize,
pub tool_log: Vec<TurnToolEntry>,
}
#[derive(Debug, Clone)]
pub struct TurnToolEntry {
pub name: String,
pub args_summary: String,
pub result_summary: String,
pub is_error: bool,
}
impl Default for TurnMetrics {
fn default() -> Self {
Self::new()
}
}
impl TurnMetrics {
pub fn new() -> Self {
Self {
started_at: Instant::now(),
tool_calls: 0,
distinct_tools: HashSet::new(),
tool_errors: 0,
same_call_streak_max: 0,
final_text_len: 0,
tool_log: Vec::new(),
}
}
pub fn record_tool(
&mut self,
name: &str,
args_summary: String,
result_summary: String,
is_error: bool,
) {
self.tool_calls += 1;
self.distinct_tools.insert(name.to_owned());
if is_error {
self.tool_errors += 1;
}
self.tool_log.push(TurnToolEntry {
name: name.to_owned(),
args_summary,
result_summary,
is_error,
});
}
pub fn duration_secs(&self) -> f32 {
self.started_at.elapsed().as_secs_f32()
}
pub fn difficulty_score(&self) -> f32 {
let tc = (self.tool_calls as f32 / 20.0).min(1.0);
let dt = (self.distinct_tools.len() as f32 / 6.0).min(1.0);
let te = (self.tool_errors as f32 / 5.0).min(1.0);
let scs = (self.same_call_streak_max as f32 / 3.0).min(1.0);
let dur = (self.duration_secs() / 120.0).min(1.0);
0.25 * tc + 0.20 * dt + 0.30 * te + 0.15 * scs + 0.10 * dur
}
pub fn signature(&self) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut sorted: Vec<&str> = self.distinct_tools.iter().map(String::as_str).collect();
sorted.sort_unstable();
let mut hasher = DefaultHasher::new();
for n in &sorted {
n.hash(&mut hasher);
}
hasher.finish()
}
}
#[derive(Debug, Default)]
struct WorkflowState {
recent_distills: Vec<i64>,
seen_signatures: HashSet<u64>,
}
fn workflow_state() -> &'static Mutex<WorkflowState> {
static STATE: OnceLock<Mutex<WorkflowState>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(WorkflowState::default()))
}
pub fn try_admit_workflow(signature: u64, max_per_hour: usize) -> bool {
let mut st = match workflow_state().lock() {
Ok(s) => s,
Err(_) => return false,
};
if st.seen_signatures.contains(&signature) {
return false;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let one_hour_ago = now - 3600;
st.recent_distills.retain(|t| *t >= one_hour_ago);
if st.recent_distills.len() >= max_per_hour {
return false;
}
st.recent_distills.push(now);
st.seen_signatures.insert(signature);
true
}
pub fn release_signature(signature: u64) {
if let Ok(mut st) = workflow_state().lock() {
st.seen_signatures.remove(&signature);
if let Some(last) = st.recent_distills.pop() {
let _ = last;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn difficulty_increases_monotonically() {
let mut easy = TurnMetrics::new();
easy.tool_calls = 1;
easy.distinct_tools.insert("t1".into());
let easy_score = easy.difficulty_score();
let mut hard = TurnMetrics::new();
hard.tool_calls = 18;
hard.distinct_tools.extend(["a", "b", "c", "d", "e"].iter().map(|s| s.to_string()));
hard.tool_errors = 4;
hard.same_call_streak_max = 3;
let hard_score = hard.difficulty_score();
assert!(hard_score > easy_score);
assert!(hard_score <= 1.0);
assert!(easy_score >= 0.0);
}
#[test]
fn signature_is_order_invariant() {
let mut a = TurnMetrics::new();
a.distinct_tools.extend(["x", "y", "z"].iter().map(|s| s.to_string()));
let mut b = TurnMetrics::new();
b.distinct_tools.extend(["z", "x", "y"].iter().map(|s| s.to_string()));
assert_eq!(a.signature(), b.signature());
}
#[test]
fn signature_distinguishes_different_palettes() {
let mut a = TurnMetrics::new();
a.distinct_tools.extend(["x", "y"].iter().map(|s| s.to_string()));
let mut b = TurnMetrics::new();
b.distinct_tools.extend(["x", "z"].iter().map(|s| s.to_string()));
assert_ne!(a.signature(), b.signature());
}
#[test]
fn record_tool_increments_counters() {
let mut m = TurnMetrics::new();
m.record_tool("read_file", "{}".into(), "ok".into(), false);
m.record_tool("read_file", "{}".into(), "ok".into(), false);
m.record_tool("execute_command", "{}".into(), "err".into(), true);
assert_eq!(m.tool_calls, 3);
assert_eq!(m.distinct_tools.len(), 2);
assert_eq!(m.tool_errors, 1);
assert_eq!(m.tool_log.len(), 3);
}
}