use super::{Analysis, Task, plural, substantive};
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct Row {
pub model: String,
pub sessions: usize,
pub cost: f64,
pub cost_available: bool,
pub calls: u64,
pub edits: u64,
pub files_edited: u64,
pub files_one_shot: u64,
pub cache_read: u64,
pub input_total: u64,
pub truncated: bool,
}
impl Row {
pub fn one_shot(&self) -> Option<f64> {
(self.files_edited >= 5)
.then(|| self.files_one_shot as f64 * 100.0 / self.files_edited as f64)
}
pub fn per_edit(&self) -> Option<f64> {
(self.cost_available && self.files_edited > 0).then(|| self.cost / self.files_edited as f64)
}
pub fn per_call(&self) -> Option<f64> {
(self.cost_available && self.calls > 0).then(|| self.cost / self.calls as f64)
}
pub fn cache_hit(&self) -> Option<f64> {
(self.input_total > 0).then(|| self.cache_read as f64 * 100.0 / self.input_total as f64)
}
}
fn fold<'a>(analyses: impl Iterator<Item = &'a Analysis>) -> Vec<Row> {
let mut acc: HashMap<String, Row> = HashMap::new();
for a in analyses {
if a.model.is_empty() {
continue;
}
let row = acc.entry(a.model.clone()).or_insert_with(|| Row {
model: a.model.clone(),
..Default::default()
});
row.sessions += 1;
row.calls += a.calls;
row.edits += a.edits;
row.files_edited += a.files_edited;
row.files_one_shot += a.files_one_shot;
row.cache_read += a.cache_read;
row.input_total += a.input_total;
row.truncated |= a.truncated;
if a.cost_available {
row.cost += a.cost;
row.cost_available = true;
}
}
let mut out: Vec<Row> = acc.into_values().collect();
out.sort_by(|a, b| {
b.cost
.partial_cmp(&a.cost)
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
pub fn rows(analyses: &[&Analysis]) -> Vec<Row> {
fold(analyses.iter().copied().filter(|a| substantive(a)))
}
fn pct(v: Option<f64>) -> String {
v.map(|v| format!("{v:.0}%")).unwrap_or_else(|| "—".into())
}
fn money(v: Option<f64>) -> String {
v.map(crate::util::adaptive_usd)
.unwrap_or_else(|| "—".into())
}
fn header(out: &mut String) {
use std::fmt::Write as _;
let _ = writeln!(
out,
" {:<28} {:>8} {:>8} {:>9} {:>9} {:>7}",
"model", "sessions", "1-shot", "$/file", "$/call", "cache"
);
}
fn line(out: &mut String, r: &Row) {
use std::fmt::Write as _;
let model: String = r.model.chars().take(28).collect();
let _ = writeln!(
out,
" {:<28} {:>8} {:>8} {:>9} {:>9} {:>7}",
model,
r.sessions,
pct(r.one_shot()),
money(r.per_edit()),
money(r.per_call()),
pct(r.cache_hit()),
);
}
pub fn report(analyses: &[&Analysis]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let live: Vec<&Analysis> = analyses
.iter()
.copied()
.filter(|a| substantive(a))
.collect();
let table = rows(analyses);
if table.is_empty() {
return "No sessions with recorded tool calls, so there is nothing to compare.\n".into();
}
out.push('\n');
let _ = writeln!(
out,
" {} across {}",
plural(live.len(), "session"),
plural(table.len(), "model")
);
out.push('\n');
header(&mut out);
for r in &table {
line(&mut out, r);
}
out.push('\n');
for task in Task::ALL {
let subset: Vec<&Analysis> = live.iter().copied().filter(|a| a.task == task).collect();
let sub = fold(subset.iter().copied());
if sub.len() < 2 {
continue;
}
let _ = writeln!(out, " {}", task.as_str());
for r in &sub {
line(&mut out, r);
}
out.push('\n');
}
let _ = writeln!(
out,
" Observational, not an experiment: these models were not given"
);
let _ = writeln!(
out,
" the same work, so a difference here may be your routing rather"
);
let _ = writeln!(
out,
" than the model. The per-task tables above are the closest this"
);
let _ = writeln!(out, " can get to comparing like with like.");
out.push('\n');
let _ = writeln!(
out,
" A session that used several models is credited entirely to the"
);
let _ = writeln!(
out,
" one that cost the most — the transcript records which model"
);
let _ = writeln!(
out,
" billed a request, not which one asked for a given tool call."
);
if table.iter().any(|r| r.truncated) {
out.push('\n');
let _ = writeln!(
out,
" Some counts are floors: the per-session tool history is capped."
);
}
out.push('\n');
out
}
pub fn as_json(analyses: &[&Analysis]) -> String {
let row = |r: &Row| {
serde_json::json!({
"model": r.model,
"sessions": r.sessions,
"usd": r.cost_available.then_some(r.cost),
"calls": r.calls,
"edits": r.edits,
"files_edited": r.files_edited,
"one_shot_pct": r.one_shot(),
"usd_per_file": r.per_edit(),
"usd_per_call": r.per_call(),
"cache_hit_pct": r.cache_hit(),
"counts_are_floors": r.truncated,
})
};
let live: Vec<&Analysis> = analyses
.iter()
.copied()
.filter(|a| substantive(a))
.collect();
let by_task: Vec<serde_json::Value> = Task::ALL
.iter()
.filter_map(|task| {
let subset: Vec<&Analysis> = live.iter().copied().filter(|a| a.task == *task).collect();
let sub = fold(subset.iter().copied());
(sub.len() >= 2).then(|| {
serde_json::json!({
"task": task.as_str(),
"models": sub.iter().map(row).collect::<Vec<_>>(),
})
})
})
.collect();
let doc = serde_json::json!({
"sessions": live.len(),
"models": rows(analyses).iter().map(row).collect::<Vec<_>>(),
"by_task": by_task,
"caveat": "Observational. These models were not given the same work, so \
a difference may be routing rather than the model. A session \
that used several models is credited entirely to the one that \
cost the most.",
});
serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".into())
}