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;
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    /// Routes to the capable tier 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    /// A Choice score for the capable candidate succeeding where the efficient one fails.
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<State>,
603    /// Classifier used when this router is embedded in another cascade.
604    inner: Arc<dyn Classifier<State>>,
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<S: Send> Classifier<S> for DefaultCategoryClassifier {
619    async fn score(
620        &self,
621        _state: &mut S,
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<State>> = 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<State>>> {
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::<State>::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(
789        inner: Arc<dyn Classifier<State>>,
790        config: ClassifierRouteConfig,
791    ) -> Result<Self> {
792        // 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.
793        if config.message_hash_fallback && config.classify_trigger == ClassifyTrigger::EveryRequest
794        {
795            return Err(LibsyError::AlgorithmError {
796                message:
797                    "message_hash_fallback requires classify_trigger = new_session or user_turn"
798                        .to_string(),
799            });
800        }
801        // Affinity comes first so a retained assignment short-circuits the judge call.
802        let mut route = FallThrough::<State>::new_with_state().with_name(ALGORITHM_NAME);
803        if let Some(affinity) =
804            affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
805        {
806            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
807            route = route
808                .with_processor(affinity.clone())
809                .with_classifier(affinity.clone());
810        }
811        let fallback = DefaultCategoryClassifier(config.default_target);
812        Ok(Self {
813            route: route
814                .with_classifier(inner.clone())
815                .with_classifier(Arc::new(fallback)),
816            inner,
817        })
818    }
819}
820
821#[async_trait]
822impl Classifier<State> for LlmTaskClassifier {
823    async fn score(
824        &self,
825        state: &mut State,
826        request: &mut Request,
827        driver: &Driver,
828    ) -> Result<(Classification, Option<Response>)> {
829        self.inner.score(state, request, driver).await
830    }
831}
832
833#[async_trait]
834impl Algorithm for LlmTaskClassifier {
835    fn name(&self) -> &str {
836        "llm_task_classifier"
837    }
838
839    async fn route(
840        self: Arc<Self>,
841        driver: Driver,
842        request: Request,
843    ) -> Result<crate::RoutingOutcome> {
844        self.route.execute(driver, request).await
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use std::collections::HashMap;
851    use std::sync::Arc;
852
853    use parking_lot::Mutex;
854    use serde_json::Value;
855
856    use super::*;
857    use switchyard_protocol::{
858        ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ModelId, ToolCall,
859        ToolResult, completion_text, text_request, text_response,
860    };
861
862    use crate::algorithms::util::llm_judge::Judge;
863    use crate::core::testing::{Serve, test_drive_with_models};
864    use switchyard_protocol::{LlmResponse, Response};
865
866    const TEST_THRESHOLD: f64 = 0.5;
867
868    type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
869
870    fn llm_config(base_threshold: f64) -> LlmCapabilityConfig {
871        LlmCapabilityConfig {
872            base_threshold,
873            ..LlmCapabilityConfig::default()
874        }
875    }
876
877    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
878        TaskClassifierConfig {
879            judge: CapabilityJudgeConfig::Llm(llm_config(base_threshold)),
880            ..TaskClassifierConfig::default()
881        }
882    }
883
884    fn policy() -> TaskClassifierPolicy {
885        TaskClassifierPolicy::new(&llm_config(TEST_THRESHOLD))
886    }
887
888    fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
889        [
890            (Category::Judge, vec![ModelId::from("judge")]),
891            (Category::Efficient, vec![ModelId::from("efficient")]),
892            (Category::Capable, vec![ModelId::from("capable")]),
893            (
894                Category::Any,
895                vec![ModelId::from("efficient"), ModelId::from("capable")],
896            ),
897        ]
898        .into()
899    }
900
901    fn policy_driver() -> Driver {
902        Driver::new("test", Arc::new(runtime_models().into())).0
903    }
904
905    fn verdict(
906        p_solve: f64,
907        capability_boundary: &str,
908        primary_rule: &str,
909    ) -> TaskClassifierVerdict {
910        TaskClassifierVerdict {
911            crux: "test crux".to_string(),
912            primary_rule: primary_rule.to_string(),
913            capability_boundary: capability_boundary.to_string(),
914            p_solve,
915        }
916    }
917
918    fn selected(
919        policy: &TaskClassifierPolicy,
920        verdict: Option<&TaskClassifierVerdict>,
921    ) -> Result<ModelId> {
922        policy
923            .to_classification(verdict, &policy_driver())?
924            .argmax(false)?
925            .map(|score| score.target)
926            .ok_or_else(|| LibsyError::AlgorithmError {
927                message: "policy abstained".to_string(),
928            })
929    }
930
931    /// Records what each target received; answers the judge with a supported verdict and
932    /// every other target with a plain completion.
933    #[derive(Default)]
934    struct Recorder {
935        calls: Mutex<Vec<String>>,
936        call_roles: Mutex<Vec<(String, bool)>>,
937        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
938        judge_system_prompts: Mutex<Vec<String>>,
939    }
940
941    impl Recorder {
942        fn calls(&self) -> Vec<String> {
943            self.calls.lock().clone()
944        }
945
946        fn call_roles(&self) -> Vec<(String, bool)> {
947            self.call_roles.lock().clone()
948        }
949
950        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
951            self.judge_max_output_tokens.lock().clone()
952        }
953
954        fn judge_system_prompts(&self) -> Vec<String> {
955            self.judge_system_prompts.lock().clone()
956        }
957
958        fn serve(self: &Arc<Self>) -> impl Serve {
959            let recorder = Arc::clone(self);
960            move |model: ModelId, request: Request| {
961                let recorder = Arc::clone(&recorder);
962                async move {
963                    let model = model.to_string();
964                    recorder.calls.lock().push(model.clone());
965                    recorder
966                        .call_roles
967                        .lock()
968                        .push((model.clone(), model != "judge"));
969                    let completion = if model == "judge" {
970                        recorder
971                            .judge_max_output_tokens
972                            .lock()
973                            .push(request.llm_request.output.max_output_tokens);
974                        recorder.judge_system_prompts.lock().extend(
975                            request
976                                .llm_request
977                                .instructions
978                                .first()
979                                .and_then(|instruction| {
980                                    instruction.content.iter().find_map(|b| {
981                                        if let ContentBlock::Text { text } = b {
982                                            Some(text.clone())
983                                        } else {
984                                            None
985                                        }
986                                    })
987                                }),
988                        );
989                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
990                    } else {
991                        format!("answer from {model}")
992                    };
993                    Ok(Response {
994                        llm_response: LlmResponse::Agg(text_response(None, completion)),
995                        metadata: request.metadata,
996                        upstream_headers: http::HeaderMap::new(),
997                    })
998                }
999            }
1000        }
1001    }
1002
1003    /// The judge times out; every other target answers normally.
1004    fn unreachable_judge() -> impl Serve {
1005        |model: ModelId, request: Request| async move {
1006            let model = model.to_string();
1007            if model == "judge" {
1008                return Err(LlmClientError::Timeout {
1009                    source: Box::new(std::io::Error::other("judge unreachable")),
1010                });
1011            }
1012            Ok(Response {
1013                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1014                metadata: request.metadata,
1015                upstream_headers: http::HeaderMap::new(),
1016            })
1017        }
1018    }
1019
1020    fn router() -> Result<Arc<LlmTaskClassifier>> {
1021        Ok(Arc::new(LlmTaskClassifier::new(
1022            LlmClassifierConfig::Capability {
1023                config: test_config(TEST_THRESHOLD),
1024            },
1025        )?))
1026    }
1027
1028    fn classify_request() -> Request {
1029        Request {
1030            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1031            raw_request: None,
1032            metadata: None,
1033        }
1034    }
1035
1036    fn classify_session_request() -> Request {
1037        Request {
1038            metadata: Some(Metadata {
1039                session_id: Some("session-1".to_string()),
1040                ..Metadata::default()
1041            }),
1042            ..classify_request()
1043        }
1044    }
1045
1046    fn classify_follow_up_request() -> Request {
1047        let mut request = classify_request();
1048        request
1049            .llm_request
1050            .messages
1051            .push(Message::text(Role::Assistant, "I will add the test."));
1052        request.llm_request.messages.push(Message::text(
1053            Role::User,
1054            "Now run the test suite and report the result.",
1055        ));
1056        request
1057    }
1058
1059    #[tokio::test]
1060    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1061        let router = router()?;
1062
1063        let (selected_model, response) = test_drive_with_models(
1064            router,
1065            classify_request(),
1066            runtime_models(),
1067            unreachable_judge(),
1068        )
1069        .await?;
1070
1071        assert_eq!(selected_model, "capable");
1072        assert_eq!(
1073            response.llm_response.as_agg().map(completion_text),
1074            Some("answer from capable".to_string())
1075        );
1076        Ok(())
1077    }
1078
1079    #[tokio::test]
1080    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1081        let recorder = Arc::new(Recorder::default());
1082        let router = router()?;
1083        let request = classify_request();
1084        let models = runtime_models();
1085
1086        test_drive_with_models(
1087            router.clone(),
1088            request.clone(),
1089            models.clone(),
1090            recorder.serve(),
1091        )
1092        .await?;
1093        test_drive_with_models(router, request, models, recorder.serve()).await?;
1094
1095        assert_eq!(
1096            recorder.calls(),
1097            vec!["judge", "efficient", "judge", "efficient"]
1098        );
1099        assert_eq!(
1100            recorder.call_roles(),
1101            vec![
1102                ("judge".to_string(), false),
1103                ("efficient".to_string(), true),
1104                ("judge".to_string(), false),
1105                ("efficient".to_string(), true),
1106            ]
1107        );
1108        Ok(())
1109    }
1110
1111    #[tokio::test]
1112    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1113        let recorder = Arc::new(Recorder::default());
1114        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1115            config: TaskClassifierConfig {
1116                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1117                    max_output_tokens: 512,
1118                    ..llm_config(TEST_THRESHOLD)
1119                }),
1120                ..test_config(TEST_THRESHOLD)
1121            },
1122        })?);
1123
1124        test_drive_with_models(
1125            router,
1126            classify_request(),
1127            runtime_models(),
1128            recorder.serve(),
1129        )
1130        .await?;
1131
1132        assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1133        Ok(())
1134    }
1135
1136    #[tokio::test]
1137    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1138        let recorder = Arc::new(Recorder::default());
1139        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1140            config: TaskClassifierConfig {
1141                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1142                    contract: ClassifierContractConfig::default()
1143                        .with_prompt("Custom capability rubric."),
1144                    ..llm_config(TEST_THRESHOLD)
1145                }),
1146                ..test_config(TEST_THRESHOLD)
1147            },
1148        })?);
1149
1150        test_drive_with_models(
1151            router,
1152            classify_request(),
1153            runtime_models(),
1154            recorder.serve(),
1155        )
1156        .await?;
1157
1158        let prompts = recorder.judge_system_prompts();
1159        assert_eq!(prompts.len(), 1);
1160        assert_eq!(prompts[0], "Custom capability rubric.");
1161        Ok(())
1162    }
1163
1164    #[tokio::test]
1165    async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1166        let recorder = Arc::new(Recorder::default());
1167        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1168            config: TaskClassifierConfig {
1169                classify_trigger: ClassifyTrigger::NewSession,
1170                ..test_config(TEST_THRESHOLD)
1171            },
1172        })?);
1173
1174        let request = classify_session_request();
1175        let models = runtime_models();
1176        test_drive_with_models(
1177            router.clone(),
1178            request.clone(),
1179            models.clone(),
1180            recorder.serve(),
1181        )
1182        .await?;
1183        test_drive_with_models(router, request, models, recorder.serve()).await?;
1184
1185        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1186        Ok(())
1187    }
1188
1189    #[tokio::test]
1190    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1191        let recorder = Arc::new(Recorder::default());
1192        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1193            config: TaskClassifierConfig {
1194                classify_trigger: ClassifyTrigger::NewSession,
1195                message_hash_fallback: true,
1196                recent_turn_window: None,
1197                ..test_config(TEST_THRESHOLD)
1198            },
1199        })?);
1200
1201        let models = runtime_models();
1202        test_drive_with_models(
1203            router.clone(),
1204            classify_request(),
1205            models.clone(),
1206            recorder.serve(),
1207        )
1208        .await?;
1209        test_drive_with_models(
1210            router,
1211            classify_follow_up_request(),
1212            models,
1213            recorder.serve(),
1214        )
1215        .await?;
1216
1217        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1218        Ok(())
1219    }
1220
1221    #[tokio::test]
1222    async fn one_classifier_uses_each_requests_runtime_models() -> Result<()> {
1223        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1224            config: TaskClassifierConfig {
1225                classify_trigger: ClassifyTrigger::NewSession,
1226                ..test_config(TEST_THRESHOLD)
1227            },
1228        })?);
1229        let calls = Arc::new(Mutex::new(Vec::new()));
1230        let serve = |calls: Arc<Mutex<Vec<String>>>| {
1231            move |model: ModelId, _request: Request| {
1232                let calls = Arc::clone(&calls);
1233                async move {
1234                    calls.lock().push(model.to_string());
1235                    let text = if model.as_str().starts_with("judge-") {
1236                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1237                    } else {
1238                        model.to_string()
1239                    };
1240                    Ok(Response {
1241                        llm_response: LlmResponse::Agg(text_response(None, text)),
1242                        metadata: None,
1243                        upstream_headers: Default::default(),
1244                    })
1245                }
1246            }
1247        };
1248        let models = |suffix: &str| -> HashMap<Category, Vec<ModelId>> {
1249            [
1250                (
1251                    Category::Judge,
1252                    vec![ModelId::from(format!("judge-{suffix}"))],
1253                ),
1254                (
1255                    Category::Efficient,
1256                    vec![ModelId::from(format!("efficient-{suffix}"))],
1257                ),
1258                (
1259                    Category::Capable,
1260                    vec![ModelId::from(format!("capable-{suffix}"))],
1261                ),
1262                (
1263                    Category::Any,
1264                    vec![
1265                        ModelId::from(format!("efficient-{suffix}")),
1266                        ModelId::from(format!("capable-{suffix}")),
1267                    ],
1268                ),
1269            ]
1270            .into()
1271        };
1272        let request = classify_session_request();
1273
1274        let (first, _) = test_drive_with_models(
1275            router.clone(),
1276            request.clone(),
1277            models("a"),
1278            serve(Arc::clone(&calls)),
1279        )
1280        .await?;
1281        let (second, _) =
1282            test_drive_with_models(router, request, models("b"), serve(Arc::clone(&calls))).await?;
1283
1284        assert_eq!(first, "efficient-a");
1285        assert_eq!(second, "efficient-b");
1286        assert_eq!(
1287            &*calls.lock(),
1288            &["judge-a", "efficient-a", "judge-b", "efficient-b"]
1289        );
1290        Ok(())
1291    }
1292
1293    #[tokio::test]
1294    async fn decision_judge_routes_on_relative_advantage() -> Result<()> {
1295        use crate::{Call, RuntimeModels, drive};
1296        use serde_json::json;
1297        use std::collections::BTreeMap;
1298        use std::sync::atomic::{AtomicUsize, Ordering};
1299        use switchyard_protocol::{
1300            BooleanEstimate, DecisionAnswer, DecisionResponse, DecisionValue, Probability,
1301            ProviderConfidence,
1302        };
1303
1304        let settings = DecisionJudgeConfig {
1305            cutoff: 0.4,
1306            instructions: None,
1307            candidates: BTreeMap::from([
1308                ("a".into(), "capable".into()),
1309                ("b".into(), "efficient".into()),
1310                ("c".into(), "third".into()),
1311            ]),
1312            evidence: json!({
1313                "candidate_descriptions": {"a": "Profile A", "b": "Profile B", "c": "Profile C"},
1314                "reference_cases": [{"task": "Independent example", "observed_solved": {"a": true, "b": null, "c": false}}],
1315                "summaries": [],
1316            }),
1317        };
1318        let mut request = classify_session_request();
1319        request.llm_request.messages.push(Message {
1320            role: Role::Assistant,
1321            content: vec![
1322                ContentBlock::Reasoning {
1323                    text: "private reasoning".into(),
1324                    signature: None,
1325                    details: vec![],
1326                },
1327                ContentBlock::Text {
1328                    text: "visible progress".into(),
1329                },
1330            ],
1331        });
1332        request
1333            .llm_request
1334            .messages
1335            .push(Message::text(Role::User, "latest follow-up"));
1336        let original_messages = request.llm_request.messages.clone();
1337        let expected_task = json!([
1338            original_messages[0].clone(),
1339            Message::text(Role::Assistant, "visible progress"),
1340            Message::text(Role::User, "latest follow-up"),
1341        ]);
1342
1343        for (name, score, fail_open, expected) in [
1344            ("above", 0.45, true, Some("capable")),
1345            ("equal", 0.4, true, Some("efficient")),
1346            ("below", 0.39, false, Some("efficient")),
1347            ("missing answer", 0.0, true, Some("capable")),
1348            ("no distribution", 0.0, true, Some("capable")),
1349            ("wrong type", 0.0, true, Some("capable")),
1350            ("out of range", 1.1, true, Some("capable")),
1351            ("nonfinite", f64::NAN, false, Some("capable")),
1352            ("provider error", 0.0, true, Some("capable")),
1353            ("provider error", 0.0, false, None),
1354            ("dropped reply", 0.0, true, Some("capable")),
1355            ("missing candidate", 0.0, true, None),
1356        ] {
1357            let mut config = settings.clone();
1358            if name == "missing candidate" {
1359                config.candidates.remove("a");
1360            }
1361            if name == "below" {
1362                config.instructions = Some(
1363                    json!({"policy": "Judge capable-only success using the supplied evidence."}),
1364                );
1365            }
1366            let override_instructions = config.instructions.clone();
1367            let router: Arc<dyn Algorithm> =
1368                Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1369                    config: TaskClassifierConfig {
1370                        judge: CapabilityJudgeConfig::Decision(config),
1371                        fail_open,
1372                        recent_turn_window: Some(3),
1373                        classify_trigger: ClassifyTrigger::NewSession,
1374                        ..TaskClassifierConfig::default()
1375                    },
1376                })?);
1377            let calls = AtomicUsize::new(0);
1378            let serve = |call| {
1379                let calls = &calls;
1380                let expected_task = &expected_task;
1381                let settings = &settings;
1382                let override_instructions = &override_instructions;
1383                async move {
1384                    let Call::Decision(call) = call else {
1385                        panic!("judge must use the decision step")
1386                    };
1387                    calls.fetch_add(1, Ordering::SeqCst);
1388                    assert_eq!(call.model, "judge");
1389                    assert_eq!(call.request.model, Some("judge".into()));
1390                    assert_eq!(
1391                        call.request.context,
1392                        json!({
1393                            "task": expected_task,
1394                            "candidates": ["a", "b", "c"],
1395                            "comparison": {"capable": "a", "efficient": "b"},
1396                            "evidence": settings.evidence,
1397                        })
1398                    );
1399                    assert_eq!(call.request.questions.len(), 1);
1400                    let question = &call.request.questions["route"];
1401                    let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1402                    else {
1403                        panic!("expected a choice")
1404                    };
1405                    assert_eq!(
1406                        options
1407                            .iter()
1408                            .map(|option| option.id.as_str())
1409                            .collect::<Vec<_>>(),
1410                        ["advantage", "no_advantage"]
1411                    );
1412                    if let Some(instructions) = override_instructions {
1413                        assert_eq!(&question.instructions, instructions);
1414                    } else {
1415                        for key in ["setting", "evidence", "comparison", "boundary", "policy"] {
1416                            assert!(question.instructions[key].as_str().is_some());
1417                        }
1418                    }
1419                    if name == "provider error" {
1420                        return call
1421                            .respond(std::future::ready(Err(LibsyError::client_call(
1422                                "judge",
1423                                LlmClientError::General("private provider body".into()),
1424                            ))))
1425                            .await;
1426                    }
1427                    if name == "dropped reply" {
1428                        drop(call);
1429                        return Ok(());
1430                    }
1431                    let value = if name == "wrong type" {
1432                        DecisionValue::Boolean(BooleanEstimate::Value(true))
1433                    } else {
1434                        DecisionValue::Choice {
1435                            selected: "no_advantage".into(),
1436                            probabilities: (name != "no distribution").then(|| {
1437                                BTreeMap::from([
1438                                    ("advantage".into(), Probability(score)),
1439                                    ("no_advantage".into(), Probability(1.0 - score)),
1440                                ])
1441                            }),
1442                        }
1443                    };
1444                    let answers = if name == "missing answer" {
1445                        BTreeMap::new()
1446                    } else {
1447                        BTreeMap::from([(
1448                            "route".into(),
1449                            DecisionAnswer {
1450                                value,
1451                                provider_confidence: Some(ProviderConfidence(0.99)),
1452                            },
1453                        )])
1454                    };
1455                    call.respond(std::future::ready(Ok(DecisionResponse {
1456                        id: None,
1457                        model: Some("provider-judge".into()),
1458                        answers,
1459                        usage: Default::default(),
1460                    })))
1461                    .await
1462                }
1463            };
1464            let models = Arc::new(RuntimeModels::new(runtime_models()));
1465            let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1466            assert_eq!(
1467                calls.load(Ordering::SeqCst),
1468                usize::from(name != "missing candidate"),
1469                "{name}"
1470            );
1471            let Some(expected) = expected else {
1472                if name == "missing candidate" {
1473                    assert!(
1474                        matches!(result, Err(LibsyError::AlgorithmError { message }) if message.contains("candidate is missing"))
1475                    );
1476                } else {
1477                    assert!(
1478                        matches!(result, Err(LibsyError::ClientCall { .. })),
1479                        "{name}"
1480                    );
1481                }
1482                continue;
1483            };
1484            let outcome = result?;
1485            assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1486            assert!(outcome.response.is_none());
1487            assert_eq!(outcome.request.llm_request.messages, original_messages);
1488            let evidence = outcome
1489                .metadata
1490                .and_then(|metadata| metadata.evidence)
1491                .expect("routing evidence");
1492            if matches!(name, "above" | "equal" | "below") {
1493                assert_eq!(evidence["source"], "decision_classifier");
1494                assert_eq!(evidence["verdict"], "relative_advantage");
1495                assert_eq!(evidence["threshold"], settings.cutoff);
1496                assert_eq!(evidence["score"], score);
1497            } else {
1498                let reason = match name {
1499                    "provider error" => "client_error",
1500                    "dropped reply" => "call_error",
1501                    _ => "invalid_verdict",
1502                };
1503                assert_eq!(
1504                    evidence,
1505                    json!({"source": "fail_open", "reason_code": reason})
1506                );
1507            }
1508            let retained = drive(router, request.clone(), models, &serve).await?;
1509            assert_eq!(retained.selected_model_id()?, expected);
1510            assert_eq!(
1511                calls.load(Ordering::SeqCst),
1512                1,
1513                "affinity should skip the judge: {name}"
1514            );
1515        }
1516        for cutoff in [-0.1, 1.1, f64::NAN] {
1517            let mut config = settings.clone();
1518            config.cutoff = cutoff;
1519            assert!(
1520                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1521                    config: TaskClassifierConfig {
1522                        judge: CapabilityJudgeConfig::Decision(config),
1523                        ..TaskClassifierConfig::default()
1524                    },
1525                })
1526                .is_err()
1527            );
1528        }
1529        let mut duplicate = settings.clone();
1530        duplicate
1531            .candidates
1532            .insert("duplicate-a".into(), "capable".into());
1533        assert!(duplicate.validate().is_err());
1534        Ok(())
1535    }
1536
1537    #[test]
1538    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1539        let policy = policy();
1540        let at_threshold = verdict(0.5, "supported", "SUP-1");
1541        let below_threshold = verdict(0.49, "supported", "SUP-1");
1542        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1543        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1544        Ok(())
1545    }
1546
1547    #[test]
1548    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1549        let borderline = verdict(0.5, "supported", "SUP-1");
1550        let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1551        let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1552        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1553        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1554        Ok(())
1555    }
1556
1557    #[test]
1558    fn classifier_config_rejects_unknown_fields() {
1559        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1560            "base_threshold": 0.5,
1561            "classifier_magic": true,
1562        }))
1563        .expect_err("unknown classifier fields must be rejected");
1564
1565        assert!(
1566            error
1567                .to_string()
1568                .contains("unknown field `classifier_magic`"),
1569            "{error}"
1570        );
1571    }
1572
1573    #[test]
1574    fn invalid_classifier_config_is_rejected() -> Result<()> {
1575        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1576            assert!(
1577                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1578                    config: test_config(bad),
1579                })
1580                .is_err(),
1581                "base threshold {bad} should be rejected"
1582            );
1583        }
1584        for config in [
1585            TaskClassifierConfig {
1586                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1587                    base_threshold: 0.5,
1588                    threshold_step: -0.1,
1589                    ..LlmCapabilityConfig::default()
1590                }),
1591                ..TaskClassifierConfig::default()
1592            },
1593            TaskClassifierConfig {
1594                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1595                    base_threshold: 0.8,
1596                    threshold_step: 0.11,
1597                    ..LlmCapabilityConfig::default()
1598                }),
1599                ..TaskClassifierConfig::default()
1600            },
1601            TaskClassifierConfig {
1602                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1603                    base_threshold: 0.5,
1604                    ..LlmCapabilityConfig::default()
1605                }),
1606                message_hash_fallback: true,
1607                ..TaskClassifierConfig::default()
1608            },
1609            TaskClassifierConfig {
1610                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1611                    base_threshold: 0.5,
1612                    max_output_tokens: 0,
1613                    ..LlmCapabilityConfig::default()
1614                }),
1615                ..TaskClassifierConfig::default()
1616            },
1617        ] {
1618            assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1619        }
1620        for base_threshold in [0.0, 1.0] {
1621            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1622                config: test_config(base_threshold),
1623            })?;
1624        }
1625        Ok(())
1626    }
1627
1628    #[test]
1629    fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1630        // Only `every_request` is rejected: it retains no target, so a fallback
1631        // identity has nothing to key on. Both retaining triggers may key the
1632        // retained target on a message hash when the caller sends no session id.
1633        for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1634            let config = TaskClassifierConfig {
1635                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1636                    base_threshold: 0.5,
1637                    ..LlmCapabilityConfig::default()
1638                }),
1639                classify_trigger: trigger,
1640                message_hash_fallback: true,
1641                ..TaskClassifierConfig::default()
1642            };
1643            LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1644                |error| LibsyError::AlgorithmError {
1645                    message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1646                },
1647            )?;
1648        }
1649        let every_request = TaskClassifierConfig {
1650            judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1651                base_threshold: 0.5,
1652                ..LlmCapabilityConfig::default()
1653            }),
1654            classify_trigger: ClassifyTrigger::EveryRequest,
1655            message_hash_fallback: true,
1656            ..TaskClassifierConfig::default()
1657        };
1658        assert!(
1659            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1660                config: every_request
1661            })
1662            .is_err(),
1663            "every_request with message_hash_fallback should stay rejected"
1664        );
1665        Ok(())
1666    }
1667
1668    #[test]
1669    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1670        let policy = policy();
1671        let inconsistent_rule = TaskClassifierVerdict {
1672            capability_boundary: "uncertain".to_string(),
1673            ..verdict(1.0, "supported", "SUP-1")
1674        };
1675        let empty_crux = TaskClassifierVerdict {
1676            crux: "  ".to_string(),
1677            ..verdict(1.0, "supported", "SUP-1")
1678        };
1679        let unusable = [
1680            Some(verdict(1.1, "supported", "SUP-1")),
1681            Some(inconsistent_rule),
1682            Some(empty_crux),
1683            None,
1684        ];
1685        for verdict in unusable {
1686            let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1687            assert!(matches!(classification, Classification::Ambiguous(_)));
1688            assert!(classification.argmax(false)?.is_none());
1689            assert!(classification.argmax(true)?.is_none());
1690        }
1691        Ok(())
1692    }
1693
1694    #[test]
1695    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1696        let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
1697            threshold_step: 0.1,
1698            ..llm_config(0.4)
1699        });
1700
1701        assert_eq!(
1702            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1703            "efficient"
1704        );
1705        assert_eq!(
1706            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1707            "capable"
1708        );
1709        assert_eq!(
1710            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1711            "efficient"
1712        );
1713        assert_eq!(
1714            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1715            "efficient"
1716        );
1717        assert_eq!(
1718            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1719            "capable"
1720        );
1721        assert_eq!(
1722            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1723            "efficient"
1724        );
1725        Ok(())
1726    }
1727
1728    /// The text of each message a judge with `recent_turn_window` would be sent.
1729    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1730    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1731        Ok(StructuredJudge::new(
1732            TaskInput { recent_turn_window },
1733            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1734            SerdeDecoder::new(),
1735            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1736        ))
1737    }
1738
1739    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1740        let judge = capability_judge(Some(recent_turn_window))?;
1741        let request = Request {
1742            llm_request: LlmRequest {
1743                messages: vec![
1744                    Message::text(Role::System, "client instructions"),
1745                    Message::text(Role::User, "initial task"),
1746                    Message::text(Role::Assistant, "old response"),
1747                    Message::text(Role::User, "old follow-up"),
1748                    Message::text(Role::Assistant, "recent 1"),
1749                    Message::text(Role::User, "recent 2"),
1750                ],
1751                ..LlmRequest::default()
1752            },
1753            raw_request: None,
1754            metadata: None,
1755        };
1756        Ok(judge
1757            .build_request(&State::default(), &request)
1758            .llm_request
1759            .messages
1760            .iter()
1761            .filter_map(|message| message.text_content("\n"))
1762            .collect())
1763    }
1764
1765    #[test]
1766    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1767        // Client instructions and the opening task, plus the last two turns.
1768        let contents = judged_contents(2)?;
1769        assert!(contents.contains(&"client instructions".to_string()));
1770        assert!(contents.contains(&"initial task".to_string()));
1771        assert!(contents.contains(&"recent 1".to_string()));
1772        assert!(contents.contains(&"recent 2".to_string()));
1773        assert!(!contents.contains(&"old response".to_string()));
1774        Ok(())
1775    }
1776
1777    #[test]
1778    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1779        let contents = judged_contents(0)?;
1780        assert!(contents.contains(&"client instructions".to_string()));
1781        assert!(contents.contains(&"initial task".to_string()));
1782        assert!(!contents.contains(&"recent 2".to_string()));
1783        Ok(())
1784    }
1785
1786    fn tool_call(id: &str) -> Message {
1787        Message {
1788            role: Role::Assistant,
1789            content: vec![ContentBlock::ToolCall(ToolCall {
1790                id: id.to_string(),
1791                name: "search".to_string(),
1792                arguments: Value::Null,
1793            })],
1794        }
1795    }
1796
1797    fn tool_result(id: &str) -> Message {
1798        Message {
1799            role: Role::Tool,
1800            content: vec![ContentBlock::ToolResult(ToolResult {
1801                tool_call_id: id.to_string(),
1802                content: vec![ContentBlock::Text {
1803                    text: "tool output".to_string(),
1804                }],
1805                is_error: None,
1806            })],
1807        }
1808    }
1809
1810    #[test]
1811    fn default_task_input_keeps_user_content_around_tool_results() {
1812        let mut result = tool_result("call-1");
1813        result.role = Role::User;
1814        let mut mixed = result.clone();
1815        mixed.content.push(ContentBlock::Text {
1816            text: "latest follow-up".to_string(),
1817        });
1818        let input = TaskInput {
1819            recent_turn_window: None,
1820        };
1821        let mut request = Request {
1822            llm_request: LlmRequest {
1823                messages: vec![
1824                    result.clone(),
1825                    Message::text(Role::User, "initial task"),
1826                    tool_call("call-1"),
1827                    mixed,
1828                    result.clone(),
1829                ],
1830                ..LlmRequest::default()
1831            },
1832            ..Request::default()
1833        };
1834        assert_eq!(
1835            input.build_messages(&State::default(), &request),
1836            vec![
1837                Message::text(Role::User, "initial task"),
1838                Message::text(Role::User, "latest follow-up"),
1839            ]
1840        );
1841        request.llm_request.messages = vec![result];
1842        assert!(input.build_messages(&State::default(), &request).is_empty());
1843    }
1844
1845    /// A count-based window can begin on a tool result, which leaves the call that
1846    /// introduced its id outside the window and the classifier history invalid.
1847    #[test]
1848    fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1849        let messages = vec![
1850            Message::text(Role::System, "client instructions"),
1851            Message::text(Role::User, "initial task"),
1852            Message::text(Role::Assistant, "old response"),
1853            tool_call("call-1"),
1854            tool_result("call-1"),
1855            Message::text(Role::Assistant, "recent 1"),
1856            Message::text(Role::User, "recent 2"),
1857            Message::text(Role::Assistant, "recent 3"),
1858            Message::text(Role::User, "recent 4"),
1859        ];
1860
1861        // The five-message tail begins exactly on the tool result.
1862        let kept = trim_messages(&messages, 5);
1863
1864        assert_eq!(
1865            kept,
1866            vec![
1867                Message::text(Role::System, "client instructions"),
1868                Message::text(Role::User, "initial task"),
1869                tool_call("call-1"),
1870                tool_result("call-1"),
1871                Message::text(Role::Assistant, "recent 1"),
1872                Message::text(Role::User, "recent 2"),
1873                Message::text(Role::Assistant, "recent 3"),
1874                Message::text(Role::User, "recent 4"),
1875            ]
1876        );
1877    }
1878
1879    /// Ids repeat across a conversation, so a later call must not stand in for the one that
1880    /// answers an earlier result.
1881    #[test]
1882    fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1883        let messages = vec![
1884            Message::text(Role::System, "client instructions"),
1885            Message::text(Role::User, "initial task"),
1886            tool_call("x"),
1887            tool_result("x"),
1888            Message::text(Role::Assistant, "later"),
1889            tool_call("x"),
1890            tool_result("x"),
1891        ];
1892
1893        // The four-message tail begins on the first result, whose own call sits one earlier.
1894        let kept = trim_messages(&messages, 4);
1895
1896        assert_eq!(
1897            kept,
1898            vec![
1899                Message::text(Role::System, "client instructions"),
1900                Message::text(Role::User, "initial task"),
1901                tool_call("x"),
1902                tool_result("x"),
1903                Message::text(Role::Assistant, "later"),
1904                tool_call("x"),
1905                tool_result("x"),
1906            ]
1907        );
1908    }
1909
1910    /// A result whose call precedes the opening task can never be paired, because trimming
1911    /// never reaches behind the task. The window must not widen hunting for it.
1912    #[test]
1913    fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1914        let messages = vec![
1915            Message::text(Role::System, "client instructions"),
1916            tool_call("orphan"),
1917            Message::text(Role::User, "initial task"),
1918            Message::text(Role::Assistant, "old response"),
1919            tool_result("orphan"),
1920            Message::text(Role::Assistant, "recent 1"),
1921            Message::text(Role::User, "recent 2"),
1922        ];
1923
1924        let kept = trim_messages(&messages, 3);
1925
1926        assert_eq!(
1927            kept,
1928            vec![
1929                Message::text(Role::System, "client instructions"),
1930                Message::text(Role::User, "initial task"),
1931                tool_result("orphan"),
1932                Message::text(Role::Assistant, "recent 1"),
1933                Message::text(Role::User, "recent 2"),
1934            ]
1935        );
1936    }
1937
1938    #[test]
1939    fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1940        let contents = judged_contents(2)?;
1941
1942        // Last, not merely present: the instruction only works in final position,
1943        // after the conversation content it is meant to outrank.
1944        assert_eq!(
1945            contents.last().map(String::as_str),
1946            Some(TRAILING_ROUTING_INSTRUCTION)
1947        );
1948        Ok(())
1949    }
1950
1951    /// Reasoning is provider-private and some upstreams reject it replayed without the
1952    /// opaque state it was issued with, so it must not reach a windowed classifier
1953    /// request. Visible assistant text and complete tool pairs still do, and a turn
1954    /// whose only content was reasoning is dropped rather than left behind empty.
1955    #[test]
1956    fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1957        let messages = vec![
1958            Message::text(Role::System, "client instructions"),
1959            Message::text(Role::User, "initial task"),
1960            Message {
1961                role: Role::Assistant,
1962                content: vec![
1963                    ContentBlock::Reasoning {
1964                        text: "private chain of thought".to_string(),
1965                        signature: None,
1966                        details: Vec::new(),
1967                    },
1968                    ContentBlock::Text {
1969                        text: "visible answer".to_string(),
1970                    },
1971                ],
1972            },
1973            tool_call("call-1"),
1974            tool_result("call-1"),
1975            Message {
1976                role: Role::Assistant,
1977                content: vec![ContentBlock::Reasoning {
1978                    text: "reasoning-only turn".to_string(),
1979                    signature: None,
1980                    details: Vec::new(),
1981                }],
1982            },
1983            Message::text(Role::User, "follow-up"),
1984        ];
1985        let request = Request {
1986            llm_request: LlmRequest {
1987                messages,
1988                ..LlmRequest::default()
1989            },
1990            raw_request: None,
1991            metadata: None,
1992        };
1993
1994        let built = TaskInput {
1995            recent_turn_window: Some(10),
1996        }
1997        .build_messages(&State::default(), &request);
1998
1999        assert!(
2000            !built
2001                .iter()
2002                .flat_map(|message| &message.content)
2003                .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2004            "{built:?}"
2005        );
2006        assert!(
2007            built
2008                .iter()
2009                .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2010        );
2011        assert!(built.contains(&tool_call("call-1")));
2012        assert!(built.contains(&tool_result("call-1")));
2013        // Six of the seven fixtures survive — the reasoning-only turn is gone entirely
2014        // rather than left behind empty — plus the trailing routing instruction.
2015        assert_eq!(built.len(), 7);
2016        assert!(built.iter().all(|message| !message.content.is_empty()));
2017    }
2018
2019    #[test]
2020    fn the_default_path_is_left_unchanged() -> Result<()> {
2021        // No window means no conversation to be distracted by, so the default
2022        // request shape stays exactly as it was.
2023        let judge = capability_judge(None)?;
2024        let request = Request {
2025            llm_request: LlmRequest {
2026                messages: vec![Message::text(Role::User, "the task")],
2027                ..LlmRequest::default()
2028            },
2029            raw_request: None,
2030            metadata: None,
2031        };
2032
2033        let built = judge.build_request(&State::default(), &request);
2034
2035        // The task message alone; the rubric reaches the judge as an instruction block
2036        // rather than a message, so nothing was dropped by it not being counted here.
2037        assert_eq!(built.llm_request.messages.len(), 1);
2038        assert!(!built.llm_request.instructions.is_empty());
2039        assert!(
2040            !built
2041                .llm_request
2042                .messages
2043                .iter()
2044                .filter_map(|message| message.text_content("\n"))
2045                .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2046        );
2047        Ok(())
2048    }
2049
2050    #[test]
2051    fn capability_judge_builds_a_structured_request() -> Result<()> {
2052        let judge = capability_judge(None)?;
2053        let request = Request {
2054            llm_request: LlmRequest {
2055                model: Some("inbound".to_string()),
2056                messages: vec![
2057                    Message::text(Role::System, "client instructions"),
2058                    Message::text(Role::Developer, "client developer instructions"),
2059                    Message::text(Role::User, "initial task"),
2060                    Message::text(Role::Assistant, "old response"),
2061                    Message::text(Role::User, "old follow-up"),
2062                    Message::text(Role::Assistant, "recent 1"),
2063                    Message::text(Role::User, "recent 2"),
2064                    Message::text(Role::Assistant, "recent 3"),
2065                    Message::text(Role::User, "recent 4"),
2066                    Message::text(Role::Assistant, "recent 5"),
2067                ],
2068                ..LlmRequest::default()
2069            },
2070            raw_request: None,
2071            metadata: None,
2072        };
2073        let judge_request = judge.build_request(&State::default(), &request);
2074
2075        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2076        assert_eq!(judge_request.llm_request.instructions.len(), 1);
2077        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2078        assert_eq!(
2079            judge_request.llm_request.instructions[0].content,
2080            InstructionBlock {
2081                role: Role::System,
2082                content: Message::text(Role::System, judge.contract().system_prompt()).content,
2083            }
2084            .content,
2085        );
2086        assert_eq!(judge_request.llm_request.messages.len(), 2);
2087        let contents = judge_request
2088            .llm_request
2089            .messages
2090            .iter()
2091            .filter_map(|message| message.text_content("\n"))
2092            .collect::<Vec<_>>();
2093        assert!(contents.contains(&"recent 4".to_string()));
2094        assert!(contents.contains(&"initial task".to_string()));
2095        assert!(!contents.contains(&"recent 5".to_string()));
2096        assert!(!contents.contains(&"client instructions".to_string()));
2097        assert_eq!(
2098            judge_request.llm_request.output.response_format,
2099            Some(judge.contract().response_format().clone())
2100        );
2101        assert_eq!(
2102            judge_request.llm_request.output.max_output_tokens,
2103            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2104        );
2105        Ok(())
2106    }
2107
2108    fn sample_value(spec: &Value) -> Value {
2109        if let Some(first) = spec
2110            .get("enum")
2111            .and_then(Value::as_array)
2112            .and_then(|values| values.first())
2113        {
2114            return first.clone();
2115        }
2116        match spec.get("type").and_then(Value::as_str) {
2117            Some("number") => serde_json::json!(0.5),
2118            Some("boolean") => serde_json::json!(false),
2119            _ => serde_json::json!("sample"),
2120        }
2121    }
2122
2123    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2124        let properties = schema
2125            .pointer("/json_schema/schema/properties")
2126            .and_then(Value::as_object)
2127            .ok_or_else(|| LibsyError::AlgorithmError {
2128                message: "packaged schema declares no properties".to_string(),
2129            })?;
2130        Ok(Value::Object(
2131            properties
2132                .iter()
2133                .map(|(name, spec)| (name.clone(), sample_value(spec)))
2134                .collect(),
2135        )
2136        .to_string())
2137    }
2138
2139    /// Built from the schema so a property added there fails here rather than silently
2140    /// rejecting every production verdict.
2141    #[test]
2142    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2143        let contract =
2144            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2145        let schema = contract.response_format();
2146        let reply = schema_shaped_verdict(schema)?;
2147        let judge: CapabilityJudge = StructuredJudge::new(
2148            TaskInput {
2149                recent_turn_window: None,
2150            },
2151            contract,
2152            SerdeDecoder::new(),
2153            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2154        );
2155
2156        let verdict = judge.parse(&text_response(None, reply))?;
2157
2158        assert!(verdict.is_valid());
2159        assert!((0.0..=1.0).contains(&verdict.p_solve));
2160        Ok(())
2161    }
2162
2163    #[test]
2164    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2165        let contract =
2166            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2167        let prompt = contract.system_prompt();
2168        let schema_name = contract
2169            .response_format()
2170            .pointer("/json_schema/name")
2171            .and_then(Value::as_str)
2172            .ok_or_else(|| LibsyError::AlgorithmError {
2173                message: "packaged response schema has no name".to_string(),
2174            })?;
2175        assert_eq!(schema_name, "CapabilityClassifierDecision");
2176        assert!(prompt.contains("SUP-1 [supported]"));
2177        assert!(prompt.contains("SUP-5 [supported]"));
2178        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2179        assert!(!prompt.contains("\"type\": \"object\""));
2180        assert!(!prompt.contains("\"json_schema\""));
2181        assert!(!prompt.contains(schema_name));
2182        let rule_values = contract
2183            .response_format()
2184            .pointer("/json_schema/schema/properties/primary_rule/enum")
2185            .and_then(Value::as_array)
2186            .ok_or_else(|| LibsyError::AlgorithmError {
2187                message: "rendered response schema has no primary rule enum".to_string(),
2188            })?;
2189        assert!(
2190            rule_values
2191                .iter()
2192                .any(|value| value.as_str() == Some("SUP-1"))
2193        );
2194        assert!(
2195            rule_values
2196                .iter()
2197                .any(|value| value.as_str() == Some("none"))
2198        );
2199        Ok(())
2200    }
2201}