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, RoutedRequest};
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,
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_fallback with the capable target instead of surfacing the error.
521        let efficient_response = match driver
522            .call_llm(RoutedRequest {
523                request: request.clone(),
524                decision: Arc::new(SimpleDecision {
525                    selected_model: self.efficient.semantic_name.clone(),
526                    reasoning: Some("escalation classifier: efficient tier".into()),
527                }),
528                ctx: Context::default(),
529            })
530            .await
531        {
532            Ok(r) => r,
533            Err(LibsyError::ClientCall {
534                source: LlmClientError::ContextWindowExceeded { .. },
535                ..
536            }) => return Ok((decisive(&self.capable.semantic_name), None)),
537            Err(e) => return Err(e),
538        };
539        let agg = efficient_response
540            .llm_response
541            .into_agg()
542            .await
543            .map_err(|e| LibsyError::AlgorithmError {
544                message: format!("failed to aggregate efficient response: {e}"),
545            })?;
546        // Append the efficient reply so the judge reads this turn's completed trajectory.
547        let mut judge_request = request.clone();
548        judge_request
549            .llm_request
550            .messages
551            .push(assistant_message(&agg));
552        let efficient_response = Response {
553            llm_response: if request.llm_request.stream {
554                LlmResponse::Stream(agg.into_stream())
555            } else {
556                LlmResponse::Agg(agg)
557            },
558            metadata: efficient_response.metadata,
559        };
560
561        let (classification, _) = self
562            .judge
563            .score(state, &mut judge_request, Some(driver))
564            .await?;
565
566        let held = streak(state);
567        let best = classification.argmax(false)?;
568        let (escalate, pending) = match &best {
569            Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
570            Some(_) => (false, 0),
571            None => (false, held),
572        };
573        state
574            .extra
575            .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
576
577        if escalate && pending >= self.confirmations {
578            // Streak confirmed: drop the efficient response, caller will serve capable.
579            return Ok((decisive(&self.capable.semantic_name), None));
580        }
581
582        Ok((
583            decisive(&self.efficient.semantic_name),
584            Some(efficient_response),
585        ))
586    }
587}
588
589/// Routes requests through a capability, escalation, or custom classifier mode.
590pub struct LlmTaskClassifier {
591    route: FallThrough<State>,
592    /// Classifier used when this router is embedded in another cascade.
593    inner: Arc<dyn Classifier<State>>,
594}
595
596struct ClassifierRouteConfig {
597    default_target: String,
598    session_affinity: bool,
599    message_hash_fallback: bool,
600}
601
602/// Complete construction settings for one LLM classifier mode.
603#[non_exhaustive]
604pub enum LlmClassifierConfig {
605    /// Routes between efficient and capable targets from a task-level verdict.
606    Capability {
607        /// Target that produces classifier verdicts.
608        judge_target: LlmTarget,
609        /// Target used when the efficient tier can handle the task.
610        efficient_target: LlmTarget,
611        /// Target used when the task needs the capable tier.
612        capable_target: LlmTarget,
613        /// Capability classifier settings.
614        config: TaskClassifierConfig,
615    },
616    /// Judges efficient responses and escalates after a confirmed streak.
617    Escalation {
618        /// Target that produces escalation verdicts.
619        judge_target: LlmTarget,
620        /// Target called before each escalation decision.
621        efficient_target: LlmTarget,
622        /// Target used after escalation is confirmed.
623        capable_target: LlmTarget,
624        /// Prompt and verdict contract settings for the escalation judge.
625        contract: ClassifierContractConfig,
626        /// Escalation policy settings.
627        config: EscalationJudgeConfig,
628        /// Maximum completion tokens available to the escalation verdict.
629        max_output_tokens: u64,
630    },
631    /// Routes among named targets using a user-supplied schema and policy.
632    Custom {
633        /// Target that produces classifier verdicts.
634        judge_target: LlmTarget,
635        /// User-facing labels paired with their resolved routing targets.
636        targets: Vec<(String, LlmTarget)>,
637        /// Label selected when the judge does not produce a usable verdict.
638        default_target: String,
639        /// Custom classifier settings.
640        config: CustomClassifierConfig,
641    },
642}
643
644impl LlmTaskClassifier {
645    /// Builds the classifier mode described by `config`.
646    ///
647    /// # Errors
648    ///
649    /// Returns an error when the selected mode's targets, contract, policy, or runtime
650    /// settings are invalid.
651    pub fn new(config: LlmClassifierConfig) -> Result<Self> {
652        match config {
653            LlmClassifierConfig::Capability {
654                judge_target,
655                efficient_target,
656                capable_target,
657                config,
658            } => Self::build_capability(judge_target, efficient_target, capable_target, config),
659            LlmClassifierConfig::Escalation {
660                judge_target,
661                efficient_target,
662                capable_target,
663                contract,
664                config,
665                max_output_tokens,
666            } => Self::build_escalation(
667                judge_target,
668                efficient_target,
669                capable_target,
670                contract,
671                config,
672                max_output_tokens,
673            ),
674            LlmClassifierConfig::Custom {
675                judge_target,
676                targets,
677                default_target,
678                config,
679            } => Self::build_custom(judge_target, targets, default_target, config),
680        }
681    }
682
683    fn build_capability(
684        judge_target: LlmTarget,
685        efficient_target: LlmTarget,
686        capable_target: LlmTarget,
687        config: TaskClassifierConfig,
688    ) -> Result<Self> {
689        config.validate()?;
690        let contract = Self::load_capability_contract(&config.contract)?;
691        let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
692        let session_affinity = config.session_affinity;
693        let message_hash_fallback = config.message_hash_fallback;
694        let classifier = Arc::new(TaskClassifier {
695            classifier: JudgeClassifier::new(
696                StructuredJudge::new(
697                    TaskInput {
698                        recent_turn_window: config.recent_turn_window,
699                    },
700                    contract,
701                    SerdeDecoder::new(),
702                    JudgeRuntimeConfig::new(config.max_output_tokens)?,
703                ),
704                judge_target.clone(),
705                TaskClassifierPolicy::new(
706                    efficient_target.semantic_name.clone(),
707                    capable_target.semantic_name.clone(),
708                    &config,
709                ),
710            ),
711            efficient_target: efficient_target.semantic_name.clone(),
712            capable_target: capable_target.semantic_name.clone(),
713        });
714        let inner: Arc<dyn Classifier<State>> = classifier.clone();
715        Self::from_classifier(
716            targets,
717            inner,
718            ClassifierRouteConfig {
719                default_target: classifier.capable_target.clone(),
720                session_affinity,
721                message_hash_fallback,
722            },
723        )
724    }
725
726    fn build_custom(
727        judge_target: LlmTarget,
728        targets: Vec<(String, LlmTarget)>,
729        default_target: String,
730        config: CustomClassifierConfig,
731    ) -> Result<Self> {
732        config.validate()?;
733        if targets.len() < 2 {
734            return Err(LibsyError::AlgorithmError {
735                message: "custom classifier requires at least two targets".to_string(),
736            });
737        }
738
739        let mut labels = BTreeSet::new();
740        let mut semantic_names = BTreeSet::new();
741        let mut target_map = BTreeMap::new();
742        let mut resolved_targets = Vec::with_capacity(targets.len());
743        for (label, target) in targets {
744            if label.trim().is_empty() || label.trim() != label {
745                return Err(LibsyError::AlgorithmError {
746                    message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
747                        .to_string(),
748                });
749            }
750            if !labels.insert(label.clone()) {
751                return Err(LibsyError::AlgorithmError {
752                    message: format!("custom classifier target label {label:?} is duplicated"),
753                });
754            }
755            if !semantic_names.insert(target.semantic_name.clone()) {
756                return Err(LibsyError::AlgorithmError {
757                    message: format!(
758                        "custom classifier resolved target {:?} is duplicated",
759                        target.semantic_name
760                    ),
761                });
762            }
763            target_map.insert(label, target.semantic_name.clone());
764            resolved_targets.push(target);
765        }
766        let default_semantic_name =
767            target_map
768                .get(&default_target)
769                .cloned()
770                .ok_or_else(|| LibsyError::AlgorithmError {
771                    message: format!(
772                        "default_target {default_target:?} must be one of the configured targets"
773                    ),
774                })?;
775
776        let CustomClassifierConfig {
777            prompt,
778            response_schema,
779            policy,
780            session_affinity,
781            message_hash_fallback,
782            recent_turn_window,
783            max_output_tokens,
784        } = config;
785        let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
786        let policy = match policy {
787            CustomClassifierPolicy::TargetSelector { selector } => {
788                CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
789                    selector, target_map,
790                )?)
791            }
792        };
793        let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
794            StructuredJudge::new(
795                TaskInput { recent_turn_window },
796                contract,
797                JsonSchemaDecoder::new(),
798                JudgeRuntimeConfig::new(max_output_tokens)?,
799            ),
800            judge_target,
801            policy,
802        ));
803
804        Self::from_classifier(
805            LlmTargetSet::new(resolved_targets),
806            classifier,
807            ClassifierRouteConfig {
808                default_target: default_semantic_name,
809                session_affinity,
810                message_hash_fallback,
811            },
812        )
813    }
814
815    fn build_escalation(
816        judge_target: LlmTarget,
817        efficient_target: LlmTarget,
818        capable_target: LlmTarget,
819        contract_config: ClassifierContractConfig,
820        config: EscalationJudgeConfig,
821        max_output_tokens: u64,
822    ) -> Result<Self> {
823        let capable_name = capable_target.semantic_name.clone();
824        let efficient_name = efficient_target.semantic_name.clone();
825        let confirmations = config.confirmations;
826        let esc = Arc::new(EscalationClassifier {
827            judge: escalation::build_judge(
828                judge_target,
829                capable_name,
830                efficient_name,
831                &contract_config,
832                config,
833                max_output_tokens,
834            )?,
835            capable: capable_target.clone(),
836            efficient: efficient_target.clone(),
837            confirmations,
838        });
839        let inner: Arc<dyn Classifier<State>> = esc.clone();
840        let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
841        Ok(Self {
842            route: FallThrough::<State>::new_with_state(targets)
843                .with_name(ALGORITHM_NAME)
844                .with_classifier(esc),
845            inner,
846        })
847    }
848
849    /// Loads the packaged capability-classifier contract.
850    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
851        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
852    }
853
854    /// Keeps affinity and fallback ordering identical across judge-backed modes.
855    fn from_classifier(
856        targets: LlmTargetSet,
857        inner: Arc<dyn Classifier<State>>,
858        config: ClassifierRouteConfig,
859    ) -> Result<Self> {
860        targets.get_target(&config.default_target)?;
861        if config.message_hash_fallback && !config.session_affinity {
862            return Err(LibsyError::AlgorithmError {
863                message: "message_hash_fallback requires session_affinity".to_string(),
864            });
865        }
866        // Affinity comes first so a retained assignment short-circuits the judge call.
867        // Note: when this classifier is embedded inside another cascade (e.g. StageRouter)
868        // the affinity processor never fires — only the inner score() is called.
869        let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
870        if config.session_affinity {
871            let affinity = if config.message_hash_fallback {
872                AffinityRouter::new().with_message_hash_fallback()
873            } else {
874                AffinityRouter::new()
875            };
876            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
877            let affinity = Arc::new(affinity);
878            route = route
879                .with_processor(affinity.clone())
880                .with_classifier(affinity);
881        }
882        let fallback = DefaultTarget::new(config.default_target);
883        Ok(Self {
884            route: route
885                .with_classifier(inner.clone())
886                .with_classifier(Arc::new(fallback)),
887            inner,
888        })
889    }
890}
891
892#[async_trait]
893impl Classifier<State> for TaskClassifier {
894    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
895        if self.efficient_target == self.capable_target {
896            None
897        } else if selected_model == self.efficient_target {
898            Some("weak")
899        } else if selected_model == self.capable_target {
900            Some("strong")
901        } else {
902            None
903        }
904    }
905
906    async fn score(
907        &self,
908        state: &mut State,
909        request: &mut Request,
910        driver: Option<&Driver>,
911    ) -> Result<(Classification, Option<Response>)> {
912        self.classifier.score(state, request, driver).await
913    }
914}
915
916#[async_trait]
917impl Classifier<State> for LlmTaskClassifier {
918    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
919        self.inner.routing_tier(selected_model)
920    }
921
922    async fn score(
923        &self,
924        state: &mut State,
925        request: &mut Request,
926        driver: Option<&Driver>,
927    ) -> Result<(Classification, Option<Response>)> {
928        self.inner.score(state, request, driver).await
929    }
930}
931
932#[async_trait]
933impl Algorithm for LlmTaskClassifier {
934    fn name(&self) -> &str {
935        "llm_task_classifier"
936    }
937
938    async fn create_run_task(
939        self: Arc<Self>,
940        ctx: Context,
941        driver: Driver,
942        request: Request,
943    ) -> Result<Response> {
944        self.route.execute(ctx, driver, request).await
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use std::sync::Arc;
951
952    use parking_lot::Mutex;
953    use serde_json::Value;
954
955    use super::*;
956    use switchyard_protocol::{
957        ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
958        completion_text, text_request, text_response,
959    };
960
961    use crate::algorithms::util::llm_judge::Judge;
962    use crate::core::testing::{Serve, drive, reply};
963    use switchyard_protocol::{Context, LlmResponse, Response};
964
965    const TEST_THRESHOLD: f64 = 0.5;
966
967    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
968        TaskClassifierConfig {
969            base_threshold,
970            ..TaskClassifierConfig::default()
971        }
972    }
973
974    fn policy() -> TaskClassifierPolicy {
975        TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
976    }
977
978    fn verdict(
979        p_solve: f64,
980        capability_boundary: &str,
981        primary_rule: &str,
982    ) -> TaskClassifierVerdict {
983        TaskClassifierVerdict {
984            crux: "test crux".to_string(),
985            primary_rule: primary_rule.to_string(),
986            capability_boundary: capability_boundary.to_string(),
987            p_solve,
988        }
989    }
990
991    fn selected(
992        policy: &TaskClassifierPolicy,
993        verdict: Option<&TaskClassifierVerdict>,
994    ) -> Result<String> {
995        policy
996            .to_classification(verdict)
997            .argmax(false)?
998            .map(|score| score.target)
999            .ok_or_else(|| LibsyError::AlgorithmError {
1000                message: "policy abstained".to_string(),
1001            })
1002    }
1003
1004    /// Records what each target received; answers the judge with a supported verdict and
1005    /// every other target with a plain completion.
1006    #[derive(Default)]
1007    struct Recorder {
1008        calls: Mutex<Vec<String>>,
1009        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1010        judge_system_prompts: Mutex<Vec<String>>,
1011    }
1012
1013    impl Recorder {
1014        fn calls(&self) -> Vec<String> {
1015            self.calls.lock().clone()
1016        }
1017
1018        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1019            self.judge_max_output_tokens.lock().clone()
1020        }
1021
1022        fn judge_system_prompts(&self) -> Vec<String> {
1023            self.judge_system_prompts.lock().clone()
1024        }
1025
1026        fn serve(self: &Arc<Self>) -> impl Serve {
1027            let recorder = Arc::clone(self);
1028            move |decision: Arc<dyn Decision>, request: Request| {
1029                let recorder = Arc::clone(&recorder);
1030                async move {
1031                    let model = decision.selected_model().to_string();
1032                    recorder.calls.lock().push(model.clone());
1033                    let completion = if model == "judge" {
1034                        recorder
1035                            .judge_max_output_tokens
1036                            .lock()
1037                            .push(request.llm_request.output.max_output_tokens);
1038                        recorder.judge_system_prompts.lock().extend(
1039                            request
1040                                .llm_request
1041                                .instructions
1042                                .first()
1043                                .and_then(|instruction| {
1044                                    instruction.content.iter().find_map(|b| {
1045                                        if let ContentBlock::Text { text } = b {
1046                                            Some(text.clone())
1047                                        } else {
1048                                            None
1049                                        }
1050                                    })
1051                                }),
1052                        );
1053                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1054                    } else {
1055                        format!("answer from {model}")
1056                    };
1057                    Ok(Response {
1058                        llm_response: LlmResponse::Agg(text_response(None, completion)),
1059                        metadata: request.metadata,
1060                    })
1061                }
1062            }
1063        }
1064    }
1065
1066    /// The judge times out; every other target answers normally.
1067    fn unreachable_judge() -> impl Serve {
1068        |decision: Arc<dyn Decision>, request: Request| async move {
1069            let model = decision.selected_model().to_string();
1070            if model == "judge" {
1071                return Err(LlmClientError::Timeout {
1072                    source: Box::new(std::io::Error::other("judge unreachable")),
1073                });
1074            }
1075            Ok(Response {
1076                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1077                metadata: request.metadata,
1078            })
1079        }
1080    }
1081
1082    fn router() -> Result<Arc<LlmTaskClassifier>> {
1083        let target = |name: &str| LlmTarget {
1084            semantic_name: name.to_string(),
1085        };
1086        Ok(Arc::new(LlmTaskClassifier::new(
1087            LlmClassifierConfig::Capability {
1088                judge_target: target("judge"),
1089                efficient_target: target("efficient"),
1090                capable_target: target("capable"),
1091                config: test_config(TEST_THRESHOLD),
1092            },
1093        )?))
1094    }
1095
1096    fn classify_request() -> Request {
1097        Request {
1098            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1099            raw_request: None,
1100            metadata: None,
1101        }
1102    }
1103
1104    fn classify_session_request() -> Request {
1105        Request {
1106            metadata: Some(Metadata {
1107                session_id: Some("session-1".to_string()),
1108                ..Metadata::default()
1109            }),
1110            ..classify_request()
1111        }
1112    }
1113
1114    fn classify_follow_up_request() -> Request {
1115        let mut request = classify_request();
1116        request
1117            .llm_request
1118            .messages
1119            .push(Message::text(Role::Assistant, "I will add the test."));
1120        request.llm_request.messages.push(Message::text(
1121            Role::User,
1122            "Now run the test suite and report the result.",
1123        ));
1124        request
1125    }
1126
1127    #[tokio::test]
1128    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1129        let router = router()?;
1130
1131        let (trace, response) = drive(
1132            router,
1133            Context::default(),
1134            classify_request(),
1135            unreachable_judge(),
1136        )
1137        .await?;
1138
1139        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1140        assert_eq!(
1141            response.llm_response.as_agg().map(completion_text),
1142            Some("answer from capable".to_string())
1143        );
1144        Ok(())
1145    }
1146
1147    #[tokio::test]
1148    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1149        let recorder = Arc::new(Recorder::default());
1150        let router = router()?;
1151        let request = classify_request;
1152
1153        drive(
1154            router.clone(),
1155            Context::default(),
1156            request(),
1157            recorder.serve(),
1158        )
1159        .await?;
1160        drive(
1161            router.clone(),
1162            Context::default(),
1163            request(),
1164            recorder.serve(),
1165        )
1166        .await?;
1167
1168        assert_eq!(
1169            recorder.calls(),
1170            vec!["judge", "efficient", "judge", "efficient"]
1171        );
1172        Ok(())
1173    }
1174
1175    #[tokio::test]
1176    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1177        let recorder = Arc::new(Recorder::default());
1178        let target = |name: &str| LlmTarget {
1179            semantic_name: name.to_string(),
1180        };
1181        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1182            judge_target: target("judge"),
1183            efficient_target: target("efficient"),
1184            capable_target: target("capable"),
1185            config: TaskClassifierConfig {
1186                max_output_tokens: 512,
1187                ..test_config(TEST_THRESHOLD)
1188            },
1189        })?);
1190
1191        drive(
1192            router,
1193            Context::default(),
1194            classify_request(),
1195            recorder.serve(),
1196        )
1197        .await?;
1198
1199        assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1200        Ok(())
1201    }
1202
1203    #[tokio::test]
1204    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1205        let recorder = Arc::new(Recorder::default());
1206        let target = |name: &str| LlmTarget {
1207            semantic_name: name.to_string(),
1208        };
1209        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1210            judge_target: target("judge"),
1211            efficient_target: target("efficient"),
1212            capable_target: target("capable"),
1213            config: TaskClassifierConfig {
1214                contract: ClassifierContractConfig::default()
1215                    .with_prompt("Custom capability rubric."),
1216                ..test_config(TEST_THRESHOLD)
1217            },
1218        })?);
1219
1220        drive(
1221            router,
1222            Context::default(),
1223            classify_request(),
1224            recorder.serve(),
1225        )
1226        .await?;
1227
1228        let prompts = recorder.judge_system_prompts();
1229        assert_eq!(prompts.len(), 1);
1230        assert_eq!(prompts[0], "Custom capability rubric.");
1231        Ok(())
1232    }
1233
1234    #[tokio::test]
1235    async fn classifier_config_enables_session_affinity() -> Result<()> {
1236        let recorder = Arc::new(Recorder::default());
1237        let target = |name: &str| LlmTarget {
1238            semantic_name: name.to_string(),
1239        };
1240        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1241            judge_target: target("judge"),
1242            efficient_target: target("efficient"),
1243            capable_target: target("capable"),
1244            config: TaskClassifierConfig {
1245                session_affinity: true,
1246                ..test_config(TEST_THRESHOLD)
1247            },
1248        })?);
1249
1250        let session_request = classify_session_request;
1251        drive(
1252            router.clone(),
1253            Context::default(),
1254            session_request(),
1255            recorder.serve(),
1256        )
1257        .await?;
1258        drive(
1259            router.clone(),
1260            Context::default(),
1261            session_request(),
1262            recorder.serve(),
1263        )
1264        .await?;
1265
1266        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1267        Ok(())
1268    }
1269
1270    #[tokio::test]
1271    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1272        let recorder = Arc::new(Recorder::default());
1273        let target = |name: &str| LlmTarget {
1274            semantic_name: name.to_string(),
1275        };
1276        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1277            judge_target: target("judge"),
1278            efficient_target: target("efficient"),
1279            capable_target: target("capable"),
1280            config: TaskClassifierConfig {
1281                session_affinity: true,
1282                message_hash_fallback: true,
1283                recent_turn_window: None,
1284                ..test_config(TEST_THRESHOLD)
1285            },
1286        })?);
1287
1288        drive(
1289            router.clone(),
1290            Context::default(),
1291            classify_request(),
1292            recorder.serve(),
1293        )
1294        .await?;
1295        drive(
1296            router.clone(),
1297            Context::default(),
1298            classify_follow_up_request(),
1299            recorder.serve(),
1300        )
1301        .await?;
1302
1303        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1304        Ok(())
1305    }
1306
1307    #[test]
1308    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1309        let policy = policy();
1310        let at_threshold = verdict(0.5, "supported", "SUP-1");
1311        let below_threshold = verdict(0.49, "supported", "SUP-1");
1312        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1313        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1314        Ok(())
1315    }
1316
1317    #[test]
1318    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1319        let borderline = verdict(0.5, "supported", "SUP-1");
1320        let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1321        let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1322        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1323        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1324        Ok(())
1325    }
1326
1327    #[test]
1328    fn classifier_config_rejects_unknown_fields() {
1329        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1330            "base_threshold": 0.5,
1331            "classifier_magic": true,
1332        }))
1333        .expect_err("unknown classifier fields must be rejected");
1334
1335        assert!(
1336            error
1337                .to_string()
1338                .contains("unknown field `classifier_magic`"),
1339            "{error}"
1340        );
1341    }
1342
1343    #[test]
1344    fn invalid_classifier_config_is_rejected() -> Result<()> {
1345        let target = |name: &str| LlmTarget {
1346            semantic_name: name.to_string(),
1347        };
1348        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1349            assert!(
1350                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1351                    judge_target: target("judge"),
1352                    efficient_target: target("e"),
1353                    capable_target: target("c"),
1354                    config: test_config(bad),
1355                })
1356                .is_err(),
1357                "base threshold {bad} should be rejected"
1358            );
1359        }
1360        for config in [
1361            TaskClassifierConfig {
1362                base_threshold: 0.5,
1363                threshold_step: -0.1,
1364                ..TaskClassifierConfig::default()
1365            },
1366            TaskClassifierConfig {
1367                base_threshold: 0.8,
1368                threshold_step: 0.11,
1369                ..TaskClassifierConfig::default()
1370            },
1371            TaskClassifierConfig {
1372                base_threshold: 0.5,
1373                message_hash_fallback: true,
1374                ..TaskClassifierConfig::default()
1375            },
1376            TaskClassifierConfig {
1377                base_threshold: 0.5,
1378                max_output_tokens: 0,
1379                ..TaskClassifierConfig::default()
1380            },
1381        ] {
1382            assert!(
1383                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1384                    judge_target: target("judge"),
1385                    efficient_target: target("e"),
1386                    capable_target: target("c"),
1387                    config,
1388                })
1389                .is_err()
1390            );
1391        }
1392        for base_threshold in [0.0, 1.0] {
1393            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1394                judge_target: target("judge"),
1395                efficient_target: target("e"),
1396                capable_target: target("c"),
1397                config: test_config(base_threshold),
1398            })?;
1399        }
1400        Ok(())
1401    }
1402
1403    #[test]
1404    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1405        let policy = policy();
1406        let inconsistent_rule = TaskClassifierVerdict {
1407            capability_boundary: "uncertain".to_string(),
1408            ..verdict(1.0, "supported", "SUP-1")
1409        };
1410        let empty_crux = TaskClassifierVerdict {
1411            crux: "  ".to_string(),
1412            ..verdict(1.0, "supported", "SUP-1")
1413        };
1414        let unusable = [
1415            Some(verdict(1.1, "supported", "SUP-1")),
1416            Some(inconsistent_rule),
1417            Some(empty_crux),
1418            None,
1419        ];
1420        for verdict in unusable {
1421            let classification = policy.to_classification(verdict.as_ref());
1422            assert!(matches!(classification, Classification::Ambiguous(_)));
1423            assert!(classification.argmax(false)?.is_none());
1424            assert!(classification.argmax(true)?.is_none());
1425        }
1426        Ok(())
1427    }
1428
1429    #[test]
1430    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1431        let policy = TaskClassifierPolicy::new(
1432            "efficient",
1433            "capable",
1434            &TaskClassifierConfig {
1435                threshold_step: 0.1,
1436                ..test_config(0.4)
1437            },
1438        );
1439
1440        assert_eq!(
1441            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1442            "efficient"
1443        );
1444        assert_eq!(
1445            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1446            "capable"
1447        );
1448        assert_eq!(
1449            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1450            "efficient"
1451        );
1452        assert_eq!(
1453            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1454            "efficient"
1455        );
1456        assert_eq!(
1457            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1458            "capable"
1459        );
1460        assert_eq!(
1461            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1462            "efficient"
1463        );
1464        Ok(())
1465    }
1466
1467    /// The text of each message a judge with `recent_turn_window` would be sent.
1468    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1469    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1470        Ok(StructuredJudge::new(
1471            TaskInput { recent_turn_window },
1472            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1473            SerdeDecoder::new(),
1474            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1475        ))
1476    }
1477
1478    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1479        let judge = capability_judge(Some(recent_turn_window))?;
1480        let request = Request {
1481            llm_request: LlmRequest {
1482                messages: vec![
1483                    Message::text(Role::System, "client instructions"),
1484                    Message::text(Role::User, "initial task"),
1485                    Message::text(Role::Assistant, "old response"),
1486                    Message::text(Role::User, "old follow-up"),
1487                    Message::text(Role::Assistant, "recent 1"),
1488                    Message::text(Role::User, "recent 2"),
1489                ],
1490                ..LlmRequest::default()
1491            },
1492            raw_request: None,
1493            metadata: None,
1494        };
1495        Ok(judge
1496            .build_request(&State::default(), &request)
1497            .llm_request
1498            .messages
1499            .iter()
1500            .filter_map(|message| message.text_content("\n"))
1501            .collect())
1502    }
1503
1504    #[test]
1505    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1506        // Client instructions and the opening task, plus the last two turns.
1507        let contents = judged_contents(2)?;
1508        assert!(contents.contains(&"client instructions".to_string()));
1509        assert!(contents.contains(&"initial task".to_string()));
1510        assert!(contents.contains(&"recent 1".to_string()));
1511        assert!(contents.contains(&"recent 2".to_string()));
1512        assert!(!contents.contains(&"old response".to_string()));
1513        Ok(())
1514    }
1515
1516    #[test]
1517    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1518        let contents = judged_contents(0)?;
1519        assert!(contents.contains(&"client instructions".to_string()));
1520        assert!(contents.contains(&"initial task".to_string()));
1521        assert!(!contents.contains(&"recent 2".to_string()));
1522        Ok(())
1523    }
1524
1525    fn tool_call(id: &str) -> Message {
1526        Message {
1527            role: Role::Assistant,
1528            content: vec![ContentBlock::ToolCall(ToolCall {
1529                id: id.to_string(),
1530                name: "search".to_string(),
1531                arguments: Value::Null,
1532            })],
1533        }
1534    }
1535
1536    fn tool_result(id: &str) -> Message {
1537        Message {
1538            role: Role::Tool,
1539            content: vec![ContentBlock::ToolResult(ToolResult {
1540                tool_call_id: id.to_string(),
1541                content: vec![ContentBlock::Text {
1542                    text: "tool output".to_string(),
1543                }],
1544                is_error: None,
1545            })],
1546        }
1547    }
1548
1549    /// A count-based window can begin on a tool result, which leaves the call that
1550    /// introduced its id outside the window and the classifier history invalid.
1551    #[test]
1552    fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1553        let messages = vec![
1554            Message::text(Role::System, "client instructions"),
1555            Message::text(Role::User, "initial task"),
1556            Message::text(Role::Assistant, "old response"),
1557            tool_call("call-1"),
1558            tool_result("call-1"),
1559            Message::text(Role::Assistant, "recent 1"),
1560            Message::text(Role::User, "recent 2"),
1561            Message::text(Role::Assistant, "recent 3"),
1562            Message::text(Role::User, "recent 4"),
1563        ];
1564
1565        // The five-message tail begins exactly on the tool result.
1566        let kept = trim_messages(&messages, 5);
1567
1568        assert_eq!(
1569            kept,
1570            vec![
1571                Message::text(Role::System, "client instructions"),
1572                Message::text(Role::User, "initial task"),
1573                tool_call("call-1"),
1574                tool_result("call-1"),
1575                Message::text(Role::Assistant, "recent 1"),
1576                Message::text(Role::User, "recent 2"),
1577                Message::text(Role::Assistant, "recent 3"),
1578                Message::text(Role::User, "recent 4"),
1579            ]
1580        );
1581    }
1582
1583    /// Ids repeat across a conversation, so a later call must not stand in for the one that
1584    /// answers an earlier result.
1585    #[test]
1586    fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1587        let messages = vec![
1588            Message::text(Role::System, "client instructions"),
1589            Message::text(Role::User, "initial task"),
1590            tool_call("x"),
1591            tool_result("x"),
1592            Message::text(Role::Assistant, "later"),
1593            tool_call("x"),
1594            tool_result("x"),
1595        ];
1596
1597        // The four-message tail begins on the first result, whose own call sits one earlier.
1598        let kept = trim_messages(&messages, 4);
1599
1600        assert_eq!(
1601            kept,
1602            vec![
1603                Message::text(Role::System, "client instructions"),
1604                Message::text(Role::User, "initial task"),
1605                tool_call("x"),
1606                tool_result("x"),
1607                Message::text(Role::Assistant, "later"),
1608                tool_call("x"),
1609                tool_result("x"),
1610            ]
1611        );
1612    }
1613
1614    /// A result whose call precedes the opening task can never be paired, because trimming
1615    /// never reaches behind the task. The window must not widen hunting for it.
1616    #[test]
1617    fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1618        let messages = vec![
1619            Message::text(Role::System, "client instructions"),
1620            tool_call("orphan"),
1621            Message::text(Role::User, "initial task"),
1622            Message::text(Role::Assistant, "old response"),
1623            tool_result("orphan"),
1624            Message::text(Role::Assistant, "recent 1"),
1625            Message::text(Role::User, "recent 2"),
1626        ];
1627
1628        let kept = trim_messages(&messages, 3);
1629
1630        assert_eq!(
1631            kept,
1632            vec![
1633                Message::text(Role::System, "client instructions"),
1634                Message::text(Role::User, "initial task"),
1635                tool_result("orphan"),
1636                Message::text(Role::Assistant, "recent 1"),
1637                Message::text(Role::User, "recent 2"),
1638            ]
1639        );
1640    }
1641
1642    #[test]
1643    fn capability_judge_builds_a_structured_request() -> Result<()> {
1644        let judge = capability_judge(None)?;
1645        let request = Request {
1646            llm_request: LlmRequest {
1647                model: Some("inbound".to_string()),
1648                messages: vec![
1649                    Message::text(Role::System, "client instructions"),
1650                    Message::text(Role::Developer, "client developer instructions"),
1651                    Message::text(Role::User, "initial task"),
1652                    Message::text(Role::Assistant, "old response"),
1653                    Message::text(Role::User, "old follow-up"),
1654                    Message::text(Role::Assistant, "recent 1"),
1655                    Message::text(Role::User, "recent 2"),
1656                    Message::text(Role::Assistant, "recent 3"),
1657                    Message::text(Role::User, "recent 4"),
1658                    Message::text(Role::Assistant, "recent 5"),
1659                ],
1660                ..LlmRequest::default()
1661            },
1662            raw_request: None,
1663            metadata: None,
1664        };
1665        let judge_request = judge.build_request(&State::default(), &request);
1666
1667        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1668        assert_eq!(judge_request.llm_request.instructions.len(), 1);
1669        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1670        assert_eq!(
1671            judge_request.llm_request.instructions[0].content,
1672            InstructionBlock {
1673                role: Role::System,
1674                content: Message::text(Role::System, judge.contract().system_prompt()).content,
1675            }
1676            .content,
1677        );
1678        assert_eq!(judge_request.llm_request.messages.len(), 2);
1679        let contents = judge_request
1680            .llm_request
1681            .messages
1682            .iter()
1683            .filter_map(|message| message.text_content("\n"))
1684            .collect::<Vec<_>>();
1685        assert!(contents.contains(&"recent 4".to_string()));
1686        assert!(contents.contains(&"initial task".to_string()));
1687        assert!(!contents.contains(&"recent 5".to_string()));
1688        assert!(!contents.contains(&"client instructions".to_string()));
1689        assert_eq!(
1690            judge_request.llm_request.output.response_format,
1691            Some(judge.contract().response_format().clone())
1692        );
1693        assert_eq!(
1694            judge_request.llm_request.output.max_output_tokens,
1695            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1696        );
1697        Ok(())
1698    }
1699
1700    fn sample_value(spec: &Value) -> Value {
1701        if let Some(first) = spec
1702            .get("enum")
1703            .and_then(Value::as_array)
1704            .and_then(|values| values.first())
1705        {
1706            return first.clone();
1707        }
1708        match spec.get("type").and_then(Value::as_str) {
1709            Some("number") => serde_json::json!(0.5),
1710            Some("boolean") => serde_json::json!(false),
1711            _ => serde_json::json!("sample"),
1712        }
1713    }
1714
1715    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1716        let properties = schema
1717            .pointer("/json_schema/schema/properties")
1718            .and_then(Value::as_object)
1719            .ok_or_else(|| LibsyError::AlgorithmError {
1720                message: "packaged schema declares no properties".to_string(),
1721            })?;
1722        Ok(Value::Object(
1723            properties
1724                .iter()
1725                .map(|(name, spec)| (name.clone(), sample_value(spec)))
1726                .collect(),
1727        )
1728        .to_string())
1729    }
1730
1731    /// Built from the schema so a property added there fails here rather than silently
1732    /// rejecting every production verdict.
1733    #[test]
1734    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1735        let contract =
1736            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1737        let schema = contract.response_format();
1738        let reply = schema_shaped_verdict(schema)?;
1739        let judge: CapabilityJudge = StructuredJudge::new(
1740            TaskInput {
1741                recent_turn_window: None,
1742            },
1743            contract,
1744            SerdeDecoder::new(),
1745            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1746        );
1747
1748        let verdict = judge.parse(&text_response(None, reply))?;
1749
1750        assert!(verdict.is_valid());
1751        assert!((0.0..=1.0).contains(&verdict.p_solve));
1752        Ok(())
1753    }
1754
1755    #[test]
1756    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1757        let contract =
1758            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1759        let prompt = contract.system_prompt();
1760        let schema_name = contract
1761            .response_format()
1762            .pointer("/json_schema/name")
1763            .and_then(Value::as_str)
1764            .ok_or_else(|| LibsyError::AlgorithmError {
1765                message: "packaged response schema has no name".to_string(),
1766            })?;
1767        assert_eq!(schema_name, "CapabilityClassifierDecision");
1768        assert!(prompt.contains("SUP-1 [supported]"));
1769        assert!(prompt.contains("SUP-5 [supported]"));
1770        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1771        assert!(!prompt.contains("\"type\": \"object\""));
1772        assert!(!prompt.contains("\"json_schema\""));
1773        assert!(!prompt.contains(schema_name));
1774        let rule_values = contract
1775            .response_format()
1776            .pointer("/json_schema/schema/properties/primary_rule/enum")
1777            .and_then(Value::as_array)
1778            .ok_or_else(|| LibsyError::AlgorithmError {
1779                message: "rendered response schema has no primary rule enum".to_string(),
1780            })?;
1781        assert!(
1782            rule_values
1783                .iter()
1784                .any(|value| value.as_str() == Some("SUP-1"))
1785        );
1786        assert!(
1787            rule_values
1788                .iter()
1789                .any(|value| value.as_str() == Some("none"))
1790        );
1791        Ok(())
1792    }
1793
1794    // ── with_escalation tests ──────────────────────────────────────────────
1795
1796    use std::collections::VecDeque;
1797
1798    use switchyard_protocol::Decision;
1799
1800    /// A queue of replies, drained in order.
1801    struct Queue(Mutex<VecDeque<String>>);
1802
1803    impl Queue {
1804        fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1805            Arc::new(Self(Mutex::new(
1806                replies.into_iter().map(String::from).collect(),
1807            )))
1808        }
1809
1810        fn take(&self) -> String {
1811            self.0
1812                .lock()
1813                .pop_front()
1814                .unwrap_or_else(|| "unexpected call".to_string())
1815        }
1816    }
1817
1818    /// Serves the judge target from `judge` and every other target from `model`, each with
1819    /// its next queued reply.
1820    fn queued(model: Arc<Queue>, judge: Arc<Queue>) -> impl Serve {
1821        move |decision: Arc<dyn Decision>, request: Request| {
1822            let queue = if decision.selected_model() == "judge" {
1823                Arc::clone(&judge)
1824            } else {
1825                Arc::clone(&model)
1826            };
1827            async move {
1828                Ok(Response {
1829                    llm_response: LlmResponse::Agg(text_response(None, queue.take())),
1830                    metadata: request.metadata,
1831                })
1832            }
1833        }
1834    }
1835
1836    /// Builds a router with escalation enabled (`confirmations=1` latches on the first verdict).
1837    fn escalation_router() -> Result<Arc<LlmTaskClassifier>> {
1838        let target = |name: &str| LlmTarget {
1839            semantic_name: name.to_string(),
1840        };
1841        Ok(Arc::new(LlmTaskClassifier::new(
1842            LlmClassifierConfig::Escalation {
1843                judge_target: target("judge"),
1844                efficient_target: target("efficient"),
1845                capable_target: target("capable"),
1846                contract: ClassifierContractConfig::default(),
1847                config: EscalationJudgeConfig {
1848                    confirmations: 1,
1849                    ..EscalationJudgeConfig::default()
1850                },
1851                max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1852            },
1853        )?))
1854    }
1855
1856    #[tokio::test]
1857    async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1858        // Judge: no escalation. Expect the efficient response to be returned directly.
1859        let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1860        let model = Queue::new(["efficient answer"]);
1861        let router = escalation_router()?;
1862
1863        let (trace, response) = drive(
1864            router,
1865            Context::default(),
1866            classify_request(),
1867            queued(model, judge),
1868        )
1869        .await?;
1870
1871        // The efficient model is the serving target, and the response comes from its call.
1872        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1873        assert_eq!(
1874            response.llm_response.as_agg().map(completion_text),
1875            Some("efficient answer".to_string())
1876        );
1877        Ok(())
1878    }
1879
1880    #[tokio::test]
1881    async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1882        let recorder = Arc::new(Recorder::default());
1883        let target = |name: &str| LlmTarget {
1884            semantic_name: name.to_string(),
1885        };
1886        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1887            judge_target: target("judge"),
1888            efficient_target: target("efficient"),
1889            capable_target: target("capable"),
1890            contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1891            config: EscalationJudgeConfig {
1892                confirmations: 1,
1893                ..EscalationJudgeConfig::default()
1894            },
1895            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1896        })?);
1897
1898        drive(
1899            router,
1900            Context::default(),
1901            classify_request(),
1902            recorder.serve(),
1903        )
1904        .await?;
1905
1906        let prompts = recorder.judge_system_prompts();
1907        assert_eq!(prompts.len(), 1);
1908        assert_eq!(prompts[0], "Custom trajectory rubric.");
1909        Ok(())
1910    }
1911
1912    #[tokio::test]
1913    async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1914        // Judge: escalate. After the efficient call, the streak confirms and capable is served.
1915        let judge = Queue::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1916        // Efficient is called first (by the classifier), then capable is called by FallThrough.
1917        let model = Queue::new(["efficient draft", "capable answer"]);
1918        let router = escalation_router()?;
1919
1920        let (trace, response) = drive(
1921            router,
1922            Context::default(),
1923            classify_request(),
1924            queued(model, judge),
1925        )
1926        .await?;
1927
1928        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1929        assert_eq!(
1930            response.llm_response.as_agg().map(completion_text),
1931            Some("capable answer".to_string())
1932        );
1933        Ok(())
1934    }
1935
1936    #[tokio::test]
1937    async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1938        // First turn: judge escalates and the streak latches.
1939        // Second turn: judge is not called again; capable is served directly.
1940        let judge = Queue::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1941        let model = Queue::new(["efficient draft", "capable t1", "capable t2"]);
1942        let router = escalation_router()?;
1943
1944        let session_request = classify_session_request();
1945        drive(
1946            router.clone(),
1947            Context::default(),
1948            session_request.clone(),
1949            queued(Arc::clone(&model), Arc::clone(&judge)),
1950        )
1951        .await?;
1952        let (trace, _) = drive(
1953            router.clone(),
1954            Context::default(),
1955            session_request,
1956            queued(model, judge),
1957        )
1958        .await?;
1959
1960        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1961        Ok(())
1962    }
1963
1964    #[tokio::test]
1965    async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
1966        // When the efficient model exceeds its context window inside score(), the classifier
1967        // must return capable rather than propagating the error — otherwise the client sees
1968        // HTTP 400 instead of a response from the strong model.
1969        let router = escalation_router()?;
1970
1971        // Efficient overflows, capable answers, and the judge must never be called.
1972        let serve = |decision: Arc<dyn Decision>, _request: Request| async move {
1973            match decision.selected_model() {
1974                "efficient" => Err(LlmClientError::ContextWindowExceeded {
1975                    model: decision.selected_model().to_string(),
1976                    message: "prompt is too long".to_string(),
1977                }),
1978                "judge" => panic!("the judge must not be consulted when efficient overflows"),
1979                _ => Ok(reply("capable answer")),
1980            }
1981        };
1982
1983        let (trace, response) =
1984            drive(router, Context::default(), classify_request(), serve).await?;
1985
1986        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1987        assert_eq!(
1988            response.llm_response.as_agg().map(completion_text),
1989            Some("capable answer".to_string())
1990        );
1991        Ok(())
1992    }
1993}