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::{BTreeMap, BTreeSet, HashSet};
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Deserializer};
11use serde_json::Value;
12use switchyard_protocol::{ContentBlock, Message, ModelId, Role};
13
14use super::escalation;
15use super::fall_through::{DefaultTarget, FallThrough};
16use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
17use super::util::affinity::{AffinityRouter, ClassifyTrigger};
18use super::util::classifier_contract::{
19    ClassifierContract, ClassifierContractConfig, ClassifierResponseFormat,
20};
21use super::util::escalation::{EscalationGateConfig, EscalationJudgeConfig};
22use super::util::llm_judge::{
23    ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
24    SerdeDecoder, StructuredJudge,
25};
26use super::util::target_selector::TargetSelectorPolicy;
27use crate::core::algorithm::{self, Algorithm, Driver};
28use crate::core::classifier::{Classification, Classifier, Score};
29use crate::core::state::State;
30use crate::{LibsyError, Result};
31use switchyard_protocol::{Request, Response};
32
33const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
34const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
35/// Telemetry label for this algorithm's spans, metrics, and logs.
36const ALGORITHM_NAME: &str = "llm_task_classifier";
37
38/// Restates the task after the conversation the judge is asked to route.
39///
40/// The rubric leads the request, which works while the payload is a single short
41/// message. Once `recent_turn_window` includes real conversation, those instructions
42/// sit far from the generation point and the judge sometimes *answers* the
43/// conversation instead of classifying it — the reply then fails to parse, the
44/// verdict is unavailable, and the turn silently falls back. Repeating the task at
45/// the end is what reliably prevents that.
46const TRAILING_ROUTING_INSTRUCTION: &str =
47    "Route the conversation above. Output ONLY the routing JSON object, nothing else.";
48
49#[derive(Deserialize)]
50#[serde(deny_unknown_fields)]
51struct TaskClassifierVerdict {
52    crux: String,
53    primary_rule: String,
54    capability_boundary: String,
55    p_solve: f64,
56}
57
58impl TaskClassifierVerdict {
59    /// Rejects malformed or internally inconsistent verdicts before policy evaluation.
60    fn is_valid(&self) -> bool {
61        (0.0..=1.0).contains(&self.p_solve)
62            && !self.crux.trim().is_empty()
63            && matches!(
64                (
65                    self.primary_rule.as_str(),
66                    self.capability_boundary.as_str()
67                ),
68                ("SUP-1" | "SUP-2" | "SUP-3" | "SUP-4" | "SUP-5", "supported")
69                    | ("UNC-1" | "UNC-2", "uncertain")
70                    | ("LIM-1" | "LIM-2", "unsupported")
71                    | ("none", "unmatched")
72            )
73    }
74
75    /// Returns the number of threshold steps assigned to this capability boundary.
76    fn boundary_steps(&self) -> Option<u8> {
77        match self.capability_boundary.as_str() {
78            "supported" => Some(0),
79            "uncertain" | "unmatched" => Some(1),
80            "unsupported" => Some(2),
81            _ => None,
82        }
83    }
84}
85
86/// Keeps the opening task and the last `recent_turn_window` turns after it. A
87/// window of `0` keeps the task alone.
88///
89/// Inbound decoders normalize client system and developer content into
90/// `LlmRequest::instructions`, so it never reaches this list.
91///
92/// Selects by reference and clones only what survives — a coding-agent
93/// conversation carries every tool result, so cloning it whole to keep a window
94/// would copy the transcript on each judged turn.
95fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
96    let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
97    let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
98    let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
99        return kept.into_iter().cloned().collect();
100    };
101    kept.push(&messages[task]);
102
103    let tail: Vec<&Message> = messages[task + 1..]
104        .iter()
105        .filter(|m| !is_instruction(m))
106        .collect();
107    kept.extend(&tail[window_start(&tail, recent_turn_window)..]);
108    kept.into_iter().cloned().collect()
109}
110
111/// The first index of the trailing window.
112///
113/// Counting messages alone can start the window between an assistant tool call and the
114/// result answering it, leaving the judge a result whose call id was never introduced. The
115/// start therefore moves back to the nearest one that keeps every tool pair whole.
116///
117/// One newest-to-oldest pass carries the ids still waiting for a call. Direction is what
118/// makes it correct: ids repeat across a conversation, and in this order a call is only
119/// ever seen after the results it could answer, so a later call — already passed — clears
120/// nothing. A result whose call sits before the opening task, which trimming never reaches,
121/// keeps the set non-empty to the end and falls back to the counted start, so an unpairable
122/// result costs one pass and cannot widen the window to the whole conversation.
123fn window_start(tail: &[&Message], recent_turn_window: usize) -> usize {
124    let counted = tail.len().saturating_sub(recent_turn_window);
125    // An empty window holds no result to pair, and the loop below never visits its start.
126    if counted == tail.len() {
127        return counted;
128    }
129    let mut unpaired: HashSet<&str> = HashSet::new();
130    for (start, message) in tail.iter().enumerate().rev() {
131        // Blocks reverse too, so a call answers a result only when it precedes it inside
132        // one message as well as across messages.
133        for block in message.content.iter().rev() {
134            match block {
135                ContentBlock::ToolResult(result) => {
136                    unpaired.insert(result.tool_call_id.as_str());
137                }
138                ContentBlock::ToolCall(call) => {
139                    unpaired.remove(call.id.as_str());
140                }
141                _ => {}
142            }
143        }
144        if start <= counted && unpaired.is_empty() {
145            return start;
146        }
147    }
148    counted
149}
150
151/// Keeps the opening task and the latest user follow-up when they differ.
152fn task_messages(messages: &[Message]) -> Vec<Message> {
153    let mut user_messages = messages.iter().filter(|message| message.role == Role::User);
154    let Some(opening_task) = user_messages.next() else {
155        return Vec::new();
156    };
157    match user_messages.next_back() {
158        Some(latest_follow_up) => vec![opening_task.clone(), latest_follow_up.clone()],
159        None => vec![opening_task.clone()],
160    }
161}
162
163/// Selects the task messages shown to capability and custom-schema classifiers.
164struct TaskInput {
165    recent_turn_window: Option<usize>,
166}
167
168impl ClassifierInput for TaskInput {
169    fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
170        // The default preserves the whole-task anchor and latest user update. A
171        // configured window widens that to the surrounding conversation.
172        let mut messages = match self.recent_turn_window {
173            Some(window) => trim_messages(&request.llm_request.messages, window),
174            None => task_messages(&request.llm_request.messages),
175        };
176        // Reasoning is provider-private and not required to classify the task. Some
177        // upstreams also reject an unsigned reasoning item replayed without the
178        // opaque state it was issued with, so it cannot travel through a windowed
179        // classifier request as ordinary history.
180        for message in &mut messages {
181            message
182                .content
183                .retain(|block| !matches!(block, ContentBlock::Reasoning { .. }));
184        }
185        messages.retain(|message| !message.content.is_empty());
186        // Only the windowed path carries assistant turns and tool traffic for the judge
187        // to be distracted by. The default path is user task messages only — the anchor
188        // and the latest follow-up — so there is nothing there to outrank.
189        if self.recent_turn_window.is_some() {
190            messages.push(Message::text(
191                Role::User,
192                TRAILING_ROUTING_INSTRUCTION.to_string(),
193            ));
194        }
195        messages
196    }
197}
198
199type CapabilityJudge = StructuredJudge<TaskInput, SerdeDecoder<TaskClassifierVerdict>>;
200
201struct TaskClassifierPolicy {
202    efficient_target: ModelId,
203    capable_target: ModelId,
204    base_threshold: f64,
205    threshold_step: f64,
206}
207
208impl TaskClassifierPolicy {
209    fn new(
210        efficient_target: impl Into<ModelId>,
211        capable_target: impl Into<ModelId>,
212        config: &TaskClassifierConfig,
213    ) -> Self {
214        Self {
215            efficient_target: efficient_target.into(),
216            capable_target: capable_target.into(),
217            base_threshold: config.base_threshold,
218            threshold_step: config.threshold_step,
219        }
220    }
221
222    /// Returns the required solve probability for one validated verdict.
223    fn threshold(&self, verdict: &TaskClassifierVerdict) -> Option<f64> {
224        Some(self.base_threshold + f64::from(verdict.boundary_steps()?) * self.threshold_step)
225    }
226}
227
228impl JudgePolicy for TaskClassifierPolicy {
229    type Verdict = TaskClassifierVerdict;
230
231    fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
232        // Judge output is untrusted. An absent, invalid, or inconsistent verdict is
233        // ambiguous so the surrounding router applies its configured fallback.
234        let Some(verdict) = verdict.filter(|verdict| verdict.is_valid()) else {
235            return Classification::Ambiguous(vec![]);
236        };
237        // A usable verdict below the capability threshold is still a decision: the judge
238        // does not trust the efficient tier with this task.
239        let Some(threshold) = self.threshold(verdict) else {
240            return Classification::Ambiguous(vec![]);
241        };
242        let target = if verdict.p_solve >= threshold
243            || (threshold - verdict.p_solve).abs() <= f64::EPSILON
244        {
245            &self.efficient_target
246        } else {
247            &self.capable_target
248        };
249        Classification::Scores(vec![Score {
250            target: target.clone(),
251            confidence: 1.0,
252        }])
253    }
254}
255
256/// Maps valid verdicts to scores, invalid verdicts to a reason, and leaves absent verdicts alone.
257fn capability_evidence(
258    policy: &TaskClassifierPolicy,
259    verdict: Option<&TaskClassifierVerdict>,
260) -> Option<Value> {
261    let verdict = verdict?;
262    let Some(threshold) = verdict
263        .is_valid()
264        .then(|| policy.threshold(verdict))
265        .flatten()
266    else {
267        return Some(serde_json::json!({
268            "source": "fail_open",
269            "reason_code": "invalid_verdict",
270        }));
271    };
272    Some(serde_json::json!({
273        "source": "llm-classifier",
274        "score": verdict.p_solve,
275        "threshold": threshold,
276    }))
277}
278
279#[derive(Clone, Debug)]
280/// Settings that control capability classifier prompting and routing.
281pub struct TaskClassifierConfig {
282    /// Lowest solve probability that routes a supported task to the efficient target.
283    pub base_threshold: f64,
284    /// Amount added per capability-boundary step.
285    ///
286    /// Supported verdicts use `base_threshold`, uncertain and unmatched verdicts use one
287    /// step, and unsupported verdicts use two steps.
288    pub threshold_step: f64,
289    /// How often the classifier re-decides this session's target.
290    pub classify_trigger: ClassifyTrigger,
291    /// Uses the first user message as the SessionKey for sticky routing when session metadata is unavailable.
292    pub message_hash_fallback: bool,
293    /// Trailing conversation turns the judge sees on top of the client
294    /// instructions and the opening task.
295    ///
296    /// `None` (the default) judges the opening task and latest user follow-up.
297    /// `Some(n)` widens that to the client instructions, the opening task, and
298    /// the last `n` turns after it.
299    pub recent_turn_window: Option<usize>,
300    /// Prompt and verdict contract settings for the classifier judge.
301    pub contract: ClassifierContractConfig,
302    /// Maximum completion tokens available to the classifier verdict.
303    pub max_output_tokens: u64,
304}
305
306/// Flat serialized shape that maps prompt settings into the runtime contract.
307#[derive(Deserialize)]
308#[serde(deny_unknown_fields)]
309struct TaskClassifierConfigWire {
310    base_threshold: f64,
311    #[serde(default)]
312    threshold_step: f64,
313    #[serde(default)]
314    classify_trigger: ClassifyTrigger,
315    #[serde(default)]
316    message_hash_fallback: bool,
317    #[serde(default)]
318    recent_turn_window: Option<usize>,
319    #[serde(default)]
320    prompt: Option<String>,
321    #[serde(default)]
322    response_format_type: ClassifierResponseFormat,
323    #[serde(default = "default_judge_max_output_tokens")]
324    max_output_tokens: u64,
325}
326
327impl<'de> Deserialize<'de> for TaskClassifierConfig {
328    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
329    where
330        D: Deserializer<'de>,
331    {
332        let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
333        let mut contract = ClassifierContractConfig::default();
334        if let Some(prompt) = wire.prompt {
335            contract = contract.with_prompt(prompt);
336        }
337        contract = contract.with_response_format_type(wire.response_format_type);
338        Ok(Self {
339            base_threshold: wire.base_threshold,
340            threshold_step: wire.threshold_step,
341            classify_trigger: wire.classify_trigger,
342            message_hash_fallback: wire.message_hash_fallback,
343            recent_turn_window: wire.recent_turn_window,
344            contract,
345            max_output_tokens: wire.max_output_tokens,
346        })
347    }
348}
349
350const fn default_judge_max_output_tokens() -> u64 {
351    DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
352}
353
354impl Default for TaskClassifierConfig {
355    fn default() -> Self {
356        Self {
357            base_threshold: 0.0,
358            threshold_step: 0.0,
359            classify_trigger: ClassifyTrigger::default(),
360            message_hash_fallback: false,
361            recent_turn_window: None,
362            contract: ClassifierContractConfig::default(),
363            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
364        }
365    }
366}
367
368impl TaskClassifierConfig {
369    /// Validates routing thresholds before the classifier is constructed.
370    fn validate(&self) -> Result<()> {
371        if !(0.0..=1.0).contains(&self.base_threshold) {
372            return Err(LibsyError::AlgorithmError {
373                message: format!(
374                    "base_threshold must be between 0 and 1, got {}",
375                    self.base_threshold
376                ),
377            });
378        }
379        if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
380            return Err(LibsyError::AlgorithmError {
381                message: format!(
382                    "threshold_step must be finite and greater than or equal to 0, got {}",
383                    self.threshold_step
384                ),
385            });
386        }
387        let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
388        if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
389            return Err(LibsyError::AlgorithmError {
390                message: format!(
391                    "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
392                ),
393            });
394        }
395        if self.max_output_tokens == 0 {
396            return Err(LibsyError::AlgorithmError {
397                message: "max_output_tokens must be at least 1".to_string(),
398            });
399        }
400        if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
401            return Err(LibsyError::AlgorithmError {
402                message: "message_hash_fallback requires classify_trigger = new_session"
403                    .to_string(),
404            });
405        }
406        Ok(())
407    }
408}
409
410/// Policy that maps a custom classifier verdict to a routing target.
411#[derive(Clone, Debug)]
412pub enum CustomClassifierPolicy {
413    /// Resolves a JSON Pointer and treats its string value as a configured target label.
414    TargetSelector {
415        /// JSON Pointer evaluated against each schema-validated verdict.
416        selector: String,
417    },
418}
419
420impl CustomClassifierPolicy {
421    /// Creates a policy that selects a target label through a JSON Pointer.
422    pub fn target_selector(selector: impl Into<String>) -> Self {
423        Self::TargetSelector {
424            selector: selector.into(),
425        }
426    }
427}
428
429/// Settings for a classifier whose JSON Schema and target-selection policy are user supplied.
430#[derive(Clone, Debug)]
431pub struct CustomClassifierConfig {
432    /// System prompt sent to the classifier judge.
433    pub prompt: String,
434    /// Inner JSON Schema placed inside the provider's structured-output wrapper.
435    pub response_schema: Value,
436    /// Deterministic policy applied after the verdict passes schema validation.
437    pub policy: CustomClassifierPolicy,
438    /// How often the classifier re-decides this session's target.
439    pub classify_trigger: ClassifyTrigger,
440    /// Uses the first user message when session metadata is unavailable.
441    pub message_hash_fallback: bool,
442    /// Trailing conversation turns shown to the classifier judge.
443    pub recent_turn_window: Option<usize>,
444    /// Maximum completion tokens available to the classifier verdict.
445    pub max_output_tokens: u64,
446}
447
448impl CustomClassifierConfig {
449    /// Creates a custom-schema classifier contract with conservative runtime defaults.
450    pub fn new(
451        prompt: impl Into<String>,
452        response_schema: Value,
453        policy: CustomClassifierPolicy,
454    ) -> Self {
455        Self {
456            prompt: prompt.into(),
457            response_schema,
458            policy,
459            classify_trigger: ClassifyTrigger::default(),
460            message_hash_fallback: false,
461            recent_turn_window: None,
462            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
463        }
464    }
465
466    fn validate(&self) -> Result<()> {
467        if self.max_output_tokens == 0 {
468            return Err(LibsyError::AlgorithmError {
469                message: "max_output_tokens must be at least 1".to_string(),
470            });
471        }
472        if self.message_hash_fallback && self.classify_trigger != ClassifyTrigger::NewSession {
473            return Err(LibsyError::AlgorithmError {
474                message: "message_hash_fallback requires classify_trigger = new_session"
475                    .to_string(),
476            });
477        }
478        Ok(())
479    }
480}
481
482enum CustomPolicyRuntime {
483    TargetSelector(TargetSelectorPolicy),
484}
485
486impl JudgePolicy for CustomPolicyRuntime {
487    type Verdict = Value;
488
489    fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
490        match self {
491            Self::TargetSelector(policy) => policy.to_classification(verdict),
492        }
493    }
494}
495
496struct TaskClassifier {
497    classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
498    capable_target: ModelId,
499}
500
501/// Builds the affinity router a trigger calls for, if any.
502fn affinity_router(
503    trigger: ClassifyTrigger,
504    message_hash_fallback: bool,
505) -> Option<Arc<AffinityRouter>> {
506    let router = match trigger {
507        ClassifyTrigger::EveryRequest => return None,
508        ClassifyTrigger::NewSession => AffinityRouter::new(),
509        ClassifyTrigger::UserTurn => AffinityRouter::new().with_release_on_user_turn(),
510    };
511    let router = if message_hash_fallback {
512        router.with_message_hash_fallback()
513    } else {
514        router
515    };
516    Some(Arc::new(router))
517}
518
519/// Routes requests through a capability, escalation, or custom classifier mode.
520pub struct LlmTaskClassifier {
521    route: FallThrough<State>,
522    /// Classifier used when this router is embedded in another cascade.
523    inner: Arc<dyn Classifier<State>>,
524}
525
526struct ClassifierRouteConfig {
527    default_target: ModelId,
528    classify_trigger: ClassifyTrigger,
529    message_hash_fallback: bool,
530}
531
532/// Complete construction settings for one LLM classifier mode.
533#[derive(Clone)]
534#[non_exhaustive]
535pub enum LlmClassifierConfig {
536    /// Routes between efficient and capable targets from a task-level verdict.
537    Capability {
538        /// Target that produces classifier verdicts.
539        judge_target: ModelId,
540        /// Target used when the efficient tier can handle the task.
541        efficient_target: ModelId,
542        /// Target used when the task needs the capable tier.
543        capable_target: ModelId,
544        /// Capability classifier settings.
545        config: TaskClassifierConfig,
546    },
547    /// Judges efficient responses and escalates after a confirmed streak.
548    Escalation {
549        /// Target that produces escalation verdicts.
550        judge_target: ModelId,
551        /// Target called before each escalation decision.
552        efficient_target: ModelId,
553        /// Target used after escalation is confirmed.
554        capable_target: ModelId,
555        /// Prompt and verdict contract settings for the escalation judge.
556        contract: ClassifierContractConfig,
557        /// Escalation policy settings.
558        config: EscalationJudgeConfig,
559        /// Maximum completion tokens available to the escalation verdict.
560        max_output_tokens: u64,
561        /// Resolved `escalation.gate.classifier_target`; `None` uses `judge_target`.
562        gate_judge_target: Option<ModelId>,
563    },
564    /// Routes among named targets using a user-supplied schema and policy.
565    Custom {
566        /// Target that produces classifier verdicts.
567        judge_target: ModelId,
568        /// User-facing labels paired with their resolved routing targets.
569        targets: Vec<(String, ModelId)>,
570        /// Label selected when the judge does not produce a usable verdict.
571        default_target: String,
572        /// Custom classifier settings.
573        config: CustomClassifierConfig,
574    },
575}
576
577impl LlmTaskClassifier {
578    /// Builds the classifier mode described by `config`.
579    ///
580    /// # Errors
581    ///
582    /// Returns an error when the selected mode's targets, contract, policy, or runtime
583    /// settings are invalid.
584    pub fn new(config: LlmClassifierConfig) -> Result<Self> {
585        match config {
586            LlmClassifierConfig::Capability {
587                judge_target,
588                efficient_target,
589                capable_target,
590                config,
591            } => Self::build_capability(judge_target, efficient_target, capable_target, config),
592            LlmClassifierConfig::Escalation {
593                judge_target,
594                efficient_target,
595                capable_target,
596                contract,
597                config,
598                max_output_tokens,
599                gate_judge_target,
600            } => Self::build_escalation(
601                gate_judge_target,
602                judge_target,
603                efficient_target,
604                capable_target,
605                contract,
606                config,
607                max_output_tokens,
608            ),
609            LlmClassifierConfig::Custom {
610                judge_target,
611                targets,
612                default_target,
613                config,
614            } => Self::build_custom(judge_target, targets, default_target, config),
615        }
616    }
617
618    fn build_capability(
619        judge_target: ModelId,
620        efficient_target: ModelId,
621        capable_target: ModelId,
622        config: TaskClassifierConfig,
623    ) -> Result<Self> {
624        config.validate()?;
625        let contract = Self::load_capability_contract(&config.contract)?;
626        let targets = vec![efficient_target.clone(), capable_target.clone()];
627        let classify_trigger = config.classify_trigger;
628        let message_hash_fallback = config.message_hash_fallback;
629        let classifier = Arc::new(TaskClassifier {
630            classifier: JudgeClassifier::new(
631                StructuredJudge::new(
632                    TaskInput {
633                        recent_turn_window: config.recent_turn_window,
634                    },
635                    contract,
636                    SerdeDecoder::new(),
637                    JudgeRuntimeConfig::new(config.max_output_tokens)?,
638                ),
639                judge_target.clone(),
640                TaskClassifierPolicy::new(
641                    efficient_target.clone(),
642                    capable_target.clone(),
643                    &config,
644                ),
645            )
646            .with_evidence(capability_evidence),
647            capable_target: capable_target.clone(),
648        });
649        let inner: Arc<dyn Classifier<State>> = classifier.clone();
650        Self::from_classifier(
651            targets,
652            inner,
653            ClassifierRouteConfig {
654                default_target: classifier.capable_target.clone(),
655                classify_trigger,
656                message_hash_fallback,
657            },
658        )
659    }
660
661    fn build_custom(
662        judge_target: ModelId,
663        targets: Vec<(String, ModelId)>,
664        default_target: String,
665        config: CustomClassifierConfig,
666    ) -> Result<Self> {
667        config.validate()?;
668        if targets.len() < 2 {
669            return Err(LibsyError::AlgorithmError {
670                message: "custom classifier requires at least two targets".to_string(),
671            });
672        }
673
674        let mut labels = BTreeSet::new();
675        let mut resolved_names = BTreeSet::new();
676        let mut target_map = BTreeMap::new();
677        let mut resolved_targets = Vec::with_capacity(targets.len());
678        for (label, target) in targets {
679            if label.trim().is_empty() || label.trim() != label {
680                return Err(LibsyError::AlgorithmError {
681                    message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
682                        .to_string(),
683                });
684            }
685            if !labels.insert(label.clone()) {
686                return Err(LibsyError::AlgorithmError {
687                    message: format!("custom classifier target label {label:?} is duplicated"),
688                });
689            }
690            if !resolved_names.insert(target.clone()) {
691                return Err(LibsyError::AlgorithmError {
692                    message: format!("custom classifier resolved target {target:?} is duplicated"),
693                });
694            }
695            target_map.insert(label, target.clone());
696            resolved_targets.push(target);
697        }
698        let default_name =
699            target_map
700                .get(&default_target)
701                .cloned()
702                .ok_or_else(|| LibsyError::AlgorithmError {
703                    message: format!(
704                        "default_target {default_target:?} must be one of the configured targets"
705                    ),
706                })?;
707
708        let CustomClassifierConfig {
709            prompt,
710            response_schema,
711            policy,
712            classify_trigger,
713            message_hash_fallback,
714            recent_turn_window,
715            max_output_tokens,
716        } = config;
717        let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
718        let policy = match policy {
719            CustomClassifierPolicy::TargetSelector { selector } => {
720                CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
721                    selector, target_map,
722                )?)
723            }
724        };
725        let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
726            StructuredJudge::new(
727                TaskInput { recent_turn_window },
728                contract,
729                JsonSchemaDecoder::new(),
730                JudgeRuntimeConfig::new(max_output_tokens)?,
731            ),
732            judge_target,
733            policy,
734        ));
735
736        Self::from_classifier(
737            resolved_targets,
738            classifier,
739            ClassifierRouteConfig {
740                default_target: default_name,
741                classify_trigger,
742                message_hash_fallback,
743            },
744        )
745    }
746
747    fn build_escalation(
748        gate_judge_target: Option<ModelId>,
749        judge_target: ModelId,
750        efficient_target: ModelId,
751        capable_target: ModelId,
752        contract_config: ClassifierContractConfig,
753        config: EscalationJudgeConfig,
754        max_output_tokens: u64,
755    ) -> Result<Self> {
756        let inner = escalation::build_classifier(
757            gate_judge_target,
758            judge_target,
759            &efficient_target,
760            &capable_target,
761            contract_config,
762            config,
763            max_output_tokens,
764        )?;
765        let targets = vec![capable_target, efficient_target];
766        Ok(Self {
767            route: FallThrough::<State>::new_with_state(targets)
768                .with_name(ALGORITHM_NAME)
769                .with_classifier(Arc::clone(&inner)),
770            inner,
771        })
772    }
773
774    /// Loads the packaged capability-classifier contract.
775    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
776        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
777    }
778
779    /// Keeps affinity and fallback ordering identical across judge-backed modes.
780    fn from_classifier(
781        targets: Vec<ModelId>,
782        inner: Arc<dyn Classifier<State>>,
783        config: ClassifierRouteConfig,
784    ) -> Result<Self> {
785        algorithm::ensure_model_is_target(&targets, &config.default_target)?;
786        if config.message_hash_fallback && config.classify_trigger != ClassifyTrigger::NewSession {
787            return Err(LibsyError::AlgorithmError {
788                message: "message_hash_fallback requires classify_trigger = new_session"
789                    .to_string(),
790            });
791        }
792        // Affinity comes first so a retained assignment short-circuits the judge call.
793        let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
794        if let Some(affinity) =
795            affinity_router(config.classify_trigger, config.message_hash_fallback).as_ref()
796        {
797            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
798            route = route
799                .with_processor(affinity.clone())
800                .with_classifier(affinity.clone());
801        }
802        let fallback = DefaultTarget::new(config.default_target);
803        Ok(Self {
804            route: route
805                .with_classifier(inner.clone())
806                .with_classifier(Arc::new(fallback)),
807            inner,
808        })
809    }
810}
811
812/// Builds the up-front capability gate an escalation route runs once per session: the packaged
813/// capability forecaster, judged through the escalation judge's target, mapped to a tier by the
814/// same threshold policy capability mode uses. The gate reads the task framing only
815/// (`recent_turn_window` unset), so it works before any trajectory exists.
816pub(super) fn build_capability_gate(
817    judge_target: ModelId,
818    efficient_target: &ModelId,
819    capable_target: &ModelId,
820    gate: &EscalationGateConfig,
821    response_format_type: ClassifierResponseFormat,
822    max_output_tokens: u64,
823) -> Result<Arc<dyn Classifier<State>>> {
824    let mut contract_config =
825        ClassifierContractConfig::default().with_response_format_type(response_format_type);
826    if let Some(prompt) = &gate.prompt {
827        contract_config = contract_config.with_prompt(prompt.clone());
828    }
829    let contract =
830        ClassifierContract::from_config(&contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)?;
831    let config = TaskClassifierConfig {
832        base_threshold: gate.base_threshold,
833        threshold_step: gate.threshold_step,
834        ..TaskClassifierConfig::default()
835    };
836    Ok(Arc::new(
837        JudgeClassifier::new(
838            StructuredJudge::new(
839                TaskInput {
840                    recent_turn_window: None,
841                },
842                contract,
843                SerdeDecoder::new(),
844                JudgeRuntimeConfig::new(max_output_tokens)?,
845            ),
846            judge_target,
847            TaskClassifierPolicy::new(efficient_target.clone(), capable_target.clone(), &config),
848        )
849        .with_evidence(capability_evidence),
850    ))
851}
852
853#[async_trait]
854impl Classifier<State> for TaskClassifier {
855    async fn score(
856        &self,
857        state: &mut State,
858        request: &mut Request,
859        driver: Option<&Driver>,
860    ) -> Result<(Classification, Option<Response>)> {
861        self.classifier.score(state, request, driver).await
862    }
863}
864
865#[async_trait]
866impl Classifier<State> for LlmTaskClassifier {
867    async fn score(
868        &self,
869        state: &mut State,
870        request: &mut Request,
871        driver: Option<&Driver>,
872    ) -> Result<(Classification, Option<Response>)> {
873        self.inner.score(state, request, driver).await
874    }
875}
876
877#[async_trait]
878impl Algorithm for LlmTaskClassifier {
879    fn name(&self) -> &str {
880        "llm_task_classifier"
881    }
882
883    async fn route(
884        self: Arc<Self>,
885        driver: Driver,
886        request: Request,
887    ) -> Result<crate::RoutingOutcome> {
888        self.route.execute(driver, request).await
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use std::sync::Arc;
895
896    use parking_lot::Mutex;
897    use serde_json::Value;
898
899    use super::*;
900    use switchyard_protocol::{
901        ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
902        completion_text, text_request, text_response,
903    };
904
905    use crate::algorithms::util::llm_judge::Judge;
906    use crate::core::testing::{Serve, test_drive};
907    use switchyard_protocol::{LlmResponse, Response};
908
909    const TEST_THRESHOLD: f64 = 0.5;
910
911    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
912        TaskClassifierConfig {
913            base_threshold,
914            ..TaskClassifierConfig::default()
915        }
916    }
917
918    fn policy() -> TaskClassifierPolicy {
919        TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
920    }
921
922    fn verdict(
923        p_solve: f64,
924        capability_boundary: &str,
925        primary_rule: &str,
926    ) -> TaskClassifierVerdict {
927        TaskClassifierVerdict {
928            crux: "test crux".to_string(),
929            primary_rule: primary_rule.to_string(),
930            capability_boundary: capability_boundary.to_string(),
931            p_solve,
932        }
933    }
934
935    fn selected(
936        policy: &TaskClassifierPolicy,
937        verdict: Option<&TaskClassifierVerdict>,
938    ) -> Result<ModelId> {
939        policy
940            .to_classification(verdict)
941            .argmax(false)?
942            .map(|score| score.target)
943            .ok_or_else(|| LibsyError::AlgorithmError {
944                message: "policy abstained".to_string(),
945            })
946    }
947
948    /// Records what each target received; answers the judge with a supported verdict and
949    /// every other target with a plain completion.
950    #[derive(Default)]
951    struct Recorder {
952        calls: Mutex<Vec<String>>,
953        call_roles: Mutex<Vec<(String, bool)>>,
954        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
955        judge_system_prompts: Mutex<Vec<String>>,
956    }
957
958    impl Recorder {
959        fn calls(&self) -> Vec<String> {
960            self.calls.lock().clone()
961        }
962
963        fn call_roles(&self) -> Vec<(String, bool)> {
964            self.call_roles.lock().clone()
965        }
966
967        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
968            self.judge_max_output_tokens.lock().clone()
969        }
970
971        fn judge_system_prompts(&self) -> Vec<String> {
972            self.judge_system_prompts.lock().clone()
973        }
974
975        fn serve(self: &Arc<Self>) -> impl Serve {
976            let recorder = Arc::clone(self);
977            move |model: ModelId, request: Request| {
978                let recorder = Arc::clone(&recorder);
979                async move {
980                    let model = model.to_string();
981                    recorder.calls.lock().push(model.clone());
982                    recorder
983                        .call_roles
984                        .lock()
985                        .push((model.clone(), model != "judge"));
986                    let completion = if model == "judge" {
987                        recorder
988                            .judge_max_output_tokens
989                            .lock()
990                            .push(request.llm_request.output.max_output_tokens);
991                        recorder.judge_system_prompts.lock().extend(
992                            request
993                                .llm_request
994                                .instructions
995                                .first()
996                                .and_then(|instruction| {
997                                    instruction.content.iter().find_map(|b| {
998                                        if let ContentBlock::Text { text } = b {
999                                            Some(text.clone())
1000                                        } else {
1001                                            None
1002                                        }
1003                                    })
1004                                }),
1005                        );
1006                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1007                    } else {
1008                        format!("answer from {model}")
1009                    };
1010                    Ok(Response {
1011                        llm_response: LlmResponse::Agg(text_response(None, completion)),
1012                        metadata: request.metadata,
1013                    })
1014                }
1015            }
1016        }
1017    }
1018
1019    /// The judge times out; every other target answers normally.
1020    fn unreachable_judge() -> impl Serve {
1021        |model: ModelId, request: Request| async move {
1022            let model = model.to_string();
1023            if model == "judge" {
1024                return Err(LlmClientError::Timeout {
1025                    source: Box::new(std::io::Error::other("judge unreachable")),
1026                });
1027            }
1028            Ok(Response {
1029                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1030                metadata: request.metadata,
1031            })
1032        }
1033    }
1034
1035    fn router() -> Result<Arc<LlmTaskClassifier>> {
1036        Ok(Arc::new(LlmTaskClassifier::new(
1037            LlmClassifierConfig::Capability {
1038                judge_target: ModelId::from("judge"),
1039                efficient_target: ModelId::from("efficient"),
1040                capable_target: ModelId::from("capable"),
1041                config: test_config(TEST_THRESHOLD),
1042            },
1043        )?))
1044    }
1045
1046    fn classify_request() -> Request {
1047        Request {
1048            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1049            raw_request: None,
1050            metadata: None,
1051        }
1052    }
1053
1054    fn classify_session_request() -> Request {
1055        Request {
1056            metadata: Some(Metadata {
1057                session_id: Some("session-1".to_string()),
1058                ..Metadata::default()
1059            }),
1060            ..classify_request()
1061        }
1062    }
1063
1064    fn classify_follow_up_request() -> Request {
1065        let mut request = classify_request();
1066        request
1067            .llm_request
1068            .messages
1069            .push(Message::text(Role::Assistant, "I will add the test."));
1070        request.llm_request.messages.push(Message::text(
1071            Role::User,
1072            "Now run the test suite and report the result.",
1073        ));
1074        request
1075    }
1076
1077    #[tokio::test]
1078    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1079        let router = router()?;
1080
1081        let (selected_model, response) =
1082            test_drive(router, classify_request(), unreachable_judge()).await?;
1083
1084        assert_eq!(selected_model, "capable");
1085        assert_eq!(
1086            response.llm_response.as_agg().map(completion_text),
1087            Some("answer from capable".to_string())
1088        );
1089        Ok(())
1090    }
1091
1092    #[tokio::test]
1093    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1094        let recorder = Arc::new(Recorder::default());
1095        let router = router()?;
1096        let request = classify_request;
1097
1098        test_drive(router.clone(), request(), recorder.serve()).await?;
1099        test_drive(router.clone(), request(), recorder.serve()).await?;
1100
1101        assert_eq!(
1102            recorder.calls(),
1103            vec!["judge", "efficient", "judge", "efficient"]
1104        );
1105        assert_eq!(
1106            recorder.call_roles(),
1107            vec![
1108                ("judge".to_string(), false),
1109                ("efficient".to_string(), true),
1110                ("judge".to_string(), false),
1111                ("efficient".to_string(), true),
1112            ]
1113        );
1114        Ok(())
1115    }
1116
1117    #[tokio::test]
1118    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1119        let recorder = Arc::new(Recorder::default());
1120        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1121            judge_target: ModelId::from("judge"),
1122            efficient_target: ModelId::from("efficient"),
1123            capable_target: ModelId::from("capable"),
1124            config: TaskClassifierConfig {
1125                max_output_tokens: 512,
1126                ..test_config(TEST_THRESHOLD)
1127            },
1128        })?);
1129
1130        test_drive(router, classify_request(), recorder.serve()).await?;
1131
1132        assert_eq!(recorder.judge_max_output_tokens(), vec![Some(512)]);
1133        Ok(())
1134    }
1135
1136    #[tokio::test]
1137    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1138        let recorder = Arc::new(Recorder::default());
1139        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1140            judge_target: ModelId::from("judge"),
1141            efficient_target: ModelId::from("efficient"),
1142            capable_target: ModelId::from("capable"),
1143            config: TaskClassifierConfig {
1144                contract: ClassifierContractConfig::default()
1145                    .with_prompt("Custom capability rubric."),
1146                ..test_config(TEST_THRESHOLD)
1147            },
1148        })?);
1149
1150        test_drive(router, classify_request(), recorder.serve()).await?;
1151
1152        let prompts = recorder.judge_system_prompts();
1153        assert_eq!(prompts.len(), 1);
1154        assert_eq!(prompts[0], "Custom capability rubric.");
1155        Ok(())
1156    }
1157
1158    #[tokio::test]
1159    async fn classifier_config_enables_new_session_trigger() -> Result<()> {
1160        let recorder = Arc::new(Recorder::default());
1161        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1162            judge_target: ModelId::from("judge"),
1163            efficient_target: ModelId::from("efficient"),
1164            capable_target: ModelId::from("capable"),
1165            config: TaskClassifierConfig {
1166                classify_trigger: ClassifyTrigger::NewSession,
1167                ..test_config(TEST_THRESHOLD)
1168            },
1169        })?);
1170
1171        let session_request = classify_session_request;
1172        test_drive(router.clone(), session_request(), recorder.serve()).await?;
1173        test_drive(router.clone(), session_request(), recorder.serve()).await?;
1174
1175        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1176        Ok(())
1177    }
1178
1179    #[tokio::test]
1180    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1181        let recorder = Arc::new(Recorder::default());
1182        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1183            judge_target: ModelId::from("judge"),
1184            efficient_target: ModelId::from("efficient"),
1185            capable_target: ModelId::from("capable"),
1186            config: TaskClassifierConfig {
1187                classify_trigger: ClassifyTrigger::NewSession,
1188                message_hash_fallback: true,
1189                recent_turn_window: None,
1190                ..test_config(TEST_THRESHOLD)
1191            },
1192        })?);
1193
1194        test_drive(router.clone(), classify_request(), recorder.serve()).await?;
1195        test_drive(
1196            router.clone(),
1197            classify_follow_up_request(),
1198            recorder.serve(),
1199        )
1200        .await?;
1201
1202        assert_eq!(recorder.calls(), vec!["judge", "efficient", "efficient"]);
1203        Ok(())
1204    }
1205
1206    #[test]
1207    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1208        let policy = policy();
1209        let at_threshold = verdict(0.5, "supported", "SUP-1");
1210        let below_threshold = verdict(0.49, "supported", "SUP-1");
1211        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1212        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1213        Ok(())
1214    }
1215
1216    #[test]
1217    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1218        let borderline = verdict(0.5, "supported", "SUP-1");
1219        let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1220        let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1221        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1222        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1223        Ok(())
1224    }
1225
1226    #[test]
1227    fn classifier_config_rejects_unknown_fields() {
1228        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1229            "base_threshold": 0.5,
1230            "classifier_magic": true,
1231        }))
1232        .expect_err("unknown classifier fields must be rejected");
1233
1234        assert!(
1235            error
1236                .to_string()
1237                .contains("unknown field `classifier_magic`"),
1238            "{error}"
1239        );
1240    }
1241
1242    #[test]
1243    fn invalid_classifier_config_is_rejected() -> Result<()> {
1244        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1245            assert!(
1246                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1247                    judge_target: ModelId::from("judge"),
1248                    efficient_target: ModelId::from("e"),
1249                    capable_target: ModelId::from("c"),
1250                    config: test_config(bad),
1251                })
1252                .is_err(),
1253                "base threshold {bad} should be rejected"
1254            );
1255        }
1256        for config in [
1257            TaskClassifierConfig {
1258                base_threshold: 0.5,
1259                threshold_step: -0.1,
1260                ..TaskClassifierConfig::default()
1261            },
1262            TaskClassifierConfig {
1263                base_threshold: 0.8,
1264                threshold_step: 0.11,
1265                ..TaskClassifierConfig::default()
1266            },
1267            TaskClassifierConfig {
1268                base_threshold: 0.5,
1269                message_hash_fallback: true,
1270                ..TaskClassifierConfig::default()
1271            },
1272            TaskClassifierConfig {
1273                base_threshold: 0.5,
1274                max_output_tokens: 0,
1275                ..TaskClassifierConfig::default()
1276            },
1277        ] {
1278            assert!(
1279                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1280                    judge_target: ModelId::from("judge"),
1281                    efficient_target: ModelId::from("e"),
1282                    capable_target: ModelId::from("c"),
1283                    config,
1284                })
1285                .is_err()
1286            );
1287        }
1288        for base_threshold in [0.0, 1.0] {
1289            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1290                judge_target: ModelId::from("judge"),
1291                efficient_target: ModelId::from("e"),
1292                capable_target: ModelId::from("c"),
1293                config: test_config(base_threshold),
1294            })?;
1295        }
1296        Ok(())
1297    }
1298
1299    #[test]
1300    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1301        let policy = policy();
1302        let inconsistent_rule = TaskClassifierVerdict {
1303            capability_boundary: "uncertain".to_string(),
1304            ..verdict(1.0, "supported", "SUP-1")
1305        };
1306        let empty_crux = TaskClassifierVerdict {
1307            crux: "  ".to_string(),
1308            ..verdict(1.0, "supported", "SUP-1")
1309        };
1310        let unusable = [
1311            Some(verdict(1.1, "supported", "SUP-1")),
1312            Some(inconsistent_rule),
1313            Some(empty_crux),
1314            None,
1315        ];
1316        for verdict in unusable {
1317            let classification = policy.to_classification(verdict.as_ref());
1318            assert!(matches!(classification, Classification::Ambiguous(_)));
1319            assert!(classification.argmax(false)?.is_none());
1320            assert!(classification.argmax(true)?.is_none());
1321        }
1322        Ok(())
1323    }
1324
1325    #[test]
1326    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1327        let policy = TaskClassifierPolicy::new(
1328            "efficient",
1329            "capable",
1330            &TaskClassifierConfig {
1331                threshold_step: 0.1,
1332                ..test_config(0.4)
1333            },
1334        );
1335
1336        assert_eq!(
1337            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1338            "efficient"
1339        );
1340        assert_eq!(
1341            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1342            "capable"
1343        );
1344        assert_eq!(
1345            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1346            "efficient"
1347        );
1348        assert_eq!(
1349            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1350            "efficient"
1351        );
1352        assert_eq!(
1353            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1354            "capable"
1355        );
1356        assert_eq!(
1357            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1358            "efficient"
1359        );
1360        Ok(())
1361    }
1362
1363    /// The text of each message a judge with `recent_turn_window` would be sent.
1364    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1365    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1366        Ok(StructuredJudge::new(
1367            TaskInput { recent_turn_window },
1368            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1369            SerdeDecoder::new(),
1370            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1371        ))
1372    }
1373
1374    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1375        let judge = capability_judge(Some(recent_turn_window))?;
1376        let request = Request {
1377            llm_request: LlmRequest {
1378                messages: vec![
1379                    Message::text(Role::System, "client instructions"),
1380                    Message::text(Role::User, "initial task"),
1381                    Message::text(Role::Assistant, "old response"),
1382                    Message::text(Role::User, "old follow-up"),
1383                    Message::text(Role::Assistant, "recent 1"),
1384                    Message::text(Role::User, "recent 2"),
1385                ],
1386                ..LlmRequest::default()
1387            },
1388            raw_request: None,
1389            metadata: None,
1390        };
1391        Ok(judge
1392            .build_request(&State::default(), &request)
1393            .llm_request
1394            .messages
1395            .iter()
1396            .filter_map(|message| message.text_content("\n"))
1397            .collect())
1398    }
1399
1400    #[test]
1401    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1402        // Client instructions and the opening task, plus the last two turns.
1403        let contents = judged_contents(2)?;
1404        assert!(contents.contains(&"client instructions".to_string()));
1405        assert!(contents.contains(&"initial task".to_string()));
1406        assert!(contents.contains(&"recent 1".to_string()));
1407        assert!(contents.contains(&"recent 2".to_string()));
1408        assert!(!contents.contains(&"old response".to_string()));
1409        Ok(())
1410    }
1411
1412    #[test]
1413    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1414        let contents = judged_contents(0)?;
1415        assert!(contents.contains(&"client instructions".to_string()));
1416        assert!(contents.contains(&"initial task".to_string()));
1417        assert!(!contents.contains(&"recent 2".to_string()));
1418        Ok(())
1419    }
1420
1421    fn tool_call(id: &str) -> Message {
1422        Message {
1423            role: Role::Assistant,
1424            content: vec![ContentBlock::ToolCall(ToolCall {
1425                id: id.to_string(),
1426                name: "search".to_string(),
1427                arguments: Value::Null,
1428            })],
1429        }
1430    }
1431
1432    fn tool_result(id: &str) -> Message {
1433        Message {
1434            role: Role::Tool,
1435            content: vec![ContentBlock::ToolResult(ToolResult {
1436                tool_call_id: id.to_string(),
1437                content: vec![ContentBlock::Text {
1438                    text: "tool output".to_string(),
1439                }],
1440                is_error: None,
1441            })],
1442        }
1443    }
1444
1445    /// A count-based window can begin on a tool result, which leaves the call that
1446    /// introduced its id outside the window and the classifier history invalid.
1447    #[test]
1448    fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1449        let messages = vec![
1450            Message::text(Role::System, "client instructions"),
1451            Message::text(Role::User, "initial task"),
1452            Message::text(Role::Assistant, "old response"),
1453            tool_call("call-1"),
1454            tool_result("call-1"),
1455            Message::text(Role::Assistant, "recent 1"),
1456            Message::text(Role::User, "recent 2"),
1457            Message::text(Role::Assistant, "recent 3"),
1458            Message::text(Role::User, "recent 4"),
1459        ];
1460
1461        // The five-message tail begins exactly on the tool result.
1462        let kept = trim_messages(&messages, 5);
1463
1464        assert_eq!(
1465            kept,
1466            vec![
1467                Message::text(Role::System, "client instructions"),
1468                Message::text(Role::User, "initial task"),
1469                tool_call("call-1"),
1470                tool_result("call-1"),
1471                Message::text(Role::Assistant, "recent 1"),
1472                Message::text(Role::User, "recent 2"),
1473                Message::text(Role::Assistant, "recent 3"),
1474                Message::text(Role::User, "recent 4"),
1475            ]
1476        );
1477    }
1478
1479    /// Ids repeat across a conversation, so a later call must not stand in for the one that
1480    /// answers an earlier result.
1481    #[test]
1482    fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1483        let messages = vec![
1484            Message::text(Role::System, "client instructions"),
1485            Message::text(Role::User, "initial task"),
1486            tool_call("x"),
1487            tool_result("x"),
1488            Message::text(Role::Assistant, "later"),
1489            tool_call("x"),
1490            tool_result("x"),
1491        ];
1492
1493        // The four-message tail begins on the first result, whose own call sits one earlier.
1494        let kept = trim_messages(&messages, 4);
1495
1496        assert_eq!(
1497            kept,
1498            vec![
1499                Message::text(Role::System, "client instructions"),
1500                Message::text(Role::User, "initial task"),
1501                tool_call("x"),
1502                tool_result("x"),
1503                Message::text(Role::Assistant, "later"),
1504                tool_call("x"),
1505                tool_result("x"),
1506            ]
1507        );
1508    }
1509
1510    /// A result whose call precedes the opening task can never be paired, because trimming
1511    /// never reaches behind the task. The window must not widen hunting for it.
1512    #[test]
1513    fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1514        let messages = vec![
1515            Message::text(Role::System, "client instructions"),
1516            tool_call("orphan"),
1517            Message::text(Role::User, "initial task"),
1518            Message::text(Role::Assistant, "old response"),
1519            tool_result("orphan"),
1520            Message::text(Role::Assistant, "recent 1"),
1521            Message::text(Role::User, "recent 2"),
1522        ];
1523
1524        let kept = trim_messages(&messages, 3);
1525
1526        assert_eq!(
1527            kept,
1528            vec![
1529                Message::text(Role::System, "client instructions"),
1530                Message::text(Role::User, "initial task"),
1531                tool_result("orphan"),
1532                Message::text(Role::Assistant, "recent 1"),
1533                Message::text(Role::User, "recent 2"),
1534            ]
1535        );
1536    }
1537
1538    #[test]
1539    fn a_window_restates_the_routing_instruction_last() -> Result<()> {
1540        let contents = judged_contents(2)?;
1541
1542        // Last, not merely present: the instruction only works in final position,
1543        // after the conversation content it is meant to outrank.
1544        assert_eq!(
1545            contents.last().map(String::as_str),
1546            Some(TRAILING_ROUTING_INSTRUCTION)
1547        );
1548        Ok(())
1549    }
1550
1551    /// Reasoning is provider-private and some upstreams reject it replayed without the
1552    /// opaque state it was issued with, so it must not reach a windowed classifier
1553    /// request. Visible assistant text and complete tool pairs still do, and a turn
1554    /// whose only content was reasoning is dropped rather than left behind empty.
1555    #[test]
1556    fn a_window_drops_reasoning_but_keeps_visible_text_and_tool_pairs() {
1557        let messages = vec![
1558            Message::text(Role::System, "client instructions"),
1559            Message::text(Role::User, "initial task"),
1560            Message {
1561                role: Role::Assistant,
1562                content: vec![
1563                    ContentBlock::Reasoning {
1564                        text: "private chain of thought".to_string(),
1565                        signature: None,
1566                        details: Vec::new(),
1567                    },
1568                    ContentBlock::Text {
1569                        text: "visible answer".to_string(),
1570                    },
1571                ],
1572            },
1573            tool_call("call-1"),
1574            tool_result("call-1"),
1575            Message {
1576                role: Role::Assistant,
1577                content: vec![ContentBlock::Reasoning {
1578                    text: "reasoning-only turn".to_string(),
1579                    signature: None,
1580                    details: Vec::new(),
1581                }],
1582            },
1583            Message::text(Role::User, "follow-up"),
1584        ];
1585        let request = Request {
1586            llm_request: LlmRequest {
1587                messages,
1588                ..LlmRequest::default()
1589            },
1590            raw_request: None,
1591            metadata: None,
1592        };
1593
1594        let built = TaskInput {
1595            recent_turn_window: Some(10),
1596        }
1597        .build_messages(&State::default(), &request);
1598
1599        assert!(
1600            !built
1601                .iter()
1602                .flat_map(|message| &message.content)
1603                .any(|block| matches!(block, ContentBlock::Reasoning { .. })),
1604            "{built:?}"
1605        );
1606        assert!(
1607            built
1608                .iter()
1609                .any(|message| message.text_content("\n").as_deref() == Some("visible answer"))
1610        );
1611        assert!(built.contains(&tool_call("call-1")));
1612        assert!(built.contains(&tool_result("call-1")));
1613        // Six of the seven fixtures survive — the reasoning-only turn is gone entirely
1614        // rather than left behind empty — plus the trailing routing instruction.
1615        assert_eq!(built.len(), 7);
1616        assert!(built.iter().all(|message| !message.content.is_empty()));
1617    }
1618
1619    #[test]
1620    fn the_default_path_is_left_unchanged() -> Result<()> {
1621        // No window means no conversation to be distracted by, so the default
1622        // request shape stays exactly as it was.
1623        let judge = capability_judge(None)?;
1624        let request = Request {
1625            llm_request: LlmRequest {
1626                messages: vec![Message::text(Role::User, "the task")],
1627                ..LlmRequest::default()
1628            },
1629            raw_request: None,
1630            metadata: None,
1631        };
1632
1633        let built = judge.build_request(&State::default(), &request);
1634
1635        // The task message alone; the rubric reaches the judge as an instruction block
1636        // rather than a message, so nothing was dropped by it not being counted here.
1637        assert_eq!(built.llm_request.messages.len(), 1);
1638        assert!(!built.llm_request.instructions.is_empty());
1639        assert!(
1640            !built
1641                .llm_request
1642                .messages
1643                .iter()
1644                .filter_map(|message| message.text_content("\n"))
1645                .any(|text| text.contains(TRAILING_ROUTING_INSTRUCTION))
1646        );
1647        Ok(())
1648    }
1649
1650    #[test]
1651    fn capability_judge_builds_a_structured_request() -> Result<()> {
1652        let judge = capability_judge(None)?;
1653        let request = Request {
1654            llm_request: LlmRequest {
1655                model: Some("inbound".to_string()),
1656                messages: vec![
1657                    Message::text(Role::System, "client instructions"),
1658                    Message::text(Role::Developer, "client developer instructions"),
1659                    Message::text(Role::User, "initial task"),
1660                    Message::text(Role::Assistant, "old response"),
1661                    Message::text(Role::User, "old follow-up"),
1662                    Message::text(Role::Assistant, "recent 1"),
1663                    Message::text(Role::User, "recent 2"),
1664                    Message::text(Role::Assistant, "recent 3"),
1665                    Message::text(Role::User, "recent 4"),
1666                    Message::text(Role::Assistant, "recent 5"),
1667                ],
1668                ..LlmRequest::default()
1669            },
1670            raw_request: None,
1671            metadata: None,
1672        };
1673        let judge_request = judge.build_request(&State::default(), &request);
1674
1675        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1676        assert_eq!(judge_request.llm_request.instructions.len(), 1);
1677        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1678        assert_eq!(
1679            judge_request.llm_request.instructions[0].content,
1680            InstructionBlock {
1681                role: Role::System,
1682                content: Message::text(Role::System, judge.contract().system_prompt()).content,
1683            }
1684            .content,
1685        );
1686        assert_eq!(judge_request.llm_request.messages.len(), 2);
1687        let contents = judge_request
1688            .llm_request
1689            .messages
1690            .iter()
1691            .filter_map(|message| message.text_content("\n"))
1692            .collect::<Vec<_>>();
1693        assert!(contents.contains(&"recent 4".to_string()));
1694        assert!(contents.contains(&"initial task".to_string()));
1695        assert!(!contents.contains(&"recent 5".to_string()));
1696        assert!(!contents.contains(&"client instructions".to_string()));
1697        assert_eq!(
1698            judge_request.llm_request.output.response_format,
1699            Some(judge.contract().response_format().clone())
1700        );
1701        assert_eq!(
1702            judge_request.llm_request.output.max_output_tokens,
1703            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1704        );
1705        Ok(())
1706    }
1707
1708    fn sample_value(spec: &Value) -> Value {
1709        if let Some(first) = spec
1710            .get("enum")
1711            .and_then(Value::as_array)
1712            .and_then(|values| values.first())
1713        {
1714            return first.clone();
1715        }
1716        match spec.get("type").and_then(Value::as_str) {
1717            Some("number") => serde_json::json!(0.5),
1718            Some("boolean") => serde_json::json!(false),
1719            _ => serde_json::json!("sample"),
1720        }
1721    }
1722
1723    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1724        let properties = schema
1725            .pointer("/json_schema/schema/properties")
1726            .and_then(Value::as_object)
1727            .ok_or_else(|| LibsyError::AlgorithmError {
1728                message: "packaged schema declares no properties".to_string(),
1729            })?;
1730        Ok(Value::Object(
1731            properties
1732                .iter()
1733                .map(|(name, spec)| (name.clone(), sample_value(spec)))
1734                .collect(),
1735        )
1736        .to_string())
1737    }
1738
1739    /// Built from the schema so a property added there fails here rather than silently
1740    /// rejecting every production verdict.
1741    #[test]
1742    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1743        let contract =
1744            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1745        let schema = contract.response_format();
1746        let reply = schema_shaped_verdict(schema)?;
1747        let judge: CapabilityJudge = StructuredJudge::new(
1748            TaskInput {
1749                recent_turn_window: None,
1750            },
1751            contract,
1752            SerdeDecoder::new(),
1753            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1754        );
1755
1756        let verdict = judge.parse(&text_response(None, reply))?;
1757
1758        assert!(verdict.is_valid());
1759        assert!((0.0..=1.0).contains(&verdict.p_solve));
1760        Ok(())
1761    }
1762
1763    #[test]
1764    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1765        let contract =
1766            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1767        let prompt = contract.system_prompt();
1768        let schema_name = contract
1769            .response_format()
1770            .pointer("/json_schema/name")
1771            .and_then(Value::as_str)
1772            .ok_or_else(|| LibsyError::AlgorithmError {
1773                message: "packaged response schema has no name".to_string(),
1774            })?;
1775        assert_eq!(schema_name, "CapabilityClassifierDecision");
1776        assert!(prompt.contains("SUP-1 [supported]"));
1777        assert!(prompt.contains("SUP-5 [supported]"));
1778        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1779        assert!(!prompt.contains("\"type\": \"object\""));
1780        assert!(!prompt.contains("\"json_schema\""));
1781        assert!(!prompt.contains(schema_name));
1782        let rule_values = contract
1783            .response_format()
1784            .pointer("/json_schema/schema/properties/primary_rule/enum")
1785            .and_then(Value::as_array)
1786            .ok_or_else(|| LibsyError::AlgorithmError {
1787                message: "rendered response schema has no primary rule enum".to_string(),
1788            })?;
1789        assert!(
1790            rule_values
1791                .iter()
1792                .any(|value| value.as_str() == Some("SUP-1"))
1793        );
1794        assert!(
1795            rule_values
1796                .iter()
1797                .any(|value| value.as_str() == Some("none"))
1798        );
1799        Ok(())
1800    }
1801}