Skip to main content

lean_ctx/core/
slo.rs

1//! Context SLOs — configurable service level objectives for context metrics.
2//!
3//! Loads SLO definitions from `.lean-ctx/slos.toml` and evaluates them
4//! against live session counters after each tool call.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::{Mutex, OnceLock};
10use std::time::{Duration, Instant};
11
12use crate::core::budget_tracker::BudgetTracker;
13use crate::core::events;
14
15// ---------------------------------------------------------------------------
16// Configuration types
17// ---------------------------------------------------------------------------
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SloConfig {
21    #[serde(default)]
22    pub slo: Vec<SloDefinition>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SloDefinition {
27    pub name: String,
28    pub metric: SloMetric,
29    pub threshold: f64,
30    #[serde(default)]
31    pub direction: SloDirection,
32    #[serde(default)]
33    pub action: SloAction,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum SloMetric {
39    SessionContextTokens,
40    SessionCostUsd,
41    CompressionRatio,
42    ShellInvocations,
43    ToolCallsTotal,
44}
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum SloDirection {
49    #[default]
50    Max,
51    Min,
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum SloAction {
57    #[default]
58    Warn,
59    Throttle,
60    Block,
61}
62
63// ---------------------------------------------------------------------------
64// Runtime state
65// ---------------------------------------------------------------------------
66
67#[derive(Debug, Clone, Serialize)]
68pub struct SloStatus {
69    pub name: String,
70    pub metric: SloMetric,
71    pub threshold: f64,
72    pub actual: f64,
73    pub direction: SloDirection,
74    pub action: SloAction,
75    pub violated: bool,
76}
77
78#[derive(Debug, Clone, Serialize)]
79pub struct SloSnapshot {
80    pub slos: Vec<SloStatus>,
81    pub violations: Vec<SloStatus>,
82    pub worst_action: Option<SloAction>,
83}
84
85#[derive(Debug, Default)]
86struct ViolationHistory {
87    entries: Vec<ViolationEntry>,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct ViolationEntry {
92    pub timestamp: String,
93    pub slo_name: String,
94    pub metric: SloMetric,
95    pub threshold: f64,
96    pub actual: f64,
97    pub action: SloAction,
98}
99
100static SLO_CONFIG: OnceLock<Mutex<Vec<SloDefinition>>> = OnceLock::new();
101static VIOLATION_LOG: OnceLock<Mutex<ViolationHistory>> = OnceLock::new();
102static EMIT_STATE: OnceLock<Mutex<HashMap<String, EmitState>>> = OnceLock::new();
103
104const VIOLATION_DEBOUNCE: Duration = Duration::from_secs(30);
105
106#[derive(Debug, Default, Clone)]
107struct EmitState {
108    last_violated: bool,
109    last_emit: Option<Instant>,
110}
111
112fn config_store() -> &'static Mutex<Vec<SloDefinition>> {
113    SLO_CONFIG.get_or_init(|| Mutex::new(load_slos_from_disk()))
114}
115
116fn violation_store() -> &'static Mutex<ViolationHistory> {
117    VIOLATION_LOG.get_or_init(|| Mutex::new(ViolationHistory::default()))
118}
119
120fn emit_state_store() -> &'static Mutex<HashMap<String, EmitState>> {
121    EMIT_STATE.get_or_init(|| Mutex::new(HashMap::new()))
122}
123
124// ---------------------------------------------------------------------------
125// Loading
126// ---------------------------------------------------------------------------
127
128fn slo_toml_paths() -> Vec<PathBuf> {
129    let mut paths = Vec::new();
130
131    if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
132        paths.push(dir.join("slos.toml"));
133    }
134
135    if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
136        paths.push(PathBuf::from(home).join(".lean-ctx").join("slos.toml"));
137    }
138
139    if let Ok(cwd) = std::env::current_dir() {
140        paths.push(cwd.join(".lean-ctx").join("slos.toml"));
141    }
142
143    paths
144}
145
146fn load_slos_from_disk() -> Vec<SloDefinition> {
147    for path in slo_toml_paths() {
148        if let Ok(content) = std::fs::read_to_string(&path) {
149            match toml::from_str::<SloConfig>(&content) {
150                Ok(cfg) => return cfg.slo,
151                Err(e) => {
152                    eprintln!("[lean-ctx] slo: parse error in {}: {e}", path.display());
153                }
154            }
155        }
156    }
157    default_slos()
158}
159
160fn default_slos() -> Vec<SloDefinition> {
161    vec![
162        SloDefinition {
163            name: "context_budget".into(),
164            metric: SloMetric::SessionContextTokens,
165            threshold: 200_000.0,
166            direction: SloDirection::Max,
167            action: SloAction::Warn,
168        },
169        SloDefinition {
170            name: "cost_per_session".into(),
171            metric: SloMetric::SessionCostUsd,
172            threshold: 5.0,
173            direction: SloDirection::Max,
174            action: SloAction::Throttle,
175        },
176        SloDefinition {
177            name: "compression_efficiency".into(),
178            metric: SloMetric::CompressionRatio,
179            // CompressionRatio = sent/original. Lower is better.
180            // Warn only when compression is poor (e.g. >75% of original tokens still sent).
181            threshold: 0.75,
182            direction: SloDirection::Max,
183            action: SloAction::Warn,
184        },
185    ]
186}
187
188pub fn reload() {
189    let fresh = load_slos_from_disk();
190    if let Ok(mut store) = config_store().lock() {
191        *store = fresh;
192    }
193}
194
195pub fn active_slos() -> Vec<SloDefinition> {
196    config_store().lock().map(|s| s.clone()).unwrap_or_default()
197}
198
199// ---------------------------------------------------------------------------
200// Evaluation
201// ---------------------------------------------------------------------------
202
203fn read_metric(metric: SloMetric) -> f64 {
204    let tracker = BudgetTracker::global();
205    match metric {
206        SloMetric::SessionContextTokens => tracker.tokens_used() as f64,
207        SloMetric::SessionCostUsd => tracker.cost_usd(),
208        SloMetric::ShellInvocations => tracker.shell_used() as f64,
209        SloMetric::CompressionRatio => {
210            let ledger = crate::core::context_ledger::ContextLedger::load();
211            if ledger.entries.is_empty() {
212                0.0
213            } else {
214                ledger.compression_ratio()
215            }
216        }
217        SloMetric::ToolCallsTotal => (tracker.tokens_used().max(1) / 1000) as f64,
218    }
219}
220
221fn is_violated(actual: f64, threshold: f64, direction: SloDirection) -> bool {
222    match direction {
223        SloDirection::Max => actual > threshold,
224        SloDirection::Min => actual < threshold,
225    }
226}
227
228pub fn evaluate() -> SloSnapshot {
229    let defs = active_slos();
230    let mut slos = Vec::with_capacity(defs.len());
231    let mut violations = Vec::new();
232    let now = Instant::now();
233    let mut emit_state = emit_state_store()
234        .lock()
235        .unwrap_or_else(std::sync::PoisonError::into_inner);
236
237    for def in &defs {
238        let actual = read_metric(def.metric);
239        let violated = is_violated(actual, def.threshold, def.direction);
240
241        let status = SloStatus {
242            name: def.name.clone(),
243            metric: def.metric,
244            threshold: def.threshold,
245            actual,
246            direction: def.direction,
247            action: def.action,
248            violated,
249        };
250
251        if violated {
252            let st = emit_state.entry(def.name.clone()).or_default();
253            let is_first = !st.last_violated;
254            let is_due = st
255                .last_emit
256                .is_none_or(|t| t.elapsed() >= VIOLATION_DEBOUNCE);
257            if is_first || is_due {
258                st.last_emit = Some(now);
259                record_violation(&status);
260                emit_slo_event(&status);
261            }
262            st.last_violated = true;
263            violations.push(status.clone());
264        } else if let Some(st) = emit_state.get_mut(&def.name) {
265            st.last_violated = false;
266        }
267
268        slos.push(status);
269    }
270
271    let worst_action = violations.iter().map(|v| v.action).max_by_key(|a| match a {
272        SloAction::Warn => 0,
273        SloAction::Throttle => 1,
274        SloAction::Block => 2,
275    });
276
277    SloSnapshot {
278        slos,
279        violations,
280        worst_action,
281    }
282}
283
284pub fn evaluate_quiet() -> SloSnapshot {
285    let defs = active_slos();
286    let mut slos = Vec::with_capacity(defs.len());
287    let mut violations = Vec::new();
288
289    for def in &defs {
290        let actual = read_metric(def.metric);
291        let violated = is_violated(actual, def.threshold, def.direction);
292
293        let status = SloStatus {
294            name: def.name.clone(),
295            metric: def.metric,
296            threshold: def.threshold,
297            actual,
298            direction: def.direction,
299            action: def.action,
300            violated,
301        };
302
303        if violated {
304            violations.push(status.clone());
305        }
306        slos.push(status);
307    }
308
309    let worst_action = violations.iter().map(|v| v.action).max_by_key(|a| match a {
310        SloAction::Warn => 0,
311        SloAction::Throttle => 1,
312        SloAction::Block => 2,
313    });
314
315    SloSnapshot {
316        slos,
317        violations,
318        worst_action,
319    }
320}
321
322fn record_violation(status: &SloStatus) {
323    if let Ok(mut hist) = violation_store().lock() {
324        let entry = ViolationEntry {
325            timestamp: chrono::Local::now()
326                .format("%Y-%m-%dT%H:%M:%S%.3f")
327                .to_string(),
328            slo_name: status.name.clone(),
329            metric: status.metric,
330            threshold: status.threshold,
331            actual: status.actual,
332            action: status.action,
333        };
334        hist.entries.push(entry);
335        if hist.entries.len() > 500 {
336            let excess = hist.entries.len() - 500;
337            hist.entries.drain(..excess);
338        }
339    }
340}
341
342fn emit_slo_event(status: &SloStatus) {
343    events::emit(events::EventKind::SloViolation {
344        slo_name: status.name.clone(),
345        metric: format!("{:?}", status.metric),
346        threshold: status.threshold,
347        actual: status.actual,
348        action: format!("{:?}", status.action),
349    });
350}
351
352pub fn violation_history(limit: usize) -> Vec<ViolationEntry> {
353    violation_store()
354        .lock()
355        .map(|h| {
356            let start = h.entries.len().saturating_sub(limit);
357            h.entries[start..].to_vec()
358        })
359        .unwrap_or_default()
360}
361
362pub fn clear_violations() {
363    if let Ok(mut hist) = violation_store().lock() {
364        hist.entries.clear();
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Formatting
370// ---------------------------------------------------------------------------
371
372impl SloSnapshot {
373    pub fn format_compact(&self) -> String {
374        let total = self.slos.len();
375        let violated = self.violations.len();
376        let mut out = format!("SLOs: {}/{} passing", total - violated, total);
377
378        for v in &self.violations {
379            out.push_str(&format!(
380                "\n  !! {} ({:?}): {:.2} vs threshold {:.2} → {:?}",
381                v.name, v.metric, v.actual, v.threshold, v.action
382            ));
383        }
384
385        out
386    }
387
388    pub fn should_block(&self) -> bool {
389        self.worst_action == Some(SloAction::Block)
390    }
391
392    pub fn should_throttle(&self) -> bool {
393        matches!(
394            self.worst_action,
395            Some(SloAction::Throttle | SloAction::Block)
396        )
397    }
398}
399
400impl std::fmt::Display for SloMetric {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        match self {
403            Self::SessionContextTokens => write!(f, "session_context_tokens"),
404            Self::SessionCostUsd => write!(f, "session_cost_usd"),
405            Self::CompressionRatio => write!(f, "compression_ratio"),
406            Self::ShellInvocations => write!(f, "shell_invocations"),
407            Self::ToolCallsTotal => write!(f, "tool_calls_total"),
408        }
409    }
410}
411
412impl std::fmt::Display for SloAction {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        match self {
415            Self::Warn => write!(f, "warn"),
416            Self::Throttle => write!(f, "throttle"),
417            Self::Block => write!(f, "block"),
418        }
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn default_slos_are_valid() {
432        let defs = default_slos();
433        assert_eq!(defs.len(), 3);
434        assert_eq!(defs[0].name, "context_budget");
435        assert_eq!(defs[1].action, SloAction::Throttle);
436        assert_eq!(defs[2].direction, SloDirection::Max);
437    }
438
439    #[test]
440    fn violation_detection_max() {
441        assert!(is_violated(60_000.0, 50_000.0, SloDirection::Max));
442        assert!(!is_violated(40_000.0, 50_000.0, SloDirection::Max));
443    }
444
445    #[test]
446    fn violation_detection_min() {
447        assert!(is_violated(0.2, 0.3, SloDirection::Min));
448        assert!(!is_violated(0.5, 0.3, SloDirection::Min));
449    }
450
451    #[test]
452    fn slo_config_parses_from_toml() {
453        let toml_str = r#"
454[[slo]]
455name = "test_budget"
456metric = "session_context_tokens"
457threshold = 100000
458action = "warn"
459
460[[slo]]
461name = "test_cost"
462metric = "session_cost_usd"
463threshold = 2.0
464action = "block"
465direction = "max"
466"#;
467        let cfg: SloConfig = toml::from_str(toml_str).unwrap();
468        assert_eq!(cfg.slo.len(), 2);
469        assert_eq!(cfg.slo[0].name, "test_budget");
470        assert_eq!(cfg.slo[0].metric, SloMetric::SessionContextTokens);
471        assert_eq!(cfg.slo[1].action, SloAction::Block);
472    }
473
474    #[test]
475    fn snapshot_format_compact() {
476        let snap = SloSnapshot {
477            slos: vec![
478                SloStatus {
479                    name: "budget".into(),
480                    metric: SloMetric::SessionContextTokens,
481                    threshold: 50000.0,
482                    actual: 30000.0,
483                    direction: SloDirection::Max,
484                    action: SloAction::Warn,
485                    violated: false,
486                },
487                SloStatus {
488                    name: "cost".into(),
489                    metric: SloMetric::SessionCostUsd,
490                    threshold: 1.0,
491                    actual: 2.5,
492                    direction: SloDirection::Max,
493                    action: SloAction::Block,
494                    violated: true,
495                },
496            ],
497            violations: vec![SloStatus {
498                name: "cost".into(),
499                metric: SloMetric::SessionCostUsd,
500                threshold: 1.0,
501                actual: 2.5,
502                direction: SloDirection::Max,
503                action: SloAction::Block,
504                violated: true,
505            }],
506            worst_action: Some(SloAction::Block),
507        };
508        let out = snap.format_compact();
509        assert!(out.contains("1/2 passing"));
510        assert!(out.contains("cost"));
511        assert!(snap.should_block());
512    }
513
514    #[test]
515    fn snapshot_no_violations() {
516        let snap = SloSnapshot {
517            slos: vec![SloStatus {
518                name: "ok".into(),
519                metric: SloMetric::SessionContextTokens,
520                threshold: 100_000.0,
521                actual: 5000.0,
522                direction: SloDirection::Max,
523                action: SloAction::Warn,
524                violated: false,
525            }],
526            violations: vec![],
527            worst_action: None,
528        };
529        assert!(!snap.should_block());
530        assert!(!snap.should_throttle());
531        assert!(snap.format_compact().contains("1/1 passing"));
532    }
533
534    #[test]
535    fn violation_history_capped() {
536        clear_violations();
537        for i in 0..10 {
538            record_violation(&SloStatus {
539                name: format!("slo_{i}"),
540                metric: SloMetric::SessionContextTokens,
541                threshold: 100.0,
542                actual: 200.0,
543                direction: SloDirection::Max,
544                action: SloAction::Warn,
545                violated: true,
546            });
547        }
548        let hist = violation_history(5);
549        assert_eq!(hist.len(), 5);
550        assert_eq!(hist[0].slo_name, "slo_5");
551    }
552}