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 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, // cold ≠ wrong — human decides
38                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        // Graceful degradation: no sidecar → no signal, not a false "all cold".
71        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        // The "warm" set: grains recalled often enough not to be cold.
80        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        // The IN-USE set: a fact a live Tool Definition pins as its evalset is
88        // load-bearing whatever recall says. Rule E1 reads that pin at review
89        // time — it is what a `code_revision` has to pass — so it is never
90        // fetched by recall and would look cold forever, and retiring it would
91        // remove the gate rather than free anything. Recall counts measure
92        // whether a fact informs *answers*; this one's job is to judge *code*,
93        // and the analyzer would otherwise be answering a question nobody
94        // asked about it.
95        //
96        // Deliberately this one reference and not a general "any hash a grain
97        // mentions": a broad sweep would also swallow the evidence a pending
98        // recommendation cites, and since this analyzer's own findings cite
99        // the fact they flag, that would make every cold finding suppress
100        // itself on the next run.
101        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"); // never recalled
155        let warm = sub.add_fact("beta", "tier", "silver"); // recalled a lot
156        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    /// The regression that motivated the in-use set: an evalset a live
166    /// Definition pins is read by Rule E1's gate at review time, never by
167    /// recall, so it looked cold forever — and the "retire candidate" it
168    /// produced named the one grain that must not be retired, since removing
169    /// it removes the gate a `code_revision` has to pass.
170    #[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        // The capability this analyzer requires; zero recalls is what makes
177        // both facts candidates in the first place.
178        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    /// And the pin has to be LIVE and a definition: a tool call that merely
190    /// mentions a hash is not a gate, and neither is a superseded tool.
191    #[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        // An execution record, not a definition.
196        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        // No telemetry injected → capability off → degrade to nothing.
209        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); // turn telemetry on without recalling the grain
218        // Default 30-day grace: a just-created grain is too new to be cold.
219        let drafts = sub.analyze(&ColdGrains::new(), 10_000);
220        assert!(drafts.is_empty());
221    }
222}