use crate::analyzer::{AnalyzeCtx, Analyzer};
use crate::analyzers::bound_evidence;
use crate::cal;
use crate::error::Result;
use crate::manifest::*;
use crate::model::{ActionKind, Severity};
use crate::recommendation::{MetricSnapshot, Proposal, RecDraft, Summary};
use std::collections::BTreeMap;
use serde_json::{json, Map};
pub struct ToolFailureClustering {
manifest: AnalyzerManifest,
}
impl ToolFailureClustering {
pub fn new() -> Self {
ToolFailureClustering {
manifest: AnalyzerManifest {
id: "loop.tool_failure/1".into(),
title: "Tool-failure clustering".into(),
description: "Clusters recurring tool failures into a memory lesson.".into(),
tier: Tier::T0,
cadence: CadenceClass::Batch,
requires: vec![],
target_classes: vec![TargetClass::Memory],
auto_apply: AutoApplyClass::Never, trust_class: TrustClass::Builtin,
params: vec![
ParamSpec::Int {
name: "min_count".into(),
default: 3,
min: 1,
max: 1000,
description: "Minimum failures in a cluster to fire.".into(),
},
ParamSpec::Float {
name: "min_rate".into(),
default: 0.4,
min: 0.0,
max: 1.0,
description: "Minimum share of this signature's opportunities \
(the tool's successes plus this cluster)."
.into(),
},
ParamSpec::Int {
name: "min_abs".into(),
default: 50,
min: 1,
max: 100_000,
description: "Absolute failure count that fires regardless of rate \
(so high-volume, moderate-rate failures aren't hidden)."
.into(),
},
ParamSpec::Int {
name: "window_days".into(),
default: 30,
min: 1,
max: 365,
description: "Lookback window.".into(),
},
],
default_on: true,
},
}
}
}
impl Default for ToolFailureClustering {
fn default() -> Self {
Self::new()
}
}
impl Analyzer for ToolFailureClustering {
fn manifest(&self) -> &AnalyzerManifest {
&self.manifest
}
fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
let min_count = ctx.params().get_int("min_count").max(1) as usize;
let min_rate = ctx.params().get_float("min_rate");
let min_abs = ctx.params().get_int("min_abs").max(1) as usize;
let window_ms = ctx.params().get_int("window_days") * 86_400_000;
let since = Some(ctx.now_ms() - window_ms);
let tools = ctx.tools_since(since)?;
let mut tool_totals: BTreeMap<String, usize> = BTreeMap::new();
let mut tool_errors: BTreeMap<String, usize> = BTreeMap::new();
let mut clusters: BTreeMap<(String, String), Vec<(String, String)>> = BTreeMap::new();
for e in &tools {
let Some(tool) = e.tool_name() else {
continue;
};
*tool_totals.entry(tool.to_string()).or_default() += 1;
if e.is_error() {
*tool_errors.entry(tool.to_string()).or_default() += 1;
let sig = normalize_signature(e.tool_content().unwrap_or(""));
clusters
.entry((tool.to_string(), sig))
.or_default()
.push((e.hash.clone(), e.namespace.clone()));
}
}
let mut drafts = Vec::new();
for ((tool, signature), mut members) in clusters {
if signature.is_empty() {
continue;
}
let count = members.len();
let calls = tool_totals.get(&tool).copied().unwrap_or(count);
let errors = tool_errors.get(&tool).copied().unwrap_or(count);
let opportunities = calls.saturating_sub(errors).saturating_add(count).max(1);
let rate = count as f64 / opportunities as f64;
if count < min_count || (rate < min_rate && count < min_abs) {
continue;
}
members.sort_by(|a, b| a.0.cmp(&b.0));
let evidence = bound_evidence(members.iter().map(|(h, _)| h.clone()).collect());
let rate_pct = (rate * 100.0).round() as i64;
let mut args = Map::new();
args.insert("tool".into(), json!(tool));
args.insert("count".into(), json!(count));
args.insert("rate".into(), json!(rate_pct));
args.insert("signature".into(), json!(signature));
let mut ns_counts: BTreeMap<&str, usize> = BTreeMap::new();
for (_, ns) in &members {
if !ns.is_empty() {
*ns_counts.entry(ns.as_str()).or_default() += 1;
}
}
let lesson_ns = ns_counts
.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(ns, _)| ns.to_string());
let mut lesson = Map::new();
lesson.insert("subject".into(), json!(tool));
lesson.insert("relation".into(), json!("fails_with"));
lesson.insert("object".into(), json!(signature));
lesson.insert("confidence".into(), json!(rate));
if let Some(ns) = &lesson_ns {
lesson.insert("namespace".into(), json!(ns));
}
let severity = if rate >= 0.7 && count >= 5 {
Severity::High
} else if rate >= 0.5 {
Severity::Medium
} else {
Severity::Low
};
drafts.push(
RecDraft::new(
format!("entity:lessons/{tool}"),
ActionKind::ClusterFailure,
Summary::new("tool_failure.cluster", args),
Proposal::Cal {
cal: cal::add("fact", &lesson),
},
)
.severity(severity)
.evidence(evidence)
.confidence(rate)
.metric(MetricSnapshot {
metric: "tool_error_recurrence".into(),
baseline: 0.0,
unit: "count".into(),
n: opportunities as u64,
window: format!("{}d", ctx.params().get_int("window_days")),
subject: Some(tool.clone()),
namespace: None,
relation: Some(signature.clone()),
query: format!(
"RECALL tools WHERE tool_name = \"{tool}\" AND is_error AND signature = \"{signature}\" SINCE <applied_at> | COUNT"
),
review_after_ms: 86_400_000,
horizons_ms: vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000],
checkpoints: Vec::new(),
higher_is_better: false,
}),
);
}
drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
Ok(drafts)
}
}
pub(crate) fn normalize_signature(content: &str) -> String {
let lowered: String = content.trim().to_lowercase().chars().take(80).collect();
let mut out = String::with_capacity(lowered.len());
for token in lowered.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
if token.contains('/') || token.contains('\\') {
out.push_str("<path>");
} else {
let mut prev_digit = false;
for c in token.chars() {
if c.is_ascii_digit() {
if !prev_digit {
out.push('#');
}
prev_digit = true;
} else {
out.push(c);
prev_digit = false;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testkit::TestSubstrate;
#[test]
fn signature_strips_digits_and_paths() {
assert_eq!(
normalize_signature("Rate limited after 4295 ms"),
"rate limited after # ms"
);
assert_eq!(
normalize_signature("open /etc/passwd failed"),
"open <path> failed"
);
}
#[test]
fn fires_on_frequent_and_dominant_cluster() {
let mut sub = TestSubstrate::new();
for _ in 0..5 {
sub.add_tool_call("stripe_refund", true, "rate_limited 429");
}
sub.add_tool_call("stripe_refund", false, "ok");
let drafts = sub.analyze(&ToolFailureClustering::new(), 10_000);
assert_eq!(drafts.len(), 1);
assert_eq!(drafts[0].action_kind, ActionKind::ClusterFailure);
assert_eq!(drafts[0].evidence.len(), 5);
}
#[test]
fn fires_on_high_volume_moderate_rate_via_absolute_count() {
let mut sub = TestSubstrate::new();
for _ in 0..10 {
sub.add_tool_call("search", true, "boom 500");
}
for _ in 0..30 {
sub.add_tool_call("search", false, "ok");
}
let drafts = sub.analyze_with(&ToolFailureClustering::new(), 10_000, &[("min_abs", serde_json::json!(10))]);
assert_eq!(drafts.len(), 1, "high-volume moderate-rate cluster fires via min_abs");
}
#[test]
fn sibling_failure_modes_do_not_mask_each_other() {
let mut sub = TestSubstrate::new();
for _ in 0..46 {
sub.add_tool_call("refund", true, "rate_limited 429");
}
for _ in 0..33 {
sub.add_tool_call("refund", true, "approval_required");
}
for _ in 0..14 {
sub.add_tool_call("refund", true, "cancel_before_refund");
}
for _ in 0..59 {
sub.add_tool_call("refund", false, "ok");
}
let drafts = sub.analyze(&ToolFailureClustering::new(), 10_000);
assert_eq!(drafts.len(), 1, "the dominant mode fires, its siblings do not");
assert_eq!(drafts[0].evidence.len(), 46);
}
#[test]
fn a_mode_that_is_a_small_share_of_its_own_opportunities_stays_silent() {
let mut sub = TestSubstrate::new();
for _ in 0..5 {
sub.add_tool_call("search", true, "boom 500");
}
for _ in 0..95 {
sub.add_tool_call("search", false, "ok");
}
assert!(sub.analyze(&ToolFailureClustering::new(), 10_000).is_empty());
}
#[test]
fn silent_below_rate_threshold() {
let mut sub = TestSubstrate::new();
sub.add_tool_call("search", true, "boom 500");
sub.add_tool_call("search", true, "boom 500");
for _ in 0..20 {
sub.add_tool_call("search", false, "ok");
}
assert!(sub
.analyze(&ToolFailureClustering::new(), 10_000)
.is_empty());
}
}