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,
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_relative_advantage() -> 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            cutoff: 0.4,
1303            instructions: None,
1304            candidates: BTreeMap::from([
1305                ("a".into(), "capable".into()),
1306                ("b".into(), "efficient".into()),
1307                ("c".into(), "third".into()),
1308            ]),
1309            evidence: json!({
1310                "candidate_descriptions": {"a": "Profile A", "b": "Profile B", "c": "Profile C"},
1311                "reference_cases": [{"task": "Independent example", "observed_solved": {"a": true, "b": null, "c": false}}],
1312                "summaries": [],
1313            }),
1314        };
1315        let mut request = classify_session_request();
1316        request.llm_request.messages.push(Message {
1317            role: Role::Assistant,
1318            content: vec![
1319                ContentBlock::Reasoning {
1320                    text: "private reasoning".into(),
1321                    signature: None,
1322                    details: vec![],
1323                },
1324                ContentBlock::Text {
1325                    text: "visible progress".into(),
1326                },
1327            ],
1328        });
1329        request
1330            .llm_request
1331            .messages
1332            .push(Message::text(Role::User, "latest follow-up"));
1333        let original_messages = request.llm_request.messages.clone();
1334        let expected_task = json!([
1335            original_messages[0].clone(),
1336            Message::text(Role::Assistant, "visible progress"),
1337            Message::text(Role::User, "latest follow-up"),
1338        ]);
1339
1340        for (name, score, fail_open, expected) in [
1341            ("above", 0.45, true, Some("capable")),
1342            ("equal", 0.4, true, Some("efficient")),
1343            ("below", 0.39, false, Some("efficient")),
1344            ("missing answer", 0.0, true, Some("capable")),
1345            ("no distribution", 0.0, true, Some("capable")),
1346            ("wrong type", 0.0, true, Some("capable")),
1347            ("out of range", 1.1, true, Some("capable")),
1348            ("nonfinite", f64::NAN, false, Some("capable")),
1349            ("provider error", 0.0, true, Some("capable")),
1350            ("provider error", 0.0, false, None),
1351            ("dropped reply", 0.0, true, Some("capable")),
1352            ("missing candidate", 0.0, true, None),
1353        ] {
1354            let mut config = settings.clone();
1355            if name == "missing candidate" {
1356                config.candidates.remove("a");
1357            }
1358            if name == "below" {
1359                config.instructions = Some(
1360                    json!({"policy": "Judge capable-only success using the supplied evidence."}),
1361                );
1362            }
1363            let override_instructions = config.instructions.clone();
1364            let router: Arc<dyn Algorithm> =
1365                Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1366                    config: TaskClassifierConfig {
1367                        judge: CapabilityJudgeConfig::Decision(config),
1368                        fail_open,
1369                        recent_turn_window: Some(3),
1370                        classify_trigger: ClassifyTrigger::NewSession,
1371                        ..TaskClassifierConfig::default()
1372                    },
1373                })?);
1374            let calls = AtomicUsize::new(0);
1375            let serve = |call| {
1376                let calls = &calls;
1377                let expected_task = &expected_task;
1378                let settings = &settings;
1379                let override_instructions = &override_instructions;
1380                async move {
1381                    let Call::Decision(call) = call else {
1382                        panic!("judge must use the decision step")
1383                    };
1384                    calls.fetch_add(1, Ordering::SeqCst);
1385                    assert_eq!(call.model, "judge");
1386                    assert_eq!(call.request.model, Some("judge".into()));
1387                    assert_eq!(
1388                        call.request.context,
1389                        json!({
1390                            "task": expected_task,
1391                            "session": "session-1",
1392                            "candidates": ["a", "b", "c"],
1393                            "comparison": {"capable": "a", "efficient": "b"},
1394                            "evidence": settings.evidence,
1395                        })
1396                    );
1397                    assert_eq!(call.request.questions.len(), 1);
1398                    let question = &call.request.questions["route"];
1399                    let switchyard_protocol::DecisionKind::Choice { options } = &question.kind
1400                    else {
1401                        panic!("expected a choice")
1402                    };
1403                    assert_eq!(
1404                        options
1405                            .iter()
1406                            .map(|option| option.id.as_str())
1407                            .collect::<Vec<_>>(),
1408                        ["advantage", "no_advantage"]
1409                    );
1410                    if let Some(instructions) = override_instructions {
1411                        assert_eq!(&question.instructions, instructions);
1412                    } else {
1413                        for key in ["setting", "evidence", "comparison", "boundary", "policy"] {
1414                            assert!(question.instructions[key].as_str().is_some());
1415                        }
1416                    }
1417                    if name == "provider error" {
1418                        return call
1419                            .respond(std::future::ready(Err(LibsyError::client_call(
1420                                "judge",
1421                                LlmClientError::General("private provider body".into()),
1422                            ))))
1423                            .await;
1424                    }
1425                    if name == "dropped reply" {
1426                        drop(call);
1427                        return Ok(());
1428                    }
1429                    let value = if name == "wrong type" {
1430                        DecisionValue::Boolean(BooleanEstimate::Value(true))
1431                    } else {
1432                        DecisionValue::Choice {
1433                            selected: "no_advantage".into(),
1434                            probabilities: (name != "no distribution").then(|| {
1435                                BTreeMap::from([
1436                                    ("advantage".into(), Probability(score)),
1437                                    ("no_advantage".into(), Probability(1.0 - score)),
1438                                ])
1439                            }),
1440                        }
1441                    };
1442                    let answers = if name == "missing answer" {
1443                        BTreeMap::new()
1444                    } else {
1445                        BTreeMap::from([(
1446                            "route".into(),
1447                            DecisionAnswer {
1448                                value,
1449                                provider_confidence: Some(ProviderConfidence(0.99)),
1450                            },
1451                        )])
1452                    };
1453                    call.respond(std::future::ready(Ok(DecisionResponse {
1454                        id: None,
1455                        model: Some("provider-judge".into()),
1456                        answers,
1457                        usage: Default::default(),
1458                    })))
1459                    .await
1460                }
1461            };
1462            let models = Arc::new(RuntimeModels::new(runtime_models()));
1463            let result = drive(router.clone(), request.clone(), models.clone(), &serve).await;
1464            assert_eq!(
1465                calls.load(Ordering::SeqCst),
1466                usize::from(name != "missing candidate"),
1467                "{name}"
1468            );
1469            let Some(expected) = expected else {
1470                if name == "missing candidate" {
1471                    assert!(
1472                        matches!(result, Err(LibsyError::AlgorithmError { message }) if message.contains("candidate is missing"))
1473                    );
1474                } else {
1475                    assert!(
1476                        matches!(result, Err(LibsyError::ClientCall { .. })),
1477                        "{name}"
1478                    );
1479                }
1480                continue;
1481            };
1482            let outcome = result?;
1483            assert_eq!(outcome.selected_model_id()?, expected, "{name}");
1484            assert!(outcome.response.is_none());
1485            assert_eq!(outcome.request.llm_request.messages, original_messages);
1486            let evidence = outcome
1487                .metadata
1488                .and_then(|metadata| metadata.evidence)
1489                .expect("routing evidence");
1490            if matches!(name, "above" | "equal" | "below") {
1491                assert_eq!(evidence["source"], "decision_classifier");
1492                assert_eq!(evidence["verdict"], "relative_advantage");
1493                assert_eq!(evidence["threshold"], settings.cutoff);
1494                assert_eq!(evidence["score"], score);
1495            } else {
1496                let reason = match name {
1497                    "provider error" => "client_error",
1498                    "dropped reply" => "call_error",
1499                    _ => "invalid_verdict",
1500                };
1501                assert_eq!(
1502                    evidence,
1503                    json!({"source": "fail_open", "reason_code": reason})
1504                );
1505            }
1506            let retained = drive(router, request.clone(), models, &serve).await?;
1507            assert_eq!(retained.selected_model_id()?, expected);
1508            assert_eq!(
1509                calls.load(Ordering::SeqCst),
1510                1,
1511                "affinity should skip the judge: {name}"
1512            );
1513        }
1514        for cutoff in [-0.1, 1.1, f64::NAN] {
1515            let mut config = settings.clone();
1516            config.cutoff = cutoff;
1517            assert!(
1518                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1519                    config: TaskClassifierConfig {
1520                        judge: CapabilityJudgeConfig::Decision(config),
1521                        ..TaskClassifierConfig::default()
1522                    },
1523                })
1524                .is_err()
1525            );
1526        }
1527        let mut duplicate = settings.clone();
1528        duplicate
1529            .candidates
1530            .insert("duplicate-a".into(), "capable".into());
1531        assert!(duplicate.validate().is_err());
1532        Ok(())
1533    }
1534
1535    #[test]
1536    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1537        let policy = policy();
1538        let at_threshold = verdict(0.5, "supported", "SUP-1");
1539        let below_threshold = verdict(0.49, "supported", "SUP-1");
1540        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1541        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1542        Ok(())
1543    }
1544
1545    #[test]
1546    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1547        let borderline = verdict(0.5, "supported", "SUP-1");
1548        let strict = TaskClassifierPolicy::new(&llm_config(0.9));
1549        let lenient = TaskClassifierPolicy::new(&llm_config(0.1));
1550        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1551        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1552        Ok(())
1553    }
1554
1555    #[test]
1556    fn classifier_config_rejects_unknown_fields() {
1557        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1558            "base_threshold": 0.5,
1559            "classifier_magic": true,
1560        }))
1561        .expect_err("unknown classifier fields must be rejected");
1562
1563        assert!(
1564            error
1565                .to_string()
1566                .contains("unknown field `classifier_magic`"),
1567            "{error}"
1568        );
1569    }
1570
1571    #[test]
1572    fn invalid_classifier_config_is_rejected() -> Result<()> {
1573        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1574            assert!(
1575                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1576                    config: test_config(bad),
1577                })
1578                .is_err(),
1579                "base threshold {bad} should be rejected"
1580            );
1581        }
1582        for config in [
1583            TaskClassifierConfig {
1584                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1585                    base_threshold: 0.5,
1586                    threshold_step: -0.1,
1587                    ..LlmCapabilityConfig::default()
1588                }),
1589                ..TaskClassifierConfig::default()
1590            },
1591            TaskClassifierConfig {
1592                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1593                    base_threshold: 0.8,
1594                    threshold_step: 0.11,
1595                    ..LlmCapabilityConfig::default()
1596                }),
1597                ..TaskClassifierConfig::default()
1598            },
1599            TaskClassifierConfig {
1600                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1601                    base_threshold: 0.5,
1602                    ..LlmCapabilityConfig::default()
1603                }),
1604                message_hash_fallback: true,
1605                ..TaskClassifierConfig::default()
1606            },
1607            TaskClassifierConfig {
1608                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1609                    base_threshold: 0.5,
1610                    max_output_tokens: 0,
1611                    ..LlmCapabilityConfig::default()
1612                }),
1613                ..TaskClassifierConfig::default()
1614            },
1615        ] {
1616            assert!(LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).is_err());
1617        }
1618        for base_threshold in [0.0, 1.0] {
1619            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1620                config: test_config(base_threshold),
1621            })?;
1622        }
1623        Ok(())
1624    }
1625
1626    #[test]
1627    fn message_hash_fallback_accepts_retaining_triggers() -> Result<()> {
1628        // Only `every_request` is rejected: it retains no target, so a fallback
1629        // identity has nothing to key on. Both retaining triggers may key the
1630        // retained target on a message hash when the caller sends no session id.
1631        for trigger in [ClassifyTrigger::NewSession, ClassifyTrigger::UserTurn] {
1632            let config = TaskClassifierConfig {
1633                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1634                    base_threshold: 0.5,
1635                    ..LlmCapabilityConfig::default()
1636                }),
1637                classify_trigger: trigger,
1638                message_hash_fallback: true,
1639                ..TaskClassifierConfig::default()
1640            };
1641            LlmTaskClassifier::new(LlmClassifierConfig::Capability { config }).map_err(
1642                |error| LibsyError::AlgorithmError {
1643                    message: format!("{trigger:?} with message_hash_fallback rejected: {error}"),
1644                },
1645            )?;
1646        }
1647        let every_request = TaskClassifierConfig {
1648            judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
1649                base_threshold: 0.5,
1650                ..LlmCapabilityConfig::default()
1651            }),
1652            classify_trigger: ClassifyTrigger::EveryRequest,
1653            message_hash_fallback: true,
1654            ..TaskClassifierConfig::default()
1655        };
1656        assert!(
1657            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1658                config: every_request
1659            })
1660            .is_err(),
1661            "every_request with message_hash_fallback should stay rejected"
1662        );
1663        Ok(())
1664    }
1665
1666    #[test]
1667    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1668        let policy = policy();
1669        let inconsistent_rule = TaskClassifierVerdict {
1670            capability_boundary: "uncertain".to_string(),
1671            ..verdict(1.0, "supported", "SUP-1")
1672        };
1673        let empty_crux = TaskClassifierVerdict {
1674            crux: "  ".to_string(),
1675            ..verdict(1.0, "supported", "SUP-1")
1676        };
1677        let unusable = [
1678            Some(verdict(1.1, "supported", "SUP-1")),
1679            Some(inconsistent_rule),
1680            Some(empty_crux),
1681            None,
1682        ];
1683        for verdict in unusable {
1684            let classification = policy.to_classification(verdict.as_ref(), &policy_driver())?;
1685            assert!(matches!(classification, Classification::Ambiguous(_)));
1686            assert!(classification.argmax(false)?.is_none());
1687            assert!(classification.argmax(true)?.is_none());
1688        }
1689        Ok(())
1690    }
1691
1692    #[test]
1693    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1694        let policy = TaskClassifierPolicy::new(&LlmCapabilityConfig {
1695            threshold_step: 0.1,
1696            ..llm_config(0.4)
1697        });
1698
1699        assert_eq!(
1700            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1701            "efficient"
1702        );
1703        assert_eq!(
1704            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1705            "capable"
1706        );
1707        assert_eq!(
1708            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1709            "efficient"
1710        );
1711        assert_eq!(
1712            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1713            "efficient"
1714        );
1715        assert_eq!(
1716            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1717            "capable"
1718        );
1719        assert_eq!(
1720            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1721            "efficient"
1722        );
1723        Ok(())
1724    }
1725
1726    /// The text of each message a judge with `recent_turn_window` would be sent.
1727    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1728    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1729        Ok(StructuredJudge::new(
1730            TaskInput { recent_turn_window },
1731            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1732            SerdeDecoder::new(),
1733            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1734        ))
1735    }
1736
1737    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1738        let judge = capability_judge(Some(recent_turn_window))?;
1739        let request = Request {
1740            llm_request: LlmRequest {
1741                messages: vec![
1742                    Message::text(Role::System, "client instructions"),
1743                    Message::text(Role::User, "initial task"),
1744                    Message::text(Role::Assistant, "old response"),
1745                    Message::text(Role::User, "old follow-up"),
1746                    Message::text(Role::Assistant, "recent 1"),
1747                    Message::text(Role::User, "recent 2"),
1748                ],
1749                ..LlmRequest::default()
1750            },
1751            raw_request: None,
1752            metadata: None,
1753        };
1754        Ok(judge
1755            .build_request(&State::default(), &request)
1756            .llm_request
1757            .messages
1758            .iter()
1759            .filter_map(|message| message.text_content("\n"))
1760            .collect())
1761    }
1762
1763    #[test]
1764    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1765        // Client instructions and the opening task, plus the last two turns.
1766        let contents = judged_contents(2)?;
1767        assert!(contents.contains(&"client instructions".to_string()));
1768        assert!(contents.contains(&"initial task".to_string()));
1769        assert!(contents.contains(&"recent 1".to_string()));
1770        assert!(contents.contains(&"recent 2".to_string()));
1771        assert!(!contents.contains(&"old response".to_string()));
1772        Ok(())
1773    }
1774
1775    #[test]
1776    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1777        let contents = judged_contents(0)?;
1778        assert!(contents.contains(&"client instructions".to_string()));
1779        assert!(contents.contains(&"initial task".to_string()));
1780        assert!(!contents.contains(&"recent 2".to_string()));
1781        Ok(())
1782    }
1783
1784    fn tool_call(id: &str) -> Message {
1785        Message {
1786            role: Role::Assistant,
1787            content: vec![ContentBlock::ToolCall(ToolCall {
1788                id: id.to_string(),
1789                name: "search".to_string(),
1790                arguments: Value::Null,
1791            })],
1792        }
1793    }
1794
1795    fn tool_result(id: &str) -> Message {
1796        Message {
1797            role: Role::Tool,
1798            content: vec![ContentBlock::ToolResult(ToolResult {
1799                tool_call_id: id.to_string(),
1800                content: vec![ContentBlock::Text {
1801                    text: "tool output".to_string(),
1802                }],
1803                is_error: None,
1804            })],
1805        }
1806    }
1807
1808    #[test]
1809    fn default_task_input_keeps_user_content_around_tool_results() {
1810        let mut result = tool_result("call-1");
1811        result.role = Role::User;
1812        let mut mixed = result.clone();
1813        mixed.content.push(ContentBlock::Text {
1814            text: "latest follow-up".to_string(),
1815        });
1816        let input = TaskInput {
1817            recent_turn_window: None,
1818        };
1819        let mut request = Request {
1820            llm_request: LlmRequest {
1821                messages: vec![
1822                    result.clone(),
1823                    Message::text(Role::User, "initial task"),
1824                    tool_call("call-1"),
1825                    mixed,
1826                    result.clone(),
1827                ],
1828                ..LlmRequest::default()
1829            },
1830            ..Request::default()
1831        };
1832        assert_eq!(
1833            input.build_messages(&State::default(), &request),
1834            vec![
1835                Message::text(Role::User, "initial task"),
1836                Message::text(Role::User, "latest follow-up"),
1837            ]
1838        );
1839        request.llm_request.messages = vec![result];
1840        assert!(input.build_messages(&State::default(), &request).is_empty());
1841    }
1842
1843    /// A count-based window can begin on a tool result, which leaves the call that
1844    /// introduced its id outside the window and the classifier history invalid.
1845    #[test]
1846    fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1847        let messages = vec![
1848            Message::text(Role::System, "client instructions"),
1849            Message::text(Role::User, "initial task"),
1850            Message::text(Role::Assistant, "old response"),
1851            tool_call("call-1"),
1852            tool_result("call-1"),
1853            Message::text(Role::Assistant, "recent 1"),
1854            Message::text(Role::User, "recent 2"),
1855            Message::text(Role::Assistant, "recent 3"),
1856            Message::text(Role::User, "recent 4"),
1857        ];
1858
1859        // The five-message tail begins exactly on the tool result.
1860        let kept = trim_messages(&messages, 5);
1861
1862        assert_eq!(
1863            kept,
1864            vec![
1865                Message::text(Role::System, "client instructions"),
1866                Message::text(Role::User, "initial task"),
1867                tool_call("call-1"),
1868                tool_result("call-1"),
1869                Message::text(Role::Assistant, "recent 1"),
1870                Message::text(Role::User, "recent 2"),
1871                Message::text(Role::Assistant, "recent 3"),
1872                Message::text(Role::User, "recent 4"),
1873            ]
1874        );
1875    }
1876
1877    /// Ids repeat across a conversation, so a later call must not stand in for the one that
1878    /// answers an earlier result.
1879    #[test]
1880    fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1881        let messages = vec![
1882            Message::text(Role::System, "client instructions"),
1883            Message::text(Role::User, "initial task"),
1884            tool_call("x"),
1885            tool_result("x"),
1886            Message::text(Role::Assistant, "later"),
1887            tool_call("x"),
1888            tool_result("x"),
1889        ];
1890
1891        // The four-message tail begins on the first result, whose own call sits one earlier.
1892        let kept = trim_messages(&messages, 4);
1893
1894        assert_eq!(
1895            kept,
1896            vec![
1897                Message::text(Role::System, "client instructions"),
1898                Message::text(Role::User, "initial task"),
1899                tool_call("x"),
1900                tool_result("x"),
1901                Message::text(Role::Assistant, "later"),
1902                tool_call("x"),
1903                tool_result("x"),
1904            ]
1905        );
1906    }
1907
1908    /// A result whose call precedes the opening task can never be paired, because trimming
1909    /// never reaches behind the task. The window must not widen hunting for it.
1910    #[test]
1911    fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1912        let messages = vec![
1913            Message::text(Role::System, "client instructions"),
1914            tool_call("orphan"),
1915            Message::text(Role::User, "initial task"),
1916            Message::text(Role::Assistant, "old response"),
1917            tool_result("orphan"),
1918            Message::text(Role::Assistant, "recent 1"),
1919            Message::text(Role::User, "recent 2"),
1920        ];
1921
1922        let kept = trim_messages(&messages, 3);
1923
1924        assert_eq!(
1925            kept,
1926            vec![
1927                Message::text(Role::System, "client instructions"),
1928                Message::text(Role::User, "initial task"),
1929                tool_result("orphan"),
1930                Message::text(Role::Assistant, "recent 1"),
1931                Message::text(Role::User, "recent 2"),
1932            ]
1933        );
1934    }
1935
1936    #[test]
1937    fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1938        let contents = judged_contents(2)?;
1939
1940        // Last, not merely present: the instruction only works in final position,
1941        // after the conversation content it is meant to outrank.
1942        assert_eq!(
1943            contents.last().map(String::as_str),
1944            Some(TRAILING_ROUTING_INSTRUCTION)
1945        );
1946        Ok(())
1947    }
1948
1949    /// Reasoning is provider-private and some upstreams reject it replayed without the
1950    /// opaque state it was issued with, so it must not reach a windowed classifier
1951    /// request. Visible assistant text and complete tool pairs still do, and a turn
1952    /// whose only content was reasoning is dropped rather than left behind empty.
1953    #[test]
1954    fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1955        let messages = vec![
1956            Message::text(Role::System, "client instructions"),
1957            Message::text(Role::User, "initial task"),
1958            Message {
1959                role: Role::Assistant,
1960                content: vec![
1961                    ContentBlock::Reasoning {
1962                        text: "private chain of thought".to_string(),
1963                        signature: None,
1964                        details: Vec::new(),
1965                    },
1966                    ContentBlock::Text {
1967                        text: "visible answer".to_string(),
1968                    },
1969                ],
1970            },
1971            tool_call("call-1"),
1972            tool_result("call-1"),
1973            Message {
1974                role: Role::Assistant,
1975                content: vec![ContentBlock::Reasoning {
1976                    text: "reasoning-only turn".to_string(),
1977                    signature: None,
1978                    details: Vec::new(),
1979                }],
1980            },
1981            Message::text(Role::User, "follow-up"),
1982        ];
1983        let request = Request {
1984            llm_request: LlmRequest {
1985                messages,
1986                ..LlmRequest::default()
1987            },
1988            raw_request: None,
1989            metadata: None,
1990        };
1991
1992        let built = TaskInput {
1993            recent_turn_window: Some(10),
1994        }
1995        .build_messages(&State::default(), &request);
1996
1997        assert!(
1998            !built
1999                .iter()
2000                .flat_map(|message| &message.content)
2001                .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
2002            "{built:?}"
2003        );
2004        assert!(
2005            built
2006                .iter()
2007                .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
2008        );
2009        assert!(built.contains(&tool_call("call-1")));
2010        assert!(built.contains(&tool_result("call-1")));
2011        // Six of the seven fixtures survive — the reasoning-only turn is gone entirely
2012        // rather than left behind empty — plus the trailing routing instruction.
2013        assert_eq!(built.len(), 7);
2014        assert!(built.iter().all(|message| !message.content.is_empty()));
2015    }
2016
2017    #[test]
2018    fn the_default_path_is_left_unchanged() -> Result<()> {
2019        // No window means no conversation to be distracted by, so the default
2020        // request shape stays exactly as it was.
2021        let judge = capability_judge(None)?;
2022        let request = Request {
2023            llm_request: LlmRequest {
2024                messages: vec![Message::text(Role::User, "the task")],
2025                ..LlmRequest::default()
2026            },
2027            raw_request: None,
2028            metadata: None,
2029        };
2030
2031        let built = judge.build_request(&State::default(), &request);
2032
2033        // The task message alone; the rubric reaches the judge as an instruction block
2034        // rather than a message, so nothing was dropped by it not being counted here.
2035        assert_eq!(built.llm_request.messages.len(), 1);
2036        assert!(!built.llm_request.instructions.is_empty());
2037        assert!(
2038            !built
2039                .llm_request
2040                .messages
2041                .iter()
2042                .filter_map(|message| message.text_content("\n"))
2043                .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
2044        );
2045        Ok(())
2046    }
2047
2048    #[test]
2049    fn capability_judge_builds_a_structured_request() -> Result<()> {
2050        let judge = capability_judge(None)?;
2051        let request = Request {
2052            llm_request: LlmRequest {
2053                model: Some("inbound".to_string()),
2054                messages: vec![
2055                    Message::text(Role::System, "client instructions"),
2056                    Message::text(Role::Developer, "client developer instructions"),
2057                    Message::text(Role::User, "initial task"),
2058                    Message::text(Role::Assistant, "old response"),
2059                    Message::text(Role::User, "old follow-up"),
2060                    Message::text(Role::Assistant, "recent 1"),
2061                    Message::text(Role::User, "recent 2"),
2062                    Message::text(Role::Assistant, "recent 3"),
2063                    Message::text(Role::User, "recent 4"),
2064                    Message::text(Role::Assistant, "recent 5"),
2065                ],
2066                ..LlmRequest::default()
2067            },
2068            raw_request: None,
2069            metadata: None,
2070        };
2071        let judge_request = judge.build_request(&State::default(), &request);
2072
2073        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
2074        assert_eq!(judge_request.llm_request.instructions.len(), 1);
2075        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
2076        assert_eq!(
2077            judge_request.llm_request.instructions[0].content,
2078            InstructionBlock {
2079                role: Role::System,
2080                content: Message::text(Role::System, judge.contract().system_prompt()).content,
2081            }
2082            .content,
2083        );
2084        assert_eq!(judge_request.llm_request.messages.len(), 2);
2085        let contents = judge_request
2086            .llm_request
2087            .messages
2088            .iter()
2089            .filter_map(|message| message.text_content("\n"))
2090            .collect::<Vec<_>>();
2091        assert!(contents.contains(&"recent 4".to_string()));
2092        assert!(contents.contains(&"initial task".to_string()));
2093        assert!(!contents.contains(&"recent 5".to_string()));
2094        assert!(!contents.contains(&"client instructions".to_string()));
2095        assert_eq!(
2096            judge_request.llm_request.output.response_format,
2097            Some(judge.contract().response_format().clone())
2098        );
2099        assert_eq!(
2100            judge_request.llm_request.output.max_output_tokens,
2101            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
2102        );
2103        Ok(())
2104    }
2105
2106    fn sample_value(spec: &Value) -> Value {
2107        if let Some(first) = spec
2108            .get("enum")
2109            .and_then(Value::as_array)
2110            .and_then(|values| values.first())
2111        {
2112            return first.clone();
2113        }
2114        match spec.get("type").and_then(Value::as_str) {
2115            Some("number") => serde_json::json!(0.5),
2116            Some("boolean") => serde_json::json!(false),
2117            _ => serde_json::json!("sample"),
2118        }
2119    }
2120
2121    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
2122        let properties = schema
2123            .pointer("/json_schema/schema/properties")
2124            .and_then(Value::as_object)
2125            .ok_or_else(|| LibsyError::AlgorithmError {
2126                message: "packaged schema declares no properties".to_string(),
2127            })?;
2128        Ok(Value::Object(
2129            properties
2130                .iter()
2131                .map(|(name, spec)| (name.clone(), sample_value(spec)))
2132                .collect(),
2133        )
2134        .to_string())
2135    }
2136
2137    /// Built from the schema so a property added there fails here rather than silently
2138    /// rejecting every production verdict.
2139    #[test]
2140    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
2141        let contract =
2142            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2143        let schema = contract.response_format();
2144        let reply = schema_shaped_verdict(schema)?;
2145        let judge: CapabilityJudge = StructuredJudge::new(
2146            TaskInput {
2147                recent_turn_window: None,
2148            },
2149            contract,
2150            SerdeDecoder::new(),
2151            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
2152        );
2153
2154        let verdict = judge.parse(&text_response(None, reply))?;
2155
2156        assert!(verdict.is_valid());
2157        assert!((0.0..=1.0).contains(&verdict.p_solve));
2158        Ok(())
2159    }
2160
2161    #[test]
2162    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
2163        let contract =
2164            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
2165        let prompt = contract.system_prompt();
2166        let schema_name = contract
2167            .response_format()
2168            .pointer("/json_schema/name")
2169            .and_then(Value::as_str)
2170            .ok_or_else(|| LibsyError::AlgorithmError {
2171                message: "packaged response schema has no name".to_string(),
2172            })?;
2173        assert_eq!(schema_name, "CapabilityClassifierDecision");
2174        assert!(prompt.contains("SUP-1 [supported]"));
2175        assert!(prompt.contains("SUP-5 [supported]"));
2176        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
2177        assert!(!prompt.contains("\"type\": \"object\""));
2178        assert!(!prompt.contains("\"json_schema\""));
2179        assert!(!prompt.contains(schema_name));
2180        let rule_values = contract
2181            .response_format()
2182            .pointer("/json_schema/schema/properties/primary_rule/enum")
2183            .and_then(Value::as_array)
2184            .ok_or_else(|| LibsyError::AlgorithmError {
2185                message: "rendered response schema has no primary rule enum".to_string(),
2186            })?;
2187        assert!(
2188            rule_values
2189                .iter()
2190                .any(|value| value.as_str() == Some("SUP-1"))
2191        );
2192        assert!(
2193            rule_values
2194                .iter()
2195                .any(|value| value.as_str() == Some("none"))
2196        );
2197        Ok(())
2198    }
2199}