areev_loop/analyzers/
coverage_gap.rs1use crate::analyzer::{AnalyzeCtx, Analyzer};
14use crate::error::Result;
15use crate::manifest::*;
16use crate::model::{ActionKind, Severity};
17use crate::recommendation::{Proposal, RecDraft, Summary};
18use serde_json::{json, Map};
19
20pub struct CoverageGap {
21 manifest: AnalyzerManifest,
22}
23
24impl CoverageGap {
25 pub fn new() -> Self {
26 CoverageGap {
27 manifest: AnalyzerManifest {
28 id: "loop.coverage_gap/1".into(),
29 title: "Coverage gap".into(),
30 description: "Flags recurring recall questions that keep returning nothing.".into(),
31 tier: Tier::T0,
32 cadence: CadenceClass::Slow,
33 requires: vec![Capability::Telemetry],
34 target_classes: vec![TargetClass::Memory],
35 auto_apply: AutoApplyClass::Never, trust_class: TrustClass::Builtin,
37 params: vec![
38 ParamSpec::Int {
39 name: "min_runs".into(),
40 default: 3,
41 min: 1,
42 max: 1_000_000,
43 description: "How many times a question must recur before it's a gap."
44 .into(),
45 },
46 ParamSpec::Float {
47 name: "min_empty_ratio".into(),
48 default: 0.8,
49 min: 0.0,
50 max: 1.0,
51 description: "Fraction of runs that returned nothing to count as a gap."
52 .into(),
53 },
54 ],
55 default_on: true,
56 },
57 }
58 }
59}
60
61impl Default for CoverageGap {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67impl Analyzer for CoverageGap {
68 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
69 let Some(tel) = ctx.telemetry()? else {
70 return Ok(Vec::new());
71 };
72 let min_runs = ctx.params().get_int("min_runs");
73 let min_empty_ratio = ctx.params().get_float("min_empty_ratio");
74
75 let mut drafts = Vec::new();
76 for q in &tel.queries {
77 if q.run_count < min_runs || q.run_count <= 0 {
78 continue;
79 }
80 let empty_ratio = q.empty_count as f64 / q.run_count as f64;
81 if empty_ratio < min_empty_ratio {
82 continue;
83 }
84 let empty_rate = (empty_ratio * 100.0).round() as i64;
85
86 let mut args = Map::new();
87 args.insert("query".into(), json!(q.sample));
88 args.insert("count".into(), json!(q.run_count));
89 args.insert("empty_rate".into(), json!(empty_rate));
90
91 let mut data = Map::new();
92 data.insert("query".into(), json!(q.sample));
93 data.insert("run_count".into(), json!(q.run_count));
94 data.insert("empty_count".into(), json!(q.empty_count));
95
96 drafts.push(
97 RecDraft::new(
98 format!("entity:coverage/{}", q.sample),
99 ActionKind::Flag,
100 Summary::new("coverage.gap", args),
101 Proposal::Data { data },
102 )
103 .severity(Severity::Medium),
104 );
105 }
106 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
107 Ok(drafts)
108 }
109
110 fn manifest(&self) -> &AnalyzerManifest {
111 &self.manifest
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::testkit::TestSubstrate;
119
120 #[test]
121 fn flags_recurring_empty_question_only() {
122 let mut sub = TestSubstrate::new();
123 sub.telemetry_query("refund policy for EU", 5, 5); sub.telemetry_query("shipping time", 4, 0); sub.telemetry_query("one-off typo", 1, 1); let drafts = sub.analyze(&CoverageGap::new(), 10_000);
128 assert_eq!(drafts.len(), 1);
129 assert_eq!(drafts[0].action_kind, ActionKind::Flag);
130 assert!(drafts[0].summary.render().contains("refund policy"));
131 }
132
133 #[test]
134 fn no_telemetry_means_no_findings() {
135 let sub = TestSubstrate::new();
136 assert!(sub.analyze(&CoverageGap::new(), 10_000).is_empty());
137 }
138}