1use crate::analyzer::{AnalyzeCtx, Analyzer};
23use crate::error::Result;
24use crate::manifest::*;
25use crate::model::{ActionKind, GrainRecord, Severity};
26use crate::recommendation::{Proposal, RecDraft, Summary};
27use serde_json::{json, Map};
28use std::collections::BTreeMap;
29
30pub struct LessonPile {
31 manifest: AnalyzerManifest,
32}
33
34impl LessonPile {
35 pub fn new() -> Self {
36 LessonPile {
37 manifest: AnalyzerManifest {
38 id: "loop.lesson_pile/1".into(),
39 title: "Lesson pile".into(),
40 description: "Flags an entity carrying more live lessons than its budget, \
41 with each member's latest outcome verdict, and cues one \
42 consolidating lesson when a model is attached."
43 .into(),
44 tier: Tier::T0,
45 cadence: CadenceClass::Slow,
46 requires: vec![],
47 target_classes: vec![TargetClass::Memory],
48 auto_apply: AutoApplyClass::Never,
49 trust_class: TrustClass::Builtin,
50 params: vec![ParamSpec::Int {
51 name: "max_active".into(),
52 default: 8,
53 min: 1,
54 max: 10_000,
55 description: "Live lessons one entity may carry before the pile is \
56 flagged for consolidation."
57 .into(),
58 }],
59 default_on: false,
60 },
61 }
62 }
63}
64
65impl Default for LessonPile {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl Analyzer for LessonPile {
72 fn manifest(&self) -> &AnalyzerManifest {
73 &self.manifest
74 }
75
76 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
77 let max_active = ctx.params().get_int("max_active").max(1) as usize;
78 let mut piles: BTreeMap<(String, String), Vec<GrainRecord>> = BTreeMap::new();
82 for f in ctx.facts()? {
83 if f.fact_relation() != Some("lesson") {
84 continue;
85 }
86 let Some(subject) = f.fact_subject() else { continue };
87 piles
88 .entry((f.namespace.clone(), subject.to_string()))
89 .or_default()
90 .push(f);
91 }
92 let mut drafts = Vec::new();
93 for ((ns, subject), mut members) in piles {
94 if members.len() <= max_active {
95 continue;
96 }
97 members.sort_by(|a, b| a.created_at_ms.cmp(&b.created_at_ms).then(a.hash.cmp(&b.hash)));
98 let hashes: Vec<String> = members.iter().map(|m| m.hash.clone()).collect();
99 let mut verdicts = Map::new();
100 let rendered: Vec<String> = members
101 .iter()
102 .map(|m| {
103 let v = ctx.verdict_for(&m.hash).unwrap_or("not applied by the loop");
104 verdicts.insert(m.hash.clone(), json!(v));
105 format!("{}… ({v})", m.hash.chars().take(8).collect::<String>())
106 })
107 .collect();
108 let target = if ns.is_empty() {
109 format!("entity:{subject}")
110 } else {
111 format!("entity:{ns}/{subject}")
112 };
113 let mut args = Map::new();
114 args.insert("count".into(), json!(members.len()));
115 args.insert("subject".into(), json!(subject));
116 args.insert("max_active".into(), json!(max_active));
117 args.insert("members".into(), json!(rendered.join(", ")));
118 let mut data = Map::new();
119 data.insert("pile".into(), json!(hashes));
120 data.insert("verdicts".into(), json!(verdicts));
121 data.insert("max_active".into(), json!(max_active));
122 drafts.push(
123 RecDraft::new(target, ActionKind::Flag, Summary::new("lesson.pile", args), Proposal::Data { data })
124 .severity(Severity::Medium)
125 .evidence(hashes),
126 );
127 }
128 Ok(drafts)
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::testkit::TestSubstrate;
136
137 fn lessons(sub: &mut TestSubstrate, subject: &str, n: usize) -> Vec<String> {
138 (0..n)
139 .map(|i| sub.add_fact(subject, "lesson", &format!("Rule number {i} for {subject}.")))
140 .collect()
141 }
142
143 #[test]
144 fn manifest_pinned_default_off() {
145 let a = LessonPile::new();
146 assert_eq!(a.manifest().id, "loop.lesson_pile/1");
147 assert!(!a.manifest().default_on, "a budget is stated, never inferred");
148 assert_eq!(a.manifest().auto_apply, AutoApplyClass::Never);
149 }
150
151 #[test]
154 fn flags_a_pile_over_budget_with_each_members_verdict() {
155 let mut sub = TestSubstrate::new();
156 let nine = lessons(&mut sub, "capture", 9);
157 sub.set_verdict(&nine[0], "held");
158 sub.set_verdict(&nine[3], "regressed");
159 let drafts = sub.analyze(&LessonPile::new(), 10_000);
160 assert_eq!(drafts.len(), 1);
161 let d = &drafts[0];
162 assert_eq!(d.action_kind, ActionKind::Flag);
163 assert_eq!(d.evidence, nine, "cites every member, oldest first");
164 let text = d.summary.render();
165 assert!(text.contains("9 live lessons") && text.contains("budget of 8"), "{text}");
166 assert!(text.contains("(held)") && text.contains("(regressed)") && text.contains("(not applied by the loop)"), "{text}");
167 match &d.proposal {
168 Proposal::Data { data } => {
169 assert_eq!(data["pile"].as_array().unwrap().len(), 9);
170 assert_eq!(data["verdicts"][&nine[3]], "regressed");
171 }
172 other => panic!("advisory data expected, got {other:?}"),
173 }
174
175 let mut sub = TestSubstrate::new();
176 lessons(&mut sub, "capture", 8);
177 assert!(sub.analyze(&LessonPile::new(), 10_000).is_empty(), "eight is within budget");
178 }
179
180 #[test]
182 fn a_retracted_lesson_does_not_count() {
183 use crate::substrate::OmsSubstrate;
184 let mut sub = TestSubstrate::new();
185 let nine = lessons(&mut sub, "capture", 9);
186 sub.inner.retract(&nine[8], "reviewer retired it").unwrap();
187 assert!(sub.analyze(&LessonPile::new(), 10_000).is_empty());
188 let drafts = sub.analyze_with(&LessonPile::new(), 10_000, &[("max_active", json!(3))]);
190 assert_eq!(drafts.len(), 1);
191 assert_eq!(drafts[0].evidence.len(), 8);
192 }
193
194 #[test]
196 fn piles_are_per_entity() {
197 let mut sub = TestSubstrate::new();
198 lessons(&mut sub, "capture", 5);
199 lessons(&mut sub, "refund", 5);
200 assert!(sub.analyze(&LessonPile::new(), 10_000).is_empty());
201 }
202}