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