areev_loop/analyzers/
goal_stagnation.rs1use crate::analyzer::{AnalyzeCtx, Analyzer};
14use crate::error::Result;
15use crate::manifest::*;
16use crate::model::{ActionKind, Severity};
17use crate::recommendation::{Proposal, RecDraft, Summary};
18use serde_json::{json, Map};
19
20pub struct GoalStagnation {
21 manifest: AnalyzerManifest,
22}
23
24impl GoalStagnation {
25 pub fn new() -> Self {
26 GoalStagnation {
27 manifest: AnalyzerManifest {
28 id: "loop.goal_stagnation/1".into(),
29 title: "Goal stagnation".into(),
30 description: "Flags active goals with little progress that have gone stale.".into(),
31 tier: Tier::T0,
32 cadence: CadenceClass::Fast,
33 requires: vec![],
34 target_classes: vec![TargetClass::Memory],
35 auto_apply: AutoApplyClass::Never,
36 trust_class: TrustClass::Builtin,
37 params: vec![
38 ParamSpec::Float {
39 name: "max_progress".into(),
40 default: 0.1,
41 min: 0.0,
42 max: 1.0,
43 description: "Progress at or below this counts as no progress.".into(),
44 },
45 ParamSpec::Int {
46 name: "min_age_days".into(),
47 default: 30,
48 min: 1,
49 max: 3650,
50 description: "Days an active low-progress goal must be old to flag.".into(),
51 },
52 ],
53 default_on: false,
55 },
56 }
57 }
58}
59
60impl Default for GoalStagnation {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl Analyzer for GoalStagnation {
67 fn manifest(&self) -> &AnalyzerManifest {
68 &self.manifest
69 }
70
71 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
72 let max_progress = ctx.params().get_float("max_progress");
73 let min_age_ms = ctx.params().get_int("min_age_days") * 86_400_000;
74 let cutoff = ctx.now_ms() - min_age_ms;
75
76 let mut drafts = Vec::new();
77 for g in ctx.goals()? {
78 if g.goal_state() != Some("active") {
79 continue;
80 }
81 if g.goal_progress() > max_progress || g.created_at_ms > cutoff {
82 continue;
83 }
84 let age_days = ((ctx.now_ms() - g.created_at_ms).max(0)) / 86_400_000;
85 let label = g
86 .str_field("subject")
87 .or_else(|| g.str_field("description"))
88 .map(|s| s.to_string())
89 .unwrap_or_else(|| short_hash(&g.hash));
90
91 let mut args = Map::new();
92 args.insert("goal".into(), json!(label));
93 args.insert("age_days".into(), json!(age_days));
94 args.insert("progress".into(), json!(g.goal_progress()));
95
96 let mut data = Map::new();
97 data.insert("goal".into(), json!(label));
98 data.insert("progress".into(), json!(g.goal_progress()));
99 data.insert("age_days".into(), json!(age_days));
100
101 drafts.push(
102 RecDraft::new(
103 format!("entity:goals/{label}"),
104 ActionKind::Flag,
105 Summary::new("goal.stagnation", args),
106 Proposal::Data { data },
107 )
108 .severity(Severity::Low)
109 .evidence(vec![g.hash.clone()]),
110 );
111 }
112 drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
113 Ok(drafts)
114 }
115}
116
117fn short_hash(h: &str) -> String {
118 h.chars().take(12).collect()
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::testkit::TestSubstrate;
125
126 #[test]
127 fn flags_old_active_no_progress_goal() {
128 let mut sub = TestSubstrate::new();
129 let day = 86_400_000;
130 let now = 100 * day;
131 sub.add_goal("ship_v2", "active", 0.05, now - 40 * day); sub.add_goal("done_thing", "satisfied", 1.0, now - 40 * day); sub.add_goal("fresh", "active", 0.0, now - 5 * day); sub.add_goal("moving", "active", 0.6, now - 40 * day); let drafts = sub.analyze(&GoalStagnation::new(), now);
136 assert_eq!(drafts.len(), 1);
137 assert!(drafts[0].summary.render().contains("ship_v2"));
138 }
139}