1use std::collections::{BTreeMap, BTreeSet, HashSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{ContentBlock, Message, ModelId, Role};
13
14use super::escalation;
15use super::fall_through::{DefaultTarget, FallThrough};
16use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
17use super::util::affinity::{AffinityRouter, ClassifyTrigger};
18use super::util::classifier_contract::{
19 ClassifierContract, ClassifierContractConfig, ClassifierResponseFormat,
20};
21use super::util::escalation::EscalationJudgeConfig;
22use super::util::llm_judge::{
23 ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
24 SerdeDecoder, StructuredJudge,
25};
26use super::util::target_selector::TargetSelectorPolicy;
27use crate::core::algorithm::{self, Algorithm, Driver};
28use crate::core::classifier::{Classification, Classifier, Score};
29use crate::core::state::State;
30use crate::{LibsyError, Result};
31use switchyard_protocol::{Request, Response};
32
33const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
34const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
35const ALGORITHM_NAME: &str = "llm_task_classifier";
37
38const TRAILING_ROUTING_INSTRUCTION: &str =
47 "Route the conversation above. Output ONLY the routing JSON object, nothing else.";
48
49#[derive(Deserialize)]
50#[serde(deny_unknown_fields)]
51struct TaskClassifierVerdict {
52 crux: String,
53 primary_rule: String,
54 capability_boundary: String,
55 p_solve: f64,
56}
57
58impl TaskClassifierVerdict {
59 fn is_valid(&self) -> bool {
61 (0.0..=1.0).contains(&self.p_solve)
62 && !self.crux.trim().is_empty()
63 && matches!(
64 (
65 self.primary_rule.as_str(),
66 self.capability_boundary.as_str()
67 ),
68 ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
69 | ("UNC-1" | "UNC-2", "uncertain")
70 | ("LIM-1" | "LIM-2", "unsupported")
71 | ("none", "unmatched")
72 )
73 }
74
75 fn boundary_steps(&self) -> Option<u8> {
77 match self.capability_boundary.as_str() {
78 "supported" => Some(0),
79 "uncertain" | "unmatched" => Some(1),
80 "unsupported" => Some(2),
81 _ => None,
82 }
83 }
84}
85
86fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
96 let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
97 let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
98 let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
99 return kept.into_iter().cloned().collect();
100 };
101 kept.push(&messages[task]);
102
103 let tail: Vec<&Message> = messages[task + 1..]
104 .iter()
105 .filter(|m| !is_instruction(m))
106 .collect();
107 kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
108 kept.into_iter().cloned().collect()
109}
110
111fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
124 let counted = tail.len().saturating_sub(recent_turn_window);
125 if counted == tail.len() {
127 return counted;
128 }
129 let mut unpaired: HashSet<&str> = HashSet::new();
130 for (start, message) in tail.iter().enumerate().rev() {
131 for block in message.content.iter().rev() {
134 match block {
135 ContentBlock::ToolResult(result) => {
136 unpaired.insert(result.tool_call_id.as_str());
137 }
138 ContentBlock::ToolCall(call) => {
139 unpaired.remove(call.id.as_str());
140 }
141 _ => {}
142 }
143 }
144 if start <= counted && unpaired.is_empty() {
145 return start;
146 }
147 }
148 counted
149}
150
151fn task_messages(messages: &[Message]) -> Vec<Message> {
153 let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
154 let Some(opening_task) = user_messages.next() else {
155 return Vec::new();
156 };
157 match user_messages.next_back() {
158 Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
159 None => vec![opening_task.clone()],
160 }
161}
162
163struct TaskInput {
165 recent_turn_window: Option<usize>,
166}
167
168impl ClassifierInput for TaskInput {
169 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
170 let mut messages = match self.recent_turn_window {
173 Some(window) => trim_messages(&request.llm_request.messages, window),
174 None => task_messages(&request.llm_request.messages),
175 };
176 if self.recent_turn_window.is_some() {
180 messages.push(Message::text(
181 Role::User,
182 TRAILING_ROUTING_INSTRUCTION.to_string(),
183 ));
184 }
185 messages
186 }
187}
188
189type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
190
191struct TaskClassifierPolicy {
192 efficient_target: ModelId,
193 capable_target: ModelId,
194 base_threshold: f64,
195 threshold_step: f64,
196}
197
198impl TaskClassifierPolicy {
199 fn new(
200 efficient_target: impl Into<ModelId>,
201 capable_target: impl Into<ModelId>,
202 config: &TaskClassifierConfig,
203 ) -> Self {
204 Self {
205 efficient_target: efficient_target.into(),
206 capable_target: capable_target.into(),
207 base_threshold: config.base_threshold,
208 threshold_step: config.threshold_step,
209 }
210 }
211
212 fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
214 Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
215 }
216}
217
218impl JudgePolicy for TaskClassifierPolicy {
219 type Verdict = TaskClassifierVerdict;
220
221 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
222 let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
225 return Classification::Ambiguous(vec![]);
226 };
227 let Some(threshold) = self.threshold(verdict) else {
230 return Classification::Ambiguous(vec![]);
231 };
232 let target = if verdict.p_solve >= threshold
233 || (threshold - verdict.p_solve).abs() <= f64::EPSILON
234 {
235 &self.efficient_target
236 } else {
237 &self.capable_target
238 };
239 Classification::Scores(vec![Score {
240 target: target.clone(),
241 confidence: 1.0,
242 }])
243 }
244}
245
246#[derive(Clone, Debug)]
247pub struct TaskClassifierConfig {
249 pub base_threshold: f64,
251 pub threshold_step: f64,
256 pub classify_trigger: ClassifyTrigger,
258 pub message_hash_fallback: bool,
260 pub recent_turn_window: Option<usize>,
267 pub contract: ClassifierContractConfig,
269 pub max_output_tokens: u64,
271}
272
273#[derive(Deserialize)]
275#[serde(deny_unknown_fields)]
276struct TaskClassifierConfigWire {
277 base_threshold: f64,
278 #[serde(default)]
279 threshold_step: f64,
280 #[serde(default)]
281 classify_trigger: ClassifyTrigger,
282 #[serde(default)]
283 message_hash_fallback: bool,
284 #[serde(default)]
285 recent_turn_window: Option<usize>,
286 #[serde(default)]
287 prompt: Option<String>,
288 #[serde(default)]
289 response_format_type: ClassifierResponseFormat,
290 #[serde(default = "default_judge_max_output_tokens")]
291 max_output_tokens: u64,
292}
293
294impl<'de> Deserialize<'de> for TaskClassifierConfig {
295 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
296 where
297 D: Deserializer<'de>,
298 {
299 let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
300 let mut contract = ClassifierContractConfig::default();
301 if let Some(prompt) = wire.prompt {
302 contract = contract.with_prompt(prompt);
303 }
304 contract = contract.with_response_format_type(wire.response_format_type);
305 Ok(Self {
306 base_threshold: wire.base_threshold,
307 threshold_step: wire.threshold_step,
308 classify_trigger: wire.classify_trigger,
309 message_hash_fallback: wire.message_hash_fallback,
310 recent_turn_window: wire.recent_turn_window,
311 contract,
312 max_output_tokens: wire.max_output_tokens,
313 })
314 }
315}
316
317const fn default_judge_max_output_tokens() -> u64 {
318 DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
319}
320
321impl Default for TaskClassifierConfig {
322 fn default() -> Self {
323 Self {
324 base_threshold: 0.0,
325 threshold_step: 0.0,
326 classify_trigger: ClassifyTrigger::default(),
327 message_hash_fallback: false,
328 recent_turn_window: None,
329 contract: ClassifierContractConfig::default(),
330 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
331 }
332 }
333}
334
335impl TaskClassifierConfig {
336 fn validate(&self) -> Result<()> {
338 if !(0.0..=1.0).contains(&self.base_threshold) {
339 return Err(LibsyError::AlgorithmError {
340 message: format!(
341 "base_threshold must be between 0 and 1, got {}",
342 self.base_threshold
343 ),
344 });
345 }
346 if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
347 return Err(LibsyError::AlgorithmError {
348 message: format!(
349 "threshold_step must be finite and greater than or equal to 0, got {}",
350 self.threshold_step
351 ),
352 });
353 }
354 let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
355 if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
356 return Err(LibsyError::AlgorithmError {
357 message: format!(
358 "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
359 ),
360 });
361 }
362 if self.max_output_tokens == 0 {
363 return Err(LibsyError::AlgorithmError {
364 message: "max_output_tokens must be at least 1".to_string(),
365 });
366 }
367 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
368 return Err(LibsyError::AlgorithmError {
369 message: "message_hash_fallback requires classify_trigger = new_session"
370 .to_string(),
371 });
372 }
373 Ok(())
374 }
375}
376
377#[derive(Clone, Debug)]
379pub enum CustomClassifierPolicy {
380 TargetSelector {
382 selector: String,
384 },
385}
386
387impl CustomClassifierPolicy {
388 pub fn target_selector(selector: impl Into<String>) -> Self {
390 Self::TargetSelector {
391 selector: selector.into(),
392 }
393 }
394}
395
396#[derive(Clone, Debug)]
398pub struct CustomClassifierConfig {
399 pub prompt: String,
401 pub response_schema: Value,
403 pub policy: CustomClassifierPolicy,
405 pub classify_trigger: ClassifyTrigger,
407 pub message_hash_fallback: bool,
409 pub recent_turn_window: Option<usize>,
411 pub max_output_tokens: u64,
413}
414
415impl CustomClassifierConfig {
416 pub fn new(
418 prompt: impl Into<String>,
419 response_schema: Value,
420 policy: CustomClassifierPolicy,
421 ) -> Self {
422 Self {
423 prompt: prompt.into(),
424 response_schema,
425 policy,
426 classify_trigger: ClassifyTrigger::default(),
427 message_hash_fallback: false,
428 recent_turn_window: None,
429 max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
430 }
431 }
432
433 fn validate(&self) -> Result<()> {
434 if self.max_output_tokens == 0 {
435 return Err(LibsyError::AlgorithmError {
436 message: "max_output_tokens must be at least 1".to_string(),
437 });
438 }
439 if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
440 return Err(LibsyError::AlgorithmError {
441 message: "message_hash_fallback requires classify_trigger = new_session"
442 .to_string(),
443 });
444 }
445 Ok(())
446 }
447}
448
449enum CustomPolicyRuntime {
450 TargetSelector(TargetSelectorPolicy),
451}
452
453impl JudgePolicy for CustomPolicyRuntime {
454 type Verdict = Value;
455
456 fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
457 match self {
458 Self::TargetSelector(policy) => policy.to_classification(verdict),
459 }
460 }
461}
462
463struct TaskClassifier {
464 classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
465 capable_target: ModelId,
466}
467
468fn affinity_router(
470 trigger: ClassifyTrigger,
471 message_hash_fallback: bool,
472) -> Option<Arc<AffinityRouter>> {
473 let router = match trigger {
474 ClassifyTrigger::EveryRequest => return None,
475 ClassifyTrigger::NewSession => AffinityRouter::new(),
476 ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
477 };
478 let router = if message_hash_fallback {
479 router.with_message_hash_fallback()
480 } else {
481 router
482 };
483 Some(Arc::new(router))
484}
485
486pub struct LlmTaskClassifier {
488 route: FallThrough<State>,
489 inner: Arc<dyn Classifier<State>>,
491}
492
493struct ClassifierRouteConfig {
494 default_target: ModelId,
495 classify_trigger: ClassifyTrigger,
496 message_hash_fallback: bool,
497}
498
499#[derive(Clone)]
501#[non_exhaustive]
502pub enum LlmClassifierConfig {
503 Capability {
505 judge_target: ModelId,
507 efficient_target: ModelId,
509 capable_target: ModelId,
511 config: TaskClassifierConfig,
513 },
514 Escalation {
516 judge_target: ModelId,
518 efficient_target: ModelId,
520 capable_target: ModelId,
522 contract: ClassifierContractConfig,
524 config: EscalationJudgeConfig,
526 max_output_tokens: u64,
528 },
529 Custom {
531 judge_target: ModelId,
533 targets: Vec<(String, ModelId)>,
535 default_target: String,
537 config: CustomClassifierConfig,
539 },
540}
541
542impl LlmTaskClassifier {
543 pub fn new(config: LlmClassifierConfig) -> Result<Self> {
550 match config {
551 LlmClassifierConfig::Capability {
552 judge_target,
553 efficient_target,
554 capable_target,
555 config,
556 } => Self::build_capability(judge_target, efficient_target, capable_target, config),
557 LlmClassifierConfig::Escalation {
558 judge_target,
559 efficient_target,
560 capable_target,
561 contract,
562 config,
563 max_output_tokens,
564 } => Self::build_escalation(
565 judge_target,
566 efficient_target,
567 capable_target,
568 contract,
569 config,
570 max_output_tokens,
571 ),
572 LlmClassifierConfig::Custom {
573 judge_target,
574 targets,
575 default_target,
576 config,
577 } => Self::build_custom(judge_target, targets, default_target, config),
578 }
579 }
580
581 fn build_capability(
582 judge_target: ModelId,
583 efficient_target: ModelId,
584 capable_target: ModelId,
585 config: TaskClassifierConfig,
586 ) -> Result<Self> {
587 config.validate()?;
588 let contract = Self::load_capability_contract(&config.contract)?;
589 let targets = vec![efficient_target.clone(), capable_target.clone()];
590 let classify_trigger = config.classify_trigger;
591 let message_hash_fallback = config.message_hash_fallback;
592 let classifier = Arc::new(TaskClassifier {
593 classifier: JudgeClassifier::new(
594 StructuredJudge::new(
595 TaskInput {
596 recent_turn_window: config.recent_turn_window,
597 },
598 contract,
599 SerdeDecoder::new(),
600 JudgeRuntimeConfig::new(config.max_output_tokens)?,
601 ),
602 judge_target.clone(),
603 TaskClassifierPolicy::new(
604 efficient_target.clone(),
605 capable_target.clone(),
606 &config,
607 ),
608 ),
609 capable_target: capable_target.clone(),
610 });
611 let inner: Arc<dyn Classifier<State>> = classifier.clone();
612 Self::from_classifier(
613 targets,
614 inner,
615 ClassifierRouteConfig {
616 default_target: classifier.capable_target.clone(),
617 classify_trigger,
618 message_hash_fallback,
619 },
620 )
621 }
622
623 fn build_custom(
624 judge_target: ModelId,
625 targets: Vec<(String, ModelId)>,
626 default_target: String,
627 config: CustomClassifierConfig,
628 ) -> Result<Self> {
629 config.validate()?;
630 if targets.len() < 2 {
631 return Err(LibsyError::AlgorithmError {
632 message: "custom classifier requires at least two targets".to_string(),
633 });
634 }
635
636 let mut labels = BTreeSet::new();
637 let mut resolved_names = BTreeSet::new();
638 let mut target_map = BTreeMap::new();
639 let mut resolved_targets = Vec::with_capacity(targets.len());
640 for (label, target) in targets {
641 if label.trim().is_empty() || label.trim() != label {
642 return Err(LibsyError::AlgorithmError {
643 message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
644 .to_string(),
645 });
646 }
647 if !labels.insert(label.clone()) {
648 return Err(LibsyError::AlgorithmError {
649 message: format!("custom classifier target label {label:?} is duplicated"),
650 });
651 }
652 if !resolved_names.insert(target.clone()) {
653 return Err(LibsyError::AlgorithmError {
654 message: format!("custom classifier resolved target {target:?} is duplicated"),
655 });
656 }
657 target_map.insert(label, target.clone());
658 resolved_targets.push(target);
659 }
660 let default_name =
661 target_map
662 .get(&default_target)
663 .cloned()
664 .ok_or_else(|| LibsyError::AlgorithmError {
665 message: format!(
666 "default_target {default_target:?} must be one of the configured targets"
667 ),
668 })?;
669
670 let CustomClassifierConfig {
671 prompt,
672 response_schema,
673 policy,
674 classify_trigger,
675 message_hash_fallback,
676 recent_turn_window,
677 max_output_tokens,
678 } = config;
679 let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
680 let policy = match policy {
681 CustomClassifierPolicy::TargetSelector { selector } => {
682 CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
683 selector, target_map,
684 )?)
685 }
686 };
687 let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
688 StructuredJudge::new(
689 TaskInput { recent_turn_window },
690 contract,
691 JsonSchemaDecoder::new(),
692 JudgeRuntimeConfig::new(max_output_tokens)?,
693 ),
694 judge_target,
695 policy,
696 ));
697
698 Self::from_classifier(
699 resolved_targets,
700 classifier,
701 ClassifierRouteConfig {
702 default_target: default_name,
703 classify_trigger,
704 message_hash_fallback,
705 },
706 )
707 }
708
709 fn build_escalation(
710 judge_target: ModelId,
711 efficient_target: ModelId,
712 capable_target: ModelId,
713 contract_config: ClassifierContractConfig,
714 config: EscalationJudgeConfig,
715 max_output_tokens: u64,
716 ) -> Result<Self> {
717 let inner = escalation::build_classifier(
718 judge_target,
719 &efficient_target,
720 &capable_target,
721 contract_config,
722 config,
723 max_output_tokens,
724 )?;
725 let targets = vec![capable_target, efficient_target];
726 Ok(Self {
727 route: FallThrough::<State>::new_with_state(targets)
728 .with_name(ALGORITHM_NAME)
729 .with_classifier(Arc::clone(&inner)),
730 inner,
731 })
732 }
733
734 fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
736 ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
737 }
738
739 fn from_classifier(
741 targets: Vec<ModelId>,
742 inner: Arc<dyn Classifier<State>>,
743 config: ClassifierRouteConfig,
744 ) -> Result<Self> {
745 algorithm::ensure_model_is_target(&targets, &config.default_target)?;
746 if config.message_hash_fallback && config.classify_trigger != ClassifyTrigger::NewSession {
747 return Err(LibsyError::AlgorithmError {
748 message: "message_hash_fallback requires classify_trigger = new_session"
749 .to_string(),
750 });
751 }
752 let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
754 if let Some(affinity) =
755 affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
756 {
757 route = route
759 .with_processor(affinity.clone())
760 .with_classifier(affinity.clone());
761 }
762 let fallback = DefaultTarget::new(config.default_target);
763 Ok(Self {
764 route: route
765 .with_classifier(inner.clone())
766 .with_classifier(Arc::new(fallback)),
767 inner,
768 })
769 }
770}
771
772#[async_trait]
773impl Classifier<State> for TaskClassifier {
774 async fn score(
775 &self,
776 state: &mut State,
777 request: &mut Request,
778 driver: Option<&Driver>,
779 ) -> Result<(Classification, Option<Response>)> {
780 self.classifier.score(state, request, driver).await
781 }
782}
783
784#[async_trait]
785impl Classifier<State> for LlmTaskClassifier {
786 async fn score(
787 &self,
788 state: &mut State,
789 request: &mut Request,
790 driver: Option<&Driver>,
791 ) -> Result<(Classification, Option<Response>)> {
792 self.inner.score(state, request, driver).await
793 }
794}
795
796#[async_trait]
797impl Algorithm for LlmTaskClassifier {
798 fn name(&self) -> &str {
799 "llm_task_classifier"
800 }
801
802 async fn route(
803 self: Arc<Self>,
804 driver: Driver,
805 request: Request,
806 ) -> Result<crate::RoutingOutcome> {
807 self.route.execute(driver, request).await
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use std::sync::Arc;
814
815 use parking_lot::Mutex;
816 use serde_json::Value;
817
818 use super::*;
819 use switchyard_protocol::{
820 ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
821 completion_text, text_request, text_response,
822 };
823
824 use crate::algorithms::util::llm_judge::Judge;
825 use crate::core::testing::{Serve, test_drive};
826 use switchyard_protocol::{LlmResponse, Response};
827
828 const TEST_THRESHOLD: f64 = 0.5;
829
830 fn test_config(base_threshold: f64) -> TaskClassifierConfig {
831 TaskClassifierConfig {
832 base_threshold,
833 ..TaskClassifierConfig::default()
834 }
835 }
836
837 fn policy() -> TaskClassifierPolicy {
838 TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
839 }
840
841 fn verdict(
842 p_solve: f64,
843 capability_boundary: &str,
844 primary_rule: &str,
845 ) -> TaskClassifierVerdict {
846 TaskClassifierVerdict {
847 crux: "test crux".to_string(),
848 primary_rule: primary_rule.to_string(),
849 capability_boundary: capability_boundary.to_string(),
850 p_solve,
851 }
852 }
853
854 fn selected(
855 policy: &TaskClassifierPolicy,
856 verdict: Option<&TaskClassifierVerdict>,
857 ) -> Result<ModelId> {
858 policy
859 .to_classification(verdict)
860 .argmax(false)?
861 .map(|score| score.target)
862 .ok_or_else(|| LibsyError::AlgorithmError {
863 message: "policy abstained".to_string(),
864 })
865 }
866
867 #[derive(Default)]
870 struct Recorder {
871 calls: Mutex<Vec<String>>,
872 call_roles: Mutex<Vec<(String, bool)>>,
873 judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
874 judge_system_prompts: Mutex<Vec<String>>,
875 }
876
877 impl Recorder {
878 fn calls(&self) -> Vec<String> {
879 self.calls.lock().clone()
880 }
881
882 fn call_roles(&self) -> Vec<(String, bool)> {
883 self.call_roles.lock().clone()
884 }
885
886 fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
887 self.judge_max_output_tokens.lock().clone()
888 }
889
890 fn judge_system_prompts(&self) -> Vec<String> {
891 self.judge_system_prompts.lock().clone()
892 }
893
894 fn serve(self: &Arc<Self>) -> impl Serve {
895 let recorder = Arc::clone(self);
896 move |model: ModelId, request: Request| {
897 let recorder = Arc::clone(&recorder);
898 async move {
899 let model = model.to_string();
900 recorder.calls.lock().push(model.clone());
901 recorder
902 .call_roles
903 .lock()
904 .push((model.clone(), model != "judge"));
905 let completion = if model == "judge" {
906 recorder
907 .judge_max_output_tokens
908 .lock()
909 .push(request.llm_request.output.max_output_tokens);
910 recorder.judge_system_prompts.lock().extend(
911 request
912 .llm_request
913 .instructions
914 .first()
915 .and_then(|instruction| {
916 instruction.content.iter().find_map(|b| {
917 if let ContentBlock::Text { text } = b {
918 Some(text.clone())
919 } else {
920 None
921 }
922 })
923 }),
924 );
925 r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
926 } else {
927 format!("answer from {model}")
928 };
929 Ok(Response {
930 llm_response: LlmResponse::Agg(text_response(None, completion)),
931 metadata: request.metadata,
932 })
933 }
934 }
935 }
936 }
937
938 fn unreachable_judge() -> impl Serve {
940 |model: ModelId, request: Request| async move {
941 let model = model.to_string();
942 if model == "judge" {
943 return Err(LlmClientError::Timeout {
944 source: Box::new(std::io::Error::other("judge unreachable")),
945 });
946 }
947 Ok(Response {
948 llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
949 metadata: request.metadata,
950 })
951 }
952 }
953
954 fn router() -> Result<Arc<LlmTaskClassifier>> {
955 Ok(Arc::new(LlmTaskClassifier::new(
956 LlmClassifierConfig::Capability {
957 judge_target: ModelId::from("judge"),
958 efficient_target: ModelId::from("efficient"),
959 capable_target: ModelId::from("capable"),
960 config: test_config(TEST_THRESHOLD),
961 },
962 )?))
963 }
964
965 fn classify_request() -> Request {
966 Request {
967 llm_request: text_request(Some("auto".to_string()), "classify this task"),
968 raw_request: None,
969 metadata: None,
970 }
971 }
972
973 fn classify_session_request() -> Request {
974 Request {
975 metadata: Some(Metadata {
976 session_id: Some("session-1".to_string()),
977 ..Metadata::default()
978 }),
979 ..classify_request()
980 }
981 }
982
983 fn classify_follow_up_request() -> Request {
984 let mut request = classify_request();
985 request
986 .llm_request
987 .messages
988 .push(Message::text(Role::Assistant, "I will add the test."));
989 request.llm_request.messages.push(Message::text(
990 Role::User,
991 "Now run the test suite and report the result.",
992 ));
993 request
994 }
995
996 #[tokio::test]
997 async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
998 let router = router()?;
999
1000 let (selected_model, response) =
1001 test_drive(router, classify_request(), unreachable_judge()).await?;
1002
1003 assert_eq!(selected_model, "capable");
1004 assert_eq!(
1005 response.llm_response.as_agg().map(completion_text),
1006 Some("answer from capable".to_string())
1007 );
1008 Ok(())
1009 }
1010
1011 #[tokio::test]
1012 async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1013 let recorder = Arc::new(Recorder::default());
1014 let router = router()?;
1015 let request = classify_request;
1016
1017 test_drive(router.clone(), request(), recorder.serve()).await?;
1018 test_drive(router.clone(), request(), recorder.serve()).await?;
1019
1020 assert_eq!(
1021 recorder.calls(),
1022 vec!["judge", "efficient", "judge", "efficient"]
1023 );
1024 assert_eq!(
1025 recorder.call_roles(),
1026 vec![
1027 ("judge".to_string(), false),
1028 ("efficient".to_string(), true),
1029 ("judge".to_string(), false),
1030 ("efficient".to_string(), true),
1031 ]
1032 );
1033 Ok(())
1034 }
1035
1036 #[tokio::test]
1037 async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1038 let recorder = Arc::new(Recorder::default());
1039 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1040 judge_target: ModelId::from("judge"),
1041 efficient_target: ModelId::from("efficient"),
1042 capable_target: ModelId::from("capable"),
1043 config: TaskClassifierConfig {
1044 max_output_tokens: 512,
1045 ..test_config(TEST_THRESHOLD)
1046 },
1047 })?);
1048
1049 test_drive(router, classify_request(), recorder.serve()).await?;
1050
1051 assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1052 Ok(())
1053 }
1054
1055 #[tokio::test]
1056 async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1057 let recorder = Arc::new(Recorder::default());
1058 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1059 judge_target: ModelId::from("judge"),
1060 efficient_target: ModelId::from("efficient"),
1061 capable_target: ModelId::from("capable"),
1062 config: TaskClassifierConfig {
1063 contract: ClassifierContractConfig::default()
1064 .with_prompt("Custom capability rubric."),
1065 ..test_config(TEST_THRESHOLD)
1066 },
1067 })?);
1068
1069 test_drive(router, classify_request(), recorder.serve()).await?;
1070
1071 let prompts = recorder.judge_system_prompts();
1072 assert_eq!(prompts.len(), 1);
1073 assert_eq!(prompts[0], "Custom capability rubric.");
1074 Ok(())
1075 }
1076
1077 #[tokio::test]
1078 async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1079 let recorder = Arc::new(Recorder::default());
1080 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1081 judge_target: ModelId::from("judge"),
1082 efficient_target: ModelId::from("efficient"),
1083 capable_target: ModelId::from("capable"),
1084 config: TaskClassifierConfig {
1085 classify_trigger: ClassifyTrigger::NewSession,
1086 ..test_config(TEST_THRESHOLD)
1087 },
1088 })?);
1089
1090 let session_request = classify_session_request;
1091 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1092 test_drive(router.clone(), session_request(), recorder.serve()).await?;
1093
1094 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1095 Ok(())
1096 }
1097
1098 #[tokio::test]
1099 async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1100 let recorder = Arc::new(Recorder::default());
1101 let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1102 judge_target: ModelId::from("judge"),
1103 efficient_target: ModelId::from("efficient"),
1104 capable_target: ModelId::from("capable"),
1105 config: TaskClassifierConfig {
1106 classify_trigger: ClassifyTrigger::NewSession,
1107 message_hash_fallback: true,
1108 recent_turn_window: None,
1109 ..test_config(TEST_THRESHOLD)
1110 },
1111 })?);
1112
1113 test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1114 test_drive(
1115 router.clone(),
1116 classify_follow_up_request(),
1117 recorder.serve(),
1118 )
1119 .await?;
1120
1121 assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1122 Ok(())
1123 }
1124
1125 #[test]
1126 fn the_threshold_boundary_is_inclusive() -> Result<()> {
1127 let policy = policy();
1128 let at_threshold = verdict(0.5, "supported", "SUP-1");
1129 let below_threshold = verdict(0.49, "supported", "SUP-1");
1130 assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1131 assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1132 Ok(())
1133 }
1134
1135 #[test]
1136 fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1137 let borderline = verdict(0.5, "supported", "SUP-1");
1138 let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1139 let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1140 assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1141 assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1142 Ok(())
1143 }
1144
1145 #[test]
1146 fn classifier_config_rejects_unknown_fields() {
1147 let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1148 "base_threshold": 0.5,
1149 "classifier_magic": true,
1150 }))
1151 .expect_err("unknown classifier fields must be rejected");
1152
1153 assert!(
1154 error
1155 .to_string()
1156 .contains("unknown field `classifier_magic`"),
1157 "{error}"
1158 );
1159 }
1160
1161 #[test]
1162 fn invalid_classifier_config_is_rejected() -> Result<()> {
1163 for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1164 assert!(
1165 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1166 judge_target: ModelId::from("judge"),
1167 efficient_target: ModelId::from("e"),
1168 capable_target: ModelId::from("c"),
1169 config: test_config(bad),
1170 })
1171 .is_err(),
1172 "base threshold {bad} should be rejected"
1173 );
1174 }
1175 for config in [
1176 TaskClassifierConfig {
1177 base_threshold: 0.5,
1178 threshold_step: -0.1,
1179 ..TaskClassifierConfig::default()
1180 },
1181 TaskClassifierConfig {
1182 base_threshold: 0.8,
1183 threshold_step: 0.11,
1184 ..TaskClassifierConfig::default()
1185 },
1186 TaskClassifierConfig {
1187 base_threshold: 0.5,
1188 message_hash_fallback: true,
1189 ..TaskClassifierConfig::default()
1190 },
1191 TaskClassifierConfig {
1192 base_threshold: 0.5,
1193 max_output_tokens: 0,
1194 ..TaskClassifierConfig::default()
1195 },
1196 ] {
1197 assert!(
1198 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1199 judge_target: ModelId::from("judge"),
1200 efficient_target: ModelId::from("e"),
1201 capable_target: ModelId::from("c"),
1202 config,
1203 })
1204 .is_err()
1205 );
1206 }
1207 for base_threshold in [0.0, 1.0] {
1208 LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1209 judge_target: ModelId::from("judge"),
1210 efficient_target: ModelId::from("e"),
1211 capable_target: ModelId::from("c"),
1212 config: test_config(base_threshold),
1213 })?;
1214 }
1215 Ok(())
1216 }
1217
1218 #[test]
1219 fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1220 let policy = policy();
1221 let inconsistent_rule = TaskClassifierVerdict {
1222 capability_boundary: "uncertain".to_string(),
1223 ..verdict(1.0, "supported", "SUP-1")
1224 };
1225 let empty_crux = TaskClassifierVerdict {
1226 crux: " ".to_string(),
1227 ..verdict(1.0, "supported", "SUP-1")
1228 };
1229 let unusable = [
1230 Some(verdict(1.1, "supported", "SUP-1")),
1231 Some(inconsistent_rule),
1232 Some(empty_crux),
1233 None,
1234 ];
1235 for verdict in unusable {
1236 let classification = policy.to_classification(verdict.as_ref());
1237 assert!(matches!(classification, Classification::Ambiguous(_)));
1238 assert!(classification.argmax(false)?.is_none());
1239 assert!(classification.argmax(true)?.is_none());
1240 }
1241 Ok(())
1242 }
1243
1244 #[test]
1245 fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1246 let policy = TaskClassifierPolicy::new(
1247 "efficient",
1248 "capable",
1249 &TaskClassifierConfig {
1250 threshold_step: 0.1,
1251 ..test_config(0.4)
1252 },
1253 );
1254
1255 assert_eq!(
1256 selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1257 "efficient"
1258 );
1259 assert_eq!(
1260 selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1261 "capable"
1262 );
1263 assert_eq!(
1264 selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1265 "efficient"
1266 );
1267 assert_eq!(
1268 selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1269 "efficient"
1270 );
1271 assert_eq!(
1272 selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1273 "capable"
1274 );
1275 assert_eq!(
1276 selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1277 "efficient"
1278 );
1279 Ok(())
1280 }
1281
1282 fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1285 Ok(StructuredJudge::new(
1286 TaskInput { recent_turn_window },
1287 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1288 SerdeDecoder::new(),
1289 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1290 ))
1291 }
1292
1293 fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1294 let judge = capability_judge(Some(recent_turn_window))?;
1295 let request = Request {
1296 llm_request: LlmRequest {
1297 messages: vec![
1298 Message::text(Role::System, "client instructions"),
1299 Message::text(Role::User, "initial task"),
1300 Message::text(Role::Assistant, "old response"),
1301 Message::text(Role::User, "old follow-up"),
1302 Message::text(Role::Assistant, "recent 1"),
1303 Message::text(Role::User, "recent 2"),
1304 ],
1305 ..LlmRequest::default()
1306 },
1307 raw_request: None,
1308 metadata: None,
1309 };
1310 Ok(judge
1311 .build_request(&State::default(), &request)
1312 .llm_request
1313 .messages
1314 .iter()
1315 .filter_map(|message| message.text_content("\n"))
1316 .collect())
1317 }
1318
1319 #[test]
1320 fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1321 let contents = judged_contents(2)?;
1323 assert!(contents.contains(&"client instructions".to_string()));
1324 assert!(contents.contains(&"initial task".to_string()));
1325 assert!(contents.contains(&"recent 1".to_string()));
1326 assert!(contents.contains(&"recent 2".to_string()));
1327 assert!(!contents.contains(&"old response".to_string()));
1328 Ok(())
1329 }
1330
1331 #[test]
1332 fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1333 let contents = judged_contents(0)?;
1334 assert!(contents.contains(&"client instructions".to_string()));
1335 assert!(contents.contains(&"initial task".to_string()));
1336 assert!(!contents.contains(&"recent 2".to_string()));
1337 Ok(())
1338 }
1339
1340 fn tool_call(id: &str) -> Message {
1341 Message {
1342 role: Role::Assistant,
1343 content: vec![ContentBlock::ToolCall(ToolCall {
1344 id: id.to_string(),
1345 name: "search".to_string(),
1346 arguments: Value::Null,
1347 })],
1348 }
1349 }
1350
1351 fn tool_result(id: &str) -> Message {
1352 Message {
1353 role: Role::Tool,
1354 content: vec![ContentBlock::ToolResult(ToolResult {
1355 tool_call_id: id.to_string(),
1356 content: vec![ContentBlock::Text {
1357 text: "tool output".to_string(),
1358 }],
1359 is_error: None,
1360 })],
1361 }
1362 }
1363
1364 #[test]
1367 fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1368 let messages = vec![
1369 Message::text(Role::System, "client instructions"),
1370 Message::text(Role::User, "initial task"),
1371 Message::text(Role::Assistant, "old response"),
1372 tool_call("call-1"),
1373 tool_result("call-1"),
1374 Message::text(Role::Assistant, "recent 1"),
1375 Message::text(Role::User, "recent 2"),
1376 Message::text(Role::Assistant, "recent 3"),
1377 Message::text(Role::User, "recent 4"),
1378 ];
1379
1380 let kept = trim_messages(&messages, 5);
1382
1383 assert_eq!(
1384 kept,
1385 vec![
1386 Message::text(Role::System, "client instructions"),
1387 Message::text(Role::User, "initial task"),
1388 tool_call("call-1"),
1389 tool_result("call-1"),
1390 Message::text(Role::Assistant, "recent 1"),
1391 Message::text(Role::User, "recent 2"),
1392 Message::text(Role::Assistant, "recent 3"),
1393 Message::text(Role::User, "recent 4"),
1394 ]
1395 );
1396 }
1397
1398 #[test]
1401 fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1402 let messages = vec![
1403 Message::text(Role::System, "client instructions"),
1404 Message::text(Role::User, "initial task"),
1405 tool_call("x"),
1406 tool_result("x"),
1407 Message::text(Role::Assistant, "later"),
1408 tool_call("x"),
1409 tool_result("x"),
1410 ];
1411
1412 let kept = trim_messages(&messages, 4);
1414
1415 assert_eq!(
1416 kept,
1417 vec![
1418 Message::text(Role::System, "client instructions"),
1419 Message::text(Role::User, "initial task"),
1420 tool_call("x"),
1421 tool_result("x"),
1422 Message::text(Role::Assistant, "later"),
1423 tool_call("x"),
1424 tool_result("x"),
1425 ]
1426 );
1427 }
1428
1429 #[test]
1432 fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1433 let messages = vec![
1434 Message::text(Role::System, "client instructions"),
1435 tool_call("orphan"),
1436 Message::text(Role::User, "initial task"),
1437 Message::text(Role::Assistant, "old response"),
1438 tool_result("orphan"),
1439 Message::text(Role::Assistant, "recent 1"),
1440 Message::text(Role::User, "recent 2"),
1441 ];
1442
1443 let kept = trim_messages(&messages, 3);
1444
1445 assert_eq!(
1446 kept,
1447 vec![
1448 Message::text(Role::System, "client instructions"),
1449 Message::text(Role::User, "initial task"),
1450 tool_result("orphan"),
1451 Message::text(Role::Assistant, "recent 1"),
1452 Message::text(Role::User, "recent 2"),
1453 ]
1454 );
1455 }
1456
1457 #[test]
1458 fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1459 let contents = judged_contents(2)?;
1460
1461 assert_eq!(
1464 contents.last().map(String::as_str),
1465 Some(TRAILING_ROUTING_INSTRUCTION)
1466 );
1467 Ok(())
1468 }
1469
1470 #[test]
1471 fn the_default_path_is_left_unchanged() -> Result<()> {
1472 let judge = capability_judge(None)?;
1475 let request = Request {
1476 llm_request: LlmRequest {
1477 messages: vec![Message::text(Role::User, "the task")],
1478 ..LlmRequest::default()
1479 },
1480 raw_request: None,
1481 metadata: None,
1482 };
1483
1484 let built = judge.build_request(&State::default(), &request);
1485
1486 assert_eq!(built.llm_request.messages.len(), 1);
1489 assert!(!built.llm_request.instructions.is_empty());
1490 assert!(
1491 !built
1492 .llm_request
1493 .messages
1494 .iter()
1495 .filter_map(|message| message.text_content("\n"))
1496 .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
1497 );
1498 Ok(())
1499 }
1500
1501 #[test]
1502 fn capability_judge_builds_a_structured_request() -> Result<()> {
1503 let judge = capability_judge(None)?;
1504 let request = Request {
1505 llm_request: LlmRequest {
1506 model: Some("inbound".to_string()),
1507 messages: vec![
1508 Message::text(Role::System, "client instructions"),
1509 Message::text(Role::Developer, "client developer instructions"),
1510 Message::text(Role::User, "initial task"),
1511 Message::text(Role::Assistant, "old response"),
1512 Message::text(Role::User, "old follow-up"),
1513 Message::text(Role::Assistant, "recent 1"),
1514 Message::text(Role::User, "recent 2"),
1515 Message::text(Role::Assistant, "recent 3"),
1516 Message::text(Role::User, "recent 4"),
1517 Message::text(Role::Assistant, "recent 5"),
1518 ],
1519 ..LlmRequest::default()
1520 },
1521 raw_request: None,
1522 metadata: None,
1523 };
1524 let judge_request = judge.build_request(&State::default(), &request);
1525
1526 assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1527 assert_eq!(judge_request.llm_request.instructions.len(), 1);
1528 assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1529 assert_eq!(
1530 judge_request.llm_request.instructions[0].content,
1531 InstructionBlock {
1532 role: Role::System,
1533 content: Message::text(Role::System, judge.contract().system_prompt()).content,
1534 }
1535 .content,
1536 );
1537 assert_eq!(judge_request.llm_request.messages.len(), 2);
1538 let contents = judge_request
1539 .llm_request
1540 .messages
1541 .iter()
1542 .filter_map(|message| message.text_content("\n"))
1543 .collect::<Vec<_>>();
1544 assert!(contents.contains(&"recent 4".to_string()));
1545 assert!(contents.contains(&"initial task".to_string()));
1546 assert!(!contents.contains(&"recent 5".to_string()));
1547 assert!(!contents.contains(&"client instructions".to_string()));
1548 assert_eq!(
1549 judge_request.llm_request.output.response_format,
1550 Some(judge.contract().response_format().clone())
1551 );
1552 assert_eq!(
1553 judge_request.llm_request.output.max_output_tokens,
1554 Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1555 );
1556 Ok(())
1557 }
1558
1559 fn sample_value(spec: &Value) -> Value {
1560 if let Some(first) = spec
1561 .get("enum")
1562 .and_then(Value::as_array)
1563 .and_then(|values| values.first())
1564 {
1565 return first.clone();
1566 }
1567 match spec.get("type").and_then(Value::as_str) {
1568 Some("number") => serde_json::json!(0.5),
1569 Some("boolean") => serde_json::json!(false),
1570 _ => serde_json::json!("sample"),
1571 }
1572 }
1573
1574 fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1575 let properties = schema
1576 .pointer("/json_schema/schema/properties")
1577 .and_then(Value::as_object)
1578 .ok_or_else(|| LibsyError::AlgorithmError {
1579 message: "packaged schema declares no properties".to_string(),
1580 })?;
1581 Ok(Value::Object(
1582 properties
1583 .iter()
1584 .map(|(name, spec)| (name.clone(), sample_value(spec)))
1585 .collect(),
1586 )
1587 .to_string())
1588 }
1589
1590 #[test]
1593 fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1594 let contract =
1595 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1596 let schema = contract.response_format();
1597 let reply = schema_shaped_verdict(schema)?;
1598 let judge: CapabilityJudge = StructuredJudge::new(
1599 TaskInput {
1600 recent_turn_window: None,
1601 },
1602 contract,
1603 SerdeDecoder::new(),
1604 JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1605 );
1606
1607 let verdict = judge.parse(&text_response(None, reply))?;
1608
1609 assert!(verdict.is_valid());
1610 assert!((0.0..=1.0).contains(&verdict.p_solve));
1611 Ok(())
1612 }
1613
1614 #[test]
1615 fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1616 let contract =
1617 LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1618 let prompt = contract.system_prompt();
1619 let schema_name = contract
1620 .response_format()
1621 .pointer("/json_schema/name")
1622 .and_then(Value::as_str)
1623 .ok_or_else(|| LibsyError::AlgorithmError {
1624 message: "packaged response schema has no name".to_string(),
1625 })?;
1626 assert_eq!(schema_name, "CapabilityClassifierDecision");
1627 assert!(prompt.contains("SUP-1 [supported]"));
1628 assert!(prompt.contains("SUP-5 [supported]"));
1629 assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1630 assert!(!prompt.contains("\"type\": \"object\""));
1631 assert!(!prompt.contains("\"json_schema\""));
1632 assert!(!prompt.contains(schema_name));
1633 let rule_values = contract
1634 .response_format()
1635 .pointer("/json_schema/schema/properties/primary_rule/enum")
1636 .and_then(Value::as_array)
1637 .ok_or_else(|| LibsyError::AlgorithmError {
1638 message: "rendered response schema has no primary rule enum".to_string(),
1639 })?;
1640 assert!(
1641 rule_values
1642 .iter()
1643 .any(|value| value.as_str() == Some("SUP-1"))
1644 );
1645 assert!(
1646 rule_values
1647 .iter()
1648 .any(|value| value.as_str() == Some("none"))
1649 );
1650 Ok(())
1651 }
1652}