areev_loop/analyzers/
skill_stall.rs1use 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, 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); sub.add_skill("write_sql", 0.9, 8); sub.add_skill("new_thing", 0.2, 1); 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}