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