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