use super::{Analysis, Task, plural, substantive};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Class {
Fix,
Habit,
Note,
}
impl Class {
fn as_str(&self) -> &'static str {
match self {
Class::Fix => "fix",
Class::Habit => "habit",
Class::Note => "note",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Basis {
Measured,
Estimated,
}
impl Basis {
fn as_str(&self) -> &'static str {
match self {
Basis::Measured => "measured",
Basis::Estimated => "estimated",
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub class: Class,
pub title: String,
pub remedy: String,
pub tokens: u64,
pub usd: f64,
pub basis: Basis,
pub sessions: usize,
pub floor: bool,
}
fn usd_per_token(analyses: &[&Analysis]) -> Option<f64> {
let (cost, tokens) = analyses
.iter()
.filter(|a| a.cost_available)
.fold((0.0, 0u64), |(c, t), a| (c + a.cost, t + a.input_total));
(tokens > 0 && cost > 0.0).then(|| cost / tokens as f64)
}
fn junk_reads(analyses: &[&Analysis], rate: Option<f64>) -> Option<Finding> {
let hit: Vec<&&Analysis> = analyses.iter().filter(|a| a.junk_reads > 0).collect();
let calls: u64 = hit.iter().map(|a| a.junk_reads).sum();
if calls == 0 {
return None;
}
let tokens: u64 = hit.iter().map(|a| a.junk_tokens).sum();
Some(Finding {
class: Class::Fix,
title: format!(
"{} into generated or vendored directories",
plural(calls as usize, "read")
),
remedy: "Name them in .claude/settings.json under permissions.deny, or \
in the ignore file your harness reads, so the agent stops \
being offered them."
.into(),
tokens,
usd: rate.map(|r| tokens as f64 * r).unwrap_or(0.0),
basis: Basis::Measured,
sessions: hit.len(),
floor: hit.iter().any(|a| a.truncated),
})
}
fn rereads(analyses: &[&Analysis], rate: Option<f64>) -> Option<Finding> {
let hit: Vec<&&Analysis> = analyses.iter().filter(|a| a.rereads > 0).collect();
let calls: u64 = hit.iter().map(|a| a.rereads).sum();
if calls < 3 {
return None;
}
let tokens: u64 = hit.iter().map(|a| a.reread_tokens).sum();
Some(Finding {
class: Class::Habit,
title: format!(
"{} re-read inside a session that had already read them",
plural(calls as usize, "file")
),
remedy: "Usually a context window that lost the file to a compaction. \
Putting the file's role in CLAUDE.md, or splitting the work \
into shorter sessions, costs less than re-reading it."
.into(),
tokens,
usd: rate.map(|r| tokens as f64 * r).unwrap_or(0.0),
basis: Basis::Measured,
sessions: hit.len(),
floor: hit.iter().any(|a| a.truncated),
})
}
fn shared_rereads(analyses: &[&Analysis]) -> Option<Finding> {
let mut across: HashMap<&str, usize> = HashMap::new();
for a in analyses {
for path in &a.read_paths {
*across.entry(path.as_str()).or_default() += 1;
}
}
let mut repeated: Vec<(&str, usize)> = across.into_iter().filter(|(_, n)| *n >= 5).collect();
if repeated.is_empty() {
return None;
}
repeated.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let worst = repeated
.iter()
.take(3)
.map(|(p, n)| format!("{} ({n}×)", short_path(p)))
.collect::<Vec<_>>()
.join(", ");
Some(Finding {
class: Class::Habit,
title: format!(
"{} read from scratch by five or more sessions",
plural(repeated.len(), "file")
),
remedy: format!(
"Most often {worst}. A file every session has to go and find is one the agent is not being told about — a line in CLAUDE.md saying what it is for costs less than reading it each time."
),
tokens: 0,
usd: 0.0,
basis: Basis::Estimated,
sessions: analyses.len(),
floor: analyses.iter().any(|a| a.truncated),
})
}
fn short_path(p: &str) -> String {
let cleaned = p.replace('\\', "/");
let parts: Vec<&str> = cleaned.rsplit('/').take(2).collect();
parts.into_iter().rev().collect::<Vec<_>>().join("/")
}
fn read_heavy(analyses: &[&Analysis]) -> Option<Finding> {
let hit: Vec<&&Analysis> = analyses
.iter()
.filter(|a| {
!matches!(a.task, Task::Exploration | Task::Conversation)
&& a.wrote() > 0
&& a.reads >= a.wrote() * 10
})
.collect();
if hit.is_empty() {
return None;
}
let cost: f64 = hit
.iter()
.filter(|a| a.cost_available)
.map(|a| a.cost)
.sum();
Some(Finding {
class: Class::Habit,
title: format!(
"{} read ten times more than edited",
plural(hit.len(), "session")
),
remedy: "The agent is hunting for context it could have been given. A \
pointer in CLAUDE.md to where the relevant code lives is the \
usual fix."
.into(),
tokens: 0,
usd: cost * 0.25,
basis: Basis::Estimated,
sessions: hit.len(),
floor: hit.iter().any(|a| a.truncated),
})
}
fn failing_calls(analyses: &[&Analysis]) -> Option<Finding> {
let hit: Vec<&&Analysis> = analyses
.iter()
.filter(|a| a.records_outcomes && a.calls >= 20 && a.errors * 10 >= a.calls)
.collect();
if hit.is_empty() {
return None;
}
let errors: u64 = hit.iter().map(|a| a.errors).sum();
let calls: u64 = hit.iter().map(|a| a.calls).sum();
let cost: f64 = hit
.iter()
.filter(|a| a.cost_available)
.map(|a| a.cost)
.sum();
Some(Finding {
class: Class::Habit,
title: format!(
"{}% of tool calls failed across {}",
errors * 100 / calls.max(1),
plural(hit.len(), "session")
),
remedy: "A retried call is billed every time. The usual causes are a \
command the agent cannot run, a path that does not exist, and \
a permission it was never granted — `cctop doctor` covers the \
last one."
.into(),
tokens: 0,
usd: match calls {
0 => 0.0,
_ => cost * errors as f64 / calls as f64,
},
basis: Basis::Estimated,
sessions: hit.len(),
floor: hit.iter().any(|a| a.truncated),
})
}
fn spent_without_editing(analyses: &[&Analysis]) -> Option<Finding> {
let hit: Vec<&&Analysis> = analyses
.iter()
.filter(|a| {
a.cost_available
&& a.cost >= 0.50
&& a.wrote() == 0
&& !matches!(
a.task,
Task::Conversation | Task::Planning | Task::Exploration
)
})
.collect();
if hit.is_empty() {
return None;
}
let cost: f64 = hit.iter().map(|a| a.cost).sum();
let mut where_: Vec<&str> = hit
.iter()
.map(|a| a.label.as_str())
.filter(|l| !l.is_empty())
.collect();
where_.sort_unstable();
where_.dedup();
let where_ = match where_.is_empty() {
true => String::new(),
false => format!(
"Mostly in {}. ",
where_
.iter()
.take(3)
.copied()
.collect::<Vec<_>>()
.join(", ")
),
};
Some(Finding {
class: Class::Note,
title: format!(
"{} spent over $0.50 and edited no file",
plural(hit.len(), "session")
),
remedy: format!(
"{where_}Not necessarily wasted — a question answered well changes no file. Worth a look only if you expected these to ship something."
),
tokens: 0,
usd: cost,
basis: Basis::Measured,
sessions: hit.len(),
floor: false,
})
}
const MINUTES_USD: f64 = 5.00;
const MATERIAL_SHARE: f64 = 0.01;
fn attention_floor(spend: f64) -> f64 {
(spend * MATERIAL_SHARE).max(MINUTES_USD)
}
fn worth_reading(f: &Finding, floor: f64) -> bool {
f.usd == 0.0 || f.usd >= floor
}
fn spend(analyses: &[&Analysis]) -> f64 {
analyses
.iter()
.filter(|a| a.cost_available)
.map(|a| a.cost)
.sum()
}
pub fn triage(analyses: &[&Analysis]) -> (Vec<Finding>, Vec<Finding>) {
let live: Vec<&Analysis> = analyses
.iter()
.copied()
.filter(|a| substantive(a))
.collect();
let floor = attention_floor(spend(&live));
let mut all = detect(&live);
all.sort_by(|a, b| {
a.class.cmp(&b.class).then(
b.usd
.partial_cmp(&a.usd)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
all.into_iter().partition(|f| worth_reading(f, floor))
}
fn detect(live: &[&Analysis]) -> Vec<Finding> {
let rate = usd_per_token(live);
[
junk_reads(live, rate),
rereads(live, rate),
shared_rereads(live),
read_heavy(live),
failing_calls(live),
spent_without_editing(live),
]
.into_iter()
.flatten()
.collect()
}
pub fn by_task(analyses: &[&Analysis]) -> Vec<(Task, usize, f64)> {
let mut acc: HashMap<Task, (usize, f64)> = HashMap::new();
for a in analyses {
let e = acc.entry(a.task).or_default();
e.0 += 1;
if a.cost_available {
e.1 += a.cost;
}
}
let mut out: Vec<(Task, usize, f64)> = Task::ALL
.iter()
.filter_map(|t| acc.get(t).map(|(n, c)| (*t, *n, *c)))
.collect();
out.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
out
}
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();
if live.is_empty() {
return "No sessions with recorded tool calls, so there is nothing to read.\n".into();
}
let (found, below) = triage(analyses);
let recoverable: f64 = found
.iter()
.filter(|f| f.class != Class::Note)
.map(|f| f.usd)
.sum();
out.push('\n');
let _ = writeln!(
out,
" {} sessions · {} · {}",
live.len(),
plural(found.len(), "finding"),
match recoverable > 0.0 {
true => format!(
"about {} looks recoverable",
crate::util::adaptive_usd(recoverable)
),
false => "nothing here has a price on it".to_string(),
}
);
out.push('\n');
if found.is_empty() {
let _ = writeln!(
out,
" Nothing worth reporting. That is a real answer, not an empty one."
);
out.push('\n');
}
if !below.is_empty() {
let small: f64 = below.iter().map(|f| f.usd).sum();
let note = format!(
"{} came to {} between them, under the {} bar that {} of spend \
sets. Acting on them costs more time than they return.",
plural(below.len(), "smaller finding"),
crate::util::adaptive_usd(small),
crate::util::adaptive_usd(attention_floor(spend(&live))),
crate::util::adaptive_usd(spend(&live)),
);
for line in textwrap(¬e, 72) {
let _ = writeln!(out, " {line}");
}
out.push('\n');
}
for f in &found {
let amount = match f.usd > 0.0 {
true => crate::util::adaptive_usd(f.usd),
false => "—".to_string(),
};
let _ = writeln!(
out,
" {:<6} {:<52} {:>9} {}",
f.class.as_str(),
ellipsise(&f.title, 52),
amount,
match f.class {
Class::Note => "observed",
_ => f.basis.as_str(),
}
);
if !f.remedy.is_empty() {
for line in textwrap(&f.remedy, 66) {
let _ = writeln!(out, " {line}");
}
}
if f.floor {
let _ = writeln!(
out,
" (a floor: the tool history is capped per session)"
);
}
out.push('\n');
}
let _ = writeln!(out, " Where the money went");
out.push('\n');
for (task, n, cost) in by_task(&live) {
let _ = writeln!(
out,
" {:<14} {:>4} sessions {:>9}",
task.as_str(),
n,
crate::util::adaptive_usd(cost)
);
}
out.push('\n');
let _ = writeln!(
out,
" Costs are estimates — see `cctop --help` and docs/costs.md."
);
let _ = writeln!(
out,
" A `measured` saving was counted from recorded tokens; an"
);
let _ = writeln!(
out,
" `estimated` one was derived from this corpus's own averages."
);
out.push('\n');
out
}
fn textwrap(s: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in s.split_whitespace() {
if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
lines.push(line);
}
lines
}
pub fn as_json(analyses: &[&Analysis]) -> String {
let live: Vec<&Analysis> = analyses
.iter()
.copied()
.filter(|a| substantive(a))
.collect();
let (found, below) = triage(analyses);
let doc = serde_json::json!({
"sessions": live.len(),
"floor_usd": attention_floor(spend(&live)),
"below_floor": {
"findings": below.len(),
"usd": below.iter().map(|f| f.usd).sum::<f64>(),
},
"findings": found.iter().map(|f| serde_json::json!({
"class": f.class.as_str(),
"title": f.title,
"remedy": f.remedy,
"tokens": f.tokens,
"usd": f.usd,
"basis": f.basis.as_str(),
"sessions": f.sessions,
"floor": f.floor,
})).collect::<Vec<_>>(),
"by_task": by_task(&live).into_iter().map(|(t, n, c)| serde_json::json!({
"task": t.as_str(),
"sessions": n,
"usd": c,
})).collect::<Vec<_>>(),
});
serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".into())
}
fn ellipsise(s: &str, width: usize) -> String {
match s.chars().count() > width {
false => s.to_string(),
true => s.chars().take(width - 1).collect::<String>() + "\u{2026}",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::insight::Task;
use crate::pricing::Provider;
fn session(cost: f64, edits: u64, task: Task) -> Analysis {
Analysis {
provider: Provider::Claude,
label: "repo".into(),
model: "claude-opus-5".into(),
cost,
cost_available: true,
task,
calls: 40,
errors: 0,
records_outcomes: true,
edits,
bash_writes: 0,
reads: 0,
files_edited: edits,
files_one_shot: edits,
read_paths: Default::default(),
junk_reads: 0,
junk_tokens: 0,
reread_tokens: 0,
rereads: 0,
cache_read: 0,
input_total: 0,
truncated: false,
}
}
#[test]
fn an_observation_is_not_a_saving() {
let sessions = [
session(20.0, 0, Task::Coding),
session(20.0, 0, Task::Coding),
];
let refs: Vec<&Analysis> = sessions.iter().collect();
let found = triage(&refs).0;
let note = found
.iter()
.find(|f| f.class == Class::Note)
.expect("sessions that spent and edited nothing");
assert!(note.usd > 0.0, "the note still reports what was spent");
let recoverable: f64 = found
.iter()
.filter(|f| f.class != Class::Note)
.map(|f| f.usd)
.sum();
assert_eq!(recoverable, 0.0, "and none of it counts as recoverable");
}
#[test]
fn actionable_findings_outrank_observations() {
let mut sessions = [
session(50.0, 0, Task::Coding),
session(50.0, 0, Task::Coding),
];
sessions[0].junk_reads = 4;
sessions[0].junk_tokens = 8000;
sessions[0].input_total = 100_000;
let refs: Vec<&Analysis> = sessions.iter().collect();
let found = triage(&refs).0;
let classes: Vec<Class> = found.iter().map(|f| f.class).collect();
let note_at = classes.iter().position(|c| *c == Class::Note);
let fix_at = classes.iter().position(|c| *c == Class::Fix);
assert!(fix_at.is_some() && note_at.is_some());
assert!(fix_at < note_at, "a fix comes before an observation");
}
#[test]
fn a_saving_worth_less_than_the_time_to_act_is_dropped() {
let mut cheap = session(2.00, 1, Task::Coding);
cheap.input_total = 1_000_000;
cheap.junk_reads = 5;
cheap.junk_tokens = 500_000; let refs = vec![&cheap];
let (kept, below) = triage(&refs);
assert!(
!kept
.iter()
.any(|f| f.title.contains("generated or vendored")),
"a fix worth a dollar loses against the minutes it takes"
);
assert_eq!(below.len(), 1, "but it is remembered, not discarded");
}
#[test]
fn the_bar_rises_with_what_the_corpus_spends() {
assert_eq!(attention_floor(40.0), MINUTES_USD);
assert_eq!(attention_floor(5_000.0), 50.0);
let big: Vec<Analysis> = (0..50)
.map(|_| {
let mut a = session(100.0, 1, Task::Coding);
a.input_total = 10_000_000;
a.junk_reads = 5;
a.junk_tokens = 80_000; a
})
.collect();
let refs: Vec<&Analysis> = big.iter().collect();
let (kept, below) = triage(&refs);
assert!(
!kept
.iter()
.any(|f| f.title.contains("generated or vendored")),
"$50 recovered out of $5000 spent is not what to lead with"
);
assert!(below.iter().any(|f| f.usd >= MINUTES_USD));
}
#[test]
fn what_was_left_out_is_still_accounted_for() {
let mut cheap = session(2.00, 1, Task::Coding);
cheap.input_total = 1_000_000;
cheap.junk_reads = 5;
cheap.junk_tokens = 500_000;
let refs = vec![&cheap];
let text = report(&refs);
assert!(
text.contains("smaller finding"),
"the suppressed findings are named, not silently dropped:\n{text}"
);
assert!(!text.contains("$0.00 looks recoverable"));
}
#[test]
fn exploration_is_not_reported_as_reading_too_much() {
let mut explore = session(5.0, 0, Task::Exploration);
explore.reads = 500;
explore.edits = 1;
explore.files_edited = 1;
let refs = vec![&explore];
assert!(
!triage(&refs)
.0
.iter()
.any(|f| f.title.contains("ten times")),
"exploring is what exploration is for"
);
}
}