1use 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#[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#[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
109fn 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
182fn 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
332impl 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#[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}