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