Skip to main content

areev_loop/analyzers/
cold_grains.rs

1//! Cold grains (T0; requires `telemetry`) — the first *utility* analyzer, not
2//! a consistency one. A fact that has sat in memory past a grace window and has
3//! **never been surfaced by recall** is memory that isn't earning its place:
4//! it costs storage and assembly budget without ever informing an answer. This
5//! is exactly the signal deterministic consistency checks can't see — it needs
6//! the recall-telemetry sidecar (§8). Advisory only: cold ≠ wrong (a rarely-hit
7//! but critical fact is legitimately cold), so it flags a retire *candidate*
8//! for human judgment and never auto-applies.
9
10use 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, // cold ≠ wrong — human decides
37                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        // Graceful degradation: no sidecar → no signal, not a false "all cold".
70        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        // The "warm" set: grains recalled often enough not to be cold.
79        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"); // never recalled
133        let warm = sub.add_fact("beta", "tier", "silver"); // recalled a lot
134        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        // No telemetry injected → capability off → degrade to nothing.
148        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); // turn telemetry on without recalling the grain
157        // Default 30-day grace: a just-created grain is too new to be cold.
158        let drafts = sub.analyze(&ColdGrains::new(), 10_000);
159        assert!(drafts.is_empty());
160    }
161}