1use crate::analyzer::{AnalyzeCtx, Analyzer};
32use crate::decide::{CauseVerdict, JudgedBy, TOOL_CAUSES};
33use crate::analyzers::bound_evidence;
34use crate::cal;
35use crate::error::Result;
36use crate::manifest::*;
37use crate::model::{ActionKind, GrainRecord, Severity};
38use crate::recommendation::{MetricSnapshot, Proposal, RecDraft, Summary};
39use std::collections::BTreeMap;
40
41use serde_json::{json, Map};
42
43pub struct ToolFailureClustering {
44 manifest: AnalyzerManifest,
45}
46
47impl ToolFailureClustering {
48 pub fn new() -> Self {
49 ToolFailureClustering {
50 manifest: AnalyzerManifest {
51 id: "loop.tool_failure/1".into(),
52 title: "Tool-failure clustering".into(),
53 description: "Clusters recurring tool failures into a memory lesson.".into(),
54 tier: Tier::T0,
55 cadence: CadenceClass::Batch,
56 requires: vec![],
57 target_classes: vec![TargetClass::Memory],
58 auto_apply: AutoApplyClass::Never, trust_class: TrustClass::Builtin,
60 params: vec![
61 ParamSpec::Int {
62 name: "min_count".into(),
63 default: 3,
64 min: 1,
65 max: 1000,
66 description: "Minimum failures in a cluster to fire.".into(),
67 },
68 ParamSpec::Float {
69 name: "min_rate".into(),
70 default: 0.4,
71 min: 0.0,
72 max: 1.0,
73 description: "Minimum share of this signature's opportunities \
74 (the tool's successes plus this cluster)."
75 .into(),
76 },
77 ParamSpec::Int {
78 name: "min_abs".into(),
79 default: 50,
80 min: 1,
81 max: 100_000,
82 description: "Absolute failure count that fires regardless of rate \
83 (so high-volume, moderate-rate failures aren't hidden)."
84 .into(),
85 },
86 ParamSpec::Int {
87 name: "window_days".into(),
88 default: 30,
89 min: 1,
90 max: 365,
91 description: "Lookback window.".into(),
92 },
93 ],
94 default_on: true,
95 },
96 }
97 }
98}
99
100impl Default for ToolFailureClustering {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl Analyzer for ToolFailureClustering {
107 fn manifest(&self) -> &AnalyzerManifest {
108 &self.manifest
109 }
110
111 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
112 let min_count = ctx.params().get_int("min_count").max(1) as usize;
113 let min_rate = ctx.params().get_float("min_rate");
114 let min_abs = ctx.params().get_int("min_abs").max(1) as usize;
115 let window_ms = ctx.params().get_int("window_days") * 86_400_000;
116 let since = Some(ctx.now_ms() - window_ms);
117
118 let tools = ctx.tools_since(since)?;
119
120 let mut tool_totals: BTreeMap<String, usize> = BTreeMap::new();
124 let mut tool_errors: BTreeMap<String, usize> = BTreeMap::new();
125 let mut clusters: BTreeMap<(String, String), Vec<Member>> = BTreeMap::new();
126 for e in &tools {
127 let Some(tool) = e.tool_name() else {
128 continue;
129 };
130 *tool_totals.entry(tool.to_string()).or_default() += 1;
131 if e.is_error() {
132 *tool_errors.entry(tool.to_string()).or_default() += 1;
133 let sig = normalize_signature(e.tool_content().unwrap_or(""));
134 clusters
135 .entry((tool.to_string(), sig))
136 .or_default()
137 .push((e.hash.clone(), e.namespace.clone(), cause_signal(e)));
138 }
139 }
140
141 let mut firing = Vec::new();
144 for ((tool, signature), members) in clusters {
145 if signature.is_empty() {
150 continue;
151 }
152 let count = members.len();
153 let calls = tool_totals.get(&tool).copied().unwrap_or(count);
157 let errors = tool_errors.get(&tool).copied().unwrap_or(count);
158 let opportunities = calls.saturating_sub(errors).saturating_add(count).max(1);
159 let rate = count as f64 / opportunities as f64;
160 if count < min_count || (rate < min_rate && count < min_abs) {
165 continue;
166 }
167 firing.push((tool, signature, members, count, rate, opportunities));
168 }
169
170 let texts: Vec<String> = {
174 let mut t: Vec<String> = firing
175 .iter()
176 .flat_map(|f| f.2.iter())
177 .filter_map(|(_, _, sig)| match sig {
178 CauseSignal::Text(t) => Some(t.clone()),
179 _ => None,
180 })
181 .collect();
182 t.sort();
183 t.dedup();
184 t
185 };
186 let classified = match (ctx.decider(), texts.is_empty()) {
187 (Some(d), false) => d.classify_causes(&texts),
188 _ => BTreeMap::new(),
189 };
190
191 let mut drafts = Vec::new();
192 for (tool, signature, mut members, count, rate, opportunities) in firing {
193 members.sort_by(|a, b| a.0.cmp(&b.0));
194 let cause = ctx.decider().and_then(|_| cluster_cause(&members, &classified));
197 let evidence = bound_evidence(members.iter().map(|(h, _, _)| h.clone()).collect());
198 let rate_pct = (rate * 100.0).round() as i64;
199
200 let mut args = Map::new();
201 args.insert("tool".into(), json!(tool));
202 args.insert("count".into(), json!(count));
203 args.insert("rate".into(), json!(rate_pct));
204 args.insert("signature".into(), json!(signature));
205 let template = match &cause {
206 Some((c, _)) => {
207 args.insert("cause".into(), json!(c));
208 "tool_failure.cluster_cause"
209 }
210 None => "tool_failure.cluster",
211 };
212
213 let mut ns_counts: BTreeMap<&str, usize> = BTreeMap::new();
220 for (_, ns, _) in &members {
221 if !ns.is_empty() {
222 *ns_counts.entry(ns.as_str()).or_default() += 1;
223 }
224 }
225 let lesson_ns = ns_counts
228 .iter()
229 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
230 .map(|(ns, _)| ns.to_string());
231 let mut lesson = Map::new();
232 lesson.insert("subject".into(), json!(tool));
233 lesson.insert("relation".into(), json!("fails_with"));
234 lesson.insert("object".into(), json!(signature));
235 lesson.insert("confidence".into(), json!(rate));
236 if let Some(ns) = &lesson_ns {
237 lesson.insert("namespace".into(), json!(ns));
238 }
239
240 let severity = if rate >= 0.7 && count >= 5 {
241 Severity::High
242 } else if rate >= 0.5 {
243 Severity::Medium
244 } else {
245 Severity::Low
246 };
247
248 let draft = RecDraft::new(
249 format!("entity:lessons/{tool}"),
250 ActionKind::ClusterFailure,
251 Summary::new(template, args),
252 Proposal::Cal {
253 cal: cal::add("fact", &lesson),
254 },
255 )
256 .severity(severity)
257 .evidence(evidence)
258 .confidence(rate)
259 .metric(MetricSnapshot {
260 metric: "tool_error_recurrence".into(),
264 baseline: 0.0,
265 unit: "count".into(),
266 n: opportunities as u64,
269 window: format!("{}d", ctx.params().get_int("window_days")),
270 subject: Some(tool.clone()),
271 namespace: None,
272 relation: Some(signature.clone()),
278 query: format!(
279 "RECALL tools WHERE tool_name = \"{tool}\" AND is_error AND signature = \"{signature}\" SINCE <applied_at> | COUNT"
280 ),
281 review_after_ms: 86_400_000,
282 horizons_ms: vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000],
285 checkpoints: Vec::new(),
286 higher_is_better: false,
288 });
289 drafts.push(match cause.and_then(|(_, j)| j) {
290 Some(j) => draft.judged_by(j),
291 None => draft,
292 });
293 }
294 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
295 Ok(drafts)
296 }
297}
298
299type Member = (String, String, CauseSignal);
301
302#[derive(Debug, Clone, PartialEq)]
304enum CauseSignal {
305 Known(String),
307 Text(String),
310 None,
312}
313
314fn cause_signal(g: &GrainRecord) -> CauseSignal {
315 let known = |s: &str| TOOL_CAUSES.iter().any(|(k, _)| *k == s);
316 match g.str_field("failure_cause").map(str::trim).filter(|s| !s.is_empty()) {
317 Some(c) if known(c) => CauseSignal::Known(c.to_string()),
318 Some(c) => CauseSignal::Text(c.to_string()),
319 None => match g.str_field("failure_detail").map(str::trim).filter(|s| !s.is_empty()) {
320 Some(d) => CauseSignal::Text(d.to_string()),
321 None => CauseSignal::None,
322 },
323 }
324}
325
326fn cluster_cause(
330 members: &[Member],
331 classified: &BTreeMap<String, CauseVerdict>,
332) -> Option<(String, Option<JudgedBy>)> {
333 let mut votes: BTreeMap<String, usize> = BTreeMap::new();
334 let mut judged: BTreeMap<String, JudgedBy> = BTreeMap::new();
335 for (_, _, sig) in members {
336 let cause = match sig {
337 CauseSignal::Known(k) => k.clone(),
338 CauseSignal::Text(t) => match classified.get(t).cloned().flatten() {
339 Some((c, _, j)) => {
340 judged.entry(c.clone()).or_insert(j);
341 c
342 }
343 None => "unknown".to_string(),
344 },
345 CauseSignal::None => continue,
346 };
347 *votes.entry(cause).or_default() += 1;
348 }
349 let (winner, _) = votes
350 .iter()
351 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))?;
352 Some((winner.clone(), judged.remove(winner)))
353}
354
355pub(crate) fn normalize_signature(content: &str) -> String {
360 let lowered: String = content.trim().to_lowercase().chars().take(80).collect();
361 let mut out = String::with_capacity(lowered.len());
362 for token in lowered.split_whitespace() {
363 if !out.is_empty() {
364 out.push(' ');
365 }
366 if token.contains('/') || token.contains('\\') {
367 out.push_str("<path>");
368 } else {
369 let mut prev_digit = false;
370 for c in token.chars() {
371 if c.is_ascii_digit() {
372 if !prev_digit {
373 out.push('#');
374 }
375 prev_digit = true;
376 } else {
377 out.push(c);
378 prev_digit = false;
379 }
380 }
381 }
382 }
383 out
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::testkit::TestSubstrate;
390
391 #[test]
392 fn signature_strips_digits_and_paths() {
393 assert_eq!(
394 normalize_signature("Rate limited after 4295 ms"),
395 "rate limited after # ms"
396 );
397 assert_eq!(
398 normalize_signature("open /etc/passwd failed"),
399 "open <path> failed"
400 );
401 }
402
403 #[test]
404 fn fires_on_frequent_and_dominant_cluster() {
405 let mut sub = TestSubstrate::new();
406 for _ in 0..5 {
407 sub.add_tool_call("stripe_refund", true, "rate_limited 429");
408 }
409 sub.add_tool_call("stripe_refund", false, "ok");
410 let drafts = sub.analyze(&ToolFailureClustering::new(), 10_000);
411 assert_eq!(drafts.len(), 1);
412 assert_eq!(drafts[0].action_kind, ActionKind::ClusterFailure);
413 assert_eq!(drafts[0].evidence.len(), 5);
414 }
415
416 #[test]
417 fn fires_on_high_volume_moderate_rate_via_absolute_count() {
418 let mut sub = TestSubstrate::new();
419 for _ in 0..10 {
422 sub.add_tool_call("search", true, "boom 500");
423 }
424 for _ in 0..30 {
425 sub.add_tool_call("search", false, "ok");
426 }
427 let drafts = sub.analyze_with(&ToolFailureClustering::new(), 10_000, &[("min_abs", serde_json::json!(10))]);
428 assert_eq!(drafts.len(), 1, "high-volume moderate-rate cluster fires via min_abs");
429 }
430
431 #[test]
432 fn sibling_failure_modes_do_not_mask_each_other() {
433 let mut sub = TestSubstrate::new();
439 for _ in 0..46 {
440 sub.add_tool_call("refund", true, "rate_limited 429");
441 }
442 for _ in 0..33 {
443 sub.add_tool_call("refund", true, "approval_required");
444 }
445 for _ in 0..14 {
446 sub.add_tool_call("refund", true, "cancel_before_refund");
447 }
448 for _ in 0..59 {
449 sub.add_tool_call("refund", false, "ok");
450 }
451 let drafts = sub.analyze(&ToolFailureClustering::new(), 10_000);
452 assert_eq!(drafts.len(), 1, "the dominant mode fires, its siblings do not");
455 assert_eq!(drafts[0].evidence.len(), 46);
456 }
457
458 #[test]
459 fn a_mode_that_is_a_small_share_of_its_own_opportunities_stays_silent() {
460 let mut sub = TestSubstrate::new();
463 for _ in 0..5 {
464 sub.add_tool_call("search", true, "boom 500");
465 }
466 for _ in 0..95 {
467 sub.add_tool_call("search", false, "ok");
468 }
469 assert!(sub.analyze(&ToolFailureClustering::new(), 10_000).is_empty());
470 }
471
472 #[test]
473 fn silent_below_rate_threshold() {
474 let mut sub = TestSubstrate::new();
475 sub.add_tool_call("search", true, "boom 500");
476 sub.add_tool_call("search", true, "boom 500");
477 for _ in 0..20 {
478 sub.add_tool_call("search", false, "ok");
479 }
480 assert!(sub
482 .analyze(&ToolFailureClustering::new(), 10_000)
483 .is_empty());
484 }
485}