Skip to main content

switchyard_libsy/algorithms/
llm_class.rs

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