Skip to main content

areev_loop/analyzers/
budget_pressure.rs

1//! Budget pressure (T0; requires `telemetry`) — when context assembly keeps
2//! overflowing its token budget, recall is being forced to drop material it
3//! selected: the memory has outgrown the window it's rendered into. The signal
4//! is the assembly-budget rollup in the telemetry sidecar (§8). Advisory and
5//! global (one finding, not per-entity): the remedy — raise the budget, tighten
6//! selection, or curate — is a human/host decision, so it never auto-applies.
7//!
8//! **Default-on.** The datasource is wired: the ASSEMBLE budget allocator
9//! (`areev-cal`) records one sample per assembly via
10//! `CalStoreFacade::note_assembly_budget` → `Areev::telemetry_note_budget`
11//! (overflow = the token budget forced grains to be dropped). With no telemetry
12//! sidecar the `requires: [telemetry]` gate degrades it cleanly to nothing.
13
14use crate::analyzer::{AnalyzeCtx, Analyzer};
15use crate::error::Result;
16use crate::manifest::*;
17use crate::model::{ActionKind, Severity};
18use crate::recommendation::{Proposal, RecDraft, Summary};
19use serde_json::{json, Map};
20
21pub struct BudgetPressure {
22    manifest: AnalyzerManifest,
23}
24
25impl BudgetPressure {
26    pub fn new() -> Self {
27        BudgetPressure {
28            manifest: AnalyzerManifest {
29                id: "loop.budget_pressure/1".into(),
30                title: "Budget pressure".into(),
31                description: "Flags context assembly that keeps overflowing its token budget."
32                    .into(),
33                tier: Tier::T0,
34                cadence: CadenceClass::Slow,
35                requires: vec![Capability::Telemetry],
36                target_classes: vec![TargetClass::Memory],
37                auto_apply: AutoApplyClass::Never, // raising a budget is a host decision
38                trust_class: TrustClass::Builtin,
39                params: vec![
40                    ParamSpec::Int {
41                        name: "min_samples".into(),
42                        default: 20,
43                        min: 1,
44                        max: 10_000_000,
45                        description: "Minimum assembly samples before overflow rate is meaningful."
46                            .into(),
47                    },
48                    ParamSpec::Float {
49                        name: "min_overflow_ratio".into(),
50                        default: 0.5,
51                        min: 0.0,
52                        max: 1.0,
53                        description: "Overflow fraction at or above which pressure is flagged."
54                            .into(),
55                    },
56                ],
57                default_on: true,
58            },
59        }
60    }
61}
62
63impl Default for BudgetPressure {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl Analyzer for BudgetPressure {
70    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
71        let Some(tel) = ctx.telemetry()? else {
72            return Ok(Vec::new());
73        };
74        let min_samples = ctx.params().get_int("min_samples");
75        let min_overflow_ratio = ctx.params().get_float("min_overflow_ratio");
76
77        let b = &tel.budget;
78        if b.sample_count < min_samples || b.sample_count <= 0 {
79            return Ok(Vec::new());
80        }
81        let ratio = b.overflow_count as f64 / b.sample_count as f64;
82        if ratio < min_overflow_ratio {
83            return Ok(Vec::new());
84        }
85        let overflow_rate = (ratio * 100.0).round() as i64;
86
87        let mut args = Map::new();
88        args.insert("overflow_rate".into(), json!(overflow_rate));
89        args.insert("samples".into(), json!(b.sample_count));
90
91        let mut data = Map::new();
92        data.insert("sample_count".into(), json!(b.sample_count));
93        data.insert("overflow_count".into(), json!(b.overflow_count));
94
95        Ok(vec![RecDraft::new(
96            "entity:budget/assembly".to_string(),
97            ActionKind::Flag,
98            Summary::new("budget.pressure", args),
99            Proposal::Data { data },
100        )
101        .severity(Severity::Medium)])
102    }
103
104    fn manifest(&self) -> &AnalyzerManifest {
105        &self.manifest
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::testkit::TestSubstrate;
113
114    #[test]
115    fn flags_sustained_overflow() {
116        let mut sub = TestSubstrate::new();
117        sub.telemetry_budget(40, 30); // 75% ≥ 50%, enough samples
118        let drafts = sub.analyze(&BudgetPressure::new(), 10_000);
119        assert_eq!(drafts.len(), 1);
120        assert_eq!(drafts[0].action_kind, ActionKind::Flag);
121        assert!(drafts[0].summary.render().contains('%'));
122    }
123
124    #[test]
125    fn low_overflow_is_fine() {
126        let mut sub = TestSubstrate::new();
127        sub.telemetry_budget(40, 5); // 12.5% < 50%
128        assert!(sub.analyze(&BudgetPressure::new(), 10_000).is_empty());
129    }
130
131    #[test]
132    fn too_few_samples_no_finding() {
133        let mut sub = TestSubstrate::new();
134        sub.telemetry_budget(5, 5); // 100% but only 5 samples < 20
135        assert!(sub.analyze(&BudgetPressure::new(), 10_000).is_empty());
136    }
137}