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