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    ToolCallCount,
45    /// Rolling p95 latency (ms) across team-server `/v1` routes (GL #391).
46    TeamQueryP95Ms,
47    /// Percentage (0–100) of non-5xx team-server requests in the rolling window.
48    TeamAvailabilityPct,
49    /// Seconds since the last successful index-mutating tool call on the team server.
50    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// ---------------------------------------------------------------------------
71// Runtime state
72// ---------------------------------------------------------------------------
73
74#[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
131// ---------------------------------------------------------------------------
132// Loading
133// ---------------------------------------------------------------------------
134
135fn 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            // CompressionRatio = sent/original. Lower is better.
187            // Warn when compression is poor (>90% of original still sent after 5000+ tokens).
188            // Previous 0.75 threshold triggered false positives for full-mode reads.
189            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
207// ---------------------------------------------------------------------------
208// Evaluation
209// ---------------------------------------------------------------------------
210
211fn 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                // Out-of-process consumers (dashboard daemon) have an empty
220                // in-memory tracker; fall back to the persisted ledger so the
221                // SLO reflects the real session instead of a hardcoded 0.
222                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            // No index write yet means "nothing to be stale" — never a violation.
245            .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    // Record that SLO evaluation happened (count-only observability).
314    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
398// ---------------------------------------------------------------------------
399// Formatting
400// ---------------------------------------------------------------------------
401
402impl 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// ---------------------------------------------------------------------------
457// Tests
458// ---------------------------------------------------------------------------
459
460#[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        // The three hosted-index gate metrics (GL #391) must round-trip
511        // through TOML and read live values without panicking.
512        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        // read_metric must work for team metrics regardless of what other
540        // (parallel) tests feed into the global store: only invariant ranges
541        // are asserted here, exact values are covered in core::team_slo.
542        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}