agtrace_engine/analysis/
lenses.rs1use super::digest::SessionDigest;
2use super::metrics::SessionMetrics;
3use std::collections::HashSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum LensType {
7 Failures,
8 Bottlenecks,
9 Toolchains,
10 Loops,
11}
12
13type PredicateFn = Box<dyn Fn(&SessionMetrics, &Thresholds) -> bool>;
14type ScoreFn = Box<dyn Fn(&SessionMetrics, u32) -> i64>;
15type ReasonFn = Box<dyn Fn(&SessionMetrics) -> String>;
16
17pub struct Lens {
18 pub lens_type: LensType,
19 predicate: PredicateFn,
20 score: ScoreFn,
21 reason: ReasonFn,
22}
23
24impl Lens {
25 pub fn failures() -> Self {
26 Self {
27 lens_type: LensType::Failures,
28 predicate: Box::new(|m, _| m.tool_failures_total > 0 || m.missing_tool_pairs > 0),
29 score: Box::new(|m, boost| {
30 (m.tool_failures_total as i64 * 100)
31 + (m.missing_tool_pairs as i64 * 50)
32 + (boost as i64)
33 }),
34 reason: Box::new(|m| {
35 format!(
36 "fails={} missing={}",
37 m.tool_failures_total, m.missing_tool_pairs
38 )
39 }),
40 }
41 }
42
43 pub fn bottlenecks() -> Self {
44 Self {
45 lens_type: LensType::Bottlenecks,
46 predicate: Box::new(|m, t| {
47 m.max_e2e_ms > t.p90_e2e_ms || m.max_tool_ms > t.p90_tool_ms
48 }),
49 score: Box::new(|m, _| (m.max_tool_ms as i64) + (m.max_e2e_ms as i64)),
50 reason: Box::new(|m| {
51 format!(
52 "max_tool={:.1}s max_e2e={:.1}s",
53 m.max_tool_ms as f64 / 1000.0,
54 m.max_e2e_ms as f64 / 1000.0
55 )
56 }),
57 }
58 }
59
60 pub fn toolchains() -> Self {
61 Self {
62 lens_type: LensType::Toolchains,
63 predicate: Box::new(|m, t| m.tool_calls_total > t.p90_tool_calls.max(5)),
64 score: Box::new(|m, boost| (m.tool_calls_total as i64 * 10) + (boost as i64)),
65 reason: Box::new(|m| {
66 format!(
67 "tool_calls={} longest_chain={}",
68 m.tool_calls_total, m.longest_chain
69 )
70 }),
71 }
72 }
73
74 pub fn loops() -> Self {
75 Self {
76 lens_type: LensType::Loops,
77 predicate: Box::new(|m, _| m.loop_signals > 0),
78 score: Box::new(|m, boost| (m.loop_signals as i64 * 100) + (boost as i64)),
79 reason: Box::new(|m| format!("loop_signals={}", m.loop_signals)),
80 }
81 }
82
83 pub fn matches(&self, metrics: &SessionMetrics, thresholds: &Thresholds) -> bool {
84 (self.predicate)(metrics, thresholds)
85 }
86
87 pub fn score(&self, metrics: &SessionMetrics, recency_boost: u32) -> i64 {
88 (self.score)(metrics, recency_boost)
89 }
90
91 pub fn reason(&self, metrics: &SessionMetrics) -> String {
92 (self.reason)(metrics)
93 }
94}
95
96#[derive(Debug, Clone)]
97pub struct Thresholds {
98 pub p90_e2e_ms: u64,
99 pub p90_tool_ms: u64,
100 pub p90_tool_calls: usize,
101}
102
103impl Thresholds {
104 pub fn compute(digests: &[SessionDigest]) -> Self {
105 let mut e2e_times: Vec<u64> = digests.iter().map(|d| d.metrics.max_e2e_ms).collect();
106 let mut tool_times: Vec<u64> = digests.iter().map(|d| d.metrics.max_tool_ms).collect();
107 let mut call_counts: Vec<usize> =
108 digests.iter().map(|d| d.metrics.tool_calls_total).collect();
109
110 e2e_times.sort_unstable();
111 tool_times.sort_unstable();
112 call_counts.sort_unstable();
113
114 let p90_idx = (digests.len() as f64 * 0.9) as usize;
115 let idx = p90_idx.min(digests.len().saturating_sub(1));
116
117 Self {
118 p90_e2e_ms: *e2e_times.get(idx).unwrap_or(&5000),
119 p90_tool_ms: *tool_times.get(idx).unwrap_or(&5000),
120 p90_tool_calls: *call_counts.get(idx).unwrap_or(&10),
121 }
122 }
123}
124
125pub fn select_sessions_by_lenses(
126 digests: &[SessionDigest],
127 thresholds: &Thresholds,
128 total_limit: usize,
129) -> Vec<SessionDigest> {
130 let lenses = vec![
131 Lens::failures(),
132 Lens::loops(),
133 Lens::bottlenecks(),
134 Lens::toolchains(),
135 ];
136
137 let limit_per_lens = (total_limit / lenses.len()).max(1);
138 let mut selected_sessions = Vec::new();
139 let mut used_ids = HashSet::new();
140
141 for lens in lenses {
142 let mut candidates: Vec<SessionDigest> = digests
143 .iter()
144 .filter(|d| !used_ids.contains(&d.session_id))
145 .filter(|d| lens.matches(&d.metrics, thresholds))
146 .cloned()
147 .collect();
148
149 candidates.sort_by(|a, b| {
150 let score_a = lens.score(&a.metrics, a.recency_boost);
151 let score_b = lens.score(&b.metrics, b.recency_boost);
152 score_b.cmp(&score_a)
153 });
154
155 for mut candidate in candidates.into_iter().take(limit_per_lens) {
156 candidate.selection_reason = Some(format!(
157 "{:?} ({})",
158 lens.lens_type,
159 lens.reason(&candidate.metrics)
160 ));
161 used_ids.insert(candidate.session_id.clone());
162 selected_sessions.push(candidate);
163 }
164 }
165
166 if selected_sessions.len() < total_limit {
168 let mut remaining: Vec<SessionDigest> = digests
169 .iter()
170 .filter(|d| !used_ids.contains(&d.session_id))
171 .cloned()
172 .collect();
173
174 remaining.sort_by_key(|d| std::cmp::Reverse(d.metrics.tool_calls_total));
175
176 for mut candidate in remaining
177 .into_iter()
178 .take(total_limit - selected_sessions.len())
179 {
180 candidate.selection_reason = Some("Activity (filler)".to_string());
181 selected_sessions.push(candidate);
182 }
183 }
184
185 selected_sessions
186}