lean_ctx/core/ocla/
routing_quality.rs1use std::collections::VecDeque;
4
5const MAX_DECISIONS: usize = 1_000;
6const QUALITY_THRESHOLD: f64 = 0.8;
7const MIN_SCORED_OUTCOMES: usize = 20;
8
9#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct RoutingDecision {
12 pub decision_id: String,
13 pub original_model: String,
14 pub routed_model: String,
15 pub reason: String,
16 pub timestamp: String,
17}
18
19#[derive(Clone, Debug, PartialEq)]
21pub struct RoutingOutcome {
22 pub decision: RoutingDecision,
23 pub quality_score: Option<f64>,
24 pub tokens_saved: u64,
25 pub latency_delta_ms: i64,
26}
27
28#[derive(Debug, Default)]
30pub struct RoutingQualityTracker {
31 outcomes: VecDeque<RoutingOutcome>,
32}
33
34impl RoutingQualityTracker {
35 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn record(&mut self, outcome: RoutingOutcome) {
42 if self.outcomes.len() == MAX_DECISIONS {
43 self.outcomes.pop_front();
44 }
45 self.outcomes.push_back(RoutingOutcome {
46 quality_score: sanitize_quality(outcome.quality_score),
47 ..outcome
48 });
49
50 if self.should_fallback() {
51 tracing::warn!(
52 success_rate = self.success_rate(),
53 "routing quality below threshold; suggest fallback"
54 );
55 }
56 }
57
58 pub fn success_rate(&self) -> f64 {
60 let scored = self.scored_outcomes();
61 if scored == 0 {
62 return 0.0;
63 }
64
65 self.outcomes
66 .iter()
67 .filter(|outcome| {
68 outcome
69 .quality_score
70 .is_some_and(|score| score >= QUALITY_THRESHOLD)
71 })
72 .count() as f64
73 / scored as f64
74 }
75
76 pub fn average_savings(&self) -> f64 {
78 if self.outcomes.is_empty() {
79 return 0.0;
80 }
81
82 self.outcomes
83 .iter()
84 .map(|outcome| outcome.tokens_saved as f64)
85 .sum::<f64>()
86 / self.outcomes.len() as f64
87 }
88
89 pub fn should_fallback(&self) -> bool {
91 self.scored_outcomes() >= MIN_SCORED_OUTCOMES && self.success_rate() < QUALITY_THRESHOLD
92 }
93
94 fn scored_outcomes(&self) -> usize {
95 self.outcomes
96 .iter()
97 .filter(|outcome| outcome.quality_score.is_some())
98 .count()
99 }
100}
101
102fn sanitize_quality(score: Option<f64>) -> Option<f64> {
103 score.filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 fn outcome(score: Option<f64>, tokens_saved: u64) -> RoutingOutcome {
111 RoutingOutcome {
112 decision: RoutingDecision {
113 decision_id: "decision-test".into(),
114 original_model: "expensive".into(),
115 routed_model: "fast".into(),
116 reason: "quality test".into(),
117 timestamp: "2026-01-01T00:00:00Z".into(),
118 },
119 quality_score: score,
120 tokens_saved,
121 latency_delta_ms: -5,
122 }
123 }
124
125 #[test]
126 fn new_tracker_starts_empty() {
127 let tracker = RoutingQualityTracker::new();
128
129 assert_eq!(tracker.success_rate(), 0.0);
130 assert_eq!(tracker.average_savings(), 0.0);
131 assert!(!tracker.should_fallback());
132 }
133
134 #[test]
135 fn mixed_outcomes_update_quality_and_savings() {
136 let mut tracker = RoutingQualityTracker::new();
137 tracker.record(outcome(Some(0.95), 100));
138 tracker.record(outcome(Some(0.4), 20));
139 tracker.record(outcome(None, 0));
140 tracker.record(outcome(Some(0.8), 40));
141
142 assert!((tracker.success_rate() - (2.0 / 3.0)).abs() < f64::EPSILON);
143 assert!((tracker.average_savings() - 40.0).abs() < f64::EPSILON);
144 assert!(!tracker.should_fallback());
145 }
146
147 #[test]
148 fn ring_buffer_discards_oldest_outcome() {
149 let mut tracker = RoutingQualityTracker::new();
150 tracker.record(outcome(Some(0.0), 0));
151 for _ in 0..MAX_DECISIONS {
152 tracker.record(outcome(Some(1.0), 100));
153 }
154
155 assert!((tracker.success_rate() - 1.0).abs() < f64::EPSILON);
156 assert!((tracker.average_savings() - 100.0).abs() < f64::EPSILON);
157 assert!(!tracker.should_fallback());
158 }
159
160 #[test]
161 fn fallback_requires_minimum_scored_samples() {
162 let mut tracker = RoutingQualityTracker::new();
163 for _ in 0..(MIN_SCORED_OUTCOMES - 1) {
164 tracker.record(outcome(Some(0.0), 0));
165 }
166 assert!(!tracker.should_fallback());
167
168 tracker.record(outcome(Some(0.0), 0));
169 assert!(tracker.should_fallback());
170 }
171
172 #[test]
173 fn invalid_and_absent_quality_do_not_count_as_failure() {
174 let mut tracker = RoutingQualityTracker::new();
175 tracker.record(outcome(None, 10));
176 tracker.record(outcome(Some(f64::NAN), 10));
177 tracker.record(outcome(Some(1.5), 10));
178
179 assert_eq!(tracker.success_rate(), 0.0);
180 assert!(!tracker.should_fallback());
181 }
182}