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