areev_loop/analyzers/
staleness.rs1use crate::analyzer::{AnalyzeCtx, Analyzer};
7use crate::cal;
8use crate::error::Result;
9use crate::manifest::*;
10use crate::model::{ActionKind, Severity};
11use crate::recommendation::{Proposal, RecDraft, Summary};
12use serde_json::{json, Map};
13
14pub struct Staleness {
15 manifest: AnalyzerManifest,
16}
17
18impl Staleness {
19 pub fn new() -> Self {
20 Staleness {
21 manifest: AnalyzerManifest {
22 id: "loop.staleness/1".into(),
23 title: "Staleness".into(),
24 description: "Proposes tombstoning grains past their declared valid_to.".into(),
25 tier: Tier::T0,
26 cadence: CadenceClass::Fast,
27 requires: vec![],
28 target_classes: vec![TargetClass::Memory],
29 auto_apply: AutoApplyClass::Never,
31 trust_class: TrustClass::Builtin,
32 params: vec![ParamSpec::Int {
33 name: "grace_days".into(),
34 default: 0,
35 min: 0,
36 max: 3650,
37 description: "Days past valid_to before proposing expiry.".into(),
38 }],
39 default_on: true,
40 },
41 }
42 }
43}
44
45impl Default for Staleness {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl Analyzer for Staleness {
52 fn manifest(&self) -> &AnalyzerManifest {
53 &self.manifest
54 }
55
56 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
57 let grace_ms = ctx.params().get_int("grace_days") * 86_400_000;
58 let cutoff = ctx.now_ms() - grace_ms;
59
60 let mut grains = ctx.facts()?;
62 grains.extend(ctx.observations()?);
63
64 let mut drafts = Vec::new();
65 for g in grains {
66 let Some(valid_to) = g.valid_to_ms else {
67 continue;
68 };
69 if valid_to >= cutoff {
70 continue;
71 }
72 let age_days = ((ctx.now_ms() - valid_to).max(0)) / 86_400_000;
73 let subject = g
74 .fact_subject()
75 .map(|s| s.to_string())
76 .unwrap_or_else(|| short_hash(&g.hash));
77
78 let mut args = Map::new();
79 args.insert("subject".into(), json!(subject));
80 args.insert("age_days".into(), json!(age_days));
81
82 drafts.push(
83 RecDraft::new(
84 format!("grain:{}", g.hash),
85 ActionKind::Expire,
86 Summary::new("staleness.expired", args),
87 Proposal::Cal {
88 cal: cal::forget(&g.hash),
89 },
90 )
91 .severity(if age_days > 90 {
92 Severity::Medium
93 } else {
94 Severity::Low
95 })
96 .evidence(vec![g.hash.clone()]),
97 );
98 }
99 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
101 Ok(drafts)
102 }
103}
104
105fn short_hash(h: &str) -> String {
106 h.chars().take(12).collect()
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::testkit::TestSubstrate;
113
114 #[test]
115 fn proposes_expiry_for_elapsed_valid_to() {
116 let mut sub = TestSubstrate::new();
117 sub.add_fact_valid_to("caller", "promo", "active", 1_000); sub.add_fact("caller", "name", "John"); let drafts = sub.analyze(&Staleness::new(), 10_000);
120 assert_eq!(drafts.len(), 1, "only the elapsed grain");
121 assert_eq!(drafts[0].action_kind, ActionKind::Expire);
122 assert!(matches!(&drafts[0].proposal, Proposal::Cal { cal } if cal.starts_with("FORGET")));
123 }
124
125 #[test]
126 fn respects_grace_period() {
127 let mut sub = TestSubstrate::new();
128 sub.add_fact_valid_to("caller", "promo", "active", 9_000);
129 let drafts = sub.analyze_with(
131 &Staleness::new(),
132 10_000,
133 &[("grace_days", serde_json::json!(1))],
134 );
135 assert!(drafts.is_empty(), "within grace window");
136 }
137}