Skip to main content

areev_loop/analyzers/
skill_stall.rs

1//! Skill stall (T0) — the on-theme analyzer for a "self-improving agents"
2//! product. A Skill grain (0x0B) carries `proficiency` (aliases `confidence`)
3//! and `practice_count`. A skill practiced many times whose proficiency stays
4//! low is one the agent keeps *doing* but isn't getting *better* at — a
5//! genuine "stop and rethink the strategy" signal, computed from the grain's
6//! own fields (no chain traversal, no telemetry). Advisory only: it surfaces
7//! the skill for human attention; there is no automatic fix, so it never
8//! 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 serde_json::{json, Map};
16
17pub struct SkillStall {
18    manifest: AnalyzerManifest,
19}
20
21impl SkillStall {
22    pub fn new() -> Self {
23        SkillStall {
24            manifest: AnalyzerManifest {
25                id: "loop.skill_stall/1".into(),
26                title: "Skill stall".into(),
27                description: "Flags skills practiced repeatedly without proficiency gain.".into(),
28                tier: Tier::T0,
29                cadence: CadenceClass::Fast,
30                requires: vec![],
31                target_classes: vec![TargetClass::Memory],
32                auto_apply: AutoApplyClass::Never, // advisory; no automatic fix
33                trust_class: TrustClass::Builtin,
34                params: vec![
35                    ParamSpec::Int {
36                        name: "min_practice".into(),
37                        default: 5,
38                        min: 1,
39                        max: 100_000,
40                        description: "Minimum practice_count before a low proficiency counts as a stall."
41                            .into(),
42                    },
43                    ParamSpec::Float {
44                        name: "max_proficiency".into(),
45                        default: 0.4,
46                        min: 0.0,
47                        max: 1.0,
48                        description: "Proficiency at or below this (despite practice) is a stall.".into(),
49                    },
50                ],
51                default_on: true,
52            },
53        }
54    }
55}
56
57impl Default for SkillStall {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl Analyzer for SkillStall {
64    fn manifest(&self) -> &AnalyzerManifest {
65        &self.manifest
66    }
67
68    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
69        let min_practice = ctx.params().get_int("min_practice");
70        let max_proficiency = ctx.params().get_float("max_proficiency");
71
72        let mut drafts = Vec::new();
73        for s in ctx.skills()? {
74            let practice = s.skill_practice_count();
75            let Some(proficiency) = s.skill_proficiency() else { continue };
76            if practice < min_practice || proficiency > max_proficiency {
77                continue;
78            }
79            let name = s.skill_name().unwrap_or("").to_string();
80
81            let mut args = Map::new();
82            args.insert("skill".into(), json!(name));
83            args.insert("practice_count".into(), json!(practice));
84            args.insert("proficiency".into(), json!(round2(proficiency)));
85
86            let mut data = Map::new();
87            data.insert("skill".into(), json!(name));
88            data.insert("proficiency".into(), json!(round2(proficiency)));
89            data.insert("practice_count".into(), json!(practice));
90
91            drafts.push(
92                RecDraft::new(
93                    format!("entity:skills/{name}"),
94                    ActionKind::Flag,
95                    Summary::new("skill.stall", args),
96                    Proposal::Data { data },
97                )
98                .severity(Severity::Medium)
99                .evidence(vec![s.hash.clone()]),
100            );
101        }
102        drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
103        Ok(drafts)
104    }
105}
106
107fn round2(x: f64) -> f64 {
108    (x * 100.0).round() / 100.0
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::testkit::TestSubstrate;
115
116    #[test]
117    fn flags_practiced_but_unimproved_skill() {
118        let mut sub = TestSubstrate::new();
119        sub.add_skill("parse_invoices", 0.25, 12); // practiced a lot, still bad
120        sub.add_skill("write_sql", 0.9, 8); // proficient — fine
121        sub.add_skill("new_thing", 0.2, 1); // barely practiced — not yet a stall
122        let drafts = sub.analyze(&SkillStall::new(), 10_000);
123        assert_eq!(drafts.len(), 1);
124        assert_eq!(drafts[0].action_kind, ActionKind::Flag);
125        assert!(drafts[0].summary.render().contains("parse_invoices"));
126    }
127}