Skip to main content

switchyard_libsy/algorithms/
llm_class.rs

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