1use crate::analyzer::{AnalyzeCtx, Analyzer};
11use crate::error::Result;
12use crate::manifest::*;
13use crate::model::{ActionKind, Severity};
14use crate::recommendation::{Proposal, RecDraft, Summary};
15use crate::substrate::ReadOpts;
16use serde_json::{json, Map};
17use std::collections::HashSet;
18
19const DAY_MS: i64 = 24 * 3600 * 1000;
20
21pub struct ColdGrains {
22 manifest: AnalyzerManifest,
23}
24
25impl ColdGrains {
26 pub fn new() -> Self {
27 ColdGrains {
28 manifest: AnalyzerManifest {
29 id: "loop.cold_grains/1".into(),
30 title: "Cold grains".into(),
31 description: "Flags facts that have never been recalled past a grace window."
32 .into(),
33 tier: Tier::T0,
34 cadence: CadenceClass::Slow,
35 requires: vec![Capability::Telemetry],
36 target_classes: vec![TargetClass::Memory],
37 auto_apply: AutoApplyClass::Never, trust_class: TrustClass::Builtin,
39 params: vec![
40 ParamSpec::Int {
41 name: "min_age_days".into(),
42 default: 30,
43 min: 0,
44 max: 3650,
45 description: "A grain younger than this is too new to call cold.".into(),
46 },
47 ParamSpec::Int {
48 name: "max_recalls".into(),
49 default: 0,
50 min: 0,
51 max: 1_000_000,
52 description: "A grain recalled at most this many times counts as cold."
53 .into(),
54 },
55 ],
56 default_on: true,
57 },
58 }
59 }
60}
61
62impl Default for ColdGrains {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl Analyzer for ColdGrains {
69 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
70 let Some(tel) = ctx.telemetry()? else {
72 return Ok(Vec::new());
73 };
74 let min_age_days = ctx.params().get_int("min_age_days");
75 let max_recalls = ctx.params().get_int("max_recalls");
76 let min_age_ms = min_age_days.saturating_mul(DAY_MS);
77 let now = ctx.now_ms();
78
79 let warm: HashSet<&str> = tel
81 .access
82 .iter()
83 .filter(|a| a.recall_count > max_recalls)
84 .map(|a| a.hash.as_str())
85 .collect();
86
87 let pinned: HashSet<String> = ctx
102 .grains_of_type(crate::model::grain_type::TOOL, ReadOpts::default())?
103 .iter()
104 .filter(|t| t.str_field("kind") == Some("definition"))
105 .filter_map(|t| t.str_field("evalset_hash").map(str::to_string))
106 .collect();
107
108 let mut drafts = Vec::new();
109 for f in ctx.facts()? {
110 let age = now - f.created_at_ms;
111 if age < min_age_ms || warm.contains(f.hash.as_str()) || pinned.contains(&f.hash) {
112 continue;
113 }
114 let subject = f.fact_subject().unwrap_or("").to_string();
115 let age_days = age / DAY_MS;
116
117 let mut args = Map::new();
118 args.insert("subject".into(), json!(subject));
119 args.insert("age_days".into(), json!(age_days));
120
121 let mut data = Map::new();
122 data.insert("hash".into(), json!(f.hash));
123 data.insert("subject".into(), json!(subject));
124 data.insert("age_days".into(), json!(age_days));
125
126 drafts.push(
127 RecDraft::new(
128 format!("entity:cold/{}", f.hash),
129 ActionKind::Flag,
130 Summary::new("cold.grain", args),
131 Proposal::Data { data },
132 )
133 .severity(Severity::Low)
134 .evidence(vec![f.hash.clone()]),
135 );
136 }
137 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
138 Ok(drafts)
139 }
140
141 fn manifest(&self) -> &AnalyzerManifest {
142 &self.manifest
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::testkit::TestSubstrate;
150
151 #[test]
152 fn flags_never_recalled_but_not_the_warm_one() {
153 let mut sub = TestSubstrate::new();
154 let cold = sub.add_fact("acme", "tier", "gold"); let warm = sub.add_fact("beta", "tier", "silver"); sub.telemetry_recall(&warm, 5);
157 let _ = cold;
158
159 let drafts = sub.analyze_with(&ColdGrains::new(), 10_000_000, &[("min_age_days", json!(0))]);
160 assert_eq!(drafts.len(), 1, "only the never-recalled grain is cold");
161 assert_eq!(drafts[0].action_kind, ActionKind::Flag);
162 assert!(drafts[0].summary.render().contains("acme"));
163 }
164
165 #[test]
171 fn an_evalset_a_live_definition_pins_is_never_cold() {
172 let mut sub = TestSubstrate::new();
173 let evalset = sub.add_fact("evalset:screen", "mg:evalset", "{\"cases\": []}");
174 let ordinary = sub.add_fact("acme", "tier", "gold");
175 sub.add_tool_def("screen", Some(&evalset));
176 sub.telemetry_recall(&ordinary, 0);
179
180 let drafts = sub.analyze_with(&ColdGrains::new(), 10_000_000, &[("min_age_days", json!(0))]);
181 assert_eq!(drafts.len(), 1, "only the unreferenced fact is cold: {drafts:?}");
182 assert!(drafts[0].summary.render().contains("acme"));
183 assert!(
184 !drafts[0].summary.render().contains("evalset"),
185 "the pinned gate must not be proposed for retirement"
186 );
187 }
188
189 #[test]
192 fn only_a_definitions_pin_protects_a_fact() {
193 let mut sub = TestSubstrate::new();
194 let evalset = sub.add_fact("evalset:screen", "mg:evalset", "{}");
195 sub.add_tool_call("screen", false, &evalset);
197 sub.telemetry_recall(&evalset, 0);
198
199 let drafts = sub.analyze_with(&ColdGrains::new(), 10_000_000, &[("min_age_days", json!(0))]);
200 assert_eq!(drafts.len(), 1, "nothing pinned it, so it is still cold");
201 assert!(drafts[0].summary.render().contains("evalset:screen"));
202 }
203
204 #[test]
205 fn no_telemetry_capability_means_no_findings() {
206 let mut sub = TestSubstrate::new();
207 sub.add_fact("acme", "tier", "gold");
208 let drafts = sub.analyze_with(&ColdGrains::new(), 10_000_000, &[("min_age_days", json!(0))]);
210 assert!(drafts.is_empty());
211 }
212
213 #[test]
214 fn young_grain_is_not_cold() {
215 let mut sub = TestSubstrate::new();
216 sub.add_fact("acme", "tier", "gold");
217 sub.telemetry_budget(1, 0); let drafts = sub.analyze(&ColdGrains::new(), 10_000);
220 assert!(drafts.is_empty());
221 }
222}