agtrace_engine/analysis/
lenses.rs

1use 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    // Fill remaining slots with high activity sessions if needed
167    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}