areev_loop/analyzers/
cold_grains.rs1use crate::analyzer::{AnalyzeCtx, Analyzer};
11use crate::error::Result;
12use crate::manifest::*;
13use crate::model::{ActionKind, Severity};
14use crate::recommendation::{Proposal, RecDraft, Summary};
15use serde_json::{json, Map};
16use std::collections::HashSet;
17
18const DAY_MS: i64 = 24 * 3600 * 1000;
19
20pub struct ColdGrains {
21 manifest: AnalyzerManifest,
22}
23
24impl ColdGrains {
25 pub fn new() -> Self {
26 ColdGrains {
27 manifest: AnalyzerManifest {
28 id: "loop.cold_grains/1".into(),
29 title: "Cold grains".into(),
30 description: "Flags facts that have never been recalled past a grace window."
31 .into(),
32 tier: Tier::T0,
33 cadence: CadenceClass::Slow,
34 requires: vec![Capability::Telemetry],
35 target_classes: vec![TargetClass::Memory],
36 auto_apply: AutoApplyClass::Never, trust_class: TrustClass::Builtin,
38 params: vec![
39 ParamSpec::Int {
40 name: "min_age_days".into(),
41 default: 30,
42 min: 0,
43 max: 3650,
44 description: "A grain younger than this is too new to call cold.".into(),
45 },
46 ParamSpec::Int {
47 name: "max_recalls".into(),
48 default: 0,
49 min: 0,
50 max: 1_000_000,
51 description: "A grain recalled at most this many times counts as cold."
52 .into(),
53 },
54 ],
55 default_on: true,
56 },
57 }
58 }
59}
60
61impl Default for ColdGrains {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67impl Analyzer for ColdGrains {
68 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
69 let Some(tel) = ctx.telemetry()? else {
71 return Ok(Vec::new());
72 };
73 let min_age_days = ctx.params().get_int("min_age_days");
74 let max_recalls = ctx.params().get_int("max_recalls");
75 let min_age_ms = min_age_days.saturating_mul(DAY_MS);
76 let now = ctx.now_ms();
77
78 let warm: HashSet<&str> = tel
80 .access
81 .iter()
82 .filter(|a| a.recall_count > max_recalls)
83 .map(|a| a.hash.as_str())
84 .collect();
85
86 let mut drafts = Vec::new();
87 for f in ctx.facts()? {
88 let age = now - f.created_at_ms;
89 if age < min_age_ms || warm.contains(f.hash.as_str()) {
90 continue;
91 }
92 let subject = f.fact_subject().unwrap_or("").to_string();
93 let age_days = age / DAY_MS;
94
95 let mut args = Map::new();
96 args.insert("subject".into(), json!(subject));
97 args.insert("age_days".into(), json!(age_days));
98
99 let mut data = Map::new();
100 data.insert("hash".into(), json!(f.hash));
101 data.insert("subject".into(), json!(subject));
102 data.insert("age_days".into(), json!(age_days));
103
104 drafts.push(
105 RecDraft::new(
106 format!("entity:cold/{}", f.hash),
107 ActionKind::Flag,
108 Summary::new("cold.grain", args),
109 Proposal::Data { data },
110 )
111 .severity(Severity::Low)
112 .evidence(vec![f.hash.clone()]),
113 );
114 }
115 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
116 Ok(drafts)
117 }
118
119 fn manifest(&self) -> &AnalyzerManifest {
120 &self.manifest
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::testkit::TestSubstrate;
128
129 #[test]
130 fn flags_never_recalled_but_not_the_warm_one() {
131 let mut sub = TestSubstrate::new();
132 let cold = sub.add_fact("acme", "tier", "gold"); let warm = sub.add_fact("beta", "tier", "silver"); sub.telemetry_recall(&warm, 5);
135 let _ = cold;
136
137 let drafts = sub.analyze_with(&ColdGrains::new(), 10_000_000, &[("min_age_days", json!(0))]);
138 assert_eq!(drafts.len(), 1, "only the never-recalled grain is cold");
139 assert_eq!(drafts[0].action_kind, ActionKind::Flag);
140 assert!(drafts[0].summary.render().contains("acme"));
141 }
142
143 #[test]
144 fn no_telemetry_capability_means_no_findings() {
145 let mut sub = TestSubstrate::new();
146 sub.add_fact("acme", "tier", "gold");
147 let drafts = sub.analyze_with(&ColdGrains::new(), 10_000_000, &[("min_age_days", json!(0))]);
149 assert!(drafts.is_empty());
150 }
151
152 #[test]
153 fn young_grain_is_not_cold() {
154 let mut sub = TestSubstrate::new();
155 sub.add_fact("acme", "tier", "gold");
156 sub.telemetry_budget(1, 0); let drafts = sub.analyze(&ColdGrains::new(), 10_000);
159 assert!(drafts.is_empty());
160 }
161}