use crate::core::algorithm::Driver;
use crate::{LibsyError, Result};
use async_trait::async_trait;
use switchyard_protocol::{Request, Response};
#[derive(Debug, Clone, PartialEq)]
pub struct Score {
pub confidence: f64,
pub target: String,
}
pub enum Classification {
Scores(Vec<Score>),
Ambiguous(Vec<Score>),
}
impl Classification {
pub fn argmax(&self, ignore_ambiguous: bool) -> Result<Option<Score>> {
match self {
Classification::Scores(scores) => argmax(scores),
Classification::Ambiguous(scores) => {
if ignore_ambiguous {
argmax(scores)
} else {
Ok(None)
}
}
}
}
}
fn argmax(scores: &[Score]) -> Result<Option<Score>> {
let mut best: Option<&Score> = None;
for score in scores.iter() {
if score.confidence.is_nan() {
return Err(LibsyError::AlgorithmError {
message: format!(
"classifier returned NaN confidence for target {:?}",
score.target
),
});
}
match best {
Some(cur_best) if score.confidence > cur_best.confidence => best = Some(score),
None => best = Some(score),
_ => {}
}
}
Ok(best.cloned())
}
#[async_trait]
pub trait Classifier<S = ()>: Send + Sync {
fn routing_tier(&self, _selected_model: &str) -> Option<&'static str> {
None
}
fn target_unavailable(&self, _request: &Request, _target: &str) {}
async fn score(
&self,
state: &mut S,
request: &mut Request,
driver: Option<&Driver>,
) -> Result<(Classification, Option<Response>)>;
}
#[cfg(test)]
mod tests {
use super::*;
use switchyard_protocol::text_request;
fn score(target: &str, confidence: f64) -> Score {
Score {
target: target.to_string(),
confidence,
}
}
#[test]
fn argmax_picks_the_highest_confidence_score() -> Result<()> {
let scores = vec![score("weak", 0.2), score("strong", 0.9), score("mid", 0.5)];
let best = Classification::Scores(scores).argmax(false)?;
assert_eq!(best, Some(score("strong", 0.9)));
Ok(())
}
#[test]
fn argmax_breaks_ties_by_cascade_order() -> Result<()> {
let scores = vec![score("first", 0.7), score("second", 0.7)];
let best = Classification::Scores(scores).argmax(false)?;
assert_eq!(best.map(|s| s.target), Some("first".to_string()));
Ok(())
}
#[test]
fn argmax_on_an_empty_set_abstains() -> Result<()> {
assert_eq!(Classification::Scores(vec![]).argmax(false)?, None);
assert_eq!(Classification::Ambiguous(vec![]).argmax(true)?, None);
Ok(())
}
#[test]
fn argmax_errors_on_nan_confidence() {
let scores = vec![score("weak", 0.3), score("strong", f64::NAN)];
assert!(matches!(
Classification::Scores(scores).argmax(false),
Err(LibsyError::AlgorithmError { message })
if message == "classifier returned NaN confidence for target \"strong\""
));
assert!(matches!(
Classification::Scores(vec![score("only", f64::NAN)]).argmax(false),
Err(LibsyError::AlgorithmError { message })
if message == "classifier returned NaN confidence for target \"only\""
));
}
#[test]
fn ambiguous_without_ignore_makes_no_choice() -> Result<()> {
let scores = vec![score("strong", 0.9)];
assert_eq!(Classification::Ambiguous(scores).argmax(false)?, None);
Ok(())
}
#[test]
fn ambiguous_with_ignore_falls_back_to_argmax() -> Result<()> {
let scores = vec![score("weak", 0.3), score("strong", 0.8)];
let best = Classification::Ambiguous(scores).argmax(true)?;
assert_eq!(best, Some(score("strong", 0.8)));
Ok(())
}
#[test]
fn scores_variant_ignores_the_ambiguous_flag() -> Result<()> {
let scores = vec![score("a", 0.4), score("b", 0.6)];
let with_ignore = Classification::Scores(scores.clone()).argmax(true)?;
let without_ignore = Classification::Scores(scores).argmax(false)?;
assert_eq!(with_ignore, without_ignore);
assert_eq!(with_ignore, Some(score("b", 0.6)));
Ok(())
}
struct RecordingClassifier;
#[async_trait]
impl Classifier<bool> for RecordingClassifier {
async fn score(
&self,
state: &mut bool,
request: &mut Request,
_driver: Option<&Driver>,
) -> Result<(Classification, Option<Response>)> {
*state = true;
let target = request.requested_model().unwrap_or("auto").to_string();
Ok((
Classification::Scores(vec![Score {
target,
confidence: 1.0,
}]),
None,
))
}
}
#[tokio::test]
async fn classifier_reads_request_and_mutates_state() -> Result<()> {
let mut state = false;
let mut request = Request {
llm_request: text_request(Some("strong".to_string()), "hi"),
raw_request: None,
metadata: None,
};
let (classification, _) = RecordingClassifier
.score(&mut state, &mut request, None)
.await?;
assert_eq!(
classification.argmax(false)?.map(|s| s.target),
Some("strong".to_string())
);
assert!(state);
Ok(())
}
struct RewritingClassifier;
#[async_trait]
impl Classifier for RewritingClassifier {
async fn score(
&self,
_state: &mut (),
request: &mut Request,
_driver: Option<&Driver>,
) -> Result<(Classification, Option<Response>)> {
request.llm_request.model = Some("rewritten".to_string());
Ok((
Classification::Scores(vec![Score {
target: "rewritten".to_string(),
confidence: 1.0,
}]),
None,
))
}
}
#[tokio::test]
async fn classifier_rewrites_the_request_in_place() -> Result<()> {
let mut state = ();
let mut request = Request {
llm_request: text_request(Some("auto".to_string()), "hi"),
raw_request: None,
metadata: None,
};
RewritingClassifier
.score(&mut state, &mut request, None)
.await?;
assert_eq!(request.requested_model(), Some("rewritten"));
Ok(())
}
}