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