Skip to main content

switchyard_libsy/algorithms/
llm_class.rs

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