1use crate::core::algorithm::Driver;
5use crate::{LibsyError, Result};
6use async_trait::async_trait;
7use switchyard_protocol::{Request, Response};
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct Score {
12 pub confidence: f64,
14 pub target: String,
16}
17
18pub enum Classification {
21 Scores(Vec<Score>),
23 Ambiguous(Vec<Score>),
26}
27
28impl Classification {
29 pub fn argmax(&self, ignore_ambiguous: bool) -> Result<Option<Score>> {
35 match self {
36 Classification::Scores(scores) => argmax(scores),
37 Classification::Ambiguous(scores) => {
38 if ignore_ambiguous {
39 argmax(scores)
40 } else {
41 Ok(None)
42 }
43 }
44 }
45 }
46}
47
48fn argmax(scores: &[Score]) -> Result<Option<Score>> {
52 let mut best: Option<&Score> = None;
53 for score in scores.iter() {
54 if score.confidence.is_nan() {
55 return Err(LibsyError::AlgorithmError {
56 message: format!(
57 "classifier returned NaN confidence for target {:?}",
58 score.target
59 ),
60 });
61 }
62 match best {
63 Some(cur_best) if score.confidence > cur_best.confidence => best = Some(score),
64 None => best = Some(score),
65 _ => {}
66 }
67 }
68 Ok(best.cloned())
69}
70
71#[async_trait]
73pub trait Classifier<S = ()>: Send + Sync {
74 fn routing_tier(&self, _selected_model: &str) -> Option<&'static str> {
76 None
77 }
78
79 fn target_unavailable(&self, _request: &Request, _target: &str) {}
83
84 async fn score(
95 &self,
96 state: &mut S,
97 request: &mut Request,
98 driver: Option<&Driver>,
99 ) -> Result<(Classification, Option<Response>)>;
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use switchyard_protocol::text_request;
106
107 fn score(target: &str, confidence: f64) -> Score {
109 Score {
110 target: target.to_string(),
111 confidence,
112 }
113 }
114
115 #[test]
116 fn argmax_picks_the_highest_confidence_score() -> Result<()> {
117 let scores = vec![score("weak", 0.2), score("strong", 0.9), score("mid", 0.5)];
118 let best = Classification::Scores(scores).argmax(false)?;
119 assert_eq!(best, Some(score("strong", 0.9)));
120 Ok(())
121 }
122
123 #[test]
124 fn argmax_breaks_ties_by_cascade_order() -> Result<()> {
125 let scores = vec![score("first", 0.7), score("second", 0.7)];
127 let best = Classification::Scores(scores).argmax(false)?;
128 assert_eq!(best.map(|s| s.target), Some("first".to_string()));
129 Ok(())
130 }
131
132 #[test]
133 fn argmax_on_an_empty_set_abstains() -> Result<()> {
134 assert_eq!(Classification::Scores(vec![]).argmax(false)?, None);
136 assert_eq!(Classification::Ambiguous(vec![]).argmax(true)?, None);
137 Ok(())
138 }
139
140 #[test]
141 fn argmax_errors_on_nan_confidence() {
142 let scores = vec![score("weak", 0.3), score("strong", f64::NAN)];
144 assert!(matches!(
145 Classification::Scores(scores).argmax(false),
146 Err(LibsyError::AlgorithmError { message })
147 if message == "classifier returned NaN confidence for target \"strong\""
148 ));
149 assert!(matches!(
151 Classification::Scores(vec![score("only", f64::NAN)]).argmax(false),
152 Err(LibsyError::AlgorithmError { message })
153 if message == "classifier returned NaN confidence for target \"only\""
154 ));
155 }
156
157 #[test]
158 fn ambiguous_without_ignore_makes_no_choice() -> Result<()> {
159 let scores = vec![score("strong", 0.9)];
161 assert_eq!(Classification::Ambiguous(scores).argmax(false)?, None);
162 Ok(())
163 }
164
165 #[test]
166 fn ambiguous_with_ignore_falls_back_to_argmax() -> Result<()> {
167 let scores = vec![score("weak", 0.3), score("strong", 0.8)];
168 let best = Classification::Ambiguous(scores).argmax(true)?;
169 assert_eq!(best, Some(score("strong", 0.8)));
170 Ok(())
171 }
172
173 #[test]
174 fn scores_variant_ignores_the_ambiguous_flag() -> Result<()> {
175 let scores = vec![score("a", 0.4), score("b", 0.6)];
177 let with_ignore = Classification::Scores(scores.clone()).argmax(true)?;
178 let without_ignore = Classification::Scores(scores).argmax(false)?;
179 assert_eq!(with_ignore, without_ignore);
180 assert_eq!(with_ignore, Some(score("b", 0.6)));
181 Ok(())
182 }
183
184 struct RecordingClassifier;
186
187 #[async_trait]
188 impl Classifier<bool> for RecordingClassifier {
189 async fn score(
190 &self,
191 state: &mut bool,
192 request: &mut Request,
193 _driver: Option<&Driver>,
194 ) -> Result<(Classification, Option<Response>)> {
195 *state = true;
196 let target = request.requested_model().unwrap_or("auto").to_string();
197 Ok((
198 Classification::Scores(vec![Score {
199 target,
200 confidence: 1.0,
201 }]),
202 None,
203 ))
204 }
205 }
206
207 #[tokio::test]
208 async fn classifier_reads_request_and_mutates_state() -> Result<()> {
209 let mut state = false;
210 let mut request = Request {
211 llm_request: text_request(Some("strong".to_string()), "hi"),
212 raw_request: None,
213 metadata: None,
214 };
215 let (classification, _) = RecordingClassifier
217 .score(&mut state, &mut request, None)
218 .await?;
219 assert_eq!(
220 classification.argmax(false)?.map(|s| s.target),
221 Some("strong".to_string())
222 );
223 assert!(state);
224 Ok(())
225 }
226
227 struct RewritingClassifier;
229
230 #[async_trait]
231 impl Classifier for RewritingClassifier {
232 async fn score(
233 &self,
234 _state: &mut (),
235 request: &mut Request,
236 _driver: Option<&Driver>,
237 ) -> Result<(Classification, Option<Response>)> {
238 request.llm_request.model = Some("rewritten".to_string());
239 Ok((
240 Classification::Scores(vec![Score {
241 target: "rewritten".to_string(),
242 confidence: 1.0,
243 }]),
244 None,
245 ))
246 }
247 }
248
249 #[tokio::test]
250 async fn classifier_rewrites_the_request_in_place() -> Result<()> {
251 let mut state = ();
252 let mut request = Request {
253 llm_request: text_request(Some("auto".to_string()), "hi"),
254 raw_request: None,
255 metadata: None,
256 };
257
258 RewritingClassifier
259 .score(&mut state, &mut request, None)
260 .await?;
261
262 assert_eq!(request.requested_model(), Some("rewritten"));
265 Ok(())
266 }
267}