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