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