Skip to main content

areev_loop/analyzers/
lesson_pile.rs

1//! Lesson pile (T0, default-off) — an active-lesson budget per entity.
2//!
3//! Every approved lesson is one more rule in the agent's prompt, forever, and
4//! the loop's own evidence says that trajectory ends in a worse agent than no
5//! lessons at all: on the ad-buy corpus (`crates/areev-bench/ADBUY.md`, seed
6//! 3) ten approved rules stated four distinct facts, each true and approvable
7//! alone, and the agent stopped emitting the very fields the rules most
8//! insistently named. Dream-RSI (arXiv 2609.14858 §5.1) measured the same
9//! shape: prompt guidance distilled from history underperformed no guidance
10//! under equal budgets. A reviewer judging one card at a time cannot see the
11//! pile; this analyzer counts it.
12//!
13//! Above `max_active` live lessons on one entity it emits an advisory Flag
14//! listing the pile with each member's latest Verify-gate verdict, so a
15//! reviewer can retire the ones that measured `regressed` or `drifted`. With
16//! an LLM attached the finding is also DISCOVER's cue to draft ONE
17//! consolidating lesson (`kind: consolidation`) through the ordinary
18//! GROUND → VERIFY path — gate-judged, human-applied, never auto-applied.
19//! Default-off: a budget is a policy a host states, not one the engine
20//! infers.
21
22use 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        // Live only: a retracted or superseded lesson is not in the prompt
79        // and does not count. Grouped per entity — (namespace, subject) —
80        // because that is the unit a prompt renders lessons for.
81        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    /// Nine live lessons on one entity under a budget of eight → one finding
152    /// citing all nine, each with its latest verdict; eight → nothing.
153    #[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    /// A retracted lesson is not in the prompt and does not count.
181    #[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        // And the budget is a parameter.
189        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    /// Piles are per entity: two entities under budget are not one pile.
195    #[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}