1use std::sync::Arc;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Deserializer};
14use switchyard_protocol::{LlmRequest, Message, OutputParams, Role, SimpleDecision};
15
16use super::fall_through::{DefaultTarget, FallThrough};
17use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
18use super::util::affinity::AffinityRouter;
19use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
20use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
21use super::util::llm_judge::{Judge, JudgeClassifier, JudgePolicy};
22use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
23use crate::core::classifier::{Classification, Classifier, Score};
24use crate::core::state::{State, StateValue};
25use crate::{LibsyError, Result};
26use switchyard_protocol::{
27 AggLlmResponse, Context, LlmResponse, Request, Response, RoutedLlmClient,
28};
29
30const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
31const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
32const ALGORITHM_NAME: &str = "llm_task_classifier";
34
35#[derive(Deserialize)]
36#[serde(deny_unknown_fields)]
37struct TaskClassifierVerdict {
38 #[serde(rename = "recommended_route")]
39 _recommended_route: String,
40 p_solve: f64,
41 confidence: f64,
42 abstain: bool,
43 capability_boundary: String,
44 #[serde(rename = "primary_rule")]
45 _primary_rule: String,
46 #[serde(rename = "crux")]
47 _crux: String,
48}
49
50impl TaskClassifierVerdict {
51 fn is_valid(&self) -> bool {
54 (0.0..=1.0).contains(&self.p_solve)
55 && (0.0..=1.0).contains(&self.confidence)
56 && matches!(
57 self.capability_boundary.as_str(),
58 "supported" | "uncertain" | "unsupported" | "unmatched"
59 )
60 }
61
62 fn is_capability_elevated(&self) -> bool {
64 matches!(
65 self.capability_boundary.as_str(),
66 "uncertain" | "unsupported" | "unmatched"
67 )
68 }
69}
70
71fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
78 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
79 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
80 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
81 return kept.into_iter().cloned().collect();
82 };
83 kept.push(&messages[task]);
84
85 let tail: Vec<&Message> = messages[task + 1..]
86 .iter()
87 .filter(|m| !is_instruction(m))
88 .collect();
89 kept.extend(&tail[tail.len().saturating_sub(recent_turn_window)..]);
90 kept.into_iter().cloned().collect()
91}
92
93struct CapabilityJudge {
94 contract: ClassifierContract,
95 max_output_tokens: u64,
96 recent_turn_window: Option<usize>,
97}
98
99impl Judge for CapabilityJudge {
100 type Verdict = TaskClassifierVerdict;
101
102 fn build_request(&self, _state: &State, request: &Request) -> Request {
103 let mut messages = match self.recent_turn_window {
106 Some(window) => trim_messages(&request.llm_request.messages, window),
107 None => request
108 .llm_request
109 .messages
110 .iter()
111 .rev()
112 .find(|message| message.role == Role::User)
113 .cloned()
114 .into_iter()
115 .collect::<Vec<_>>(),
116 };
117 messages.insert(
118 0,
119 Message::text(Role::System, self.contract.system_prompt().to_string()),
120 );
121 Request {
122 llm_request: LlmRequest {
123 model: request.llm_request.model.clone(),
124 messages,
125 output: OutputParams {
126 max_output_tokens: Some(self.max_output_tokens),
127 response_format: Some(self.contract.response_format().clone()),
128 },
129 ..LlmRequest::default()
130 },
131 raw_request: None,
132 metadata: request.metadata.clone(),
133 }
134 }
135}
136
137struct TaskClassifierPolicy {
138 efficient_target: String,
139 capable_target: String,
140 base_threshold: f64,
141 min_confidence: f64,
142 capability_elevated_floor: Option<f64>,
143}
144
145impl TaskClassifierPolicy {
146 fn new(
147 efficient_target: impl Into<String>,
148 capable_target: impl Into<String>,
149 config: &TaskClassifierConfig,
150 ) -> Self {
151 Self {
152 efficient_target: efficient_target.into(),
153 capable_target: capable_target.into(),
154 base_threshold: config.base_threshold,
155 min_confidence: config.min_confidence,
156 capability_elevated_floor: config.capability_elevated_floor,
157 }
158 }
159
160 fn threshold(&self, verdict: &TaskClassifierVerdict) -> f64 {
162 if verdict.is_capability_elevated() {
163 self.capability_elevated_floor
164 .unwrap_or(self.base_threshold)
165 } else {
166 self.base_threshold
167 }
168 }
169}
170
171impl JudgePolicy for TaskClassifierPolicy {
172 type Verdict = TaskClassifierVerdict;
173
174 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
175 let Some(verdict) =
180 verdict.filter(|v| v.is_valid() && !v.abstain && v.confidence >= self.min_confidence)
181 else {
182 return Classification::Ambiguous(vec![]);
183 };
184 let target = if verdict.p_solve >= self.threshold(verdict) {
187 &self.efficient_target
188 } else {
189 &self.capable_target
190 };
191 Classification::Scores(vec![Score {
192 target: target.clone(),
193 confidence: 1.0,
194 }])
195 }
196}
197
198#[derive(Clone, Debug)]
199pub struct TaskClassifierConfig {
201 pub base_threshold: f64,
203 pub min_confidence: f64,
205 pub capability_elevated_floor: Option<f64>,
207 pub session_affinity: bool,
209 pub message_hash_fallback: bool,
211 pub recent_turn_window: Option<usize>,
218 pub contract: ClassifierContractConfig,
220 pub max_output_tokens: u64,
222}
223
224#[derive(Deserialize)]
226#[serde(deny_unknown_fields)]
227struct TaskClassifierConfigWire {
228 base_threshold: f64,
229 #[serde(default)]
230 min_confidence: f64,
231 #[serde(default)]
232 capability_elevated_floor: Option<f64>,
233 #[serde(default)]
234 session_affinity: bool,
235 #[serde(default)]
236 message_hash_fallback: bool,
237 #[serde(default)]
238 recent_turn_window: Option<usize>,
239 #[serde(default)]
240 prompt: Option<String>,
241 #[serde(default = "default_judge_max_output_tokens")]
242 max_output_tokens: u64,
243}
244
245impl<'de> Deserialize<'de> for TaskClassifierConfig {
246 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
247 where
248 D: Deserializer<'de>,
249 {
250 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
251 let mut contract = ClassifierContractConfig::default();
252 if let Some(prompt) = wire.prompt {
253 contract = contract.with_prompt(prompt);
254 }
255 Ok(Self {
256 base_threshold: wire.base_threshold,
257 min_confidence: wire.min_confidence,
258 capability_elevated_floor: wire.capability_elevated_floor,
259 session_affinity: wire.session_affinity,
260 message_hash_fallback: wire.message_hash_fallback,
261 recent_turn_window: wire.recent_turn_window,
262 contract,
263 max_output_tokens: wire.max_output_tokens,
264 })
265 }
266}
267
268const fn default_judge_max_output_tokens() -> u64 {
269 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
270}
271
272impl Default for TaskClassifierConfig {
273 fn default() -> Self {
274 Self {
275 base_threshold: 0.0,
276 min_confidence: 0.0,
277 capability_elevated_floor: None,
278 session_affinity: false,
279 message_hash_fallback: false,
280 recent_turn_window: None,
281 contract: ClassifierContractConfig::default(),
282 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
283 }
284 }
285}
286
287impl TaskClassifierConfig {
288 fn validate(&self) -> Result<()> {
290 if !(0.0..=1.0).contains(&self.base_threshold) {
291 return Err(LibsyError::AlgorithmError {
292 message: format!(
293 "base_threshold must be between 0 and 1, got {}",
294 self.base_threshold
295 ),
296 });
297 }
298 if !(0.0..=1.0).contains(&self.min_confidence) {
299 return Err(LibsyError::AlgorithmError {
300 message: format!(
301 "min_confidence must be between 0 and 1, got {}",
302 self.min_confidence
303 ),
304 });
305 }
306 if let Some(floor) = self.capability_elevated_floor {
307 if !(0.0..=1.0).contains(&floor) {
308 return Err(LibsyError::AlgorithmError {
309 message: format!(
310 "capability_elevated_floor must be between 0 and 1, got {floor}"
311 ),
312 });
313 }
314 if floor <= self.base_threshold {
315 return Err(LibsyError::AlgorithmError {
316 message: format!(
317 "capability_elevated_floor must be greater than base_threshold, got {floor}"
318 ),
319 });
320 }
321 }
322 if self.max_output_tokens == 0 {
323 return Err(LibsyError::AlgorithmError {
324 message: "max_output_tokens must be at least 1".to_string(),
325 });
326 }
327 if self.message_hash_fallback && !self.session_affinity {
328 return Err(LibsyError::AlgorithmError {
329 message: "message_hash_fallback requires session_affinity".to_string(),
330 });
331 }
332 Ok(())
333 }
334}
335
336struct TaskClassifier {
337 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
338 efficient_target: String,
339 capable_target: String,
340}
341
342const STREAK_KEY: &str = "escalation_streak";
346
347fn streak(state: &State) -> u32 {
348 match state.extra.get(STREAK_KEY) {
349 Some(StateValue::Count(n)) => *n,
350 _ => 0,
351 }
352}
353
354fn decisive(target: &str) -> Classification {
355 Classification::Scores(vec![Score {
356 target: target.to_string(),
357 confidence: 1.0,
358 }])
359}
360
361fn assistant_message(response: &AggLlmResponse) -> Message {
362 Message {
363 role: Role::Assistant,
364 content: response
365 .first_output()
366 .map(|output| output.content.clone())
367 .unwrap_or_default(),
368 }
369}
370
371struct EscalationClassifier {
375 judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
376 capable: LlmTarget,
377 efficient: LlmTarget,
378 confirmations: u32,
380}
381
382#[async_trait]
383impl Classifier<State> for EscalationClassifier {
384 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
385 if self.capable.semantic_name == self.efficient.semantic_name {
386 None
387 } else if selected_model == self.capable.semantic_name {
388 Some("strong")
389 } else if selected_model == self.efficient.semantic_name {
390 Some("weak")
391 } else {
392 None
393 }
394 }
395
396 async fn score(
397 &self,
398 state: &mut State,
399 request: &mut Request,
400 driver: Option<&Driver>,
401 ) -> Result<(Classification, Option<Response>)> {
402 let Some(driver) = driver else {
403 return Err(LibsyError::AlgorithmError {
404 message: "escalation classifier requires a driver".into(),
405 });
406 };
407
408 if streak(state) >= self.confirmations {
410 return Ok((decisive(&self.capable.semantic_name), None));
411 }
412
413 let efficient_response = driver
418 .call_llm_target(
419 Context::default(),
420 &self.efficient,
421 request.clone(),
422 Arc::new(SimpleDecision {
423 selected_model: self.efficient.semantic_name.clone(),
424 reasoning: Some("escalation classifier: efficient tier".into()),
425 }),
426 )
427 .await?;
428 let agg = efficient_response
429 .llm_response
430 .into_agg()
431 .await
432 .map_err(|e| LibsyError::AlgorithmError {
433 message: format!("failed to aggregate efficient response: {e}"),
434 })?;
435 let mut judge_request = request.clone();
437 judge_request
438 .llm_request
439 .messages
440 .push(assistant_message(&agg));
441 let efficient_response = Response {
442 llm_response: if request.llm_request.stream {
443 LlmResponse::Stream(agg.into_stream())
444 } else {
445 LlmResponse::Agg(agg)
446 },
447 metadata: efficient_response.metadata,
448 };
449
450 let (classification, _) = self
451 .judge
452 .score(state, &mut judge_request, Some(driver))
453 .await?;
454
455 let held = streak(state);
456 let best = classification.argmax(false)?;
457 let (escalate, pending) = match &best {
458 Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
459 Some(_) => (false, 0),
460 None => (false, held),
461 };
462 state
463 .extra
464 .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
465
466 if escalate && pending >= self.confirmations {
467 return Ok((decisive(&self.capable.semantic_name), None));
469 }
470
471 Ok((
472 decisive(&self.efficient.semantic_name),
473 Some(efficient_response),
474 ))
475 }
476}
477
478pub struct LlmTaskClassifier {
485 route: FallThrough<State>,
486 inner: Arc<dyn Classifier<State>>,
488}
489
490impl LlmTaskClassifier {
491 pub fn new(
504 judge_target: LlmTarget,
505 efficient_target: LlmTarget,
506 capable_target: LlmTarget,
507 config: TaskClassifierConfig,
508 ) -> Result<Self> {
509 config.validate()?;
510 let contract = Self::load_capability_contract(&config.contract)?;
511 let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
512 let session_affinity = config.session_affinity;
513 let message_hash_fallback = config.message_hash_fallback;
514 let classifier = Arc::new(TaskClassifier {
515 classifier: JudgeClassifier::new(
516 CapabilityJudge {
517 contract,
518 max_output_tokens: config.max_output_tokens,
519 recent_turn_window: config.recent_turn_window,
520 },
521 judge_target.clone(),
522 TaskClassifierPolicy::new(
523 efficient_target.semantic_name.clone(),
524 capable_target.semantic_name.clone(),
525 &config,
526 ),
527 ),
528 efficient_target: efficient_target.semantic_name.clone(),
529 capable_target: capable_target.semantic_name.clone(),
530 });
531 let inner: Arc<dyn Classifier<State>> = classifier.clone();
532
533 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
537 if session_affinity {
538 let affinity = if message_hash_fallback {
539 AffinityRouter::new().with_message_hash_fallback()
540 } else {
541 AffinityRouter::new()
542 };
543 let affinity = Arc::new(affinity);
545 route = route
546 .with_processor(affinity.clone())
547 .with_classifier(affinity);
548 }
549 let capable_fallback = DefaultTarget::new(classifier.capable_target.clone());
552 Ok(Self {
553 route: route
554 .with_classifier(inner.clone())
555 .with_classifier(Arc::new(capable_fallback)),
556 inner,
557 })
558 }
559
560 pub fn new_with_escalation(
568 judge_target: LlmTarget,
569 efficient_target: LlmTarget,
570 capable_target: LlmTarget,
571 config: EscalationJudgeConfig,
572 max_output_tokens: u64,
573 ) -> Result<Self> {
574 Self::new_with_escalation_contract(
575 judge_target,
576 efficient_target,
577 capable_target,
578 ClassifierContractConfig::default(),
579 config,
580 max_output_tokens,
581 )
582 }
583
584 pub fn new_with_escalation_contract(
586 judge_target: LlmTarget,
587 efficient_target: LlmTarget,
588 capable_target: LlmTarget,
589 contract_config: ClassifierContractConfig,
590 config: EscalationJudgeConfig,
591 max_output_tokens: u64,
592 ) -> Result<Self> {
593 let capable_name = capable_target.semantic_name.clone();
594 let efficient_name = efficient_target.semantic_name.clone();
595 let confirmations = config.confirmations;
596 let esc = Arc::new(EscalationClassifier {
597 judge: escalation::build_judge(
598 judge_target,
599 capable_name,
600 efficient_name,
601 &contract_config,
602 config,
603 max_output_tokens,
604 )?,
605 capable: capable_target.clone(),
606 efficient: efficient_target.clone(),
607 confirmations,
608 });
609 let inner: Arc<dyn Classifier<State>> = esc.clone();
610 let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
611 Ok(Self {
612 route: FallThrough::<State>::new_with_state(targets)
613 .with_name(ALGORITHM_NAME)
614 .with_classifier(esc),
615 inner,
616 })
617 }
618
619 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
621 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
622 }
623}
624
625#[async_trait]
626impl Classifier<State> for TaskClassifier {
627 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
628 if self.efficient_target == self.capable_target {
629 None
630 } else if selected_model == self.efficient_target {
631 Some("weak")
632 } else if selected_model == self.capable_target {
633 Some("strong")
634 } else {
635 None
636 }
637 }
638
639 async fn score(
640 &self,
641 state: &mut State,
642 request: &mut Request,
643 driver: Option<&Driver>,
644 ) -> Result<(Classification, Option<Response>)> {
645 self.classifier.score(state, request, driver).await
646 }
647}
648
649#[async_trait]
650impl Classifier<State> for LlmTaskClassifier {
651 fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
652 self.inner.routing_tier(selected_model)
653 }
654
655 async fn score(
656 &self,
657 state: &mut State,
658 request: &mut Request,
659 driver: Option<&Driver>,
660 ) -> Result<(Classification, Option<Response>)> {
661 self.inner.score(state, request, driver).await
662 }
663}
664
665#[async_trait]
666impl Algorithm for LlmTaskClassifier {
667 fn name(&self) -> &str {
668 "llm_task_classifier"
669 }
670
671 fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
672 self.route.count_tokens_client()
673 }
674
675 async fn create_run_task(
676 self: Arc<Self>,
677 ctx: Context,
678 driver: Driver,
679 request: Request,
680 ) -> Result<Response> {
681 self.route.execute(ctx, driver, request).await
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use std::sync::Arc;
688
689 use parking_lot::Mutex;
690 use serde_json::Value;
691
692 use super::*;
693 use switchyard_protocol::{
694 LlmClientError, Metadata, completion_text, text_request, text_response,
695 };
696
697 use crate::core::algorithm::Algorithm;
698 use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
699
700 const TEST_THRESHOLD: f64 = 0.5;
701
702 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
703 TaskClassifierConfig {
704 base_threshold,
705 ..TaskClassifierConfig::default()
706 }
707 }
708
709 fn policy() -> TaskClassifierPolicy {
710 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
711 }
712
713 fn verdict(p_solve: f64, confidence: f64, abstain: bool) -> TaskClassifierVerdict {
715 TaskClassifierVerdict {
716 _recommended_route: "efficient".to_string(),
717 p_solve,
718 confidence,
719 abstain,
720 capability_boundary: "supported".to_string(),
721 _primary_rule: "SUP-1".to_string(),
722 _crux: "test crux".to_string(),
723 }
724 }
725
726 fn selected(
727 policy: &TaskClassifierPolicy,
728 verdict: Option<&TaskClassifierVerdict>,
729 ) -> Result<String> {
730 policy
731 .to_classification(verdict)
732 .argmax(false)?
733 .map(|score| score.target)
734 .ok_or_else(|| LibsyError::AlgorithmError {
735 message: "policy abstained".to_string(),
736 })
737 }
738
739 #[derive(Default)]
740 struct PerRequestClient {
741 calls: Mutex<Vec<String>>,
742 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
743 judge_system_prompts: Mutex<Vec<String>>,
744 }
745
746 impl PerRequestClient {
747 fn calls(&self) -> Vec<String> {
748 self.calls.lock().clone()
749 }
750
751 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
752 self.judge_max_output_tokens.lock().clone()
753 }
754
755 fn judge_system_prompts(&self) -> Vec<String> {
756 self.judge_system_prompts.lock().clone()
757 }
758 }
759
760 #[async_trait]
761 impl RoutedLlmClient for PerRequestClient {
762 async fn call(
763 &self,
764 _ctx: Context,
765 request: Request,
766 decision: Arc<dyn Decision>,
767 ) -> std::result::Result<Response, LlmClientError> {
768 let model = decision.selected_model().to_string();
769 self.calls.lock().push(model.clone());
770 let completion = if model == "judge" {
771 self.judge_max_output_tokens
772 .lock()
773 .push(request.llm_request.output.max_output_tokens);
774 self.judge_system_prompts.lock().extend(
775 request
776 .llm_request
777 .messages
778 .first()
779 .and_then(|message| message.text_content("\n")),
780 );
781 r#"{"recommended_route":"efficient","p_solve":0.9,"confidence":0.9,"abstain":false,"capability_boundary":"supported","primary_rule":"SUP-1","crux":"bounded task"}"#.to_string()
782 } else {
783 format!("answer from {model}")
784 };
785 Ok(Response {
786 llm_response: LlmResponse::Agg(text_response(None, completion)),
787 metadata: request.metadata,
788 })
789 }
790 }
791
792 struct UnreachableJudgeClient;
793
794 #[async_trait]
795 impl RoutedLlmClient for UnreachableJudgeClient {
796 async fn call(
797 &self,
798 _ctx: Context,
799 request: Request,
800 decision: Arc<dyn Decision>,
801 ) -> std::result::Result<Response, LlmClientError> {
802 let model = decision.selected_model().to_string();
803 if model == "judge" {
804 return Err(LlmClientError::Timeout {
805 source: Box::new(std::io::Error::other("judge unreachable")),
806 });
807 }
808 Ok(Response {
809 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
810 metadata: request.metadata,
811 })
812 }
813 }
814
815 fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
816 let target = |name: &str| LlmTarget {
817 semantic_name: name.to_string(),
818 llm_client: Some(client.clone()),
819 };
820 Ok(Arc::new(LlmTaskClassifier::new(
821 target("judge"),
822 target("efficient"),
823 target("capable"),
824 test_config(TEST_THRESHOLD),
825 )?))
826 }
827
828 fn classify_request() -> Request {
829 Request {
830 llm_request: text_request(Some("auto".to_string()), "classify this task"),
831 raw_request: None,
832 metadata: None,
833 }
834 }
835
836 fn classify_session_request() -> Request {
837 Request {
838 metadata: Some(Metadata {
839 session_id: Some("session-1".to_string()),
840 ..Metadata::default()
841 }),
842 ..classify_request()
843 }
844 }
845
846 fn classify_follow_up_request() -> Request {
847 let mut request = classify_request();
848 request
849 .llm_request
850 .messages
851 .push(Message::text(Role::Assistant, "I will add the test."));
852 request.llm_request.messages.push(Message::text(
853 Role::User,
854 "Now run the test suite and report the result.",
855 ));
856 request
857 }
858
859 #[tokio::test]
860 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
861 let router = router(Arc::new(UnreachableJudgeClient))?;
862
863 let (trace, response) = router.run(Context::default(), classify_request()).await?;
864
865 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
866 assert_eq!(
867 response.llm_response.as_agg().map(completion_text),
868 Some("answer from capable".to_string())
869 );
870 Ok(())
871 }
872
873 #[tokio::test]
874 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
875 let client = Arc::new(PerRequestClient::default());
876 let router = router(client.clone())?;
877 let request = classify_request;
878
879 router.clone().run(Context::default(), request()).await?;
880 router.clone().run(Context::default(), request()).await?;
881
882 assert_eq!(
883 client.calls(),
884 vec!["judge", "efficient", "judge", "efficient"]
885 );
886 Ok(())
887 }
888
889 #[tokio::test]
890 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
891 let client = Arc::new(PerRequestClient::default());
892 let target = |name: &str| LlmTarget {
893 semantic_name: name.to_string(),
894 llm_client: Some(client.clone()),
895 };
896 let router = Arc::new(LlmTaskClassifier::new(
897 target("judge"),
898 target("efficient"),
899 target("capable"),
900 TaskClassifierConfig {
901 max_output_tokens: 512,
902 ..test_config(TEST_THRESHOLD)
903 },
904 )?);
905
906 router.run(Context::default(), classify_request()).await?;
907
908 assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
909 Ok(())
910 }
911
912 #[tokio::test]
913 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
914 let client = Arc::new(PerRequestClient::default());
915 let target = |name: &str| LlmTarget {
916 semantic_name: name.to_string(),
917 llm_client: Some(client.clone()),
918 };
919 let router = Arc::new(LlmTaskClassifier::new(
920 target("judge"),
921 target("efficient"),
922 target("capable"),
923 TaskClassifierConfig {
924 contract: ClassifierContractConfig::default()
925 .with_prompt("Custom capability rubric:\n{{RESPONSE_SCHEMA}}"),
926 ..test_config(TEST_THRESHOLD)
927 },
928 )?);
929
930 router.run(Context::default(), classify_request()).await?;
931
932 let prompts = client.judge_system_prompts();
933 assert_eq!(prompts.len(), 1);
934 assert!(prompts[0].starts_with("Custom capability rubric:"));
935 assert!(prompts[0].contains("\"recommended_route\""));
936 assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
937 Ok(())
938 }
939
940 #[tokio::test]
941 async fn classifier_config_enables_session_affinity() -> Result<()> {
942 let client = Arc::new(PerRequestClient::default());
943 let target = |name: &str| LlmTarget {
944 semantic_name: name.to_string(),
945 llm_client: Some(client.clone()),
946 };
947 let router = Arc::new(LlmTaskClassifier::new(
948 target("judge"),
949 target("efficient"),
950 target("capable"),
951 TaskClassifierConfig {
952 session_affinity: true,
953 ..test_config(TEST_THRESHOLD)
954 },
955 )?);
956
957 router
958 .clone()
959 .run(Context::default(), classify_session_request())
960 .await?;
961 router
962 .clone()
963 .run(Context::default(), classify_session_request())
964 .await?;
965
966 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
967 Ok(())
968 }
969
970 #[tokio::test]
971 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
972 let client = Arc::new(PerRequestClient::default());
973 let target = |name: &str| LlmTarget {
974 semantic_name: name.to_string(),
975 llm_client: Some(client.clone()),
976 };
977 let router = Arc::new(LlmTaskClassifier::new(
978 target("judge"),
979 target("efficient"),
980 target("capable"),
981 TaskClassifierConfig {
982 session_affinity: true,
983 message_hash_fallback: true,
984 recent_turn_window: None,
985 ..test_config(TEST_THRESHOLD)
986 },
987 )?);
988
989 router
990 .clone()
991 .run(Context::default(), classify_request())
992 .await?;
993 router
994 .clone()
995 .run(Context::default(), classify_follow_up_request())
996 .await?;
997
998 assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
999 Ok(())
1000 }
1001
1002 #[test]
1003 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1004 let policy = policy();
1005 let at_threshold = verdict(0.5, 0.0, false);
1006 let below_threshold = verdict(0.49, 1.0, false);
1007 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1008 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1009 Ok(())
1010 }
1011
1012 #[test]
1013 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1014 let borderline = verdict(0.5, 1.0, false);
1015 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1016 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1017 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1018 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1019 Ok(())
1020 }
1021
1022 #[test]
1023 fn classifier_config_rejects_unknown_fields() {
1024 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1025 "base_threshold": 0.5,
1026 "classifier_magic": true,
1027 }))
1028 .expect_err("unknown classifier fields must be rejected");
1029
1030 assert!(
1031 error
1032 .to_string()
1033 .contains("unknown field `classifier_magic`"),
1034 "{error}"
1035 );
1036 }
1037
1038 #[test]
1039 fn invalid_classifier_config_is_rejected() -> Result<()> {
1040 let target = |name: &str| LlmTarget {
1041 semantic_name: name.to_string(),
1042 llm_client: None,
1043 };
1044 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1045 assert!(
1046 LlmTaskClassifier::new(
1047 target("judge"),
1048 target("e"),
1049 target("c"),
1050 test_config(bad),
1051 )
1052 .is_err(),
1053 "base threshold {bad} should be rejected"
1054 );
1055 }
1056 for config in [
1057 TaskClassifierConfig {
1058 base_threshold: 0.5,
1059 min_confidence: 1.1,
1060 ..TaskClassifierConfig::default()
1061 },
1062 TaskClassifierConfig {
1063 base_threshold: 0.5,
1064 capability_elevated_floor: Some(0.5),
1065 ..TaskClassifierConfig::default()
1066 },
1067 TaskClassifierConfig {
1068 base_threshold: 0.5,
1069 message_hash_fallback: true,
1070 ..TaskClassifierConfig::default()
1071 },
1072 TaskClassifierConfig {
1073 base_threshold: 0.5,
1074 max_output_tokens: 0,
1075 ..TaskClassifierConfig::default()
1076 },
1077 ] {
1078 assert!(
1079 LlmTaskClassifier::new(target("judge"), target("e"), target("c"), config).is_err()
1080 );
1081 }
1082 LlmTaskClassifier::new(target("judge"), target("e"), target("c"), test_config(0.0))?;
1083 LlmTaskClassifier::new(target("judge"), target("e"), target("c"), test_config(1.0))?;
1084 Ok(())
1085 }
1086
1087 #[test]
1088 fn an_unusable_verdict_abstains() -> Result<()> {
1089 let policy = policy();
1093 let invalid_boundary = TaskClassifierVerdict {
1094 capability_boundary: "unknown".to_string(),
1095 ..verdict(1.0, 1.0, false)
1096 };
1097 let unusable = [
1098 Some(verdict(1.1, 1.0, false)),
1099 Some(verdict(1.0, 1.0, true)),
1100 Some(invalid_boundary),
1101 None,
1102 ];
1103 for verdict in unusable {
1104 let classification = policy.to_classification(verdict.as_ref());
1105 assert!(matches!(classification, Classification::Ambiguous(_)));
1106 assert!(classification.argmax(false)?.is_none());
1107 assert!(classification.argmax(true)?.is_none());
1108 }
1109 Ok(())
1110 }
1111
1112 #[test]
1113 fn elevated_capability_floor_is_a_targeted_safety_brake() -> Result<()> {
1114 let policy = TaskClassifierPolicy::new(
1115 "efficient",
1116 "capable",
1117 &TaskClassifierConfig {
1118 capability_elevated_floor: Some(0.45),
1119 ..test_config(0.25)
1120 },
1121 );
1122 let supported = verdict(0.30, 1.0, false);
1123 let elevated = TaskClassifierVerdict {
1124 capability_boundary: "uncertain".to_string(),
1125 ..verdict(0.30, 1.0, false)
1126 };
1127 let strong_elevated = TaskClassifierVerdict {
1128 capability_boundary: "unsupported".to_string(),
1129 ..verdict(0.50, 1.0, false)
1130 };
1131
1132 assert_eq!(selected(&policy, Some(&supported))?, "efficient");
1133 assert_eq!(selected(&policy, Some(&elevated))?, "capable");
1134 assert_eq!(selected(&policy, Some(&strong_elevated))?, "efficient");
1135 Ok(())
1136 }
1137
1138 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1141 let judge = CapabilityJudge {
1142 contract: LlmTaskClassifier::load_capability_contract(
1143 &ClassifierContractConfig::default(),
1144 )?,
1145 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1146 recent_turn_window: Some(recent_turn_window),
1147 };
1148 let request = Request {
1149 llm_request: LlmRequest {
1150 messages: vec![
1151 Message::text(Role::System, "client instructions"),
1152 Message::text(Role::User, "initial task"),
1153 Message::text(Role::Assistant, "old response"),
1154 Message::text(Role::User, "old follow-up"),
1155 Message::text(Role::Assistant, "recent 1"),
1156 Message::text(Role::User, "recent 2"),
1157 ],
1158 ..LlmRequest::default()
1159 },
1160 raw_request: None,
1161 metadata: None,
1162 };
1163 Ok(judge
1164 .build_request(&State::default(), &request)
1165 .llm_request
1166 .messages
1167 .iter()
1168 .filter_map(|message| message.text_content("\n"))
1169 .collect())
1170 }
1171
1172 #[test]
1173 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1174 let contents = judged_contents(2)?;
1176 assert!(contents.contains(&"client instructions".to_string()));
1177 assert!(contents.contains(&"initial task".to_string()));
1178 assert!(contents.contains(&"recent 1".to_string()));
1179 assert!(contents.contains(&"recent 2".to_string()));
1180 assert!(!contents.contains(&"old response".to_string()));
1181 Ok(())
1182 }
1183
1184 #[test]
1185 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1186 let contents = judged_contents(0)?;
1187 assert!(contents.contains(&"client instructions".to_string()));
1188 assert!(contents.contains(&"initial task".to_string()));
1189 assert!(!contents.contains(&"recent 2".to_string()));
1190 Ok(())
1191 }
1192
1193 #[test]
1194 fn capability_judge_builds_a_structured_request() -> Result<()> {
1195 let judge = CapabilityJudge {
1196 contract: LlmTaskClassifier::load_capability_contract(
1197 &ClassifierContractConfig::default(),
1198 )?,
1199 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1200 recent_turn_window: None,
1201 };
1202 let request = Request {
1203 llm_request: LlmRequest {
1204 model: Some("inbound".to_string()),
1205 messages: vec![
1206 Message::text(Role::System, "client instructions"),
1207 Message::text(Role::Developer, "client developer instructions"),
1208 Message::text(Role::User, "initial task"),
1209 Message::text(Role::Assistant, "old response"),
1210 Message::text(Role::User, "old follow-up"),
1211 Message::text(Role::Assistant, "recent 1"),
1212 Message::text(Role::User, "recent 2"),
1213 Message::text(Role::Assistant, "recent 3"),
1214 Message::text(Role::User, "recent 4"),
1215 Message::text(Role::Assistant, "recent 5"),
1216 ],
1217 ..LlmRequest::default()
1218 },
1219 raw_request: None,
1220 metadata: None,
1221 };
1222 let judge_request = judge.build_request(&State::default(), &request);
1223
1224 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1225 assert_eq!(judge_request.llm_request.messages.len(), 2);
1226 let contents = judge_request
1227 .llm_request
1228 .messages
1229 .iter()
1230 .filter_map(|message| message.text_content("\n"))
1231 .collect::<Vec<_>>();
1232 assert!(contents.contains(&"recent 4".to_string()));
1233 assert!(!contents.contains(&"initial task".to_string()));
1234 assert!(!contents.contains(&"recent 5".to_string()));
1235 assert!(!contents.contains(&"client instructions".to_string()));
1236 assert_eq!(
1237 judge_request.llm_request.output.response_format,
1238 Some(judge.contract.response_format().clone())
1239 );
1240 assert_eq!(
1241 judge_request.llm_request.output.max_output_tokens,
1242 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1243 );
1244 Ok(())
1245 }
1246
1247 fn sample_value(spec: &Value) -> Value {
1248 if let Some(first) = spec
1249 .get("enum")
1250 .and_then(Value::as_array)
1251 .and_then(|values| values.first())
1252 {
1253 return first.clone();
1254 }
1255 match spec.get("type").and_then(Value::as_str) {
1256 Some("number") => serde_json::json!(0.5),
1257 Some("boolean") => serde_json::json!(false),
1258 _ => serde_json::json!("sample"),
1259 }
1260 }
1261
1262 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1263 let properties = schema
1264 .pointer("/json_schema/schema/properties")
1265 .and_then(Value::as_object)
1266 .ok_or_else(|| LibsyError::AlgorithmError {
1267 message: "packaged schema declares no properties".to_string(),
1268 })?;
1269 Ok(Value::Object(
1270 properties
1271 .iter()
1272 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1273 .collect(),
1274 )
1275 .to_string())
1276 }
1277
1278 #[test]
1281 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1282 let contract =
1283 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1284 let schema = contract.response_format();
1285 let reply = schema_shaped_verdict(schema)?;
1286 let judge = CapabilityJudge {
1287 contract,
1288 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1289 recent_turn_window: None,
1290 };
1291
1292 let verdict = judge.parse(&text_response(None, reply))?;
1293
1294 assert!(verdict.is_valid());
1295 assert!(!verdict.abstain);
1296 Ok(())
1297 }
1298
1299 #[test]
1300 fn prompt_includes_concrete_rules_and_schema() -> Result<()> {
1301 let contract =
1302 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1303 let prompt = contract.system_prompt();
1304 assert!(prompt.contains("SUP-1 [supported]"));
1305 assert!(!prompt.contains("{{CAPABILITY_RULES}}"));
1306 assert!(!prompt.contains("{{PRIMARY_RULE_VALUES}}"));
1307 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1308 assert!(prompt.contains("\"type\": \"object\""));
1309 assert!(!prompt.contains("\"json_schema\""));
1310 assert!(!prompt.contains("\"CapabilityClassifierDecision\""));
1311 let rule_values = contract
1312 .response_format()
1313 .pointer("/json_schema/schema/properties/primary_rule/enum")
1314 .and_then(Value::as_array)
1315 .ok_or_else(|| LibsyError::AlgorithmError {
1316 message: "rendered response schema has no primary rule enum".to_string(),
1317 })?;
1318 assert!(
1319 rule_values
1320 .iter()
1321 .any(|value| value.as_str() == Some("SUP-1"))
1322 );
1323 assert!(
1324 rule_values
1325 .iter()
1326 .any(|value| value.as_str() == Some("none"))
1327 );
1328 Ok(())
1329 }
1330
1331 use std::collections::VecDeque;
1334
1335 use switchyard_protocol::LlmClientError as ClientError;
1336
1337 use switchyard_protocol::Decision;
1338
1339 struct QueuedClient {
1341 replies: Mutex<VecDeque<String>>,
1342 }
1343
1344 impl QueuedClient {
1345 fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1346 Arc::new(Self {
1347 replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1348 })
1349 }
1350 }
1351
1352 #[async_trait]
1353 impl RoutedLlmClient for QueuedClient {
1354 async fn call(
1355 &self,
1356 _ctx: Context,
1357 request: Request,
1358 _decision: Arc<dyn Decision>,
1359 ) -> std::result::Result<Response, ClientError> {
1360 let reply = self
1361 .replies
1362 .lock()
1363 .pop_front()
1364 .unwrap_or_else(|| "unexpected call".to_string());
1365 Ok(Response {
1366 llm_response: LlmResponse::Agg(text_response(None, reply)),
1367 metadata: request.metadata,
1368 })
1369 }
1370 }
1371
1372 fn escalation_router(
1374 client: Arc<dyn RoutedLlmClient>,
1375 judge_client: Arc<dyn RoutedLlmClient>,
1376 ) -> Result<Arc<LlmTaskClassifier>> {
1377 let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1378 semantic_name: name.to_string(),
1379 llm_client: Some(c),
1380 };
1381 Ok(Arc::new(LlmTaskClassifier::new_with_escalation(
1382 target("judge", judge_client),
1383 target("efficient", client.clone()),
1384 target("capable", client),
1385 EscalationJudgeConfig {
1386 confirmations: 1,
1387 ..EscalationJudgeConfig::default()
1388 },
1389 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1390 )?))
1391 }
1392
1393 #[tokio::test]
1394 async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1395 let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1397 let model_client = QueuedClient::new(["efficient answer"]);
1398 let router = escalation_router(model_client, judge_client)?;
1399 let request = classify_request();
1400
1401 let (trace, response) = router.run(Context::default(), request).await?;
1402
1403 assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1405 assert_eq!(
1406 response.llm_response.as_agg().map(completion_text),
1407 Some("efficient answer".to_string())
1408 );
1409 Ok(())
1410 }
1411
1412 #[tokio::test]
1413 async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1414 let client = Arc::new(PerRequestClient::default());
1415 let target = |name: &str| LlmTarget {
1416 semantic_name: name.to_string(),
1417 llm_client: Some(client.clone()),
1418 };
1419 let router = Arc::new(LlmTaskClassifier::new_with_escalation_contract(
1420 target("judge"),
1421 target("efficient"),
1422 target("capable"),
1423 ClassifierContractConfig::default()
1424 .with_prompt("Custom trajectory rubric:\n{{RESPONSE_SCHEMA}}"),
1425 EscalationJudgeConfig {
1426 confirmations: 1,
1427 ..EscalationJudgeConfig::default()
1428 },
1429 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1430 )?);
1431
1432 router.run(Context::default(), classify_request()).await?;
1433
1434 let prompts = client.judge_system_prompts();
1435 assert_eq!(prompts.len(), 1);
1436 assert!(prompts[0].starts_with("Custom trajectory rubric:"));
1437 assert!(prompts[0].contains("\"escalate\""));
1438 assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
1439 Ok(())
1440 }
1441
1442 #[tokio::test]
1443 async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1444 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1446 let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1448 let router = escalation_router(model_client, judge_client)?;
1449 let request = classify_request();
1450
1451 let (trace, response) = router.run(Context::default(), request).await?;
1452
1453 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1454 assert_eq!(
1455 response.llm_response.as_agg().map(completion_text),
1456 Some("capable answer".to_string())
1457 );
1458 Ok(())
1459 }
1460
1461 #[tokio::test]
1462 async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1463 let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1466 let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1467 let router = escalation_router(model_client, judge_client)?;
1468
1469 let session_request = classify_session_request();
1470 router
1471 .clone()
1472 .run(Context::default(), session_request.clone())
1473 .await?;
1474 let (trace, _) = router
1475 .clone()
1476 .run(Context::default(), session_request)
1477 .await?;
1478
1479 assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1480 Ok(())
1481 }
1482}