Skip to main content

areev_loop/analyzers/
coverage_gap.rs

1//! Coverage gap (T0; requires `telemetry`) — recurring *questions the memory
2//! can't answer*. When the same recall query keeps coming back empty, the agent
3//! is repeatedly reaching for knowledge that was never stored: a gap the memory
4//! should be filled to close. Deterministic consistency checks are blind to
5//! this — it lives entirely in the recall-telemetry query rollups (§8).
6//! Advisory: the fix is to *add* the missing memory (a human/host act), so it
7//! flags the gap and never auto-applies.
8//!
9//! A T1 refinement (embedding-clustered near-duplicate questions) is a natural
10//! extension; v1 keys on the exact recurring query, which is precise and needs
11//! no embedder.
12
13use 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, // the fix is to ADD memory — human act
36                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); // recurs, always empty → gap
124        sub.telemetry_query("shipping time", 4, 0); // recurs, always answered → fine
125        sub.telemetry_query("one-off typo", 1, 1); // below min_runs → ignore
126
127        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}