1use 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#[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#[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
124fn 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 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
199fn 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
368impl 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#[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}