Skip to main content

areev_loop/analyzers/
staleness.rs

1//! Staleness (T0): grains whose declared `valid_to` has elapsed. The honest
2//! framing — "expiry you declared" — only; the soft never-recalled tier is
3//! deferred (§8). One recommendation per grain (single-grain FORGET), so each
4//! dedups on its own target.
5
6use 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                // FORGET has no inverse: never auto-apply (also destructive-gated).
30                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        // valid_to may appear on facts or observations.
61        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        // Deterministic ordering by target.
100        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); // expired at t=1000
118        sub.add_fact("caller", "name", "John"); // no valid_to
119        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        // grace_days default 0 → expired; set grace to keep it alive.
130        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}