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