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::HashSet;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{Category, ContentBlock, Message, Role};
13
14mod decision;
15pub use decision::{DecisionChoice, DecisionJudgeConfig, DirectClassifierConfig};
16use decision::{DecisionClassifier, DirectClassifier};
17
18use super::escalation;
19use super::fall_through::FallThrough;
20use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
21use super::util::affinity::{AffinityRouter, ClassifyTrigger, is_user_turn};
22use super::util::classifier_contract::{
23    ClassifierContract, ClassifierContractConfig, ClassifierResponseFormat,
24};
25use super::util::escalation::EscalationJudgeConfig;
26use super::util::llm_judge::{
27    ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
28    SerdeDecoder, StructuredJudge,
29};
30use super::util::target_selector::TargetSelectorPolicy;
31use crate::core::algorithm::{Algorithm, Driver};
32use crate::core::classifier::{Classification, Classifier, Score};
33use crate::core::state::State;
34use crate::{LibsyError, Result};
35use switchyard_protocol::{Request, Response};
36
37const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
38const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
39/// Telemetry label for this algorithm's spans, metrics, and logs.
40const ALGORITHM_NAME: &str = "llm_task_classifier";
41
42/// Restates the task after the conversation the judge is asked to route.
43///
44/// The rubric leads the request, which works while the payload is a single short
45/// message. Once `recent_turn_window` includes real conversation, those instructions
46/// sit far from the generation point and the judge sometimes *answers* the
47/// conversation instead of classifying it — the reply then fails to parse, the
48/// verdict is unavailable, and the turn silently falls back. Repeating the task at
49/// the end is what reliably prevents that.
50const TRAILING_ROUTING_INSTRUCTION: &str =
51    "Route the conversation above. Output ONLY the routing JSON object, nothing else.";
52
53#[derive(Deserialize)]
54#[serde(deny_unknown_fields)]
55struct TaskClassifierVerdict {
56    crux: String,
57    primary_rule: String,
58    capability_boundary: String,
59    p_solve: f64,
60}
61
62impl TaskClassifierVerdict {
63    /// Rejects malformed or internally inconsistent verdicts before policy evaluation.
64    fn is_valid(&self) -> bool {
65        (0.0..=1.0).contains(&self.p_solve)
66            && !self.crux.trim().is_empty()
67            && matches!(
68                (
69                    self.primary_rule.as_str(),
70                    self.capability_boundary.as_str()
71                ),
72                ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
73                    | ("UNC-1" | "UNC-2", "uncertain")
74                    | ("LIM-1" | "LIM-2", "unsupported")
75                    | ("none", "unmatched")
76            )
77    }
78
79    /// Returns the number of threshold steps assigned to this capability boundary.
80    ///
81    /// `unmatched_steps` comes from the route configuration: a request that matches no
82    /// capability rule has no evidence behind its solve probability, so operators can
83    /// require more confidence for it than for an uncertain verdict.
84    fn boundary_steps(&self, unmatched_steps: u8) -> Option<u8> {
85        match self.capability_boundary.as_str() {
86            "supported" => Some(0),
87            "uncertain" => Some(1),
88            "unmatched" => Some(unmatched_steps),
89            "unsupported" => Some(2),
90            _ => None,
91        }
92    }
93}
94
95/// Keeps the opening task and the last `recent_turn_window` turns after it. A
96/// window of `0` keeps the task alone.
97///
98/// Inline system and developer messages are kept, in their original order,
99/// ahead of the task.
100///
101/// Selects by reference and clones only what survives — a coding-agent
102/// conversation carries every tool result, so cloning it whole to keep a window
103/// would copy the transcript on each judged turn.
104fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
105    let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
106    let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
107    let Some(task) = messages.iter().position(is_user_turn) else {
108        return kept.into_iter().cloned().collect();
109    };
110    kept.push(&messages[task]);
111
112    let tail: Vec<&Message> = messages[task + 1..]
113        .iter()
114        .filter(|m| !is_instruction(m))
115        .collect();
116    kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
117    kept.into_iter().cloned().collect()
118}
119
120/// The first index of the trailing window.
121///
122/// Counting messages alone can start the window between an assistant tool call and the
123/// result answering it, leaving the judge a result whose call id was never introduced. The
124/// start therefore moves back to the nearest one that keeps every tool pair whole.
125///
126/// One newest-to-oldest pass carries the ids still waiting for a call. Direction is what
127/// makes it correct: ids repeat across a conversation, and in this order a call is only
128/// ever seen after the results it could answer, so a later call — already passed — clears
129/// nothing. A result whose call sits before the opening task, which trimming never reaches,
130/// keeps the set non-empty to the end and falls back to the counted start, so an unpairable
131/// result costs one pass and cannot widen the window to the whole conversation.
132fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
133    let counted = tail.len().saturating_sub(recent_turn_window);
134    // An empty window holds no result to pair, and the loop below never visits its start.
135    if counted == tail.len() {
136        return counted;
137    }
138    let mut unpaired: HashSet<&str> = HashSet::new();
139    for (start, message) in tail.iter().enumerate().rev() {
140        // Blocks reverse too, so a call answers a result only when it precedes it inside
141        // one message as well as across messages.
142        for block in message.content.iter().rev() {
143            match block {
144                ContentBlock::ToolResult(result) => {
145                    unpaired.insert(result.tool_call_id.as_str());
146                }
147                ContentBlock::ToolCall(call) => {
148                    unpaired.remove(call.id.as_str());
149                }
150                _ => {}
151            }
152        }
153        if start <= counted && unpaired.is_empty() {
154            return start;
155        }
156    }
157    counted
158}
159
160/// Keeps the opening task and the latest user follow-up when they differ.
161fn task_messages(messages: &[Message]) -> Vec<Message> {
162    // Decoders also use the user role for tool results. Select ordinary user content
163    // first, so a tool result cannot replace the opening task or latest follow-up.
164    let is_task_content = |block: &ContentBlock| {
165        !matches!(
166            block,
167            ContentBlock::ToolCall(_)
168                | ContentBlock::ToolResult(_)
169                | ContentBlock::Reasoning { .. }
170        )
171    };
172    let mut user_messages = messages
173        .iter()
174        .filter(|message| is_user_turn(message) && message.content.iter().any(is_task_content));
175    let Some(opening_task) = user_messages.next() else {
176        return Vec::new();
177    };
178    [Some(opening_task), user_messages.next_back()]
179        .into_iter()
180        .flatten()
181        .map(|message| {
182            Message::new(
183                Role::User,
184                message
185                    .content
186                    .iter()
187                    .filter(|block| is_task_content(block))
188                    .cloned()
189                    .collect(),
190            )
191        })
192        .collect()
193}
194
195/// Selects the task messages shown to capability and custom-schema classifiers.
196struct TaskInput {
197    recent_turn_window: Option<usize>,
198}
199
200impl TaskInput {
201    /// Selects conversation content without adding judge instructions.
202    fn messages(&self, request: &Request) -> Vec<Message> {
203        // The default preserves the whole-task anchor and latest user update. A
204        // configured window widens that to the surrounding conversation.
205        let mut messages = match self.recent_turn_window {
206            Some(window) => trim_messages(&request.llm_request.messages, window),
207            None => task_messages(&request.llm_request.messages),
208        };
209        // Reasoning is provider-private and not required to classify the task. Some
210        // upstreams also reject an unsigned reasoning item replayed without the
211        // opaque state it was issued with, so it cannot travel through a windowed
212        // classifier request as ordinary history. Empty text goes with it: OpenAI
213        // Chat decoding leaves an empty text placeholder beside the reasoning of a
214        // reasoning-only turn, and an Anthropic judge rejects a message whose text
215        // block is empty.
216        for message in &mut messages {
217            message.content.retain(|block| match block {
218                ContentBlock::Reasoning { .. } => false,
219                ContentBlock::Text { text } => !text.is_empty(),
220                _ => true,
221            });
222        }
223        messages.retain(|message| !message.content.is_empty());
224        messages
225    }
226}
227
228impl ClassifierInput for TaskInput {
229    fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
230        let mut messages = self.messages(request);
231        // Only the windowed path carries assistant turns and tool traffic for the judge
232        // to be distracted by. The default path is user task messages only — the anchor
233        // and the latest follow-up — so there is nothing there to outrank.
234        if self.recent_turn_window.is_some() {
235            messages.push(Message::text(
236                Role::User,
237                TRAILING_ROUTING_INSTRUCTION.to_string(),
238            ));
239        }
240        messages
241    }
242}
243
244struct TaskClassifierPolicy {
245    base_threshold: f64,
246    threshold_step: f64,
247    unmatched_steps: u8,
248}
249
250impl TaskClassifierPolicy {
251    fn new(config: &LlmCapabilityConfig) -> Self {
252        Self {
253            base_threshold: config.base_threshold,
254            threshold_step: config.threshold_step,
255            unmatched_steps: config.unmatched_steps,
256        }
257    }
258
259    /// Returns the required solve probability for one validated verdict.
260    fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
261        let steps = verdict.boundary_steps(self.unmatched_steps)?;
262        Some(self.base_threshold + f64::from(steps) * self.threshold_step)
263    }
264}
265
266impl JudgePolicy for TaskClassifierPolicy {
267    type Verdict = TaskClassifierVerdict;
268
269    fn to_classification(
270        &self,
271        verdict: Option<&Self::Verdict>,
272        driver: &Driver,
273    ) -> Result<Classification> {
274        // Judge output is untrusted. An absent, invalid, or inconsistent verdict is
275        // ambiguous so the surrounding router applies its configured fallback.
276        let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
277            return Ok(Classification::Ambiguous(vec![]));
278        };
279        // A usable verdict below the capability threshold is still a decision: the judge
280        // does not trust the efficient tier with this task.
281        let Some(threshold) = self.threshold(verdict) else {
282            return Ok(Classification::Ambiguous(vec![]));
283        };
284        let category = if verdict.p_solve >= threshold
285            || (threshold - verdict.p_solve).abs() <= f64::EPSILON
286        {
287            Category::Efficient
288        } else {
289            Category::Capable
290        };
291        // The chosen category may have no models configured. That is ambiguous, not an
292        // error, so the surrounding router applies its configured fallback.
293        let Some(target) = driver.models_for(&category).first().cloned() else {
294            return Ok(Classification::Ambiguous(vec![]));
295        };
296        Ok(Classification::Scores(vec![Score {
297            target,
298            confidence: 1.0,
299            category: Some(category),
300        }]))
301    }
302}
303
304/// Maps valid verdicts to scores, invalid verdicts to a reason, and leaves absent verdicts alone.
305fn capability_evidence(
306    policy: &TaskClassifierPolicy,
307    verdict: Option<&TaskClassifierVerdict>,
308) -> Option<Value> {
309    let verdict = verdict?;
310    let Some(threshold) = verdict
311        .is_valid()
312        .then(|| policy.threshold(verdict))
313        .flatten()
314    else {
315        return Some(serde_json::json!({
316            "source": "fail_open",
317            "reason_code": "invalid_verdict",
318        }));
319    };
320    Some(serde_json::json!({
321        "source": "llm-classifier",
322        "score": verdict.p_solve,
323        "threshold": threshold,
324    }))
325}
326
327/// Settings shared by capability judges and their surrounding route.
328#[derive(Clone, Debug)]
329pub struct TaskClassifierConfig {
330    /// The judge's prediction contract and routing policy.
331    pub judge: CapabilityJudgeConfig,
332    /// Routes to the capable tier on judge client failures and deadlines. Defaults to true.
333    pub fail_open: bool,
334    /// How often the classifier re-decides this session's target.
335    pub classify_trigger: ClassifyTrigger,
336    /// Uses the first user message as the SessionKey for sticky routing when session metadata is unavailable.
337    pub message_hash_fallback: bool,
338    /// `None` judges the opening task and latest user follow-up. `Some(n)` also
339    /// includes the surrounding conversation using the last `n` turns.
340    pub recent_turn_window: Option<usize>,
341}
342
343/// Selects how a capability judge obtains and interprets its prediction.
344#[derive(Clone, Debug)]
345pub enum CapabilityJudgeConfig {
346    /// A structured LLM verdict with a solve probability and capability boundary.
347    Llm(LlmCapabilityConfig),
348    /// A Choice score for the capable candidate succeeding where the efficient one fails.
349    Decision(DecisionJudgeConfig),
350}
351
352impl Default for CapabilityJudgeConfig {
353    fn default() -> Self {
354        Self::Llm(LlmCapabilityConfig::default())
355    }
356}
357
358/// Prompt, output, and solve-probability settings for an LLM capability judge.
359#[derive(Clone, Debug)]
360pub struct LlmCapabilityConfig {
361    /// Lowest solve probability that routes a supported task to the efficient target.
362    pub base_threshold: f64,
363    /// Amount added per capability-boundary step: zero for supported, one for
364    /// uncertain, `unmatched_steps` for unmatched, and two for unsupported.
365    pub threshold_step: f64,
366    /// Threshold steps applied to an unmatched verdict (no capability rule applies).
367    ///
368    /// Defaults to 1, the same as an uncertain verdict. 2 treats it like unsupported.
369    pub unmatched_steps: u8,
370    /// Prompt and verdict contract settings for the classifier judge.
371    pub contract: ClassifierContractConfig,
372    /// Maximum completion tokens available to the classifier verdict.
373    pub max_output_tokens: u64,
374    /// Whole-consultation bound on the judge call, in milliseconds. Covers the model
375    /// call and the response drain; on expiry the judge is treated as unavailable and
376    /// follows the route's `fail_open` setting. `None` is unbounded.
377    pub judge_deadline_ms: Option<u64>,
378}
379
380impl Default for LlmCapabilityConfig {
381    fn default() -> Self {
382        Self {
383            base_threshold: 0.0,
384            threshold_step: 0.0,
385            unmatched_steps: DEFAULT_UNMATCHED_STEPS,
386            contract: ClassifierContractConfig::default(),
387            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
388            judge_deadline_ms: None,
389        }
390    }
391}
392
393/// Flat serialized shape that maps prompt settings into the runtime contract.
394#[derive(Deserialize)]
395#[serde(deny_unknown_fields)]
396struct TaskClassifierConfigWire {
397    base_threshold: f64,
398    #[serde(default = "default_fail_open")]
399    fail_open: bool,
400    #[serde(default)]
401    threshold_step: f64,
402    #[serde(default = "default_unmatched_steps")]
403    unmatched_steps: u8,
404    #[serde(default)]
405    classify_trigger: ClassifyTrigger,
406    #[serde(default)]
407    message_hash_fallback: bool,
408    #[serde(default)]
409    recent_turn_window: Option<usize>,
410    #[serde(default)]
411    prompt: Option<String>,
412    #[serde(default)]
413    response_format_type: ClassifierResponseFormat,
414    #[serde(default = "default_judge_max_output_tokens")]
415    max_output_tokens: u64,
416    #[serde(default)]
417    judge_deadline_ms: Option<u64>,
418}
419
420impl<'de> Deserialize<'de> for TaskClassifierConfig {
421    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
422    where
423        D: Deserializer<'de>,
424    {
425        let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
426        let mut contract = ClassifierContractConfig::default();
427        if let Some(prompt) = wire.prompt {
428            contract = contract.with_prompt(prompt);
429        }
430        contract = contract.with_response_format_type(wire.response_format_type);
431        Ok(Self {
432            judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
433                base_threshold: wire.base_threshold,
434                threshold_step: wire.threshold_step,
435                unmatched_steps: wire.unmatched_steps,
436                contract,
437                max_output_tokens: wire.max_output_tokens,
438                judge_deadline_ms: wire.judge_deadline_ms,
439            }),
440            fail_open: wire.fail_open,
441            classify_trigger: wire.classify_trigger,
442            message_hash_fallback: wire.message_hash_fallback,
443            recent_turn_window: wire.recent_turn_window,
444        })
445    }
446}
447
448const fn default_fail_open() -> bool {
449    true
450}
451
452/// Unmatched verdicts share the uncertain step unless the route says otherwise.
453pub const DEFAULT_UNMATCHED_STEPS: u8 = 1;
454
455const fn default_unmatched_steps() -> u8 {
456    DEFAULT_UNMATCHED_STEPS
457}
458
459const fn default_judge_max_output_tokens() -> u64 {
460    DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
461}
462
463impl Default for TaskClassifierConfig {
464    fn default() -> Self {
465        Self {
466            judge: CapabilityJudgeConfig::default(),
467            fail_open: default_fail_open(),
468            classify_trigger: ClassifyTrigger::default(),
469            message_hash_fallback: false,
470            recent_turn_window: None,
471        }
472    }
473}
474
475impl LlmCapabilityConfig {
476    fn validate(&self) -> Result<()> {
477        if !(0.0..=1.0).contains(&self.base_threshold) {
478            return Err(LibsyError::AlgorithmError {
479                message: format!(
480                    "base_threshold must be between 0 and 1, got {}",
481                    self.base_threshold
482                ),
483            });
484        }
485        if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
486            return Err(LibsyError::AlgorithmError {
487                message: format!(
488                    "threshold_step must be finite and greater than or equal to 0, got {}",
489                    self.threshold_step
490                ),
491            });
492        }
493        if self.unmatched_steps > 2 {
494            return Err(LibsyError::AlgorithmError {
495                message: format!(
496                    "unmatched_steps must be 0, 1, or 2, got {}",
497                    self.unmatched_steps
498                ),
499            });
500        }
501        let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
502        if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
503            return Err(LibsyError::AlgorithmError {
504                message: format!(
505                    "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
506                ),
507            });
508        }
509        if self.max_output_tokens == 0 {
510            return Err(LibsyError::AlgorithmError {
511                message: "max_output_tokens must be at least 1".to_string(),
512            });
513        }
514        Ok(())
515    }
516}
517
518impl TaskClassifierConfig {
519    fn validate(&self) -> Result<()> {
520        match &self.judge {
521            CapabilityJudgeConfig::Llm(config) => config.validate()?,
522            CapabilityJudgeConfig::Decision(config) => config.validate()?,
523        }
524        // Only `every_request` is rejected: it retains no target, so a fallback identity has nothing to key on. Both retaining triggers can key the retained target on a message hash when the caller sends no session id.
525        if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
526            return Err(LibsyError::AlgorithmError {
527                message:
528                    "message_hash_fallback requires classify_trigger = new_session or user_turn"
529                        .to_string(),
530            });
531        }
532        Ok(())
533    }
534}
535
536/// Policy that maps a custom classifier verdict to a routing target.
537#[derive(Clone, Debug)]
538pub enum CustomClassifierPolicy {
539    /// Resolves a JSON Pointer and treats its string value as a model category.
540    TargetSelector {
541        /// JSON Pointer evaluated against each schema-validated verdict.
542        selector: String,
543    },
544}
545
546impl CustomClassifierPolicy {
547    /// Creates a policy that selects a model category through a JSON Pointer.
548    pub fn target_selector(selector: impl Into<String>) -> Self {
549        Self::TargetSelector {
550            selector: selector.into(),
551        }
552    }
553}
554
555/// Settings for a classifier whose JSON Schema and target-selection policy are user supplied.
556#[derive(Clone, Debug)]
557pub struct CustomClassifierConfig {
558    /// System prompt sent to the classifier judge.
559    pub prompt: String,
560    /// Inner JSON Schema placed inside the provider's structured-output wrapper.
561    pub response_schema: Value,
562    /// Deterministic policy applied after the verdict passes schema validation.
563    pub policy: CustomClassifierPolicy,
564    /// How often the classifier re-decides this session's target.
565    pub classify_trigger: ClassifyTrigger,
566    /// Uses the first user message when session metadata is unavailable.
567    pub message_hash_fallback: bool,
568    /// Trailing conversation turns shown to the classifier judge.
569    pub recent_turn_window: Option<usize>,
570    /// Maximum completion tokens available to the classifier verdict.
571    pub max_output_tokens: u64,
572    /// Whole-consultation bound on the judge call, in milliseconds. `None` is unbounded.
573    pub judge_deadline_ms: Option<u64>,
574}
575
576impl CustomClassifierConfig {
577    /// Creates a custom-schema classifier contract with conservative runtime defaults.
578    pub fn new(
579        prompt: impl Into<String>,
580        response_schema: Value,
581        policy: CustomClassifierPolicy,
582    ) -> Self {
583        Self {
584            prompt: prompt.into(),
585            response_schema,
586            policy,
587            classify_trigger: ClassifyTrigger::default(),
588            message_hash_fallback: false,
589            recent_turn_window: None,
590            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
591            judge_deadline_ms: None,
592        }
593    }
594
595    fn validate(&self) -> Result<()> {
596        if self.max_output_tokens == 0 {
597            return Err(LibsyError::AlgorithmError {
598                message: "max_output_tokens must be at least 1".to_string(),
599            });
600        }
601        // Only `every_request` is rejected: it retains no target, so a fallback identity has nothing to key on. Both retaining triggers can key the retained target on a message hash when the caller sends no session id.
602        if self.message_hash_fallback && self.classify_trigger == ClassifyTrigger::EveryRequest {
603            return Err(LibsyError::AlgorithmError {
604                message:
605                    "message_hash_fallback requires classify_trigger = new_session or user_turn"
606                        .to_string(),
607            });
608        }
609        Ok(())
610    }
611}
612
613enum CustomPolicyRuntime {
614    TargetSelector(TargetSelectorPolicy),
615}
616
617impl JudgePolicy for CustomPolicyRuntime {
618    type Verdict = Value;
619
620    fn to_classification(
621        &self,
622        verdict: Option<&Self::Verdict>,
623        driver: &Driver,
624    ) -> Result<Classification> {
625        match self {
626            Self::TargetSelector(policy) => policy.to_classification(verdict, driver),
627        }
628    }
629}
630
631/// Builds the affinity router a trigger calls for, if any.
632fn affinity_router(
633    trigger: ClassifyTrigger,
634    message_hash_fallback: bool,
635) -> Option<Arc<AffinityRouter>> {
636    let router = match trigger {
637        ClassifyTrigger::EveryRequest => return None,
638        ClassifyTrigger::NewSession => AffinityRouter::new(),
639        ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
640    };
641    let router = if message_hash_fallback {
642        router.with_message_hash_fallback()
643    } else {
644        router
645    };
646    Some(Arc::new(router))
647}
648
649/// Routes requests through a capability, escalation, custom, or direct classifier mode.
650pub struct LlmTaskClassifier {
651    route: FallThrough,
652    /// Classifier used when this router is embedded in another cascade.
653    inner: Arc<dyn Classifier>,
654}
655
656struct ClassifierRouteConfig {
657    default_target: Category,
658    classify_trigger: ClassifyTrigger,
659    message_hash_fallback: bool,
660}
661
662/// Terminal classifier for a cascade whose classifiers may all abstain.
663/// Closes a cascade with the first runtime model in `category`.
664pub struct DefaultCategoryClassifier(pub Category);
665
666#[async_trait]
667impl Classifier for DefaultCategoryClassifier {
668    async fn score(
669        &self,
670        _state: &mut State,
671        _request: &mut Request,
672        driver: &Driver,
673    ) -> Result<(Classification, Option<Response>)> {
674        let target = driver.first_model_for(&self.0)?;
675        driver.set_evidence_if_empty(serde_json::json!({"source": "fall_open"}));
676        Ok((
677            Classification::Scores(vec![Score {
678                target: target.clone(),
679                confidence: 0.0,
680                category: Some(self.0.clone()),
681            }]),
682            None,
683        ))
684    }
685}
686
687/// Complete construction settings for one LLM classifier mode.
688#[derive(Clone)]
689#[non_exhaustive]
690pub enum LlmClassifierConfig {
691    /// Routes between efficient and capable targets from a task-level verdict.
692    Capability {
693        /// Capability classifier settings.
694        config: TaskClassifierConfig,
695    },
696    /// Judges efficient responses and escalates after a confirmed streak.
697    Escalation {
698        /// Prompt and verdict contract settings for the escalation judge.
699        contract: ClassifierContractConfig,
700        /// Escalation policy settings.
701        config: EscalationJudgeConfig,
702        /// Maximum completion tokens available to the escalation verdict.
703        max_output_tokens: u64,
704        /// Whole-consultation bound on the escalation judge call, in milliseconds.
705        judge_deadline_ms: Option<u64>,
706    },
707    /// Routes among model categories using a user-supplied schema and policy.
708    Custom {
709        /// Category selected when the judge does not produce a usable verdict.
710        default_target: Category,
711        /// Custom classifier settings.
712        config: CustomClassifierConfig,
713    },
714    /// Routes to the model category a decision service selects.
715    Direct {
716        /// Category used when the decision call fails or returns an invalid choice.
717        default_target: Category,
718        /// Direct selection settings.
719        config: DirectClassifierConfig,
720    },
721}
722
723impl LlmTaskClassifier {
724    /// Builds the classifier mode described by `config`.
725    ///
726    /// # Errors
727    ///
728    /// Returns an error when the selected mode's targets, contract, policy, or runtime
729    /// settings are invalid.
730    pub fn new(config: LlmClassifierConfig) -> Result<Self> {
731        match config {
732            LlmClassifierConfig::Capability { config } => Self::build_capability(config),
733            LlmClassifierConfig::Escalation {
734                contract,
735                config,
736                max_output_tokens,
737                judge_deadline_ms,
738            } => Self::build_escalation(contract, config, max_output_tokens, judge_deadline_ms),
739            LlmClassifierConfig::Custom {
740                default_target,
741                config,
742            } => Self::build_custom(default_target, config),
743            LlmClassifierConfig::Direct {
744                default_target,
745                config,
746            } => Self::build_direct(default_target, config),
747        }
748    }
749
750    fn build_capability(config: TaskClassifierConfig) -> Result<Self> {
751        config.validate()?;
752        let classify_trigger = config.classify_trigger;
753        let message_hash_fallback = config.message_hash_fallback;
754        let input = TaskInput {
755            recent_turn_window: config.recent_turn_window,
756        };
757        let classifier: Arc<dyn Classifier> = match config.judge {
758            CapabilityJudgeConfig::Llm(judge) => Arc::new(
759                JudgeClassifier::new(
760                    StructuredJudge::new(
761                        input,
762                        Self::load_capability_contract(&judge.contract)?,
763                        SerdeDecoder::new(),
764                        JudgeRuntimeConfig::new(judge.max_output_tokens)?
765                            .with_deadline_ms(judge.judge_deadline_ms)?,
766                    ),
767                    TaskClassifierPolicy::new(&judge),
768                )
769                .with_error_recovery(config.fail_open)
770                .with_evidence(capability_evidence),
771            ),
772            CapabilityJudgeConfig::Decision(judge) => {
773                Arc::new(DecisionClassifier::new(judge, input, config.fail_open)?)
774            }
775        };
776        Self::from_classifier(
777            classifier,
778            ClassifierRouteConfig {
779                default_target: Category::Capable,
780                classify_trigger,
781                message_hash_fallback,
782            },
783        )
784    }
785
786    fn build_custom(default_target: Category, config: CustomClassifierConfig) -> Result<Self> {
787        Self::from_classifier(
788            Self::custom_classifier(&config, false)?,
789            ClassifierRouteConfig {
790                default_target,
791                classify_trigger: config.classify_trigger,
792                message_hash_fallback: config.message_hash_fallback,
793            },
794        )
795    }
796
797    fn build_direct(default_target: Category, config: DirectClassifierConfig) -> Result<Self> {
798        if !config.options.contains(&default_target) {
799            return Err(LibsyError::AlgorithmError {
800                message: "direct default_target must be one of the options".into(),
801            });
802        }
803        let route = ClassifierRouteConfig {
804            default_target,
805            classify_trigger: config.classify_trigger,
806            message_hash_fallback: config.message_hash_fallback,
807        };
808        Self::from_classifier(Arc::new(DirectClassifier::new(config)?), route)
809    }
810
811    /// The judge face alone, for composite's own trigger and retained tier.
812    pub(crate) fn custom_classifier(
813        config: &CustomClassifierConfig,
814        fail_open: bool,
815    ) -> Result<Arc<dyn Classifier>> {
816        config.validate()?;
817        let contract =
818            ClassifierContract::from_inner_schema(&config.prompt, config.response_schema.clone())?;
819        let policy = match &config.policy {
820            CustomClassifierPolicy::TargetSelector { selector } => {
821                CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(selector)?)
822            }
823        };
824        let classifier = JudgeClassifier::new(
825            StructuredJudge::new(
826                TaskInput {
827                    recent_turn_window: config.recent_turn_window,
828                },
829                contract,
830                JsonSchemaDecoder::new(),
831                JudgeRuntimeConfig::new(config.max_output_tokens)?
832                    .with_deadline_ms(config.judge_deadline_ms)?,
833            ),
834            policy,
835        )
836        .with_error_recovery(fail_open);
837        let classifier = if fail_open {
838            classifier.with_evidence(|_, verdict| {
839                verdict.map(|_| serde_json::json!({"source": "llm-classifier"}))
840            })
841        } else {
842            classifier
843        };
844        Ok(Arc::new(classifier))
845    }
846
847    fn build_escalation(
848        contract_config: ClassifierContractConfig,
849        config: EscalationJudgeConfig,
850        max_output_tokens: u64,
851        judge_deadline_ms: Option<u64>,
852    ) -> Result<Self> {
853        let inner = escalation::build_classifier(
854            contract_config,
855            config,
856            max_output_tokens,
857            judge_deadline_ms,
858        )?;
859        Ok(Self {
860            route: FallThrough::new_with_state()
861                .with_name(ALGORITHM_NAME)
862                .with_classifier(Arc::clone(&inner)),
863            inner,
864        })
865    }
866
867    /// Loads the packaged capability-classifier contract.
868    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
869        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
870    }
871
872    /// Keeps affinity and fallback ordering identical across judge-backed modes.
873    fn from_classifier(inner: Arc<dyn Classifier>, config: ClassifierRouteConfig) -> Result<Self> {
874        // Only `every_request` is rejected: it retains no target, so a fallback identity has nothing to key on. Both retaining triggers can key the retained target on a message hash when the caller sends no session id.
875        if config.message_hash_fallback && config.classify_trigger == ClassifyTrigger::EveryRequest
876        {
877            return Err(LibsyError::AlgorithmError {
878                message:
879                    "message_hash_fallback requires classify_trigger = new_session or user_turn"
880                        .to_string(),
881            });
882        }
883        // Affinity comes first so a retained assignment short-circuits the judge call.
884        let mut route = FallThrough::new_with_state().with_name(ALGORITHM_NAME);
885        if let Some(affinity) =
886            affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
887        {
888            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
889            route = route
890                .with_processor(affinity.clone())
891                .with_classifier(affinity.clone());
892        }
893        let fallback = DefaultCategoryClassifier(config.default_target);
894        Ok(Self {
895            route: route
896                .with_classifier(inner.clone())
897                .with_classifier(Arc::new(fallback)),
898            inner,
899        })
900    }
901}
902
903#[async_trait]
904impl Classifier for LlmTaskClassifier {
905    async fn score(
906        &self,
907        state: &mut State,
908        request: &mut Request,
909        driver: &Driver,
910    ) -> Result<(Classification, Option<Response>)> {
911        self.inner.score(state, request, driver).await
912    }
913}
914
915#[async_trait]
916impl Algorithm for LlmTaskClassifier {
917    fn name(&self) -> &str {
918        "llm_task_classifier"
919    }
920
921    async fn route(
922        self: Arc<Self>,
923        driver: Driver,
924        request: Request,
925    ) -> Result<crate::RoutingOutcome> {
926        self.route.execute(driver, request).await
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use std::collections::HashMap;
933    use std::sync::Arc;
934
935    use parking_lot::Mutex;
936    use serde_json::Value;
937
938    use super::*;
939    use switchyard_protocol::{
940        ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId, ToolCall,
941        ToolResult, completion_text, text_request, text_response,
942    };
943
944    use crate::algorithms::util::llm_judge::Judge;
945    use crate::core::testing::{Serve, test_drive_with_models};
946    use switchyard_protocol::{LlmResponse, Response};
947
948    const TEST_THRESHOLD: f64 = 0.5;
949
950    type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
951
952    fn llm_config(base_threshold: f64) -> LlmCapabilityConfig {
953        LlmCapabilityConfig {
954            base_threshold,
955            ..LlmCapabilityConfig::default()
956        }
957    }
958
959    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
960        TaskClassifierConfig {
961            judge: CapabilityJudgeConfig::Llm(llm_config(base_threshold)),
962            ..TaskClassifierConfig::default()
963        }
964    }
965
966    fn policy() -> TaskClassifierPolicy {
967        TaskClassifierPolicy::new(&llm_config(TEST_THRESHOLD))
968    }
969
970    fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
971        [
972            (Category::Judge, vec![ModelId::from("judge")]),
973            (Category::Efficient, vec![ModelId::from("efficient")]),
974            (Category::Capable, vec![ModelId::from("capable")]),
975            (
976                Category::Any,
977                vec![ModelId::from("efficient"), ModelId::from("capable")],
978            ),
979        ]
980        .into()
981    }
982
983    fn policy_driver() -> Driver {
984        Driver::new("test", Arc::new(runtime_models().into())).0
985    }
986
987    fn verdict(
988        p_solve: f64,
989        capability_boundary: &str,
990        primary_rule: &str,
991    ) -> TaskClassifierVerdict {
992        TaskClassifierVerdict {
993            crux: "test crux".to_string(),
994            primary_rule: primary_rule.to_string(),
995            capability_boundary: capability_boundary.to_string(),
996            p_solve,
997        }
998    }
999
1000    fn selected(
1001        policy: &TaskClassifierPolicy,
1002        verdict: Option<&TaskClassifierVerdict>,
1003    ) -> Result<ModelId> {
1004        policy
1005            .to_classification(verdict, &policy_driver())?
1006            .argmax(false)?
1007            .map(|score| score.target)
1008            .ok_or_else(|| LibsyError::AlgorithmError {
1009                message: "policy abstained".to_string(),
1010            })
1011    }
1012
1013    /// Records what each target received; answers the judge with a supported verdict and
1014    /// every other target with a plain completion.
1015    #[derive(Default)]
1016    struct Recorder {
1017        calls: Mutex<Vec<String>>,
1018        call_roles: Mutex<Vec<(String, bool)>>,
1019        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1020        judge_system_prompts: Mutex<Vec<String>>,
1021    }
1022
1023    impl Recorder {
1024        fn calls(&self) -> Vec<String> {
1025            self.calls.lock().clone()
1026        }
1027
1028        fn call_roles(&self) -> Vec<(String, bool)> {
1029            self.call_roles.lock().clone()
1030        }
1031
1032        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1033            self.judge_max_output_tokens.lock().clone()
1034        }
1035
1036        fn judge_system_prompts(&self) -> Vec<String> {
1037            self.judge_system_prompts.lock().clone()
1038        }
1039
1040        fn serve(self: &Arc<Self>) -> impl Serve {
1041            let recorder = Arc::clone(self);
1042            move |model: ModelId, request: Request| {
1043                let recorder = Arc::clone(&recorder);
1044                async move {
1045                    let model = model.to_string();
1046                    recorder.calls.lock().push(model.clone());
1047                    recorder
1048                        .call_roles
1049                        .lock()
1050                        .push((model.clone(), model != "judge"));
1051                    let completion = if model == "judge" {
1052                        recorder
1053                            .judge_max_output_tokens
1054                            .lock()
1055                            .push(request.llm_request.output.max_output_tokens);
1056                        recorder.judge_system_prompts.lock().extend(
1057                            request
1058                                .llm_request
1059                                .instructions
1060                                .first()
1061                                .and_then(|instruction| {
1062                                    instruction.content.iter().find_map(|b| {
1063                                        if let ContentBlock::Text { text } = b {
1064                                            Some(text.clone())
1065                                        } else {
1066                                            None
1067                                        }
1068                                    })
1069                                }),
1070                        );
1071                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1072                    } else {
1073                        format!("answer from {model}")
1074                    };
1075                    Ok(Response {
1076                        llm_response: LlmResponse::Agg(text_response(None, completion)),
1077                        metadata: request.metadata,
1078                        upstream_headers: http::HeaderMap::new(),
1079                    })
1080                }
1081            }
1082        }
1083    }
1084
1085    /// The judge times out; every other target answers normally.
1086    fn unreachable_judge() -> impl Serve {
1087        |model: ModelId, request: Request| async move {
1088            let model = model.to_string();
1089            if model == "judge" {
1090                return Err(LlmClientError::Timeout {
1091                    source: Box::new(std::io::Error::other("judge unreachable")),
1092                });
1093            }
1094            Ok(Response {
1095                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1096                metadata: request.metadata,
1097                upstream_headers: http::HeaderMap::new(),
1098            })
1099        }
1100    }
1101
1102    /// The judge stalls before answering; every other target answers normally.
1103    fn slow_judge(delay: std::time::Duration) -> impl Serve {
1104        move |model: ModelId, request: Request| {
1105            let model = model.to_string();
1106            let slow = model == "judge";
1107            async move {
1108                if slow {
1109                    tokio::time::sleep(delay).await;
1110                }
1111                Ok(Response {
1112                    llm_response: LlmResponse::Agg(text_response(
1113                        None,
1114                        format!("answer from {model}"),
1115                    )),
1116                    metadata: request.metadata,
1117                    upstream_headers: http::HeaderMap::new(),
1118                })
1119            }
1120        }
1121    }
1122
1123    /// The judge accepts the call but its stream never carries a chunk.
1124    fn stalled_judge_stream() -> impl Serve {
1125        use futures::StreamExt;
1126        |model: ModelId, request: Request| async move {
1127            let model = model.to_string();
1128            let llm_response = if model == "judge" {
1129                LlmResponse::Stream(futures::stream::pending().boxed())
1130            } else {
1131                LlmResponse::Agg(text_response(None, format!("answer from {model}")))
1132            };
1133            Ok(Response {
1134                llm_response,
1135                metadata: request.metadata,
1136                upstream_headers: http::HeaderMap::new(),
1137            })
1138        }
1139    }
1140
1141    fn deadline_router(fail_open: bool) -> Result<Arc<LlmTaskClassifier>> {
1142        Ok(Arc::new(LlmTaskClassifier::new(
1143            LlmClassifierConfig::Capability {
1144                config: TaskClassifierConfig {
1145                    judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1146                        judge_deadline_ms: Some(10),
1147                        ..llm_config(TEST_THRESHOLD)
1148                    }),
1149                    fail_open,
1150                    ..TaskClassifierConfig::default()
1151                },
1152            },
1153        )?))
1154    }
1155
1156    fn router() -> Result<Arc<LlmTaskClassifier>> {
1157        Ok(Arc::new(LlmTaskClassifier::new(
1158            LlmClassifierConfig::Capability {
1159                config: test_config(TEST_THRESHOLD),
1160            },
1161        )?))
1162    }
1163
1164    fn classify_request() -> Request {
1165        Request {
1166            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1167            raw_request: None,
1168            metadata: None,
1169        }
1170    }
1171
1172    fn classify_session_request() -> Request {
1173        Request {
1174            metadata: Some(Metadata {
1175                session_id: Some("session-1".to_string()),
1176                ..Metadata::default()
1177            }),
1178            ..classify_request()
1179        }
1180    }
1181
1182    fn classify_follow_up_request() -> Request {
1183        let mut request = classify_request();
1184        request
1185            .llm_request
1186            .messages
1187            .push(Message::text(Role::Assistant, "I will add the test."));
1188        request.llm_request.messages.push(Message::text(
1189            Role::User,
1190            "Now run the test suite and report the result.",
1191        ));
1192        request
1193    }
1194
1195    #[tokio::test]
1196    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1197        let router = router()?;
1198
1199        let (selected_model, response) = test_drive_with_models(
1200            router,
1201            classify_request(),
1202            runtime_models(),
1203            unreachable_judge(),
1204        )
1205        .await?;
1206
1207        assert_eq!(selected_model, "capable");
1208        assert_eq!(
1209            response.llm_response.as_agg().map(completion_text),
1210            Some("answer from capable".to_string())
1211        );
1212        Ok(())
1213    }
1214
1215    #[tokio::test]
1216    async fn a_judge_past_its_deadline_routes_capable() -> Result<()> {
1217        let (selected_model, response) = test_drive_with_models(
1218            deadline_router(true)?,
1219            classify_request(),
1220            runtime_models(),
1221            slow_judge(std::time::Duration::from_millis(500)),
1222        )
1223        .await?;
1224
1225        assert_eq!(selected_model, "capable");
1226        assert_eq!(
1227            response.llm_response.as_agg().map(completion_text),
1228            Some("answer from capable".to_string())
1229        );
1230        Ok(())
1231    }
1232
1233    #[tokio::test]
1234    async fn a_stalled_judge_stream_is_cut_by_the_deadline() -> Result<()> {
1235        // The judge returned headers promptly, so only a bound on the whole
1236        // consultation — the drain included — can end this turn.
1237        let (selected_model, _) = test_drive_with_models(
1238            deadline_router(true)?,
1239            classify_request(),
1240            runtime_models(),
1241            stalled_judge_stream(),
1242        )
1243        .await?;
1244
1245        assert_eq!(selected_model, "capable");
1246        Ok(())
1247    }
1248
1249    #[tokio::test]
1250    async fn a_judge_past_its_deadline_stops_when_not_failing_open() -> Result<()> {
1251        let outcome = test_drive_with_models(
1252            deadline_router(false)?,
1253            classify_request(),
1254            runtime_models(),
1255            slow_judge(std::time::Duration::from_millis(500)),
1256        )
1257        .await;
1258        let error = outcome
1259            .err()
1260            .expect("a deadline expiry with fail_open = false must stop the request");
1261
1262        assert!(
1263            matches!(
1264                error,
1265                LibsyError::ClientCall {
1266                    source: LlmClientError::Timeout { .. },
1267                    ..
1268                }
1269            ),
1270            "expected a client timeout, got {error:?}"
1271        );
1272        Ok(())
1273    }
1274
1275    #[test]
1276    fn a_zero_judge_deadline_is_rejected_in_every_mode() {
1277        let capability = LlmClassifierConfig::Capability {
1278            config: TaskClassifierConfig {
1279                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1280                    judge_deadline_ms: Some(0),
1281                    ..llm_config(TEST_THRESHOLD)
1282                }),
1283                ..TaskClassifierConfig::default()
1284            },
1285        };
1286        assert!(
1287            LlmTaskClassifier::new(capability).is_err(),
1288            "capability mode must reject judge_deadline_ms = 0"
1289        );
1290
1291        let mut custom = CustomClassifierConfig::new(
1292            "Route by topic.",
1293            serde_json::json!({"type": "object"}),
1294            CustomClassifierPolicy::target_selector("/target"),
1295        );
1296        custom.judge_deadline_ms = Some(0);
1297        assert!(
1298            LlmTaskClassifier::new(LlmClassifierConfig::Custom {
1299                default_target: Category::Capable,
1300                config: custom,
1301            })
1302            .is_err(),
1303            "custom mode must reject judge_deadline_ms = 0"
1304        );
1305
1306        let escalation = LlmClassifierConfig::Escalation {
1307            contract: ClassifierContractConfig::default(),
1308            config: EscalationJudgeConfig::default(),
1309            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1310            judge_deadline_ms: Some(0),
1311        };
1312        assert!(
1313            LlmTaskClassifier::new(escalation).is_err(),
1314            "escalation mode must reject judge_deadline_ms = 0"
1315        );
1316    }
1317
1318    #[test]
1319    fn classifier_config_parses_a_judge_deadline() {
1320        let config: TaskClassifierConfig = serde_json::from_value(serde_json::json!({
1321            "base_threshold": 0.5,
1322            "judge_deadline_ms": 250,
1323        }))
1324        .expect("a configured judge deadline should parse");
1325        let CapabilityJudgeConfig::Llm(judge) = config.judge else {
1326            panic!("expected the LLM judge variant");
1327        };
1328        assert_eq!(judge.judge_deadline_ms, Some(250));
1329
1330        let config: TaskClassifierConfig = serde_json::from_value(serde_json::json!({
1331            "base_threshold": 0.5,
1332        }))
1333        .expect("an unset judge deadline should parse");
1334        let CapabilityJudgeConfig::Llm(judge) = config.judge else {
1335            panic!("expected the LLM judge variant");
1336        };
1337        assert_eq!(judge.judge_deadline_ms, None);
1338    }
1339
1340    #[tokio::test]
1341    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1342        let recorder = Arc::new(Recorder::default());
1343        let router = router()?;
1344        let request = classify_request();
1345        let models = runtime_models();
1346
1347        test_drive_with_models(
1348            router.clone(),
1349            request.clone(),
1350            models.clone(),
1351            recorder.serve(),
1352        )
1353        .await?;
1354        test_drive_with_models(router, request, models, recorder.serve()).await?;
1355
1356        assert_eq!(
1357            recorder.calls(),
1358            vec!["judge", "efficient", "judge", "efficient"]
1359        );
1360        assert_eq!(
1361            recorder.call_roles(),
1362            vec![
1363                ("judge".to_string(), false),
1364                ("efficient".to_string(), true),
1365                ("judge".to_string(), false),
1366                ("efficient".to_string(), true),
1367            ]
1368        );
1369        Ok(())
1370    }
1371
1372    #[tokio::test]
1373    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1374        let recorder = Arc::new(Recorder::default());
1375        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1376            config: TaskClassifierConfig {
1377                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1378                    max_output_tokens: 512,
1379                    ..llm_config(TEST_THRESHOLD)
1380                }),
1381                ..test_config(TEST_THRESHOLD)
1382            },
1383        })?);
1384
1385        test_drive_with_models(
1386            router,
1387            classify_request(),
1388            runtime_models(),
1389            recorder.serve(),
1390        )
1391        .await?;
1392
1393        assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1394        Ok(())
1395    }
1396
1397    #[tokio::test]
1398    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1399        let recorder = Arc::new(Recorder::default());
1400        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1401            config: TaskClassifierConfig {
1402                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1403                    contract: ClassifierContractConfig::default()
1404                        .with_prompt("Custom capability rubric."),
1405                    ..llm_config(TEST_THRESHOLD)
1406                }),
1407                ..test_config(TEST_THRESHOLD)
1408            },
1409        })?);
1410
1411        test_drive_with_models(
1412            router,
1413            classify_request(),
1414            runtime_models(),
1415            recorder.serve(),
1416        )
1417        .await?;
1418
1419        let prompts = recorder.judge_system_prompts();
1420        assert_eq!(prompts.len(), 1);
1421        assert_eq!(prompts[0], "Custom capability rubric.");
1422        Ok(())
1423    }
1424
1425    #[tokio::test]
1426    async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1427        let recorder = Arc::new(Recorder::default());
1428        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1429            config: TaskClassifierConfig {
1430                classify_trigger: ClassifyTrigger::NewSession,
1431                ..test_config(TEST_THRESHOLD)
1432            },
1433        })?);
1434
1435        let request = classify_session_request();
1436        let models = runtime_models();
1437        test_drive_with_models(
1438            router.clone(),
1439            request.clone(),
1440            models.clone(),
1441            recorder.serve(),
1442        )
1443        .await?;
1444        test_drive_with_models(router, request, models, recorder.serve()).await?;
1445
1446        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1447        Ok(())
1448    }
1449
1450    #[tokio::test]
1451    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1452        let recorder = Arc::new(Recorder::default());
1453        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1454            config: TaskClassifierConfig {
1455                classify_trigger: ClassifyTrigger::NewSession,
1456                message_hash_fallback: true,
1457                recent_turn_window: None,
1458                ..test_config(TEST_THRESHOLD)
1459            },
1460        })?);
1461
1462        let models = runtime_models();
1463        test_drive_with_models(
1464            router.clone(),
1465            classify_request(),
1466            models.clone(),
1467            recorder.serve(),
1468        )
1469        .await?;
1470        test_drive_with_models(
1471            router,
1472            classify_follow_up_request(),
1473            models,
1474            recorder.serve(),
1475        )
1476        .await?;
1477
1478        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1479        Ok(())
1480    }
1481
1482    #[tokio::test]
1483    async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1484        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1485            config: TaskClassifierConfig {
1486                classify_trigger: ClassifyTrigger::NewSession,
1487                ..test_config(TEST_THRESHOLD)
1488            },
1489        })?);
1490        let calls = Arc::new(Mutex::new(Vec::new()));
1491        let serve = |calls: Arc<Mutex<Vec<String>>>| {
1492            move |model: ModelId, _request: Request| {
1493                let calls = Arc::clone(&calls);
1494                async move {
1495                    calls.lock().push(model.to_string());
1496                    let text = if model.as_str().starts_with("judge-") {
1497                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1498                    } else {
1499                        model.to_string()
1500                    };
1501                    Ok(Response {
1502                        llm_response: LlmResponse::Agg(text_response(None, text)),
1503                        metadata: None,
1504                        upstream_headers: Default::default(),
1505                    })
1506                }
1507            }
1508        };
1509        let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1510            [
1511                (
1512                    Category::Judge,
1513                    vec![ModelId::from(format!("judge-{suffix}"))],
1514                ),
1515                (
1516                    Category::Efficient,
1517                    vec![ModelId::from(format!("efficient-{suffix}"))],
1518                ),
1519                (
1520                    Category::Capable,
1521                    vec![ModelId::from(format!("capable-{suffix}"))],
1522                ),
1523                (
1524                    Category::Any,
1525                    vec![
1526                        ModelId::from(format!("efficient-{suffix}")),
1527                        ModelId::from(format!("capable-{suffix}")),
1528                    ],
1529                ),
1530            ]
1531            .into()
1532        };
1533        let request = classify_session_request();
1534
1535        let (first, _) = test_drive_with_models(
1536            router.clone(),
1537            request.clone(),
1538            models("a"),
1539            serve(Arc::clone(&calls)),
1540        )
1541        .await?;
1542        let (second, _) =
1543            test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1544
1545        assert_eq!(first, "efficient-a");
1546        assert_eq!(second, "efficient-b");
1547        assert_eq!(
1548            &*calls.lock(),
1549            &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1550        );
1551        Ok(())
1552    }
1553
1554    #[tokio::test]
1555    async fn decision_judge_routes_on_capable_probability() -> Result<()> {
1556        use crate::{Call, RuntimeModels, drive};
1557        use serde_json::json;
1558        use std::collections::BTreeMap;
1559        use std::sync::atomic::{AtomicUsize, Ordering};
1560        use switchyard_protocol::{
1561            BooleanEstimate, DecisionAnswer, DecisionResponse, DecisionValue, Probability,
1562            ProviderConfidence,
1563        };
1564
1565        let settings = DecisionJudgeConfig {
1566            cutoff: Some(0.4),
1567            ..DecisionJudgeConfig::default()
1568        };
1569        let mut request = classify_session_request();
1570        request.llm_request.messages.push(Message::new(
1571            Role::Assistant,
1572            vec![
1573                ContentBlock::Reasoning {
1574                    text: "private reasoning".into(),
1575                    signature: None,
1576                    details: vec![],
1577                },
1578                ContentBlock::Text {
1579                    text: "visible progress".into(),
1580                },
1581            ],
1582        ));
1583        request
1584            .llm_request
1585            .messages
1586            .push(Message::text(Role::User, "latest follow-up"));
1587        let original_messages = request.llm_request.messages.clone();
1588        let expected_task = json!([
1589            original_messages[0].clone(),
1590            Message::text(Role::Assistant, "visible progress"),
1591            Message::text(Role::User, "latest follow-up"),
1592        ]);
1593
1594        for (name, score, fail_open, expected) in [
1595            ("above", 0.45, true, Some("capable")),
1596            ("equal", 0.4, true, Some("efficient")),
1597            ("below", 0.39, false, Some("efficient")),
1598            ("missing answer", 0.0, true, Some("capable")),
1599            ("no distribution", 0.0, true, Some("capable")),
1600            ("wrong type", 0.0, true, Some("capable")),
1601            ("out of range", 1.1, true, Some("capable")),
1602            ("nonfinite", f64::NAN, true, Some("capable")),
1603            ("missing probability", 0.0, true, Some("capable")),
1604            ("missing answer", 0.0, false, None),
1605            ("no distribution", 0.0, false, None),
1606            ("wrong type", 0.0, false, None),
1607            ("out of range", 1.1, false, None),
1608            ("negative", -0.1, false, None),
1609            ("nonfinite", f64::NAN, false, None),
1610            ("infinite", f64::INFINITY, false, None),
1611            ("missing probability", 0.0, false, None),
1612            ("provider error", 0.0, true, Some("capable")),
1613            ("provider error", 0.0, false, None),
1614            ("dropped reply", 0.0, true, Some("capable")),
1615            ("custom choices", 0.45, false, Some("capable")),
1616        ] {
1617            let mut config = settings.clone();
1618            if name == "below" {
1619                config.instructions = Some("Choose capable when the task needs it.".into());
1620            }
1621            if name == "custom choices" {
1622                config.choices[0].id = "complex".into();
1623                config.choices[1].id = "simple".into();
1624                config.apply_cutoff_to = "complex".into();
1625            }
1626            let override_instructions = config.instructions.clone();
1627            let choice_ids = [config.choices[0].id.clone(), config.choices[1].id.clone()];
1628            let router: Arc<dyn Algorithm> =
1629                Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1630                    config: TaskClassifierConfig {
1631                        judge: CapabilityJudgeConfig::Decision(config),
1632                        fail_open,
1633                        recent_turn_window: None,
1634                        classify_trigger: ClassifyTrigger::NewSession,
1635                        ..TaskClassifierConfig::default()
1636                    },
1637                })?);
1638            let calls = AtomicUsize::new(0);
1639            let serve = |call| {
1640                let calls = &calls;
1641                let expected_task = &expected_task;
1642                let override_instructions = &override_instructions;
1643                let [capable_id, efficient_id] = choice_ids.clone();
1644                async move {
1645                    let Call::Decision(call) = call else {
1646                        panic!("judge must use the decision step")
1647                    };
1648                    calls.fetch_add(1, Ordering::SeqCst);
1649                    assert_eq!(call.model, "judge");
1650                    assert_eq!(call.request.model, Some("judge".into()));
1651                    assert_eq!(
1652                        serde_json::to_value(&call.request.input).unwrap(),
1653                        *expected_task
1654                    );
1655                    assert_eq!(call.request.questions.len(), 1);
1656                    let question = &call.request.questions["route"];
1657                    let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1658                    else {
1659                        panic!("expected a choice")
1660                    };
1661                    assert_eq!(
1662                        options
1663                            .iter()
1664                            .map(|option| option.id.as_str())
1665                            .collect::<Vec<_>>(),
1666                        [capable_id.as_str(), efficient_id.as_str()]
1667                    );
1668                    if let Some(instructions) = override_instructions {
1669                        assert_eq!(&question.instructions, instructions);
1670                    } else {
1671                        assert!(
1672                            question
1673                                .instructions
1674                                .as_str()
1675                                .is_some_and(|text| !text.is_empty())
1676                        );
1677                    }
1678                    if name == "provider error" {
1679                        return call
1680                            .respond(std::future::ready(Err(LibsyError::client_call(
1681                                "judge",
1682                                LlmClientError::General("private provider body".into()),
1683                            ))))
1684                            .await;
1685                    }
1686                    if name == "dropped reply" {
1687                        drop(call);
1688                        return Ok(());
1689                    }
1690                    let value = if name == "wrong type" {
1691                        DecisionValue::Boolean(BooleanEstimate::Value(true))
1692                    } else {
1693                        DecisionValue::Choice {
1694                            selected: efficient_id.clone(),
1695                            probabilities: (name != "no distribution").then(|| {
1696                                BTreeMap::from([
1697                                    (
1698                                        if name == "missing probability" {
1699                                            "other".into()
1700                                        } else {
1701                                            capable_id.clone()
1702                                        },
1703                                        Probability(score),
1704                                    ),
1705                                    (efficient_id.clone(), Probability(1.0 - score)),
1706                                ])
1707                            }),
1708                        }
1709                    };
1710                    let answers = if name == "missing answer" {
1711                        BTreeMap::new()
1712                    } else {
1713                        BTreeMap::from([(
1714                            "route".into(),
1715                            DecisionAnswer {
1716                                value,
1717                                provider_confidence: Some(ProviderConfidence(0.99)),
1718                            },
1719                        )])
1720                    };
1721                    call.respond(std::future::ready(Ok(DecisionResponse {
1722                        id: None,
1723                        model: Some("provider-judge".into()),
1724                        answers,
1725                        usage: Default::default(),
1726                    })))
1727                    .await
1728                }
1729            };
1730            let models = Arc::new(RuntimeModels::new(runtime_models()));
1731            let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1732            assert_eq!(calls.load(Ordering::SeqCst), 1, "{name}");
1733            let Some(expected) = expected else {
1734                if name == "provider error" {
1735                    assert!(
1736                        matches!(result, Err(LibsyError::ClientCall { .. })),
1737                        "{name}"
1738                    );
1739                } else {
1740                    assert!(
1741                        matches!(result, Err(LibsyError::InvalidDecision { target, .. }) if target == "judge"),
1742                        "expected invalid decision error: {name}"
1743                    );
1744                }
1745                continue;
1746            };
1747            let outcome = result?;
1748            assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1749            assert!(outcome.response.is_none());
1750            assert_eq!(outcome.request.llm_request.messages, original_messages);
1751            let metadata = outcome.metadata.expect("routing metadata");
1752            assert_eq!(metadata.judgments.len(), 1);
1753            let evidence = metadata.evidence.expect("routing evidence");
1754            if matches!(name, "above" | "equal" | "below" | "custom choices") {
1755                assert_eq!(metadata.judgments[0].target.as_deref(), Some(expected));
1756                assert_eq!(evidence["source"], "decision_classifier");
1757                assert_eq!(evidence["verdict"], expected);
1758                assert_eq!(evidence["capable_threshold"], 0.4);
1759                assert_eq!(evidence["capable_score"], score);
1760            } else {
1761                let reason = match name {
1762                    "provider error" => "client_error",
1763                    "dropped reply" => "call_error",
1764                    _ => "invalid_verdict",
1765                };
1766                assert_eq!(
1767                    evidence,
1768                    json!({"source": "fail_open", "reason_code": reason})
1769                );
1770            }
1771            let retained = drive(router, request.clone(), models, &serve).await?;
1772            assert_eq!(retained.selected_model_id()?, expected);
1773            assert!(
1774                retained
1775                    .metadata
1776                    .expect("retained metadata")
1777                    .judgments
1778                    .is_empty()
1779            );
1780            assert_eq!(
1781                calls.load(Ordering::SeqCst),
1782                1,
1783                "affinity should skip the judge: {name}"
1784            );
1785        }
1786        for cutoff in [-0.1, 1.1, f64::NAN] {
1787            let mut config = settings.clone();
1788            config.cutoff = Some(cutoff);
1789            assert!(
1790                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1791                    config: TaskClassifierConfig {
1792                        judge: CapabilityJudgeConfig::Decision(config),
1793                        ..TaskClassifierConfig::default()
1794                    },
1795                })
1796                .is_err()
1797            );
1798        }
1799        Ok(())
1800    }
1801
1802    #[tokio::test]
1803    async fn direct_judge_falls_back_to_the_default_target() -> Result<()> {
1804        use crate::{Call, RuntimeModels, drive};
1805        use std::collections::BTreeMap;
1806        use switchyard_protocol::{DecisionAnswer, DecisionResponse, DecisionValue};
1807
1808        for (reply, fail_open, expected) in [
1809            (Some("unknown"), true, Some("efficient")),
1810            (Some("unknown"), false, None),
1811            (None, true, Some("efficient")),
1812            (None, false, None),
1813        ] {
1814            let router: Arc<dyn Algorithm> =
1815                Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Direct {
1816                    default_target: Category::Efficient,
1817                    config: DirectClassifierConfig {
1818                        options: vec![Category::Efficient, Category::Capable],
1819                        fail_open,
1820                        classify_trigger: ClassifyTrigger::EveryRequest,
1821                        message_hash_fallback: false,
1822                        recent_turn_window: None,
1823                    },
1824                })?);
1825            let serve = |call| async move {
1826                let Call::Decision(call) = call else {
1827                    panic!("judge must use the decision step")
1828                };
1829                let response = match reply {
1830                    Some(selected) => Ok(DecisionResponse {
1831                        id: None,
1832                        model: None,
1833                        answers: BTreeMap::from([(
1834                            "route".into(),
1835                            DecisionAnswer {
1836                                value: DecisionValue::Choice {
1837                                    selected: selected.into(),
1838                                    probabilities: None,
1839                                },
1840                                provider_confidence: None,
1841                            },
1842                        )]),
1843                        usage: Default::default(),
1844                    }),
1845                    None => Err(LibsyError::client_call(
1846                        "judge",
1847                        LlmClientError::General("unavailable".into()),
1848                    )),
1849                };
1850                call.respond(std::future::ready(response)).await
1851            };
1852            let models = Arc::new(RuntimeModels::new(runtime_models()));
1853            let result = drive(router, classify_request(), models, &serve).await;
1854            match expected {
1855                Some(expected) => assert_eq!(result?.selected_model_id()?, expected),
1856                None => assert!(result.is_err()),
1857            }
1858        }
1859        Ok(())
1860    }
1861
1862    #[test]
1863    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1864        let policy = policy();
1865        let at_threshold = verdict(0.5, "supported", "SUP-1");
1866        let below_threshold = verdict(0.49, "supported", "SUP-1");
1867        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1868        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1869        Ok(())
1870    }
1871
1872    #[test]
1873    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1874        let borderline = verdict(0.5, "supported", "SUP-1");
1875        let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1876        let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1877        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1878        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1879        Ok(())
1880    }
1881
1882    #[test]
1883    fn classifier_config_rejects_unknown_fields() {
1884        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1885            "base_threshold": 0.5,
1886            "classifier_magic": true,
1887        }))
1888        .expect_err("unknown classifier fields must be rejected");
1889
1890        assert!(
1891            error
1892                .to_string()
1893                .contains("unknown field `classifier_magic`"),
1894            "{error}"
1895        );
1896    }
1897
1898    #[test]
1899    fn invalid_classifier_config_is_rejected() -> Result<()> {
1900        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1901            assert!(
1902                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1903                    config: test_config(bad),
1904                })
1905                .is_err(),
1906                "base threshold {bad} should be rejected"
1907            );
1908        }
1909        for config in [
1910            TaskClassifierConfig {
1911                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1912                    base_threshold: 0.5,
1913                    threshold_step: -0.1,
1914                    ..LlmCapabilityConfig::default()
1915                }),
1916                ..TaskClassifierConfig::default()
1917            },
1918            TaskClassifierConfig {
1919                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1920                    base_threshold: 0.8,
1921                    threshold_step: 0.11,
1922                    ..LlmCapabilityConfig::default()
1923                }),
1924                ..TaskClassifierConfig::default()
1925            },
1926            TaskClassifierConfig {
1927                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1928                    base_threshold: 0.5,
1929                    ..LlmCapabilityConfig::default()
1930                }),
1931                message_hash_fallback: true,
1932                ..TaskClassifierConfig::default()
1933            },
1934            TaskClassifierConfig {
1935                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1936                    base_threshold: 0.5,
1937                    max_output_tokens: 0,
1938                    ..LlmCapabilityConfig::default()
1939                }),
1940                ..TaskClassifierConfig::default()
1941            },
1942        ] {
1943            assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1944        }
1945        for base_threshold in [0.0, 1.0] {
1946            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1947                config: test_config(base_threshold),
1948            })?;
1949        }
1950        Ok(())
1951    }
1952
1953    #[test]
1954    fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1955        // Only `every_request` is rejected: it retains no target, so a fallback
1956        // identity has nothing to key on. Both retaining triggers may key the
1957        // retained target on a message hash when the caller sends no session id.
1958        for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1959            let config = TaskClassifierConfig {
1960                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1961                    base_threshold: 0.5,
1962                    ..LlmCapabilityConfig::default()
1963                }),
1964                classify_trigger: trigger,
1965                message_hash_fallback: true,
1966                ..TaskClassifierConfig::default()
1967            };
1968            LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1969                |error| LibsyError::AlgorithmError {
1970                    message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1971                },
1972            )?;
1973        }
1974        let every_request = TaskClassifierConfig {
1975            judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1976                base_threshold: 0.5,
1977                ..LlmCapabilityConfig::default()
1978            }),
1979            classify_trigger: ClassifyTrigger::EveryRequest,
1980            message_hash_fallback: true,
1981            ..TaskClassifierConfig::default()
1982        };
1983        assert!(
1984            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1985                config: every_request
1986            })
1987            .is_err(),
1988            "every_request with message_hash_fallback should stay rejected"
1989        );
1990        Ok(())
1991    }
1992
1993    #[test]
1994    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1995        let policy = policy();
1996        let inconsistent_rule = TaskClassifierVerdict {
1997            capability_boundary: "uncertain".to_string(),
1998            ..verdict(1.0, "supported", "SUP-1")
1999        };
2000        let empty_crux = TaskClassifierVerdict {
2001            crux: "  ".to_string(),
2002            ..verdict(1.0, "supported", "SUP-1")
2003        };
2004        let unusable = [
2005            Some(verdict(1.1, "supported", "SUP-1")),
2006            Some(inconsistent_rule),
2007            Some(empty_crux),
2008            None,
2009        ];
2010        for verdict in unusable {
2011            let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
2012            assert!(matches!(classification, Classification::Ambiguous(_)));
2013            assert!(classification.argmax(false)?.is_none());
2014            assert!(classification.argmax(true)?.is_none());
2015        }
2016        Ok(())
2017    }
2018
2019    #[test]
2020    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
2021        let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
2022            threshold_step: 0.1,
2023            ..llm_config(0.4)
2024        });
2025
2026        assert_eq!(
2027            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
2028            "efficient"
2029        );
2030        assert_eq!(
2031            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
2032            "capable"
2033        );
2034        assert_eq!(
2035            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
2036            "efficient"
2037        );
2038        assert_eq!(
2039            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
2040            "efficient"
2041        );
2042        assert_eq!(
2043            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
2044            "capable"
2045        );
2046        assert_eq!(
2047            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
2048            "efficient"
2049        );
2050        Ok(())
2051    }
2052
2053    #[test]
2054    fn unmatched_steps_raise_only_the_unmatched_threshold() -> Result<()> {
2055        let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
2056            threshold_step: 0.1,
2057            unmatched_steps: 2,
2058            ..llm_config(0.75)
2059        });
2060
2061        // 0.85 clears one step but not two: unmatched now routes to the capable target.
2062        assert_eq!(
2063            selected(&policy, Some(&verdict(0.85, "unmatched", "none")))?,
2064            "capable"
2065        );
2066        assert_eq!(
2067            selected(&policy, Some(&verdict(0.95, "unmatched", "none")))?,
2068            "efficient"
2069        );
2070        // Other boundaries keep their steps.
2071        assert_eq!(
2072            selected(&policy, Some(&verdict(0.85, "uncertain", "UNC-1")))?,
2073            "efficient"
2074        );
2075        assert_eq!(
2076            selected(&policy, Some(&verdict(0.75, "supported", "SUP-2")))?,
2077            "efficient"
2078        );
2079        Ok(())
2080    }
2081
2082    #[test]
2083    fn unmatched_steps_default_to_one_and_reject_values_above_two() {
2084        let parsed: TaskClassifierConfig =
2085            serde_json::from_value(serde_json::json!({ "base_threshold": 0.5 }))
2086                .expect("config without unmatched_steps parses");
2087        let CapabilityJudgeConfig::Llm(judge) = &parsed.judge else {
2088            panic!("a flat capability config builds an LLM judge");
2089        };
2090        assert_eq!(judge.unmatched_steps, 1);
2091
2092        let invalid = TaskClassifierConfig {
2093            judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
2094                unmatched_steps: 3,
2095                ..llm_config(0.5)
2096            }),
2097            ..TaskClassifierConfig::default()
2098        };
2099        assert!(invalid.validate().is_err());
2100    }
2101
2102    /// The text of each message a judge with `recent_turn_window` would be sent.
2103    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
2104    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
2105        Ok(StructuredJudge::new(
2106            TaskInput { recent_turn_window },
2107            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
2108            SerdeDecoder::new(),
2109            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2110        ))
2111    }
2112
2113    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
2114        let judge = capability_judge(Some(recent_turn_window))?;
2115        let request = Request {
2116            llm_request: LlmRequest {
2117                messages: vec![
2118                    Message::text(Role::System, "client instructions"),
2119                    Message::text(
2120                        Role::User,
2121                        "<environment_context>setup</environment_context>",
2122                    ),
2123                    Message::text(Role::User, "initial task"),
2124                    Message::text(Role::Assistant, "old response"),
2125                    Message::text(Role::User, "old follow-up"),
2126                    Message::text(Role::Assistant, "recent 1"),
2127                    Message::text(Role::User, "recent 2"),
2128                ],
2129                ..LlmRequest::default()
2130            },
2131            raw_request: None,
2132            metadata: None,
2133        };
2134        Ok(judge
2135            .build_request(&State::default(), &request)
2136            .llm_request
2137            .messages
2138            .iter()
2139            .filter_map(|message| message.text_content("\n"))
2140            .collect())
2141    }
2142
2143    #[test]
2144    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
2145        // Client instructions and the opening task, plus the last two turns.
2146        let contents = judged_contents(2)?;
2147        assert!(contents.contains(&"client instructions".to_string()));
2148        assert!(contents.contains(&"initial task".to_string()));
2149        assert!(contents.contains(&"recent 1".to_string()));
2150        assert!(contents.contains(&"recent 2".to_string()));
2151        assert!(!contents.contains(&"old response".to_string()));
2152        Ok(())
2153    }
2154
2155    #[test]
2156    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
2157        let contents = judged_contents(0)?;
2158        assert!(contents.contains(&"client instructions".to_string()));
2159        assert!(contents.contains(&"initial task".to_string()));
2160        assert!(!contents.contains(&"recent 2".to_string()));
2161        Ok(())
2162    }
2163
2164    fn tool_call(id: &str) -> Message {
2165        Message::new(
2166            Role::Assistant,
2167            vec![ContentBlock::ToolCall(ToolCall {
2168                id: id.to_string(),
2169                name: "search".to_string(),
2170                arguments: Value::Null,
2171            })],
2172        )
2173    }
2174
2175    fn tool_result(id: &str) -> Message {
2176        Message::new(
2177            Role::Tool,
2178            vec![ContentBlock::ToolResult(ToolResult {
2179                tool_call_id: id.to_string(),
2180                content: vec![ContentBlock::Text {
2181                    text: "tool output".to_string(),
2182                }],
2183                is_error: None,
2184            })],
2185        )
2186    }
2187
2188    #[test]
2189    fn default_task_input_keeps_user_content_around_tool_results() {
2190        let context = Message::text(
2191            Role::User,
2192            "<environment_context><current_date>2026-10-31</current_date></environment_context>",
2193        );
2194        let mut result = tool_result("call-1");
2195        result.role = Role::User;
2196        let mut mixed = result.clone();
2197        mixed.content.push(ContentBlock::Text {
2198            text: "latest follow-up".to_string(),
2199        });
2200        let input = TaskInput {
2201            recent_turn_window: None,
2202        };
2203        let mut request = Request {
2204            llm_request: LlmRequest {
2205                messages: vec![
2206                    context.clone(),
2207                    result.clone(),
2208                    Message::text(Role::User, "initial task"),
2209                    tool_call("call-1"),
2210                    mixed,
2211                    result.clone(),
2212                    context,
2213                ],
2214                ..LlmRequest::default()
2215            },
2216            ..Request::default()
2217        };
2218        assert_eq!(
2219            input.build_messages(&State::default(), &request),
2220            vec![
2221                Message::text(Role::User, "initial task"),
2222                Message::text(Role::User, "latest follow-up"),
2223            ]
2224        );
2225        request.llm_request.messages = vec![result];
2226        assert!(input.build_messages(&State::default(), &request).is_empty());
2227    }
2228
2229    /// A count-based window can begin on a tool result, which leaves the call that
2230    /// introduced its id outside the window and the classifier history invalid.
2231    #[test]
2232    fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
2233        let messages = vec![
2234            Message::text(Role::System, "client instructions"),
2235            Message::text(Role::User, "initial task"),
2236            Message::text(Role::Assistant, "old response"),
2237            tool_call("call-1"),
2238            tool_result("call-1"),
2239            Message::text(Role::Assistant, "recent 1"),
2240            Message::text(Role::User, "recent 2"),
2241            Message::text(Role::Assistant, "recent 3"),
2242            Message::text(Role::User, "recent 4"),
2243        ];
2244
2245        // The five-message tail begins exactly on the tool result.
2246        let kept = trim_messages(&messages, 5);
2247
2248        assert_eq!(
2249            kept,
2250            vec![
2251                Message::text(Role::System, "client instructions"),
2252                Message::text(Role::User, "initial task"),
2253                tool_call("call-1"),
2254                tool_result("call-1"),
2255                Message::text(Role::Assistant, "recent 1"),
2256                Message::text(Role::User, "recent 2"),
2257                Message::text(Role::Assistant, "recent 3"),
2258                Message::text(Role::User, "recent 4"),
2259            ]
2260        );
2261    }
2262
2263    /// Ids repeat across a conversation, so a later call must not stand in for the one that
2264    /// answers an earlier result.
2265    #[test]
2266    fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
2267        let messages = vec![
2268            Message::text(Role::System, "client instructions"),
2269            Message::text(Role::User, "initial task"),
2270            tool_call("x"),
2271            tool_result("x"),
2272            Message::text(Role::Assistant, "later"),
2273            tool_call("x"),
2274            tool_result("x"),
2275        ];
2276
2277        // The four-message tail begins on the first result, whose own call sits one earlier.
2278        let kept = trim_messages(&messages, 4);
2279
2280        assert_eq!(
2281            kept,
2282            vec![
2283                Message::text(Role::System, "client instructions"),
2284                Message::text(Role::User, "initial task"),
2285                tool_call("x"),
2286                tool_result("x"),
2287                Message::text(Role::Assistant, "later"),
2288                tool_call("x"),
2289                tool_result("x"),
2290            ]
2291        );
2292    }
2293
2294    /// A result whose call precedes the opening task can never be paired, because trimming
2295    /// never reaches behind the task. The window must not widen hunting for it.
2296    #[test]
2297    fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
2298        let messages = vec![
2299            Message::text(Role::System, "client instructions"),
2300            tool_call("orphan"),
2301            Message::text(Role::User, "initial task"),
2302            Message::text(Role::Assistant, "old response"),
2303            tool_result("orphan"),
2304            Message::text(Role::Assistant, "recent 1"),
2305            Message::text(Role::User, "recent 2"),
2306        ];
2307
2308        let kept = trim_messages(&messages, 3);
2309
2310        assert_eq!(
2311            kept,
2312            vec![
2313                Message::text(Role::System, "client instructions"),
2314                Message::text(Role::User, "initial task"),
2315                tool_result("orphan"),
2316                Message::text(Role::Assistant, "recent 1"),
2317                Message::text(Role::User, "recent 2"),
2318            ]
2319        );
2320    }
2321
2322    #[test]
2323    fn a_window_restates_the_routing_instruction_last() -> Result<()> {
2324        let contents = judged_contents(2)?;
2325
2326        // Last, not merely present: the instruction only works in final position,
2327        // after the conversation content it is meant to outrank.
2328        assert_eq!(
2329            contents.last().map(String::as_str),
2330            Some(TRAILING_ROUTING_INSTRUCTION)
2331        );
2332        Ok(())
2333    }
2334
2335    /// Reasoning is provider-private and some upstreams reject it replayed without the
2336    /// opaque state it was issued with, so it must not reach a windowed classifier
2337    /// request. Visible assistant text and complete tool pairs still do, and a turn
2338    /// whose only content was reasoning is dropped rather than left behind empty.
2339    #[test]
2340    fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
2341        let messages = vec![
2342            Message::text(Role::System, "client instructions"),
2343            Message::text(Role::User, "initial task"),
2344            Message::new(
2345                Role::Assistant,
2346                vec![
2347                    ContentBlock::Reasoning {
2348                        text: "private chain of thought".to_string(),
2349                        signature: None,
2350                        details: Vec::new(),
2351                    },
2352                    ContentBlock::Text {
2353                        text: "visible answer".to_string(),
2354                    },
2355                ],
2356            ),
2357            tool_call("call-1"),
2358            tool_result("call-1"),
2359            Message::new(
2360                Role::Assistant,
2361                vec![ContentBlock::Reasoning {
2362                    text: "reasoning-only turn".to_string(),
2363                    signature: None,
2364                    details: Vec::new(),
2365                }],
2366            ),
2367            Message::text(Role::User, "follow-up"),
2368        ];
2369        let request = Request {
2370            llm_request: LlmRequest {
2371                messages,
2372                ..LlmRequest::default()
2373            },
2374            raw_request: None,
2375            metadata: None,
2376        };
2377
2378        let built = TaskInput {
2379            recent_turn_window: Some(10),
2380        }
2381        .build_messages(&State::default(), &request);
2382
2383        assert!(
2384            !built
2385                .iter()
2386                .flat_map(|message| &message.content)
2387                .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2388            "{built:?}"
2389        );
2390        assert!(
2391            built
2392                .iter()
2393                .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2394        );
2395        assert!(built.contains(&tool_call("call-1")));
2396        assert!(built.contains(&tool_result("call-1")));
2397        // Six of the seven fixtures survive — the reasoning-only turn is gone entirely
2398        // rather than left behind empty — plus the trailing routing instruction.
2399        assert_eq!(built.len(), 7);
2400        assert!(built.iter().all(|message| !message.content.is_empty()));
2401    }
2402
2403    #[test]
2404    fn a_window_drops_a_turn_left_empty_once_its_reasoning_is_removed() {
2405        // OpenAI Chat decoding places an empty text placeholder beside the reasoning of
2406        // an assistant message that carries `reasoning_content` alone. Removing the
2407        // reasoning has to take the placeholder with it: an Anthropic judge rejects a
2408        // message whose text is empty, and the whole client request then fails.
2409        let body = serde_json::json!({
2410            "model": "qa/windowed",
2411            "messages": [
2412                {"role": "user", "content": "Reply with the number four."},
2413                {"role": "assistant", "reasoning_content": "private chain of thought"},
2414                {"role": "user", "content": "This is still a simple number lookup."}
2415            ]
2416        });
2417        let request = Request {
2418            llm_request: switchyard_translation::decode_request(
2419                switchyard_translation::WireFormat::OpenAiChat,
2420                &body,
2421            )
2422            .expect("client request decodes"),
2423            ..Request::default()
2424        };
2425
2426        let built = TaskInput {
2427            recent_turn_window: Some(8),
2428        }
2429        .build_messages(&State::default(), &request);
2430
2431        assert_eq!(
2432            built,
2433            vec![
2434                Message::text(Role::User, "Reply with the number four."),
2435                Message::text(Role::User, "This is still a simple number lookup."),
2436                Message::text(Role::User, TRAILING_ROUTING_INSTRUCTION),
2437            ]
2438        );
2439        let judge_request = LlmRequest {
2440            messages: built,
2441            ..LlmRequest::default()
2442        };
2443        let encoded = switchyard_translation::encode_request(
2444            &judge_request,
2445            switchyard_translation::WireFormat::AnthropicMessages,
2446        )
2447        .expect("judge request encodes");
2448        let has_empty_text = encoded["messages"]
2449            .as_array()
2450            .expect("messages")
2451            .iter()
2452            .flat_map(|message| match &message["content"] {
2453                Value::Array(blocks) => blocks.iter().map(|block| &block["text"]).collect(),
2454                content => vec![content],
2455            })
2456            .any(|text| text.as_str().is_some_and(str::is_empty));
2457        assert!(!has_empty_text, "{encoded}");
2458    }
2459
2460    #[test]
2461    fn the_default_path_is_left_unchanged() -> Result<()> {
2462        // No window means no conversation to be distracted by, so the default
2463        // request shape stays exactly as it was.
2464        let judge = capability_judge(None)?;
2465        let request = Request {
2466            llm_request: LlmRequest {
2467                messages: vec![Message::text(Role::User, "the task")],
2468                ..LlmRequest::default()
2469            },
2470            raw_request: None,
2471            metadata: None,
2472        };
2473
2474        let built = judge.build_request(&State::default(), &request);
2475
2476        // The task message alone; the rubric reaches the judge as an instruction block
2477        // rather than a message, so nothing was dropped by it not being counted here.
2478        assert_eq!(built.llm_request.messages.len(), 1);
2479        assert!(!built.llm_request.instructions.is_empty());
2480        assert!(
2481            !built
2482                .llm_request
2483                .messages
2484                .iter()
2485                .filter_map(|message| message.text_content("\n"))
2486                .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2487        );
2488        Ok(())
2489    }
2490
2491    #[test]
2492    fn capability_judge_builds_a_structured_request() -> Result<()> {
2493        let judge = capability_judge(None)?;
2494        let request = Request {
2495            llm_request: LlmRequest {
2496                model: Some("inbound".to_string()),
2497                messages: vec![
2498                    Message::text(Role::System, "client instructions"),
2499                    Message::text(Role::Developer, "client developer instructions"),
2500                    Message::text(Role::User, "initial task"),
2501                    Message::text(Role::Assistant, "old response"),
2502                    Message::text(Role::User, "old follow-up"),
2503                    Message::text(Role::Assistant, "recent 1"),
2504                    Message::text(Role::User, "recent 2"),
2505                    Message::text(Role::Assistant, "recent 3"),
2506                    Message::text(Role::User, "recent 4"),
2507                    Message::text(Role::Assistant, "recent 5"),
2508                ],
2509                ..LlmRequest::default()
2510            },
2511            raw_request: None,
2512            metadata: None,
2513        };
2514        let judge_request = judge.build_request(&State::default(), &request);
2515
2516        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2517        assert_eq!(judge_request.llm_request.instructions.len(), 1);
2518        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2519        assert_eq!(
2520            judge_request.llm_request.instructions[0].content,
2521            InstructionBlock {
2522                role: Role::System,
2523                content: Message::text(Role::System, judge.contract().system_prompt()).content,
2524            }
2525            .content,
2526        );
2527        assert_eq!(judge_request.llm_request.messages.len(), 2);
2528        let contents = judge_request
2529            .llm_request
2530            .messages
2531            .iter()
2532            .filter_map(|message| message.text_content("\n"))
2533            .collect::<Vec<_>>();
2534        assert!(contents.contains(&"recent 4".to_string()));
2535        assert!(contents.contains(&"initial task".to_string()));
2536        assert!(!contents.contains(&"recent 5".to_string()));
2537        assert!(!contents.contains(&"client instructions".to_string()));
2538        assert_eq!(
2539            judge_request.llm_request.output.response_format,
2540            Some(judge.contract().response_format().clone())
2541        );
2542        assert_eq!(
2543            judge_request.llm_request.output.max_output_tokens,
2544            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2545        );
2546        Ok(())
2547    }
2548
2549    fn sample_value(spec: &Value) -> Value {
2550        if let Some(first) = spec
2551            .get("enum")
2552            .and_then(Value::as_array)
2553            .and_then(|values| values.first())
2554        {
2555            return first.clone();
2556        }
2557        match spec.get("type").and_then(Value::as_str) {
2558            Some("number") => serde_json::json!(0.5),
2559            Some("boolean") => serde_json::json!(false),
2560            _ => serde_json::json!("sample"),
2561        }
2562    }
2563
2564    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2565        let properties = schema
2566            .pointer("/json_schema/schema/properties")
2567            .and_then(Value::as_object)
2568            .ok_or_else(|| LibsyError::AlgorithmError {
2569                message: "packaged schema declares no properties".to_string(),
2570            })?;
2571        Ok(Value::Object(
2572            properties
2573                .iter()
2574                .map(|(name, spec)| (name.clone(), sample_value(spec)))
2575                .collect(),
2576        )
2577        .to_string())
2578    }
2579
2580    /// Built from the schema so a property added there fails here rather than silently
2581    /// rejecting every production verdict.
2582    #[test]
2583    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2584        let contract =
2585            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2586        let schema = contract.response_format();
2587        let reply = schema_shaped_verdict(schema)?;
2588        let judge: CapabilityJudge = StructuredJudge::new(
2589            TaskInput {
2590                recent_turn_window: None,
2591            },
2592            contract,
2593            SerdeDecoder::new(),
2594            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2595        );
2596
2597        let verdict = judge.parse(&text_response(None, reply))?;
2598
2599        assert!(verdict.is_valid());
2600        assert!((0.0..=1.0).contains(&verdict.p_solve));
2601        Ok(())
2602    }
2603
2604    #[test]
2605    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2606        let contract =
2607            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2608        let prompt = contract.system_prompt();
2609        let schema_name = contract
2610            .response_format()
2611            .pointer("/json_schema/name")
2612            .and_then(Value::as_str)
2613            .ok_or_else(|| LibsyError::AlgorithmError {
2614                message: "packaged response schema has no name".to_string(),
2615            })?;
2616        assert_eq!(schema_name, "CapabilityClassifierDecision");
2617        assert!(prompt.contains("SUP-1 [supported]"));
2618        assert!(prompt.contains("SUP-5 [supported]"));
2619        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2620        assert!(!prompt.contains("\"type\": \"object\""));
2621        assert!(!prompt.contains("\"json_schema\""));
2622        assert!(!prompt.contains(schema_name));
2623        let rule_values = contract
2624            .response_format()
2625            .pointer("/json_schema/schema/properties/primary_rule/enum")
2626            .and_then(Value::as_array)
2627            .ok_or_else(|| LibsyError::AlgorithmError {
2628                message: "rendered response schema has no primary rule enum".to_string(),
2629            })?;
2630        assert!(
2631            rule_values
2632                .iter()
2633                .any(|value| value.as_str() == Some("SUP-1"))
2634        );
2635        assert!(
2636            rule_values
2637                .iter()
2638                .any(|value| value.as_str() == Some("none"))
2639        );
2640        Ok(())
2641    }
2642}