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 ToolCallCount,
45 TeamQueryP95Ms,
47 TeamAvailabilityPct,
49 TeamIndexLagSeconds,
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SloDirection {
56 #[default]
57 Max,
58 Min,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum SloAction {
64 #[default]
65 Warn,
66 Throttle,
67 Block,
68}
69
70#[derive(Debug, Clone, Serialize)]
75pub struct SloStatus {
76 pub name: String,
77 pub metric: SloMetric,
78 pub threshold: f64,
79 pub actual: f64,
80 pub direction: SloDirection,
81 pub action: SloAction,
82 pub violated: bool,
83}
84
85#[derive(Debug, Clone, Serialize)]
86pub struct SloSnapshot {
87 pub slos: Vec<SloStatus>,
88 pub violations: Vec<SloStatus>,
89 pub worst_action: Option<SloAction>,
90}
91
92#[derive(Debug, Default)]
93struct ViolationHistory {
94 entries: Vec<ViolationEntry>,
95}
96
97#[derive(Debug, Clone, Serialize)]
98pub struct ViolationEntry {
99 pub timestamp: String,
100 pub slo_name: String,
101 pub metric: SloMetric,
102 pub threshold: f64,
103 pub actual: f64,
104 pub action: SloAction,
105}
106
107static SLO_CONFIG: OnceLock<Mutex<Vec<SloDefinition>>> = OnceLock::new();
108static VIOLATION_LOG: OnceLock<Mutex<ViolationHistory>> = OnceLock::new();
109static EMIT_STATE: OnceLock<Mutex<HashMap<String, EmitState>>> = OnceLock::new();
110
111const VIOLATION_DEBOUNCE: Duration = Duration::from_secs(30);
112
113#[derive(Debug, Default, Clone)]
114struct EmitState {
115 last_violated: bool,
116 last_emit: Option<Instant>,
117}
118
119fn config_store() -> &'static Mutex<Vec<SloDefinition>> {
120 SLO_CONFIG.get_or_init(|| Mutex::new(load_slos_from_disk()))
121}
122
123fn violation_store() -> &'static Mutex<ViolationHistory> {
124 VIOLATION_LOG.get_or_init(|| Mutex::new(ViolationHistory::default()))
125}
126
127fn emit_state_store() -> &'static Mutex<HashMap<String, EmitState>> {
128 EMIT_STATE.get_or_init(|| Mutex::new(HashMap::new()))
129}
130
131fn slo_toml_paths() -> Vec<PathBuf> {
136 let mut paths = Vec::new();
137
138 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
139 paths.push(dir.join("slos.toml"));
140 }
141
142 if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
143 paths.push(PathBuf::from(home).join(".lean-ctx").join("slos.toml"));
144 }
145
146 if let Ok(cwd) = std::env::current_dir() {
147 paths.push(cwd.join(".lean-ctx").join("slos.toml"));
148 }
149
150 paths
151}
152
153fn load_slos_from_disk() -> Vec<SloDefinition> {
154 for path in slo_toml_paths() {
155 if let Ok(content) = std::fs::read_to_string(&path) {
156 match toml::from_str::<SloConfig>(&content) {
157 Ok(cfg) => return cfg.slo,
158 Err(e) => {
159 tracing::warn!("slo: parse error in {}: {e}", path.display());
160 }
161 }
162 }
163 }
164 default_slos()
165}
166
167fn default_slos() -> Vec<SloDefinition> {
168 vec![
169 SloDefinition {
170 name: "context_budget".into(),
171 metric: SloMetric::SessionContextTokens,
172 threshold: 200_000.0,
173 direction: SloDirection::Max,
174 action: SloAction::Warn,
175 },
176 SloDefinition {
177 name: "cost_per_session".into(),
178 metric: SloMetric::SessionCostUsd,
179 threshold: 5.0,
180 direction: SloDirection::Max,
181 action: SloAction::Throttle,
182 },
183 SloDefinition {
184 name: "compression_efficiency".into(),
185 metric: SloMetric::CompressionRatio,
186 threshold: 0.90,
190 direction: SloDirection::Max,
191 action: SloAction::Warn,
192 },
193 ]
194}
195
196pub fn reload() {
197 let fresh = load_slos_from_disk();
198 if let Ok(mut store) = config_store().lock() {
199 *store = fresh;
200 }
201}
202
203pub fn active_slos() -> Vec<SloDefinition> {
204 config_store().lock().map(|s| s.clone()).unwrap_or_default()
205}
206
207fn read_metric(metric: SloMetric) -> f64 {
212 let tracker = BudgetTracker::global();
213 match metric {
214 SloMetric::SessionContextTokens => {
215 let live = tracker.tokens_used();
216 if live > 0 {
217 live as f64
218 } else {
219 crate::core::context_ledger::ContextLedger::load().total_tokens_sent as f64
223 }
224 }
225 SloMetric::SessionCostUsd => tracker.cost_usd(),
226 SloMetric::ShellInvocations => tracker.shell_used() as f64,
227 SloMetric::CompressionRatio => {
228 let ledger = crate::core::context_ledger::ContextLedger::load();
229 let total_original: usize = ledger.entries.iter().map(|e| e.original_tokens).sum();
230 if total_original < 5000 {
231 0.0
232 } else {
233 ledger.compression_ratio()
234 }
235 }
236 SloMetric::ToolCallsTotal | SloMetric::ToolCallCount => tracker.tool_calls_count() as f64,
237 SloMetric::TeamQueryP95Ms => crate::core::team_slo::global().snapshot().p95_ms,
238 SloMetric::TeamAvailabilityPct => {
239 crate::core::team_slo::global().snapshot().availability_pct
240 }
241 SloMetric::TeamIndexLagSeconds => crate::core::team_slo::global()
242 .snapshot()
243 .index_lag_seconds
244 .unwrap_or(0.0),
246 }
247}
248
249fn is_violated(actual: f64, threshold: f64, direction: SloDirection) -> bool {
250 match direction {
251 SloDirection::Max => actual > threshold,
252 SloDirection::Min => actual < threshold,
253 }
254}
255
256pub fn evaluate() -> SloSnapshot {
257 let defs = active_slos();
258 let mut slos = Vec::with_capacity(defs.len());
259 let mut violations = Vec::new();
260 let now = Instant::now();
261 let mut emit_state = emit_state_store()
262 .lock()
263 .unwrap_or_else(std::sync::PoisonError::into_inner);
264
265 for def in &defs {
266 let actual = read_metric(def.metric);
267 let violated = is_violated(actual, def.threshold, def.direction);
268
269 let status = SloStatus {
270 name: def.name.clone(),
271 metric: def.metric,
272 threshold: def.threshold,
273 actual,
274 direction: def.direction,
275 action: def.action,
276 violated,
277 };
278
279 if violated {
280 let st = emit_state.entry(def.name.clone()).or_default();
281 let is_first = !st.last_violated;
282 let is_due = st
283 .last_emit
284 .is_none_or(|t| t.elapsed() >= VIOLATION_DEBOUNCE);
285 if is_first || is_due {
286 st.last_emit = Some(now);
287 record_violation(&status);
288 emit_slo_event(&status);
289 }
290 st.last_violated = true;
291 violations.push(status.clone());
292 } else if let Some(st) = emit_state.get_mut(&def.name) {
293 st.last_violated = false;
294 }
295
296 slos.push(status);
297 }
298
299 let worst_action = violations.iter().map(|v| v.action).max_by_key(|a| match a {
300 SloAction::Warn => 0,
301 SloAction::Throttle => 1,
302 SloAction::Block => 2,
303 });
304
305 SloSnapshot {
306 slos,
307 violations,
308 worst_action,
309 }
310}
311
312pub fn evaluate_quiet() -> SloSnapshot {
313 crate::core::verification_observability::record_slo_eval();
315 let defs = active_slos();
316 let mut slos = Vec::with_capacity(defs.len());
317 let mut violations = Vec::new();
318
319 for def in &defs {
320 let actual = read_metric(def.metric);
321 let violated = is_violated(actual, def.threshold, def.direction);
322
323 let status = SloStatus {
324 name: def.name.clone(),
325 metric: def.metric,
326 threshold: def.threshold,
327 actual,
328 direction: def.direction,
329 action: def.action,
330 violated,
331 };
332
333 if violated {
334 violations.push(status.clone());
335 }
336 slos.push(status);
337 }
338
339 let worst_action = violations.iter().map(|v| v.action).max_by_key(|a| match a {
340 SloAction::Warn => 0,
341 SloAction::Throttle => 1,
342 SloAction::Block => 2,
343 });
344
345 SloSnapshot {
346 slos,
347 violations,
348 worst_action,
349 }
350}
351
352fn record_violation(status: &SloStatus) {
353 if let Ok(mut hist) = violation_store().lock() {
354 let entry = ViolationEntry {
355 timestamp: chrono::Local::now()
356 .format("%Y-%m-%dT%H:%M:%S%.3f")
357 .to_string(),
358 slo_name: status.name.clone(),
359 metric: status.metric,
360 threshold: status.threshold,
361 actual: status.actual,
362 action: status.action,
363 };
364 hist.entries.push(entry);
365 if hist.entries.len() > 500 {
366 let excess = hist.entries.len() - 500;
367 hist.entries.drain(..excess);
368 }
369 }
370}
371
372fn emit_slo_event(status: &SloStatus) {
373 events::emit(events::EventKind::SloViolation {
374 slo_name: status.name.clone(),
375 metric: format!("{:?}", status.metric),
376 threshold: status.threshold,
377 actual: status.actual,
378 action: format!("{:?}", status.action),
379 });
380}
381
382pub fn violation_history(limit: usize) -> Vec<ViolationEntry> {
383 violation_store()
384 .lock()
385 .map(|h| {
386 let start = h.entries.len().saturating_sub(limit);
387 h.entries[start..].to_vec()
388 })
389 .unwrap_or_default()
390}
391
392pub fn clear_violations() {
393 if let Ok(mut hist) = violation_store().lock() {
394 hist.entries.clear();
395 }
396}
397
398impl SloSnapshot {
403 pub fn format_compact(&self) -> String {
404 let total = self.slos.len();
405 let violated = self.violations.len();
406 let mut out = format!("SLOs: {}/{} passing", total - violated, total);
407
408 for v in &self.violations {
409 out.push_str(&format!(
410 "\n !! {} ({:?}): {:.2} vs threshold {:.2} → {:?}",
411 v.name, v.metric, v.actual, v.threshold, v.action
412 ));
413 }
414
415 out
416 }
417
418 pub fn should_block(&self) -> bool {
419 self.worst_action == Some(SloAction::Block)
420 }
421
422 pub fn should_throttle(&self) -> bool {
423 matches!(
424 self.worst_action,
425 Some(SloAction::Throttle | SloAction::Block)
426 )
427 }
428}
429
430impl std::fmt::Display for SloMetric {
431 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432 match self {
433 Self::SessionContextTokens => write!(f, "session_context_tokens"),
434 Self::SessionCostUsd => write!(f, "session_cost_usd"),
435 Self::CompressionRatio => write!(f, "compression_ratio"),
436 Self::ShellInvocations => write!(f, "shell_invocations"),
437 Self::ToolCallsTotal => write!(f, "tool_calls_total"),
438 Self::ToolCallCount => write!(f, "tool_call_count"),
439 Self::TeamQueryP95Ms => write!(f, "team_query_p95_ms"),
440 Self::TeamAvailabilityPct => write!(f, "team_availability_pct"),
441 Self::TeamIndexLagSeconds => write!(f, "team_index_lag_seconds"),
442 }
443 }
444}
445
446impl std::fmt::Display for SloAction {
447 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448 match self {
449 Self::Warn => write!(f, "warn"),
450 Self::Throttle => write!(f, "throttle"),
451 Self::Block => write!(f, "block"),
452 }
453 }
454}
455
456#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn default_slos_are_valid() {
466 let defs = default_slos();
467 assert_eq!(defs.len(), 3);
468 assert_eq!(defs[0].name, "context_budget");
469 assert_eq!(defs[1].action, SloAction::Throttle);
470 assert_eq!(defs[2].direction, SloDirection::Max);
471 }
472
473 #[test]
474 fn violation_detection_max() {
475 assert!(is_violated(60_000.0, 50_000.0, SloDirection::Max));
476 assert!(!is_violated(40_000.0, 50_000.0, SloDirection::Max));
477 }
478
479 #[test]
480 fn violation_detection_min() {
481 assert!(is_violated(0.2, 0.3, SloDirection::Min));
482 assert!(!is_violated(0.5, 0.3, SloDirection::Min));
483 }
484
485 #[test]
486 fn slo_config_parses_from_toml() {
487 let toml_str = r#"
488[[slo]]
489name = "test_budget"
490metric = "session_context_tokens"
491threshold = 100000
492action = "warn"
493
494[[slo]]
495name = "test_cost"
496metric = "session_cost_usd"
497threshold = 2.0
498action = "block"
499direction = "max"
500"#;
501 let cfg: SloConfig = toml::from_str(toml_str).unwrap();
502 assert_eq!(cfg.slo.len(), 2);
503 assert_eq!(cfg.slo[0].name, "test_budget");
504 assert_eq!(cfg.slo[0].metric, SloMetric::SessionContextTokens);
505 assert_eq!(cfg.slo[1].action, SloAction::Block);
506 }
507
508 #[test]
509 fn team_slo_metrics_parse_and_evaluate() {
510 let toml_str = r#"
513[[slo]]
514name = "hosted_index_latency"
515metric = "team_query_p95_ms"
516threshold = 500
517action = "warn"
518
519[[slo]]
520name = "hosted_index_availability"
521metric = "team_availability_pct"
522threshold = 99.5
523direction = "min"
524action = "warn"
525
526[[slo]]
527name = "hosted_index_freshness"
528metric = "team_index_lag_seconds"
529threshold = 300
530action = "warn"
531"#;
532 let cfg: SloConfig = toml::from_str(toml_str).unwrap();
533 assert_eq!(cfg.slo.len(), 3);
534 assert_eq!(cfg.slo[0].metric, SloMetric::TeamQueryP95Ms);
535 assert_eq!(cfg.slo[1].metric, SloMetric::TeamAvailabilityPct);
536 assert_eq!(cfg.slo[1].direction, SloDirection::Min);
537 assert_eq!(cfg.slo[2].metric, SloMetric::TeamIndexLagSeconds);
538
539 let availability = read_metric(SloMetric::TeamAvailabilityPct);
543 assert!((0.0..=100.0).contains(&availability));
544 let lag = read_metric(SloMetric::TeamIndexLagSeconds);
545 assert!(lag >= 0.0);
546 let p95 = read_metric(SloMetric::TeamQueryP95Ms);
547 assert!(p95 >= 0.0);
548
549 assert_eq!(SloMetric::TeamQueryP95Ms.to_string(), "team_query_p95_ms");
550 assert_eq!(
551 SloMetric::TeamAvailabilityPct.to_string(),
552 "team_availability_pct"
553 );
554 assert_eq!(
555 SloMetric::TeamIndexLagSeconds.to_string(),
556 "team_index_lag_seconds"
557 );
558 }
559
560 #[test]
561 fn snapshot_format_compact() {
562 let snap = SloSnapshot {
563 slos: vec![
564 SloStatus {
565 name: "budget".into(),
566 metric: SloMetric::SessionContextTokens,
567 threshold: 50000.0,
568 actual: 30000.0,
569 direction: SloDirection::Max,
570 action: SloAction::Warn,
571 violated: false,
572 },
573 SloStatus {
574 name: "cost".into(),
575 metric: SloMetric::SessionCostUsd,
576 threshold: 1.0,
577 actual: 2.5,
578 direction: SloDirection::Max,
579 action: SloAction::Block,
580 violated: true,
581 },
582 ],
583 violations: vec![SloStatus {
584 name: "cost".into(),
585 metric: SloMetric::SessionCostUsd,
586 threshold: 1.0,
587 actual: 2.5,
588 direction: SloDirection::Max,
589 action: SloAction::Block,
590 violated: true,
591 }],
592 worst_action: Some(SloAction::Block),
593 };
594 let out = snap.format_compact();
595 assert!(out.contains("1/2 passing"));
596 assert!(out.contains("cost"));
597 assert!(snap.should_block());
598 }
599
600 #[test]
601 fn snapshot_no_violations() {
602 let snap = SloSnapshot {
603 slos: vec![SloStatus {
604 name: "ok".into(),
605 metric: SloMetric::SessionContextTokens,
606 threshold: 100_000.0,
607 actual: 5000.0,
608 direction: SloDirection::Max,
609 action: SloAction::Warn,
610 violated: false,
611 }],
612 violations: vec![],
613 worst_action: None,
614 };
615 assert!(!snap.should_block());
616 assert!(!snap.should_throttle());
617 assert!(snap.format_compact().contains("1/1 passing"));
618 }
619
620 #[test]
621 fn violation_history_capped() {
622 clear_violations();
623 for i in 0..10 {
624 record_violation(&SloStatus {
625 name: format!("slo_{i}"),
626 metric: SloMetric::SessionContextTokens,
627 threshold: 100.0,
628 actual: 200.0,
629 direction: SloDirection::Max,
630 action: SloAction::Warn,
631 violated: true,
632 });
633 }
634 let hist = violation_history(5);
635 assert_eq!(hist.len(), 5);
636 assert_eq!(hist[0].slo_name, "slo_5");
637 }
638}