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