Skip to main content

switchyard_libsy/algorithms/
llm_class.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Judge-backed capability, escalation, and custom-policy routing.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{Message, Role, SimpleDecision};
13
14use super::fall_through::{DefaultTarget, FallThrough};
15use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
16use super::util::affinity::AffinityRouter;
17use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
18use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
19use super::util::llm_judge::{
20    ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
21    SerdeDecoder, StructuredJudge,
22};
23use super::util::target_selector::TargetSelectorPolicy;
24use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
25use crate::core::classifier::{Classification, Classifier, Score};
26use crate::core::state::{State, StateValue};
27use crate::{LibsyError, Result};
28use switchyard_protocol::{
29    AggLlmResponse, Context, LlmClientError, LlmResponse, Request, Response, RoutedLlmClient,
30};
31
32const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
33const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
34/// Telemetry label for this algorithm's spans, metrics, and logs.
35const ALGORITHM_NAME: &str = "llm_task_classifier";
36
37#[derive(Deserialize)]
38#[serde(deny_unknown_fields)]
39struct TaskClassifierVerdict {
40    crux: String,
41    primary_rule: String,
42    capability_boundary: String,
43    p_solve: f64,
44}
45
46impl TaskClassifierVerdict {
47    /// Rejects malformed or internally inconsistent verdicts before policy evaluation.
48    fn is_valid(&self) -> bool {
49        (0.0..=1.0).contains(&self.p_solve)
50            && !self.crux.trim().is_empty()
51            && matches!(
52                (
53                    self.primary_rule.as_str(),
54                    self.capability_boundary.as_str()
55                ),
56                ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
57                    | ("UNC-1" | "UNC-2", "uncertain")
58                    | ("LIM-1" | "LIM-2", "unsupported")
59                    | ("none", "unmatched")
60            )
61    }
62
63    /// Returns the number of threshold steps assigned to this capability boundary.
64    fn boundary_steps(&self) -> Option<u8> {
65        match self.capability_boundary.as_str() {
66            "supported" => Some(0),
67            "uncertain" | "unmatched" => Some(1),
68            "unsupported" => Some(2),
69            _ => None,
70        }
71    }
72}
73
74/// Keeps the opening task and the last `recent_turn_window` turns after it. A
75/// window of `0` keeps the task alone.
76///
77/// Inbound decoders normalize client system and developer content into
78/// `LlmRequest::instructions`, so it never reaches this list.
79///
80/// Selects by reference and clones only what survives — a coding-agent
81/// conversation carries every tool result, so cloning it whole to keep a window
82/// would copy the transcript on each judged turn.
83fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
84    let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
85    let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
86    let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
87        return kept.into_iter().cloned().collect();
88    };
89    kept.push(&messages[task]);
90
91    let tail: Vec<&Message> = messages[task + 1..]
92        .iter()
93        .filter(|m| !is_instruction(m))
94        .collect();
95    kept.extend(&tail[tail.len().saturating_sub(recent_turn_window)..]);
96    kept.into_iter().cloned().collect()
97}
98
99/// Keeps the opening task and the latest user follow-up when they differ.
100fn task_messages(messages: &[Message]) -> Vec<Message> {
101    let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
102    let Some(opening_task) = user_messages.next() else {
103        return Vec::new();
104    };
105    match user_messages.next_back() {
106        Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
107        None => vec![opening_task.clone()],
108    }
109}
110
111/// Selects the task messages shown to capability and custom-schema classifiers.
112struct TaskInput {
113    recent_turn_window: Option<usize>,
114}
115
116impl ClassifierInput for TaskInput {
117    fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
118        // The default preserves the whole-task anchor and latest user update. A
119        // configured window widens that to the surrounding conversation.
120        match self.recent_turn_window {
121            Some(window) => trim_messages(&request.llm_request.messages, window),
122            None => task_messages(&request.llm_request.messages),
123        }
124    }
125}
126
127type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
128
129struct TaskClassifierPolicy {
130    efficient_target: String,
131    capable_target: String,
132    base_threshold: f64,
133    threshold_step: f64,
134}
135
136impl TaskClassifierPolicy {
137    fn new(
138        efficient_target: impl Into<String>,
139        capable_target: impl Into<String>,
140        config: &TaskClassifierConfig,
141    ) -> Self {
142        Self {
143            efficient_target: efficient_target.into(),
144            capable_target: capable_target.into(),
145            base_threshold: config.base_threshold,
146            threshold_step: config.threshold_step,
147        }
148    }
149
150    /// Returns the required solve probability for one validated verdict.
151    fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
152        Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
153    }
154}
155
156impl JudgePolicy for TaskClassifierPolicy {
157    type Verdict = TaskClassifierVerdict;
158
159    fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
160        // Judge output is untrusted. An absent, invalid, or inconsistent verdict is
161        // ambiguous so the surrounding router applies its configured fallback.
162        let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
163            return Classification::Ambiguous(vec![]);
164        };
165        // A usable verdict below the capability threshold is still a decision: the judge
166        // does not trust the efficient tier with this task.
167        let Some(threshold) = self.threshold(verdict) else {
168            return Classification::Ambiguous(vec![]);
169        };
170        let target = if verdict.p_solve >= threshold
171            || (threshold - verdict.p_solve).abs() <= f64::EPSILON
172        {
173            &self.efficient_target
174        } else {
175            &self.capable_target
176        };
177        Classification::Scores(vec![Score {
178            target: target.clone(),
179            confidence: 1.0,
180        }])
181    }
182}
183
184#[derive(Clone, Debug)]
185/// Settings that control capability classifier prompting and routing.
186pub struct TaskClassifierConfig {
187    /// Lowest solve probability that routes a supported task to the efficient target.
188    pub base_threshold: f64,
189    /// Amount added per capability-boundary step.
190    ///
191    /// Supported verdicts use `base_threshold`, uncertain and unmatched verdicts use one
192    /// step, and unsupported verdicts use two steps.
193    pub threshold_step: f64,
194    /// Enables session affinity before the judge-backed classifier.
195    pub session_affinity: bool,
196    /// Uses the first user message as the SessionKey for sticky routing when session metadata is unavailable.
197    pub message_hash_fallback: bool,
198    /// Trailing conversation turns the judge sees on top of the client
199    /// instructions and the opening task.
200    ///
201    /// `None` (the default) judges the opening task and latest user follow-up.
202    /// `Some(n)` widens that to the client instructions, the opening task, and
203    /// the last `n` turns after it.
204    pub recent_turn_window: Option<usize>,
205    /// Prompt and verdict contract settings for the classifier judge.
206    pub contract: ClassifierContractConfig,
207    /// Maximum completion tokens available to the classifier verdict.
208    pub max_output_tokens: u64,
209}
210
211/// Flat serialized shape that maps prompt settings into the runtime contract.
212#[derive(Deserialize)]
213#[serde(deny_unknown_fields)]
214struct TaskClassifierConfigWire {
215    base_threshold: f64,
216    #[serde(default)]
217    threshold_step: f64,
218    #[serde(default)]
219    session_affinity: bool,
220    #[serde(default)]
221    message_hash_fallback: bool,
222    #[serde(default)]
223    recent_turn_window: Option<usize>,
224    #[serde(default)]
225    prompt: Option<String>,
226    #[serde(default = "default_judge_max_output_tokens")]
227    max_output_tokens: u64,
228}
229
230impl<'de> Deserialize<'de> for TaskClassifierConfig {
231    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
232    where
233        D: Deserializer<'de>,
234    {
235        let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
236        let mut contract = ClassifierContractConfig::default();
237        if let Some(prompt) = wire.prompt {
238            contract = contract.with_prompt(prompt);
239        }
240        Ok(Self {
241            base_threshold: wire.base_threshold,
242            threshold_step: wire.threshold_step,
243            session_affinity: wire.session_affinity,
244            message_hash_fallback: wire.message_hash_fallback,
245            recent_turn_window: wire.recent_turn_window,
246            contract,
247            max_output_tokens: wire.max_output_tokens,
248        })
249    }
250}
251
252const fn default_judge_max_output_tokens() -> u64 {
253    DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
254}
255
256impl Default for TaskClassifierConfig {
257    fn default() -> Self {
258        Self {
259            base_threshold: 0.0,
260            threshold_step: 0.0,
261            session_affinity: false,
262            message_hash_fallback: false,
263            recent_turn_window: None,
264            contract: ClassifierContractConfig::default(),
265            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
266        }
267    }
268}
269
270impl TaskClassifierConfig {
271    /// Validates routing thresholds before the classifier is constructed.
272    fn validate(&self) -> Result<()> {
273        if !(0.0..=1.0).contains(&self.base_threshold) {
274            return Err(LibsyError::AlgorithmError {
275                message: format!(
276                    "base_threshold must be between 0 and 1, got {}",
277                    self.base_threshold
278                ),
279            });
280        }
281        if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
282            return Err(LibsyError::AlgorithmError {
283                message: format!(
284                    "threshold_step must be finite and greater than or equal to 0, got {}",
285                    self.threshold_step
286                ),
287            });
288        }
289        let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
290        if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
291            return Err(LibsyError::AlgorithmError {
292                message: format!(
293                    "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
294                ),
295            });
296        }
297        if self.max_output_tokens == 0 {
298            return Err(LibsyError::AlgorithmError {
299                message: "max_output_tokens must be at least 1".to_string(),
300            });
301        }
302        if self.message_hash_fallback && !self.session_affinity {
303            return Err(LibsyError::AlgorithmError {
304                message: "message_hash_fallback requires session_affinity".to_string(),
305            });
306        }
307        Ok(())
308    }
309}
310
311/// Policy that maps a custom classifier verdict to a routing target.
312#[derive(Clone, Debug)]
313pub enum CustomClassifierPolicy {
314    /// Resolves a JSON Pointer and treats its string value as a configured target label.
315    TargetSelector {
316        /// JSON Pointer evaluated against each schema-validated verdict.
317        selector: String,
318    },
319}
320
321impl CustomClassifierPolicy {
322    /// Creates a policy that selects a target label through a JSON Pointer.
323    pub fn target_selector(selector: impl Into<String>) -> Self {
324        Self::TargetSelector {
325            selector: selector.into(),
326        }
327    }
328}
329
330/// Settings for a classifier whose JSON Schema and target-selection policy are user supplied.
331#[derive(Clone, Debug)]
332pub struct CustomClassifierConfig {
333    /// System prompt sent to the classifier judge.
334    pub prompt: String,
335    /// Inner JSON Schema placed inside the provider's structured-output wrapper.
336    pub response_schema: Value,
337    /// Deterministic policy applied after the verdict passes schema validation.
338    pub policy: CustomClassifierPolicy,
339    /// Enables session affinity before the judge-backed classifier.
340    pub session_affinity: bool,
341    /// Uses the first user message when session metadata is unavailable.
342    pub message_hash_fallback: bool,
343    /// Trailing conversation turns shown to the classifier judge.
344    pub recent_turn_window: Option<usize>,
345    /// Maximum completion tokens available to the classifier verdict.
346    pub max_output_tokens: u64,
347}
348
349impl CustomClassifierConfig {
350    /// Creates a custom-schema classifier contract with conservative runtime defaults.
351    pub fn new(
352        prompt: impl Into<String>,
353        response_schema: Value,
354        policy: CustomClassifierPolicy,
355    ) -> Self {
356        Self {
357            prompt: prompt.into(),
358            response_schema,
359            policy,
360            session_affinity: false,
361            message_hash_fallback: false,
362            recent_turn_window: None,
363            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
364        }
365    }
366
367    fn validate(&self) -> Result<()> {
368        if self.max_output_tokens == 0 {
369            return Err(LibsyError::AlgorithmError {
370                message: "max_output_tokens must be at least 1".to_string(),
371            });
372        }
373        if self.message_hash_fallback && !self.session_affinity {
374            return Err(LibsyError::AlgorithmError {
375                message: "message_hash_fallback requires session_affinity".to_string(),
376            });
377        }
378        Ok(())
379    }
380}
381
382enum CustomPolicyRuntime {
383    TargetSelector(TargetSelectorPolicy),
384}
385
386impl JudgePolicy for CustomPolicyRuntime {
387    type Verdict = Value;
388
389    fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
390        match self {
391            Self::TargetSelector(policy) => policy.to_classification(verdict),
392        }
393    }
394}
395
396struct TaskClassifier {
397    classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
398    efficient_target: String,
399    capable_target: String,
400}
401
402// ── Escalation classifier ──────────────────────────────────────────────────
403
404/// Session-state key holding the consecutive-escalate streak.
405const STREAK_KEY: &str = "escalation_streak";
406
407fn streak(state: &State) -> u32 {
408    match state.extra.get(STREAK_KEY) {
409        Some(StateValue::Count(n)) => *n,
410        _ => 0,
411    }
412}
413
414fn decisive(target: &str) -> Classification {
415    Classification::Scores(vec![Score {
416        target: target.to_string(),
417        confidence: 1.0,
418    }])
419}
420
421fn assistant_message(response: &AggLlmResponse) -> Message {
422    Message {
423        role: Role::Assistant,
424        content: response
425            .first_output()
426            .map(|output| output.content.clone())
427            .unwrap_or_default(),
428    }
429}
430
431/// Calls the efficient model, judges its response, and latches to capable once the streak
432/// confirms. Returns the efficient response directly when not escalating so the caller does
433/// not pay for a second model call.
434struct EscalationClassifier {
435    judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
436    capable: LlmTarget,
437    efficient: LlmTarget,
438    /// Consecutive escalate verdicts required to latch.
439    confirmations: u32,
440}
441
442#[async_trait]
443impl Classifier<State> for EscalationClassifier {
444    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
445        if self.capable.semantic_name == self.efficient.semantic_name {
446            None
447        } else if selected_model == self.capable.semantic_name {
448            Some("strong")
449        } else if selected_model == self.efficient.semantic_name {
450            Some("weak")
451        } else {
452            None
453        }
454    }
455
456    async fn score(
457        &self,
458        state: &mut State,
459        request: &mut Request,
460        driver: Option<&Driver>,
461    ) -> Result<(Classification, Option<Response>)> {
462        let Some(driver) = driver else {
463            return Err(LibsyError::AlgorithmError {
464                message: "escalation classifier requires a driver".into(),
465            });
466        };
467
468        // A confirmed session stays capable without a judge call.
469        if streak(state) >= self.confirmations {
470            return Ok((decisive(&self.capable.semantic_name), None));
471        }
472
473        // Call efficient model and buffer the response so the judge can read it.
474        // `Classifier::score` takes no `ctx`, so inner calls use Context::default() and their
475        // spans carry algorithm="" rather than the algorithm name. Known gap shared with the
476        // task classifier's judge consultation.
477        //
478        // If the efficient model exceeds its context window, fall through to capable: returning
479        // `(decisive(capable), None)` tells FallThrough::execute to call
480        // call_llm_with_overflow_fallback with the capable target instead of surfacing the error.
481        let efficient_response = match driver
482            .call_llm_target(
483                Context::default(),
484                &self.efficient,
485                request.clone(),
486                Arc::new(SimpleDecision {
487                    selected_model: self.efficient.semantic_name.clone(),
488                    reasoning: Some("escalation classifier: efficient tier".into()),
489                }),
490            )
491            .await
492        {
493            Ok(r) => r,
494            Err(LibsyError::ClientCall {
495                source: LlmClientError::ContextWindowExceeded { .. },
496                ..
497            }) => return Ok((decisive(&self.capable.semantic_name), None)),
498            Err(e) => return Err(e),
499        };
500        let agg = efficient_response
501            .llm_response
502            .into_agg()
503            .await
504            .map_err(|e| LibsyError::AlgorithmError {
505                message: format!("failed to aggregate efficient response: {e}"),
506            })?;
507        // Append the efficient reply so the judge reads this turn's completed trajectory.
508        let mut judge_request = request.clone();
509        judge_request
510            .llm_request
511            .messages
512            .push(assistant_message(&agg));
513        let efficient_response = Response {
514            llm_response: if request.llm_request.stream {
515                LlmResponse::Stream(agg.into_stream())
516            } else {
517                LlmResponse::Agg(agg)
518            },
519            metadata: efficient_response.metadata,
520        };
521
522        let (classification, _) = self
523            .judge
524            .score(state, &mut judge_request, Some(driver))
525            .await?;
526
527        let held = streak(state);
528        let best = classification.argmax(false)?;
529        let (escalate, pending) = match &best {
530            Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
531            Some(_) => (false, 0),
532            None => (false, held),
533        };
534        state
535            .extra
536            .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
537
538        if escalate && pending >= self.confirmations {
539            // Streak confirmed: drop the efficient response, caller will serve capable.
540            return Ok((decisive(&self.capable.semantic_name), None));
541        }
542
543        Ok((
544            decisive(&self.efficient.semantic_name),
545            Some(efficient_response),
546        ))
547    }
548}
549
550/// Routes requests through a capability, escalation, or custom classifier mode.
551pub struct LlmTaskClassifier {
552    route: FallThrough<State>,
553    /// Classifier used when this router is embedded in another cascade.
554    inner: Arc<dyn Classifier<State>>,
555}
556
557struct ClassifierRouteConfig {
558    default_target: String,
559    session_affinity: bool,
560    message_hash_fallback: bool,
561}
562
563/// Complete construction settings for one LLM classifier mode.
564#[non_exhaustive]
565pub enum LlmClassifierConfig {
566    /// Routes between efficient and capable targets from a task-level verdict.
567    Capability {
568        /// Target that produces classifier verdicts.
569        judge_target: LlmTarget,
570        /// Target used when the efficient tier can handle the task.
571        efficient_target: LlmTarget,
572        /// Target used when the task needs the capable tier.
573        capable_target: LlmTarget,
574        /// Capability classifier settings.
575        config: TaskClassifierConfig,
576    },
577    /// Judges efficient responses and escalates after a confirmed streak.
578    Escalation {
579        /// Target that produces escalation verdicts.
580        judge_target: LlmTarget,
581        /// Target called before each escalation decision.
582        efficient_target: LlmTarget,
583        /// Target used after escalation is confirmed.
584        capable_target: LlmTarget,
585        /// Prompt and verdict contract settings for the escalation judge.
586        contract: ClassifierContractConfig,
587        /// Escalation policy settings.
588        config: EscalationJudgeConfig,
589        /// Maximum completion tokens available to the escalation verdict.
590        max_output_tokens: u64,
591    },
592    /// Routes among named targets using a user-supplied schema and policy.
593    Custom {
594        /// Target that produces classifier verdicts.
595        judge_target: LlmTarget,
596        /// User-facing labels paired with their resolved routing targets.
597        targets: Vec<(String, LlmTarget)>,
598        /// Label selected when the judge does not produce a usable verdict.
599        default_target: String,
600        /// Custom classifier settings.
601        config: CustomClassifierConfig,
602    },
603}
604
605impl LlmTaskClassifier {
606    /// Builds the classifier mode described by `config`.
607    ///
608    /// # Errors
609    ///
610    /// Returns an error when the selected mode's targets, contract, policy, or runtime
611    /// settings are invalid.
612    pub fn new(config: LlmClassifierConfig) -> Result<Self> {
613        match config {
614            LlmClassifierConfig::Capability {
615                judge_target,
616                efficient_target,
617                capable_target,
618                config,
619            } => Self::build_capability(judge_target, efficient_target, capable_target, config),
620            LlmClassifierConfig::Escalation {
621                judge_target,
622                efficient_target,
623                capable_target,
624                contract,
625                config,
626                max_output_tokens,
627            } => Self::build_escalation(
628                judge_target,
629                efficient_target,
630                capable_target,
631                contract,
632                config,
633                max_output_tokens,
634            ),
635            LlmClassifierConfig::Custom {
636                judge_target,
637                targets,
638                default_target,
639                config,
640            } => Self::build_custom(judge_target, targets, default_target, config),
641        }
642    }
643
644    fn build_capability(
645        judge_target: LlmTarget,
646        efficient_target: LlmTarget,
647        capable_target: LlmTarget,
648        config: TaskClassifierConfig,
649    ) -> Result<Self> {
650        config.validate()?;
651        let contract = Self::load_capability_contract(&config.contract)?;
652        let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
653        let session_affinity = config.session_affinity;
654        let message_hash_fallback = config.message_hash_fallback;
655        let classifier = Arc::new(TaskClassifier {
656            classifier: JudgeClassifier::new(
657                StructuredJudge::new(
658                    TaskInput {
659                        recent_turn_window: config.recent_turn_window,
660                    },
661                    contract,
662                    SerdeDecoder::new(),
663                    JudgeRuntimeConfig::new(config.max_output_tokens)?,
664                ),
665                judge_target.clone(),
666                TaskClassifierPolicy::new(
667                    efficient_target.semantic_name.clone(),
668                    capable_target.semantic_name.clone(),
669                    &config,
670                ),
671            ),
672            efficient_target: efficient_target.semantic_name.clone(),
673            capable_target: capable_target.semantic_name.clone(),
674        });
675        let inner: Arc<dyn Classifier<State>> = classifier.clone();
676        Self::from_classifier(
677            targets,
678            inner,
679            ClassifierRouteConfig {
680                default_target: classifier.capable_target.clone(),
681                session_affinity,
682                message_hash_fallback,
683            },
684        )
685    }
686
687    fn build_custom(
688        judge_target: LlmTarget,
689        targets: Vec<(String, LlmTarget)>,
690        default_target: String,
691        config: CustomClassifierConfig,
692    ) -> Result<Self> {
693        config.validate()?;
694        if targets.len() < 2 {
695            return Err(LibsyError::AlgorithmError {
696                message: "custom classifier requires at least two targets".to_string(),
697            });
698        }
699
700        let mut labels = BTreeSet::new();
701        let mut semantic_names = BTreeSet::new();
702        let mut target_map = BTreeMap::new();
703        let mut resolved_targets = Vec::with_capacity(targets.len());
704        for (label, target) in targets {
705            if label.trim().is_empty() || label.trim() != label {
706                return Err(LibsyError::AlgorithmError {
707                    message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
708                        .to_string(),
709                });
710            }
711            if !labels.insert(label.clone()) {
712                return Err(LibsyError::AlgorithmError {
713                    message: format!("custom classifier target label {label:?} is duplicated"),
714                });
715            }
716            if !semantic_names.insert(target.semantic_name.clone()) {
717                return Err(LibsyError::AlgorithmError {
718                    message: format!(
719                        "custom classifier resolved target {:?} is duplicated",
720                        target.semantic_name
721                    ),
722                });
723            }
724            target_map.insert(label, target.semantic_name.clone());
725            resolved_targets.push(target);
726        }
727        let default_semantic_name =
728            target_map
729                .get(&default_target)
730                .cloned()
731                .ok_or_else(|| LibsyError::AlgorithmError {
732                    message: format!(
733                        "default_target {default_target:?} must be one of the configured targets"
734                    ),
735                })?;
736
737        let CustomClassifierConfig {
738            prompt,
739            response_schema,
740            policy,
741            session_affinity,
742            message_hash_fallback,
743            recent_turn_window,
744            max_output_tokens,
745        } = config;
746        let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
747        let policy = match policy {
748            CustomClassifierPolicy::TargetSelector { selector } => {
749                CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
750                    selector, target_map,
751                )?)
752            }
753        };
754        let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
755            StructuredJudge::new(
756                TaskInput { recent_turn_window },
757                contract,
758                JsonSchemaDecoder::new(),
759                JudgeRuntimeConfig::new(max_output_tokens)?,
760            ),
761            judge_target,
762            policy,
763        ));
764
765        Self::from_classifier(
766            LlmTargetSet::new(resolved_targets),
767            classifier,
768            ClassifierRouteConfig {
769                default_target: default_semantic_name,
770                session_affinity,
771                message_hash_fallback,
772            },
773        )
774    }
775
776    fn build_escalation(
777        judge_target: LlmTarget,
778        efficient_target: LlmTarget,
779        capable_target: LlmTarget,
780        contract_config: ClassifierContractConfig,
781        config: EscalationJudgeConfig,
782        max_output_tokens: u64,
783    ) -> Result<Self> {
784        let capable_name = capable_target.semantic_name.clone();
785        let efficient_name = efficient_target.semantic_name.clone();
786        let confirmations = config.confirmations;
787        let esc = Arc::new(EscalationClassifier {
788            judge: escalation::build_judge(
789                judge_target,
790                capable_name,
791                efficient_name,
792                &contract_config,
793                config,
794                max_output_tokens,
795            )?,
796            capable: capable_target.clone(),
797            efficient: efficient_target.clone(),
798            confirmations,
799        });
800        let inner: Arc<dyn Classifier<State>> = esc.clone();
801        let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
802        Ok(Self {
803            route: FallThrough::<State>::new_with_state(targets)
804                .with_name(ALGORITHM_NAME)
805                .with_classifier(esc),
806            inner,
807        })
808    }
809
810    /// Loads the packaged capability-classifier contract.
811    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
812        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
813    }
814
815    /// Keeps affinity and fallback ordering identical across judge-backed modes.
816    fn from_classifier(
817        targets: LlmTargetSet,
818        inner: Arc<dyn Classifier<State>>,
819        config: ClassifierRouteConfig,
820    ) -> Result<Self> {
821        targets.get_target(&config.default_target)?;
822        if config.message_hash_fallback && !config.session_affinity {
823            return Err(LibsyError::AlgorithmError {
824                message: "message_hash_fallback requires session_affinity".to_string(),
825            });
826        }
827        // Affinity comes first so a retained assignment short-circuits the judge call.
828        // Note: when this classifier is embedded inside another cascade (e.g. StageRouter)
829        // the affinity processor never fires — only the inner score() is called.
830        let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
831        if config.session_affinity {
832            let affinity = if config.message_hash_fallback {
833                AffinityRouter::new().with_message_hash_fallback()
834            } else {
835                AffinityRouter::new()
836            };
837            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
838            let affinity = Arc::new(affinity);
839            route = route
840                .with_processor(affinity.clone())
841                .with_classifier(affinity);
842        }
843        let fallback = DefaultTarget::new(config.default_target);
844        Ok(Self {
845            route: route
846                .with_classifier(inner.clone())
847                .with_classifier(Arc::new(fallback)),
848            inner,
849        })
850    }
851}
852
853#[async_trait]
854impl Classifier<State> for TaskClassifier {
855    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
856        if self.efficient_target == self.capable_target {
857            None
858        } else if selected_model == self.efficient_target {
859            Some("weak")
860        } else if selected_model == self.capable_target {
861            Some("strong")
862        } else {
863            None
864        }
865    }
866
867    async fn score(
868        &self,
869        state: &mut State,
870        request: &mut Request,
871        driver: Option<&Driver>,
872    ) -> Result<(Classification, Option<Response>)> {
873        self.classifier.score(state, request, driver).await
874    }
875}
876
877#[async_trait]
878impl Classifier<State> for LlmTaskClassifier {
879    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
880        self.inner.routing_tier(selected_model)
881    }
882
883    async fn score(
884        &self,
885        state: &mut State,
886        request: &mut Request,
887        driver: Option<&Driver>,
888    ) -> Result<(Classification, Option<Response>)> {
889        self.inner.score(state, request, driver).await
890    }
891}
892
893#[async_trait]
894impl Algorithm for LlmTaskClassifier {
895    fn name(&self) -> &str {
896        "llm_task_classifier"
897    }
898
899    fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
900        self.route.count_tokens_client()
901    }
902
903    async fn create_run_task(
904        self: Arc<Self>,
905        ctx: Context,
906        driver: Driver,
907        request: Request,
908    ) -> Result<Response> {
909        self.route.execute(ctx, driver, request).await
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use std::sync::Arc;
916
917    use parking_lot::Mutex;
918    use serde_json::Value;
919
920    use super::*;
921    use switchyard_protocol::{
922        ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, completion_text,
923        text_request, text_response,
924    };
925
926    use crate::algorithms::util::llm_judge::Judge;
927    use crate::core::algorithm::Algorithm;
928    use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
929
930    const TEST_THRESHOLD: f64 = 0.5;
931
932    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
933        TaskClassifierConfig {
934            base_threshold,
935            ..TaskClassifierConfig::default()
936        }
937    }
938
939    fn policy() -> TaskClassifierPolicy {
940        TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
941    }
942
943    fn verdict(
944        p_solve: f64,
945        capability_boundary: &str,
946        primary_rule: &str,
947    ) -> TaskClassifierVerdict {
948        TaskClassifierVerdict {
949            crux: "test crux".to_string(),
950            primary_rule: primary_rule.to_string(),
951            capability_boundary: capability_boundary.to_string(),
952            p_solve,
953        }
954    }
955
956    fn selected(
957        policy: &TaskClassifierPolicy,
958        verdict: Option<&TaskClassifierVerdict>,
959    ) -> Result<String> {
960        policy
961            .to_classification(verdict)
962            .argmax(false)?
963            .map(|score| score.target)
964            .ok_or_else(|| LibsyError::AlgorithmError {
965                message: "policy abstained".to_string(),
966            })
967    }
968
969    #[derive(Default)]
970    struct PerRequestClient {
971        calls: Mutex<Vec<String>>,
972        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
973        judge_system_prompts: Mutex<Vec<String>>,
974    }
975
976    impl PerRequestClient {
977        fn calls(&self) -> Vec<String> {
978            self.calls.lock().clone()
979        }
980
981        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
982            self.judge_max_output_tokens.lock().clone()
983        }
984
985        fn judge_system_prompts(&self) -> Vec<String> {
986            self.judge_system_prompts.lock().clone()
987        }
988    }
989
990    #[async_trait]
991    impl RoutedLlmClient for PerRequestClient {
992        async fn call(
993            &self,
994            _ctx: Context,
995            request: Request,
996            decision: Arc<dyn Decision>,
997        ) -> std::result::Result<Response, LlmClientError> {
998            let model = decision.selected_model().to_string();
999            self.calls.lock().push(model.clone());
1000            let completion = if model == "judge" {
1001                self.judge_max_output_tokens
1002                    .lock()
1003                    .push(request.llm_request.output.max_output_tokens);
1004                self.judge_system_prompts.lock().extend(
1005                    request
1006                        .llm_request
1007                        .instructions
1008                        .first()
1009                        .and_then(|instruction| {
1010                            instruction.content.iter().find_map(|b| {
1011                                if let ContentBlock::Text { text } = b {
1012                                    Some(text.clone())
1013                                } else {
1014                                    None
1015                                }
1016                            })
1017                        }),
1018                );
1019                r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1020            } else {
1021                format!("answer from {model}")
1022            };
1023            Ok(Response {
1024                llm_response: LlmResponse::Agg(text_response(None, completion)),
1025                metadata: request.metadata,
1026            })
1027        }
1028    }
1029
1030    struct UnreachableJudgeClient;
1031
1032    #[async_trait]
1033    impl RoutedLlmClient for UnreachableJudgeClient {
1034        async fn call(
1035            &self,
1036            _ctx: Context,
1037            request: Request,
1038            decision: Arc<dyn Decision>,
1039        ) -> std::result::Result<Response, LlmClientError> {
1040            let model = decision.selected_model().to_string();
1041            if model == "judge" {
1042                return Err(LlmClientError::Timeout {
1043                    source: Box::new(std::io::Error::other("judge unreachable")),
1044                });
1045            }
1046            Ok(Response {
1047                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1048                metadata: request.metadata,
1049            })
1050        }
1051    }
1052
1053    fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1054        let target = |name: &str| LlmTarget {
1055            semantic_name: name.to_string(),
1056            llm_client: Some(client.clone()),
1057        };
1058        Ok(Arc::new(LlmTaskClassifier::new(
1059            LlmClassifierConfig::Capability {
1060                judge_target: target("judge"),
1061                efficient_target: target("efficient"),
1062                capable_target: target("capable"),
1063                config: test_config(TEST_THRESHOLD),
1064            },
1065        )?))
1066    }
1067
1068    fn classify_request() -> Request {
1069        Request {
1070            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1071            raw_request: None,
1072            metadata: None,
1073        }
1074    }
1075
1076    fn classify_session_request() -> Request {
1077        Request {
1078            metadata: Some(Metadata {
1079                session_id: Some("session-1".to_string()),
1080                ..Metadata::default()
1081            }),
1082            ..classify_request()
1083        }
1084    }
1085
1086    fn classify_follow_up_request() -> Request {
1087        let mut request = classify_request();
1088        request
1089            .llm_request
1090            .messages
1091            .push(Message::text(Role::Assistant, "I will add the test."));
1092        request.llm_request.messages.push(Message::text(
1093            Role::User,
1094            "Now run the test suite and report the result.",
1095        ));
1096        request
1097    }
1098
1099    #[tokio::test]
1100    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1101        let router = router(Arc::new(UnreachableJudgeClient))?;
1102
1103        let (trace, response) = router.run(Context::default(), classify_request()).await?;
1104
1105        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1106        assert_eq!(
1107            response.llm_response.as_agg().map(completion_text),
1108            Some("answer from capable".to_string())
1109        );
1110        Ok(())
1111    }
1112
1113    #[tokio::test]
1114    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1115        let client = Arc::new(PerRequestClient::default());
1116        let router = router(client.clone())?;
1117        let request = classify_request;
1118
1119        router.clone().run(Context::default(), request()).await?;
1120        router.clone().run(Context::default(), request()).await?;
1121
1122        assert_eq!(
1123            client.calls(),
1124            vec!["judge", "efficient", "judge", "efficient"]
1125        );
1126        Ok(())
1127    }
1128
1129    #[tokio::test]
1130    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1131        let client = Arc::new(PerRequestClient::default());
1132        let target = |name: &str| LlmTarget {
1133            semantic_name: name.to_string(),
1134            llm_client: Some(client.clone()),
1135        };
1136        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1137            judge_target: target("judge"),
1138            efficient_target: target("efficient"),
1139            capable_target: target("capable"),
1140            config: TaskClassifierConfig {
1141                max_output_tokens: 512,
1142                ..test_config(TEST_THRESHOLD)
1143            },
1144        })?);
1145
1146        router.run(Context::default(), classify_request()).await?;
1147
1148        assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1149        Ok(())
1150    }
1151
1152    #[tokio::test]
1153    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1154        let client = Arc::new(PerRequestClient::default());
1155        let target = |name: &str| LlmTarget {
1156            semantic_name: name.to_string(),
1157            llm_client: Some(client.clone()),
1158        };
1159        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1160            judge_target: target("judge"),
1161            efficient_target: target("efficient"),
1162            capable_target: target("capable"),
1163            config: TaskClassifierConfig {
1164                contract: ClassifierContractConfig::default()
1165                    .with_prompt("Custom capability rubric."),
1166                ..test_config(TEST_THRESHOLD)
1167            },
1168        })?);
1169
1170        router.run(Context::default(), classify_request()).await?;
1171
1172        let prompts = client.judge_system_prompts();
1173        assert_eq!(prompts.len(), 1);
1174        assert_eq!(prompts[0], "Custom capability rubric.");
1175        Ok(())
1176    }
1177
1178    #[tokio::test]
1179    async fn classifier_config_enables_session_affinity() -> Result<()> {
1180        let client = Arc::new(PerRequestClient::default());
1181        let target = |name: &str| LlmTarget {
1182            semantic_name: name.to_string(),
1183            llm_client: Some(client.clone()),
1184        };
1185        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1186            judge_target: target("judge"),
1187            efficient_target: target("efficient"),
1188            capable_target: target("capable"),
1189            config: TaskClassifierConfig {
1190                session_affinity: true,
1191                ..test_config(TEST_THRESHOLD)
1192            },
1193        })?);
1194
1195        router
1196            .clone()
1197            .run(Context::default(), classify_session_request())
1198            .await?;
1199        router
1200            .clone()
1201            .run(Context::default(), classify_session_request())
1202            .await?;
1203
1204        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1205        Ok(())
1206    }
1207
1208    #[tokio::test]
1209    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1210        let client = Arc::new(PerRequestClient::default());
1211        let target = |name: &str| LlmTarget {
1212            semantic_name: name.to_string(),
1213            llm_client: Some(client.clone()),
1214        };
1215        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1216            judge_target: target("judge"),
1217            efficient_target: target("efficient"),
1218            capable_target: target("capable"),
1219            config: TaskClassifierConfig {
1220                session_affinity: true,
1221                message_hash_fallback: true,
1222                recent_turn_window: None,
1223                ..test_config(TEST_THRESHOLD)
1224            },
1225        })?);
1226
1227        router
1228            .clone()
1229            .run(Context::default(), classify_request())
1230            .await?;
1231        router
1232            .clone()
1233            .run(Context::default(), classify_follow_up_request())
1234            .await?;
1235
1236        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1237        Ok(())
1238    }
1239
1240    #[test]
1241    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1242        let policy = policy();
1243        let at_threshold = verdict(0.5, "supported", "SUP-1");
1244        let below_threshold = verdict(0.49, "supported", "SUP-1");
1245        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1246        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1247        Ok(())
1248    }
1249
1250    #[test]
1251    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1252        let borderline = verdict(0.5, "supported", "SUP-1");
1253        let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1254        let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1255        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1256        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1257        Ok(())
1258    }
1259
1260    #[test]
1261    fn classifier_config_rejects_unknown_fields() {
1262        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1263            "base_threshold": 0.5,
1264            "classifier_magic": true,
1265        }))
1266        .expect_err("unknown classifier fields must be rejected");
1267
1268        assert!(
1269            error
1270                .to_string()
1271                .contains("unknown field `classifier_magic`"),
1272            "{error}"
1273        );
1274    }
1275
1276    #[test]
1277    fn invalid_classifier_config_is_rejected() -> Result<()> {
1278        let target = |name: &str| LlmTarget {
1279            semantic_name: name.to_string(),
1280            llm_client: None,
1281        };
1282        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1283            assert!(
1284                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1285                    judge_target: target("judge"),
1286                    efficient_target: target("e"),
1287                    capable_target: target("c"),
1288                    config: test_config(bad),
1289                })
1290                .is_err(),
1291                "base threshold {bad} should be rejected"
1292            );
1293        }
1294        for config in [
1295            TaskClassifierConfig {
1296                base_threshold: 0.5,
1297                threshold_step: -0.1,
1298                ..TaskClassifierConfig::default()
1299            },
1300            TaskClassifierConfig {
1301                base_threshold: 0.8,
1302                threshold_step: 0.11,
1303                ..TaskClassifierConfig::default()
1304            },
1305            TaskClassifierConfig {
1306                base_threshold: 0.5,
1307                message_hash_fallback: true,
1308                ..TaskClassifierConfig::default()
1309            },
1310            TaskClassifierConfig {
1311                base_threshold: 0.5,
1312                max_output_tokens: 0,
1313                ..TaskClassifierConfig::default()
1314            },
1315        ] {
1316            assert!(
1317                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1318                    judge_target: target("judge"),
1319                    efficient_target: target("e"),
1320                    capable_target: target("c"),
1321                    config,
1322                })
1323                .is_err()
1324            );
1325        }
1326        for base_threshold in [0.0, 1.0] {
1327            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1328                judge_target: target("judge"),
1329                efficient_target: target("e"),
1330                capable_target: target("c"),
1331                config: test_config(base_threshold),
1332            })?;
1333        }
1334        Ok(())
1335    }
1336
1337    #[test]
1338    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1339        let policy = policy();
1340        let inconsistent_rule = TaskClassifierVerdict {
1341            capability_boundary: "uncertain".to_string(),
1342            ..verdict(1.0, "supported", "SUP-1")
1343        };
1344        let empty_crux = TaskClassifierVerdict {
1345            crux: "  ".to_string(),
1346            ..verdict(1.0, "supported", "SUP-1")
1347        };
1348        let unusable = [
1349            Some(verdict(1.1, "supported", "SUP-1")),
1350            Some(inconsistent_rule),
1351            Some(empty_crux),
1352            None,
1353        ];
1354        for verdict in unusable {
1355            let classification = policy.to_classification(verdict.as_ref());
1356            assert!(matches!(classification, Classification::Ambiguous(_)));
1357            assert!(classification.argmax(false)?.is_none());
1358            assert!(classification.argmax(true)?.is_none());
1359        }
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1365        let policy = TaskClassifierPolicy::new(
1366            "efficient",
1367            "capable",
1368            &TaskClassifierConfig {
1369                threshold_step: 0.1,
1370                ..test_config(0.4)
1371            },
1372        );
1373
1374        assert_eq!(
1375            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1376            "efficient"
1377        );
1378        assert_eq!(
1379            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1380            "capable"
1381        );
1382        assert_eq!(
1383            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1384            "efficient"
1385        );
1386        assert_eq!(
1387            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1388            "efficient"
1389        );
1390        assert_eq!(
1391            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1392            "capable"
1393        );
1394        assert_eq!(
1395            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1396            "efficient"
1397        );
1398        Ok(())
1399    }
1400
1401    /// The text of each message a judge with `recent_turn_window` would be sent.
1402    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1403    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1404        Ok(StructuredJudge::new(
1405            TaskInput { recent_turn_window },
1406            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1407            SerdeDecoder::new(),
1408            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1409        ))
1410    }
1411
1412    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1413        let judge = capability_judge(Some(recent_turn_window))?;
1414        let request = Request {
1415            llm_request: LlmRequest {
1416                messages: vec![
1417                    Message::text(Role::System, "client instructions"),
1418                    Message::text(Role::User, "initial task"),
1419                    Message::text(Role::Assistant, "old response"),
1420                    Message::text(Role::User, "old follow-up"),
1421                    Message::text(Role::Assistant, "recent 1"),
1422                    Message::text(Role::User, "recent 2"),
1423                ],
1424                ..LlmRequest::default()
1425            },
1426            raw_request: None,
1427            metadata: None,
1428        };
1429        Ok(judge
1430            .build_request(&State::default(), &request)
1431            .llm_request
1432            .messages
1433            .iter()
1434            .filter_map(|message| message.text_content("\n"))
1435            .collect())
1436    }
1437
1438    #[test]
1439    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1440        // Client instructions and the opening task, plus the last two turns.
1441        let contents = judged_contents(2)?;
1442        assert!(contents.contains(&"client instructions".to_string()));
1443        assert!(contents.contains(&"initial task".to_string()));
1444        assert!(contents.contains(&"recent 1".to_string()));
1445        assert!(contents.contains(&"recent 2".to_string()));
1446        assert!(!contents.contains(&"old response".to_string()));
1447        Ok(())
1448    }
1449
1450    #[test]
1451    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1452        let contents = judged_contents(0)?;
1453        assert!(contents.contains(&"client instructions".to_string()));
1454        assert!(contents.contains(&"initial task".to_string()));
1455        assert!(!contents.contains(&"recent 2".to_string()));
1456        Ok(())
1457    }
1458
1459    #[test]
1460    fn capability_judge_builds_a_structured_request() -> Result<()> {
1461        let judge = capability_judge(None)?;
1462        let request = Request {
1463            llm_request: LlmRequest {
1464                model: Some("inbound".to_string()),
1465                messages: vec![
1466                    Message::text(Role::System, "client instructions"),
1467                    Message::text(Role::Developer, "client developer instructions"),
1468                    Message::text(Role::User, "initial task"),
1469                    Message::text(Role::Assistant, "old response"),
1470                    Message::text(Role::User, "old follow-up"),
1471                    Message::text(Role::Assistant, "recent 1"),
1472                    Message::text(Role::User, "recent 2"),
1473                    Message::text(Role::Assistant, "recent 3"),
1474                    Message::text(Role::User, "recent 4"),
1475                    Message::text(Role::Assistant, "recent 5"),
1476                ],
1477                ..LlmRequest::default()
1478            },
1479            raw_request: None,
1480            metadata: None,
1481        };
1482        let judge_request = judge.build_request(&State::default(), &request);
1483
1484        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1485        assert_eq!(judge_request.llm_request.instructions.len(), 1);
1486        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1487        assert_eq!(
1488            judge_request.llm_request.instructions[0].content,
1489            InstructionBlock {
1490                role: Role::System,
1491                content: Message::text(Role::System, judge.contract().system_prompt()).content,
1492            }
1493            .content,
1494        );
1495        assert_eq!(judge_request.llm_request.messages.len(), 2);
1496        let contents = judge_request
1497            .llm_request
1498            .messages
1499            .iter()
1500            .filter_map(|message| message.text_content("\n"))
1501            .collect::<Vec<_>>();
1502        assert!(contents.contains(&"recent 4".to_string()));
1503        assert!(contents.contains(&"initial task".to_string()));
1504        assert!(!contents.contains(&"recent 5".to_string()));
1505        assert!(!contents.contains(&"client instructions".to_string()));
1506        assert_eq!(
1507            judge_request.llm_request.output.response_format,
1508            Some(judge.contract().response_format().clone())
1509        );
1510        assert_eq!(
1511            judge_request.llm_request.output.max_output_tokens,
1512            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1513        );
1514        Ok(())
1515    }
1516
1517    fn sample_value(spec: &Value) -> Value {
1518        if let Some(first) = spec
1519            .get("enum")
1520            .and_then(Value::as_array)
1521            .and_then(|values| values.first())
1522        {
1523            return first.clone();
1524        }
1525        match spec.get("type").and_then(Value::as_str) {
1526            Some("number") => serde_json::json!(0.5),
1527            Some("boolean") => serde_json::json!(false),
1528            _ => serde_json::json!("sample"),
1529        }
1530    }
1531
1532    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1533        let properties = schema
1534            .pointer("/json_schema/schema/properties")
1535            .and_then(Value::as_object)
1536            .ok_or_else(|| LibsyError::AlgorithmError {
1537                message: "packaged schema declares no properties".to_string(),
1538            })?;
1539        Ok(Value::Object(
1540            properties
1541                .iter()
1542                .map(|(name, spec)| (name.clone(), sample_value(spec)))
1543                .collect(),
1544        )
1545        .to_string())
1546    }
1547
1548    /// Built from the schema so a property added there fails here rather than silently
1549    /// rejecting every production verdict.
1550    #[test]
1551    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1552        let contract =
1553            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1554        let schema = contract.response_format();
1555        let reply = schema_shaped_verdict(schema)?;
1556        let judge: CapabilityJudge = StructuredJudge::new(
1557            TaskInput {
1558                recent_turn_window: None,
1559            },
1560            contract,
1561            SerdeDecoder::new(),
1562            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1563        );
1564
1565        let verdict = judge.parse(&text_response(None, reply))?;
1566
1567        assert!(verdict.is_valid());
1568        assert!((0.0..=1.0).contains(&verdict.p_solve));
1569        Ok(())
1570    }
1571
1572    #[test]
1573    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1574        let contract =
1575            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1576        let prompt = contract.system_prompt();
1577        let schema_name = contract
1578            .response_format()
1579            .pointer("/json_schema/name")
1580            .and_then(Value::as_str)
1581            .ok_or_else(|| LibsyError::AlgorithmError {
1582                message: "packaged response schema has no name".to_string(),
1583            })?;
1584        assert_eq!(schema_name, "CapabilityClassifierDecision");
1585        assert!(prompt.contains("SUP-1 [supported]"));
1586        assert!(prompt.contains("SUP-5 [supported]"));
1587        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1588        assert!(!prompt.contains("\"type\": \"object\""));
1589        assert!(!prompt.contains("\"json_schema\""));
1590        assert!(!prompt.contains(schema_name));
1591        let rule_values = contract
1592            .response_format()
1593            .pointer("/json_schema/schema/properties/primary_rule/enum")
1594            .and_then(Value::as_array)
1595            .ok_or_else(|| LibsyError::AlgorithmError {
1596                message: "rendered response schema has no primary rule enum".to_string(),
1597            })?;
1598        assert!(
1599            rule_values
1600                .iter()
1601                .any(|value| value.as_str() == Some("SUP-1"))
1602        );
1603        assert!(
1604            rule_values
1605                .iter()
1606                .any(|value| value.as_str() == Some("none"))
1607        );
1608        Ok(())
1609    }
1610
1611    // ── with_escalation tests ──────────────────────────────────────────────
1612
1613    use std::collections::VecDeque;
1614
1615    use switchyard_protocol::LlmClientError as ClientError;
1616
1617    use switchyard_protocol::Decision;
1618
1619    /// Serves each call with the next queued reply.
1620    struct QueuedClient {
1621        replies: Mutex<VecDeque<String>>,
1622    }
1623
1624    impl QueuedClient {
1625        fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1626            Arc::new(Self {
1627                replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1628            })
1629        }
1630    }
1631
1632    #[async_trait]
1633    impl RoutedLlmClient for QueuedClient {
1634        async fn call(
1635            &self,
1636            _ctx: Context,
1637            request: Request,
1638            _decision: Arc<dyn Decision>,
1639        ) -> std::result::Result<Response, ClientError> {
1640            let reply = self
1641                .replies
1642                .lock()
1643                .pop_front()
1644                .unwrap_or_else(|| "unexpected call".to_string());
1645            Ok(Response {
1646                llm_response: LlmResponse::Agg(text_response(None, reply)),
1647                metadata: request.metadata,
1648            })
1649        }
1650    }
1651
1652    /// Builds a router with escalation enabled (`confirmations=1` latches on the first verdict).
1653    fn escalation_router(
1654        client: Arc<dyn RoutedLlmClient>,
1655        judge_client: Arc<dyn RoutedLlmClient>,
1656    ) -> Result<Arc<LlmTaskClassifier>> {
1657        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1658            semantic_name: name.to_string(),
1659            llm_client: Some(c),
1660        };
1661        Ok(Arc::new(LlmTaskClassifier::new(
1662            LlmClassifierConfig::Escalation {
1663                judge_target: target("judge", judge_client),
1664                efficient_target: target("efficient", client.clone()),
1665                capable_target: target("capable", client),
1666                contract: ClassifierContractConfig::default(),
1667                config: EscalationJudgeConfig {
1668                    confirmations: 1,
1669                    ..EscalationJudgeConfig::default()
1670                },
1671                max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1672            },
1673        )?))
1674    }
1675
1676    #[tokio::test]
1677    async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1678        // Judge: no escalation. Expect the efficient response to be returned directly.
1679        let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1680        let model_client = QueuedClient::new(["efficient answer"]);
1681        let router = escalation_router(model_client, judge_client)?;
1682        let request = classify_request();
1683
1684        let (trace, response) = router.run(Context::default(), request).await?;
1685
1686        // The efficient model is the serving target, and the response comes from its call.
1687        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1688        assert_eq!(
1689            response.llm_response.as_agg().map(completion_text),
1690            Some("efficient answer".to_string())
1691        );
1692        Ok(())
1693    }
1694
1695    #[tokio::test]
1696    async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1697        let client = Arc::new(PerRequestClient::default());
1698        let target = |name: &str| LlmTarget {
1699            semantic_name: name.to_string(),
1700            llm_client: Some(client.clone()),
1701        };
1702        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1703            judge_target: target("judge"),
1704            efficient_target: target("efficient"),
1705            capable_target: target("capable"),
1706            contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1707            config: EscalationJudgeConfig {
1708                confirmations: 1,
1709                ..EscalationJudgeConfig::default()
1710            },
1711            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1712        })?);
1713
1714        router.run(Context::default(), classify_request()).await?;
1715
1716        let prompts = client.judge_system_prompts();
1717        assert_eq!(prompts.len(), 1);
1718        assert_eq!(prompts[0], "Custom trajectory rubric.");
1719        Ok(())
1720    }
1721
1722    #[tokio::test]
1723    async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1724        // Judge: escalate. After the efficient call, the streak confirms and capable is served.
1725        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1726        // Efficient is called first (by the classifier), then capable is called by FallThrough.
1727        let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1728        let router = escalation_router(model_client, judge_client)?;
1729        let request = classify_request();
1730
1731        let (trace, response) = router.run(Context::default(), request).await?;
1732
1733        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1734        assert_eq!(
1735            response.llm_response.as_agg().map(completion_text),
1736            Some("capable answer".to_string())
1737        );
1738        Ok(())
1739    }
1740
1741    #[tokio::test]
1742    async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1743        // First turn: judge escalates and the streak latches.
1744        // Second turn: judge is not called again; capable is served directly.
1745        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1746        let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1747        let router = escalation_router(model_client, judge_client)?;
1748
1749        let session_request = classify_session_request();
1750        router
1751            .clone()
1752            .run(Context::default(), session_request.clone())
1753            .await?;
1754        let (trace, _) = router
1755            .clone()
1756            .run(Context::default(), session_request)
1757            .await?;
1758
1759        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1760        Ok(())
1761    }
1762
1763    #[tokio::test]
1764    async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1765        // When the efficient model exceeds its context window inside score(), the classifier
1766        // must return capable rather than propagating the error — otherwise the client sees
1767        // HTTP 400 instead of a response from the strong model.
1768        struct OverflowClient;
1769        #[async_trait]
1770        impl RoutedLlmClient for OverflowClient {
1771            async fn call(
1772                &self,
1773                _ctx: Context,
1774                _request: Request,
1775                decision: Arc<dyn Decision>,
1776            ) -> std::result::Result<Response, LlmClientError> {
1777                Err(LlmClientError::ContextWindowExceeded {
1778                    model: decision.selected_model().to_string(),
1779                    message: "prompt is too long".to_string(),
1780                })
1781            }
1782        }
1783
1784        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1785            semantic_name: name.to_string(),
1786            llm_client: Some(c),
1787        };
1788        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1789            judge_target: target("judge", QueuedClient::new([])), // must not be called
1790            efficient_target: target("efficient", Arc::new(OverflowClient)),
1791            capable_target: target("capable", QueuedClient::new(["capable answer"])),
1792            contract: ClassifierContractConfig::default(),
1793            config: EscalationJudgeConfig {
1794                confirmations: 1,
1795                ..EscalationJudgeConfig::default()
1796            },
1797            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1798        })?);
1799
1800        let (trace, response) = router.run(Context::default(), classify_request()).await?;
1801
1802        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1803        assert_eq!(
1804            response.llm_response.as_agg().map(completion_text),
1805            Some("capable answer".to_string())
1806        );
1807        Ok(())
1808    }
1809}