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 crate::text::{completion_text, text_request, text_response};
919    use switchyard_protocol::{LlmClientError, LlmRequest, Metadata};
920
921    use crate::algorithms::util::llm_judge::Judge;
922    use crate::core::algorithm::Algorithm;
923    use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
924
925    const TEST_THRESHOLD: f64 = 0.5;
926
927    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
928        TaskClassifierConfig {
929            base_threshold,
930            ..TaskClassifierConfig::default()
931        }
932    }
933
934    fn policy() -> TaskClassifierPolicy {
935        TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
936    }
937
938    /// A verdict whose non-routing fields are fixed — only the three the policy reads vary.
939    fn verdict(p_solve: f64, confidence: f64, abstain: bool) -> TaskClassifierVerdict {
940        TaskClassifierVerdict {
941            _recommended_route: "efficient".to_string(),
942            p_solve,
943            confidence,
944            abstain,
945            capability_boundary: "supported".to_string(),
946            _primary_rule: "SUP-1".to_string(),
947            _crux: "test crux".to_string(),
948        }
949    }
950
951    fn selected(
952        policy: &TaskClassifierPolicy,
953        verdict: Option<&TaskClassifierVerdict>,
954    ) -> Result<String> {
955        policy
956            .to_classification(verdict)
957            .argmax(false)?
958            .map(|score| score.target)
959            .ok_or_else(|| LibsyError::AlgorithmError {
960                message: "policy abstained".to_string(),
961            })
962    }
963
964    #[derive(Default)]
965    struct PerRequestClient {
966        calls: Mutex<Vec<String>>,
967        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
968        judge_system_prompts: Mutex<Vec<String>>,
969    }
970
971    impl PerRequestClient {
972        fn calls(&self) -> Vec<String> {
973            self.calls.lock().clone()
974        }
975
976        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
977            self.judge_max_output_tokens.lock().clone()
978        }
979
980        fn judge_system_prompts(&self) -> Vec<String> {
981            self.judge_system_prompts.lock().clone()
982        }
983    }
984
985    #[async_trait]
986    impl RoutedLlmClient for PerRequestClient {
987        async fn call(
988            &self,
989            _ctx: Context,
990            request: Request,
991            decision: Arc<dyn Decision>,
992        ) -> std::result::Result<Response, LlmClientError> {
993            let model = decision.selected_model().to_string();
994            self.calls.lock().push(model.clone());
995            let completion = if model == "judge" {
996                self.judge_max_output_tokens
997                    .lock()
998                    .push(request.llm_request.output.max_output_tokens);
999                self.judge_system_prompts.lock().extend(
1000                    request
1001                        .llm_request
1002                        .messages
1003                        .first()
1004                        .and_then(|message| message.text_content("\n")),
1005                );
1006                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()
1007            } else {
1008                format!("answer from {model}")
1009            };
1010            Ok(Response {
1011                llm_response: LlmResponse::Agg(text_response(None, completion)),
1012                metadata: request.metadata,
1013            })
1014        }
1015    }
1016
1017    struct UnreachableJudgeClient;
1018
1019    #[async_trait]
1020    impl RoutedLlmClient for UnreachableJudgeClient {
1021        async fn call(
1022            &self,
1023            _ctx: Context,
1024            request: Request,
1025            decision: Arc<dyn Decision>,
1026        ) -> std::result::Result<Response, LlmClientError> {
1027            let model = decision.selected_model().to_string();
1028            if model == "judge" {
1029                return Err(LlmClientError::Timeout {
1030                    source: Box::new(std::io::Error::other("judge unreachable")),
1031                });
1032            }
1033            Ok(Response {
1034                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1035                metadata: request.metadata,
1036            })
1037        }
1038    }
1039
1040    fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1041        let target = |name: &str| LlmTarget {
1042            semantic_name: name.to_string(),
1043            llm_client: Some(client.clone()),
1044        };
1045        Ok(Arc::new(LlmTaskClassifier::new(
1046            LlmClassifierConfig::Capability {
1047                judge_target: target("judge"),
1048                efficient_target: target("efficient"),
1049                capable_target: target("capable"),
1050                config: test_config(TEST_THRESHOLD),
1051            },
1052        )?))
1053    }
1054
1055    fn classify_request() -> Request {
1056        Request {
1057            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1058            raw_request: None,
1059            metadata: None,
1060        }
1061    }
1062
1063    fn classify_session_request() -> Request {
1064        Request {
1065            metadata: Some(Metadata {
1066                session_id: Some("session-1".to_string()),
1067                ..Metadata::default()
1068            }),
1069            ..classify_request()
1070        }
1071    }
1072
1073    fn classify_follow_up_request() -> Request {
1074        let mut request = classify_request();
1075        request
1076            .llm_request
1077            .messages
1078            .push(Message::text(Role::Assistant, "I will add the test."));
1079        request.llm_request.messages.push(Message::text(
1080            Role::User,
1081            "Now run the test suite and report the result.",
1082        ));
1083        request
1084    }
1085
1086    #[tokio::test]
1087    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1088        let router = router(Arc::new(UnreachableJudgeClient))?;
1089
1090        let (trace, response) = router.run(Context::default(), classify_request()).await?;
1091
1092        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1093        assert_eq!(
1094            response.llm_response.as_agg().map(completion_text),
1095            Some("answer from capable".to_string())
1096        );
1097        Ok(())
1098    }
1099
1100    #[tokio::test]
1101    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1102        let client = Arc::new(PerRequestClient::default());
1103        let router = router(client.clone())?;
1104        let request = classify_request;
1105
1106        router.clone().run(Context::default(), request()).await?;
1107        router.clone().run(Context::default(), request()).await?;
1108
1109        assert_eq!(
1110            client.calls(),
1111            vec!["judge", "efficient", "judge", "efficient"]
1112        );
1113        Ok(())
1114    }
1115
1116    #[tokio::test]
1117    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1118        let client = Arc::new(PerRequestClient::default());
1119        let target = |name: &str| LlmTarget {
1120            semantic_name: name.to_string(),
1121            llm_client: Some(client.clone()),
1122        };
1123        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1124            judge_target: target("judge"),
1125            efficient_target: target("efficient"),
1126            capable_target: target("capable"),
1127            config: TaskClassifierConfig {
1128                max_output_tokens: 512,
1129                ..test_config(TEST_THRESHOLD)
1130            },
1131        })?);
1132
1133        router.run(Context::default(), classify_request()).await?;
1134
1135        assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1136        Ok(())
1137    }
1138
1139    #[tokio::test]
1140    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1141        let client = Arc::new(PerRequestClient::default());
1142        let target = |name: &str| LlmTarget {
1143            semantic_name: name.to_string(),
1144            llm_client: Some(client.clone()),
1145        };
1146        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1147            judge_target: target("judge"),
1148            efficient_target: target("efficient"),
1149            capable_target: target("capable"),
1150            config: TaskClassifierConfig {
1151                contract: ClassifierContractConfig::default()
1152                    .with_prompt("Custom capability rubric:\n{{RESPONSE_SCHEMA}}"),
1153                ..test_config(TEST_THRESHOLD)
1154            },
1155        })?);
1156
1157        router.run(Context::default(), classify_request()).await?;
1158
1159        let prompts = client.judge_system_prompts();
1160        assert_eq!(prompts.len(), 1);
1161        assert!(prompts[0].starts_with("Custom capability rubric:"));
1162        assert!(prompts[0].contains("\"recommended_route\""));
1163        assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
1164        Ok(())
1165    }
1166
1167    #[tokio::test]
1168    async fn classifier_config_enables_session_affinity() -> Result<()> {
1169        let client = Arc::new(PerRequestClient::default());
1170        let target = |name: &str| LlmTarget {
1171            semantic_name: name.to_string(),
1172            llm_client: Some(client.clone()),
1173        };
1174        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1175            judge_target: target("judge"),
1176            efficient_target: target("efficient"),
1177            capable_target: target("capable"),
1178            config: TaskClassifierConfig {
1179                session_affinity: true,
1180                ..test_config(TEST_THRESHOLD)
1181            },
1182        })?);
1183
1184        router
1185            .clone()
1186            .run(Context::default(), classify_session_request())
1187            .await?;
1188        router
1189            .clone()
1190            .run(Context::default(), classify_session_request())
1191            .await?;
1192
1193        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1194        Ok(())
1195    }
1196
1197    #[tokio::test]
1198    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1199        let client = Arc::new(PerRequestClient::default());
1200        let target = |name: &str| LlmTarget {
1201            semantic_name: name.to_string(),
1202            llm_client: Some(client.clone()),
1203        };
1204        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1205            judge_target: target("judge"),
1206            efficient_target: target("efficient"),
1207            capable_target: target("capable"),
1208            config: TaskClassifierConfig {
1209                session_affinity: true,
1210                message_hash_fallback: true,
1211                recent_turn_window: None,
1212                ..test_config(TEST_THRESHOLD)
1213            },
1214        })?);
1215
1216        router
1217            .clone()
1218            .run(Context::default(), classify_request())
1219            .await?;
1220        router
1221            .clone()
1222            .run(Context::default(), classify_follow_up_request())
1223            .await?;
1224
1225        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1226        Ok(())
1227    }
1228
1229    #[test]
1230    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1231        let policy = policy();
1232        let at_threshold = verdict(0.5, 0.0, false);
1233        let below_threshold = verdict(0.49, 1.0, false);
1234        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1235        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1236        Ok(())
1237    }
1238
1239    #[test]
1240    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1241        let borderline = verdict(0.5, 1.0, false);
1242        let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1243        let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1244        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1245        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1246        Ok(())
1247    }
1248
1249    #[test]
1250    fn classifier_config_rejects_unknown_fields() {
1251        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1252            "base_threshold": 0.5,
1253            "classifier_magic": true,
1254        }))
1255        .expect_err("unknown classifier fields must be rejected");
1256
1257        assert!(
1258            error
1259                .to_string()
1260                .contains("unknown field `classifier_magic`"),
1261            "{error}"
1262        );
1263    }
1264
1265    #[test]
1266    fn invalid_classifier_config_is_rejected() -> Result<()> {
1267        let target = |name: &str| LlmTarget {
1268            semantic_name: name.to_string(),
1269            llm_client: None,
1270        };
1271        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1272            assert!(
1273                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1274                    judge_target: target("judge"),
1275                    efficient_target: target("e"),
1276                    capable_target: target("c"),
1277                    config: test_config(bad),
1278                })
1279                .is_err(),
1280                "base threshold {bad} should be rejected"
1281            );
1282        }
1283        for config in [
1284            TaskClassifierConfig {
1285                base_threshold: 0.5,
1286                min_confidence: 1.1,
1287                ..TaskClassifierConfig::default()
1288            },
1289            TaskClassifierConfig {
1290                base_threshold: 0.5,
1291                capability_elevated_floor: Some(0.5),
1292                ..TaskClassifierConfig::default()
1293            },
1294            TaskClassifierConfig {
1295                base_threshold: 0.5,
1296                message_hash_fallback: true,
1297                ..TaskClassifierConfig::default()
1298            },
1299            TaskClassifierConfig {
1300                base_threshold: 0.5,
1301                max_output_tokens: 0,
1302                ..TaskClassifierConfig::default()
1303            },
1304        ] {
1305            assert!(
1306                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1307                    judge_target: target("judge"),
1308                    efficient_target: target("e"),
1309                    capable_target: target("c"),
1310                    config,
1311                })
1312                .is_err()
1313            );
1314        }
1315        for base_threshold in [0.0, 1.0] {
1316            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1317                judge_target: target("judge"),
1318                efficient_target: target("e"),
1319                capable_target: target("c"),
1320                config: test_config(base_threshold),
1321            })?;
1322        }
1323        Ok(())
1324    }
1325
1326    #[test]
1327    fn an_unusable_verdict_abstains() -> Result<()> {
1328        // Invalid, abstained, unintelligible, or absent: the judge could not tell,
1329        // so it declines to decide and leaves the fallback to whoever composed the
1330        // cascade.
1331        let policy = policy();
1332        let invalid_boundary = TaskClassifierVerdict {
1333            capability_boundary: "unknown".to_string(),
1334            ..verdict(1.0, 1.0, false)
1335        };
1336        let unusable = [
1337            Some(verdict(1.1, 1.0, false)),
1338            Some(verdict(1.0, 1.0, true)),
1339            Some(invalid_boundary),
1340            None,
1341        ];
1342        for verdict in unusable {
1343            let classification = policy.to_classification(verdict.as_ref());
1344            assert!(matches!(classification, Classification::Ambiguous(_)));
1345            assert!(classification.argmax(false)?.is_none());
1346            assert!(classification.argmax(true)?.is_none());
1347        }
1348        Ok(())
1349    }
1350
1351    #[test]
1352    fn elevated_capability_floor_is_a_targeted_safety_brake() -> Result<()> {
1353        let policy = TaskClassifierPolicy::new(
1354            "efficient",
1355            "capable",
1356            &TaskClassifierConfig {
1357                capability_elevated_floor: Some(0.45),
1358                ..test_config(0.25)
1359            },
1360        );
1361        let supported = verdict(0.30, 1.0, false);
1362        let elevated = TaskClassifierVerdict {
1363            capability_boundary: "uncertain".to_string(),
1364            ..verdict(0.30, 1.0, false)
1365        };
1366        let strong_elevated = TaskClassifierVerdict {
1367            capability_boundary: "unsupported".to_string(),
1368            ..verdict(0.50, 1.0, false)
1369        };
1370
1371        assert_eq!(selected(&policy, Some(&supported))?, "efficient");
1372        assert_eq!(selected(&policy, Some(&elevated))?, "capable");
1373        assert_eq!(selected(&policy, Some(&strong_elevated))?, "efficient");
1374        Ok(())
1375    }
1376
1377    /// The text of each message a judge with `recent_turn_window` would be sent.
1378    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1379    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1380        Ok(StructuredJudge::new(
1381            TaskInput { recent_turn_window },
1382            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1383            SerdeDecoder::new(),
1384            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1385        ))
1386    }
1387
1388    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1389        let judge = capability_judge(Some(recent_turn_window))?;
1390        let request = Request {
1391            llm_request: LlmRequest {
1392                messages: vec![
1393                    Message::text(Role::System, "client instructions"),
1394                    Message::text(Role::User, "initial task"),
1395                    Message::text(Role::Assistant, "old response"),
1396                    Message::text(Role::User, "old follow-up"),
1397                    Message::text(Role::Assistant, "recent 1"),
1398                    Message::text(Role::User, "recent 2"),
1399                ],
1400                ..LlmRequest::default()
1401            },
1402            raw_request: None,
1403            metadata: None,
1404        };
1405        Ok(judge
1406            .build_request(&State::default(), &request)
1407            .llm_request
1408            .messages
1409            .iter()
1410            .filter_map(|message| message.text_content("\n"))
1411            .collect())
1412    }
1413
1414    #[test]
1415    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1416        // Client instructions and the opening task, plus the last two turns.
1417        let contents = judged_contents(2)?;
1418        assert!(contents.contains(&"client instructions".to_string()));
1419        assert!(contents.contains(&"initial task".to_string()));
1420        assert!(contents.contains(&"recent 1".to_string()));
1421        assert!(contents.contains(&"recent 2".to_string()));
1422        assert!(!contents.contains(&"old response".to_string()));
1423        Ok(())
1424    }
1425
1426    #[test]
1427    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1428        let contents = judged_contents(0)?;
1429        assert!(contents.contains(&"client instructions".to_string()));
1430        assert!(contents.contains(&"initial task".to_string()));
1431        assert!(!contents.contains(&"recent 2".to_string()));
1432        Ok(())
1433    }
1434
1435    #[test]
1436    fn capability_judge_builds_a_structured_request() -> Result<()> {
1437        let judge = capability_judge(None)?;
1438        let request = Request {
1439            llm_request: LlmRequest {
1440                model: Some("inbound".to_string()),
1441                messages: vec![
1442                    Message::text(Role::System, "client instructions"),
1443                    Message::text(Role::Developer, "client developer instructions"),
1444                    Message::text(Role::User, "initial task"),
1445                    Message::text(Role::Assistant, "old response"),
1446                    Message::text(Role::User, "old follow-up"),
1447                    Message::text(Role::Assistant, "recent 1"),
1448                    Message::text(Role::User, "recent 2"),
1449                    Message::text(Role::Assistant, "recent 3"),
1450                    Message::text(Role::User, "recent 4"),
1451                    Message::text(Role::Assistant, "recent 5"),
1452                ],
1453                ..LlmRequest::default()
1454            },
1455            raw_request: None,
1456            metadata: None,
1457        };
1458        let judge_request = judge.build_request(&State::default(), &request);
1459
1460        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1461        assert_eq!(judge_request.llm_request.messages.len(), 2);
1462        let contents = judge_request
1463            .llm_request
1464            .messages
1465            .iter()
1466            .filter_map(|message| message.text_content("\n"))
1467            .collect::<Vec<_>>();
1468        assert!(contents.contains(&"recent 4".to_string()));
1469        assert!(!contents.contains(&"initial task".to_string()));
1470        assert!(!contents.contains(&"recent 5".to_string()));
1471        assert!(!contents.contains(&"client instructions".to_string()));
1472        assert_eq!(
1473            judge_request.llm_request.output.response_format,
1474            Some(judge.contract().response_format().clone())
1475        );
1476        assert_eq!(
1477            judge_request.llm_request.output.max_output_tokens,
1478            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1479        );
1480        Ok(())
1481    }
1482
1483    fn sample_value(spec: &Value) -> Value {
1484        if let Some(first) = spec
1485            .get("enum")
1486            .and_then(Value::as_array)
1487            .and_then(|values| values.first())
1488        {
1489            return first.clone();
1490        }
1491        match spec.get("type").and_then(Value::as_str) {
1492            Some("number") => serde_json::json!(0.5),
1493            Some("boolean") => serde_json::json!(false),
1494            _ => serde_json::json!("sample"),
1495        }
1496    }
1497
1498    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1499        let properties = schema
1500            .pointer("/json_schema/schema/properties")
1501            .and_then(Value::as_object)
1502            .ok_or_else(|| LibsyError::AlgorithmError {
1503                message: "packaged schema declares no properties".to_string(),
1504            })?;
1505        Ok(Value::Object(
1506            properties
1507                .iter()
1508                .map(|(name, spec)| (name.clone(), sample_value(spec)))
1509                .collect(),
1510        )
1511        .to_string())
1512    }
1513
1514    /// Built from the schema so a property added there fails here rather than silently
1515    /// rejecting every production verdict.
1516    #[test]
1517    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1518        let contract =
1519            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1520        let schema = contract.response_format();
1521        let reply = schema_shaped_verdict(schema)?;
1522        let judge: CapabilityJudge = StructuredJudge::new(
1523            TaskInput {
1524                recent_turn_window: None,
1525            },
1526            contract,
1527            SerdeDecoder::new(),
1528            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1529        );
1530
1531        let verdict = judge.parse(&text_response(None, reply))?;
1532
1533        assert!(verdict.is_valid());
1534        assert!(!verdict.abstain);
1535        Ok(())
1536    }
1537
1538    #[test]
1539    fn prompt_includes_concrete_rules_and_schema() -> Result<()> {
1540        let contract =
1541            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1542        let prompt = contract.system_prompt();
1543        assert!(prompt.contains("SUP-1 [supported]"));
1544        assert!(!prompt.contains("{{CAPABILITY_RULES}}"));
1545        assert!(!prompt.contains("{{PRIMARY_RULE_VALUES}}"));
1546        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1547        assert!(prompt.contains("\"type\": \"object\""));
1548        assert!(!prompt.contains("\"json_schema\""));
1549        assert!(!prompt.contains("\"CapabilityClassifierDecision\""));
1550        let rule_values = contract
1551            .response_format()
1552            .pointer("/json_schema/schema/properties/primary_rule/enum")
1553            .and_then(Value::as_array)
1554            .ok_or_else(|| LibsyError::AlgorithmError {
1555                message: "rendered response schema has no primary rule enum".to_string(),
1556            })?;
1557        assert!(
1558            rule_values
1559                .iter()
1560                .any(|value| value.as_str() == Some("SUP-1"))
1561        );
1562        assert!(
1563            rule_values
1564                .iter()
1565                .any(|value| value.as_str() == Some("none"))
1566        );
1567        Ok(())
1568    }
1569
1570    // ── with_escalation tests ──────────────────────────────────────────────
1571
1572    use std::collections::VecDeque;
1573
1574    use switchyard_protocol::LlmClientError as ClientError;
1575
1576    use switchyard_protocol::Decision;
1577
1578    /// Serves each call with the next queued reply.
1579    struct QueuedClient {
1580        replies: Mutex<VecDeque<String>>,
1581    }
1582
1583    impl QueuedClient {
1584        fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1585            Arc::new(Self {
1586                replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1587            })
1588        }
1589    }
1590
1591    #[async_trait]
1592    impl RoutedLlmClient for QueuedClient {
1593        async fn call(
1594            &self,
1595            _ctx: Context,
1596            request: Request,
1597            _decision: Arc<dyn Decision>,
1598        ) -> std::result::Result<Response, ClientError> {
1599            let reply = self
1600                .replies
1601                .lock()
1602                .pop_front()
1603                .unwrap_or_else(|| "unexpected call".to_string());
1604            Ok(Response {
1605                llm_response: LlmResponse::Agg(text_response(None, reply)),
1606                metadata: request.metadata,
1607            })
1608        }
1609    }
1610
1611    /// Builds a router with escalation enabled (`confirmations=1` latches on the first verdict).
1612    fn escalation_router(
1613        client: Arc<dyn RoutedLlmClient>,
1614        judge_client: Arc<dyn RoutedLlmClient>,
1615    ) -> Result<Arc<LlmTaskClassifier>> {
1616        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1617            semantic_name: name.to_string(),
1618            llm_client: Some(c),
1619        };
1620        Ok(Arc::new(LlmTaskClassifier::new(
1621            LlmClassifierConfig::Escalation {
1622                judge_target: target("judge", judge_client),
1623                efficient_target: target("efficient", client.clone()),
1624                capable_target: target("capable", client),
1625                contract: ClassifierContractConfig::default(),
1626                config: EscalationJudgeConfig {
1627                    confirmations: 1,
1628                    ..EscalationJudgeConfig::default()
1629                },
1630                max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1631            },
1632        )?))
1633    }
1634
1635    #[tokio::test]
1636    async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1637        // Judge: no escalation. Expect the efficient response to be returned directly.
1638        let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1639        let model_client = QueuedClient::new(["efficient answer"]);
1640        let router = escalation_router(model_client, judge_client)?;
1641        let request = classify_request();
1642
1643        let (trace, response) = router.run(Context::default(), request).await?;
1644
1645        // The efficient model is the serving target, and the response comes from its call.
1646        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1647        assert_eq!(
1648            response.llm_response.as_agg().map(completion_text),
1649            Some("efficient answer".to_string())
1650        );
1651        Ok(())
1652    }
1653
1654    #[tokio::test]
1655    async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1656        let client = Arc::new(PerRequestClient::default());
1657        let target = |name: &str| LlmTarget {
1658            semantic_name: name.to_string(),
1659            llm_client: Some(client.clone()),
1660        };
1661        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1662            judge_target: target("judge"),
1663            efficient_target: target("efficient"),
1664            capable_target: target("capable"),
1665            contract: ClassifierContractConfig::default()
1666                .with_prompt("Custom trajectory rubric:\n{{RESPONSE_SCHEMA}}"),
1667            config: EscalationJudgeConfig {
1668                confirmations: 1,
1669                ..EscalationJudgeConfig::default()
1670            },
1671            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1672        })?);
1673
1674        router.run(Context::default(), classify_request()).await?;
1675
1676        let prompts = client.judge_system_prompts();
1677        assert_eq!(prompts.len(), 1);
1678        assert!(prompts[0].starts_with("Custom trajectory rubric:"));
1679        assert!(prompts[0].contains("\"escalate\""));
1680        assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
1681        Ok(())
1682    }
1683
1684    #[tokio::test]
1685    async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1686        // Judge: escalate. After the efficient call, the streak confirms and capable is served.
1687        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1688        // Efficient is called first (by the classifier), then capable is called by FallThrough.
1689        let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1690        let router = escalation_router(model_client, judge_client)?;
1691        let request = classify_request();
1692
1693        let (trace, response) = router.run(Context::default(), request).await?;
1694
1695        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1696        assert_eq!(
1697            response.llm_response.as_agg().map(completion_text),
1698            Some("capable answer".to_string())
1699        );
1700        Ok(())
1701    }
1702
1703    #[tokio::test]
1704    async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1705        // First turn: judge escalates and the streak latches.
1706        // Second turn: judge is not called again; capable is served directly.
1707        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1708        let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1709        let router = escalation_router(model_client, judge_client)?;
1710
1711        let session_request = classify_session_request();
1712        router
1713            .clone()
1714            .run(Context::default(), session_request.clone())
1715            .await?;
1716        let (trace, _) = router
1717            .clone()
1718            .run(Context::default(), session_request)
1719            .await?;
1720
1721        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1722        Ok(())
1723    }
1724}