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