Skip to main content

areev_loop/analyzers/
goal_stagnation.rs

1//! Goal stagnation (T0) — a Goal grain (0x07) that is still `active`, has made
2//! little progress, and is old. Computed from the grain's own `goal_state` +
3//! `progress` fields (the field-based form the review asked about — not the
4//! weaker "no progress events" form). Advisory only; never auto-applies.
5//!
6//! **Default-OFF (opt-in).** "Low progress on an old active goal" is genuinely
7//! ambiguous — legitimate long-running work looks identical to a stall — so
8//! this ships opt-in rather than default-on until field precision is validated
9//! on real corpora. Enable it per file by setting `enabled: true` for
10//! `loop.goal_stagnation/1` via the console's per-analyzer config
11//! (`POST /api/loop/config`) or the bindings' `set_analyzer_config`.
12
13use 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                // Opt-in: "stalled" is ambiguous; validate field precision first.
54                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); // old, no progress
132        sub.add_goal("done_thing", "satisfied", 1.0, now - 40 * day); // not active
133        sub.add_goal("fresh", "active", 0.0, now - 5 * day); // too young
134        sub.add_goal("moving", "active", 0.6, now - 40 * day); // progressing
135        let drafts = sub.analyze(&GoalStagnation::new(), now);
136        assert_eq!(drafts.len(), 1);
137        assert!(drafts[0].summary.render().contains("ship_v2"));
138    }
139}