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//! Task-level capability routing with a judge-backed classifier.
5//!
6//! The algorithm owns a [`FallThrough`] cascade. Its classifier judges the
7//! full inbound request and selects one decisive target. Invalid, abstained, or unavailable judge
8//! output always selects the capable target.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Deserializer};
14use switchyard_protocol::{LlmRequest, Message, OutputParams, Role, SimpleDecision};
15
16use super::fall_through::{DefaultTarget, FallThrough};
17use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
18use super::util::affinity::AffinityRouter;
19use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
20use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
21use super::util::llm_judge::{Judge, JudgeClassifier, JudgePolicy};
22use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
23use crate::core::classifier::{Classification, Classifier, Score};
24use crate::core::state::{State, StateValue};
25use crate::{LibsyError, Result};
26use switchyard_protocol::{
27    AggLlmResponse, Context, LlmResponse, Request, Response, RoutedLlmClient,
28};
29
30const PROMPT_TEMPLATE: &str = include_str!("../prompts/capability-classifier/prompt.md");
31const SCHEMA_TEMPLATE: &str = include_str!("../prompts/capability-classifier/schema.json");
32/// Telemetry label for this algorithm's spans, metrics, and logs.
33const ALGORITHM_NAME: &str = "llm_task_classifier";
34
35#[derive(Deserialize)]
36#[serde(deny_unknown_fields)]
37struct TaskClassifierVerdict {
38    #[serde(rename = "recommended_route")]
39    _recommended_route: String,
40    p_solve: f64,
41    confidence: f64,
42    abstain: bool,
43    capability_boundary: String,
44    #[serde(rename = "primary_rule")]
45    _primary_rule: String,
46    #[serde(rename = "crux")]
47    _crux: String,
48}
49
50impl TaskClassifierVerdict {
51    /// Reject out-of-range probabilities before the policy can route efficiently. Range
52    /// containment also rejects NaN and the infinities, which compare false against both bounds.
53    fn is_valid(&self) -> bool {
54        (0.0..=1.0).contains(&self.p_solve)
55            && (0.0..=1.0).contains(&self.confidence)
56            && matches!(
57                self.capability_boundary.as_str(),
58                "supported" | "uncertain" | "unsupported" | "unmatched"
59            )
60    }
61
62    /// Whether the capability boundary requires the elevated routing threshold.
63    fn is_capability_elevated(&self) -> bool {
64        matches!(
65            self.capability_boundary.as_str(),
66            "uncertain" | "unsupported" | "unmatched"
67        )
68    }
69}
70
71/// Keeps client instructions, the opening task, and the last `recent_turn_window`
72/// turns after it. A window of `0` keeps the instructions and the task alone.
73///
74/// Selects by reference and clones only what survives — a coding-agent
75/// conversation carries every tool result, so cloning it whole to keep a window
76/// would copy the transcript on each judged turn.
77fn trim_messages(messages: &[Message], recent_turn_window: usize) -> Vec<Message> {
78    let is_instruction = |message: &Message| matches!(message.role, Role::System | Role::Developer);
79    let mut kept: Vec<&Message> = messages.iter().filter(|m| is_instruction(m)).collect();
80    let Some(task) = messages.iter().position(|m| m.role == Role::User) else {
81        return kept.into_iter().cloned().collect();
82    };
83    kept.push(&messages[task]);
84
85    let tail: Vec<&Message> = messages[task + 1..]
86        .iter()
87        .filter(|m| !is_instruction(m))
88        .collect();
89    kept.extend(&tail[tail.len().saturating_sub(recent_turn_window)..]);
90    kept.into_iter().cloned().collect()
91}
92
93struct CapabilityJudge {
94    contract: ClassifierContract,
95    max_output_tokens: u64,
96    recent_turn_window: Option<usize>,
97}
98
99impl Judge for CapabilityJudge {
100    type Verdict = TaskClassifierVerdict;
101
102    fn build_request(&self, _state: &State, request: &Request) -> Request {
103        // Task-based routing judges the newest user message alone. A configured
104        // window widens that to the surrounding conversation.
105        let mut messages = match self.recent_turn_window {
106            Some(window) => trim_messages(&request.llm_request.messages, window),
107            None => request
108                .llm_request
109                .messages
110                .iter()
111                .rev()
112                .find(|message| message.role == Role::User)
113                .cloned()
114                .into_iter()
115                .collect::<Vec<_>>(),
116        };
117        messages.insert(
118            0,
119            Message::text(Role::System, self.contract.system_prompt().to_string()),
120        );
121        Request {
122            llm_request: LlmRequest {
123                model: request.llm_request.model.clone(),
124                messages,
125                output: OutputParams {
126                    max_output_tokens: Some(self.max_output_tokens),
127                    response_format: Some(self.contract.response_format().clone()),
128                },
129                ..LlmRequest::default()
130            },
131            raw_request: None,
132            metadata: request.metadata.clone(),
133        }
134    }
135}
136
137struct TaskClassifierPolicy {
138    efficient_target: String,
139    capable_target: String,
140    base_threshold: f64,
141    min_confidence: f64,
142    capability_elevated_floor: Option<f64>,
143}
144
145impl TaskClassifierPolicy {
146    fn new(
147        efficient_target: impl Into<String>,
148        capable_target: impl Into<String>,
149        config: &TaskClassifierConfig,
150    ) -> Self {
151        Self {
152            efficient_target: efficient_target.into(),
153            capable_target: capable_target.into(),
154            base_threshold: config.base_threshold,
155            min_confidence: config.min_confidence,
156            capability_elevated_floor: config.capability_elevated_floor,
157        }
158    }
159
160    /// Returns the required solve probability for one validated verdict.
161    fn threshold(&self, verdict: &TaskClassifierVerdict) -> f64 {
162        if verdict.is_capability_elevated() {
163            self.capability_elevated_floor
164                .unwrap_or(self.base_threshold)
165        } else {
166            self.base_threshold
167        }
168    }
169}
170
171impl JudgePolicy for TaskClassifierPolicy {
172    type Verdict = TaskClassifierVerdict;
173
174    fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
175        // Judge output is untrusted, so only a complete, valid, non-abstained verdict that
176        // clears the configured confidence decides. Anything else is "I could not tell" —
177        // reported as ambiguous so the composition around this classifier chooses the
178        // fallback, rather than this policy silently imposing one.
179        let Some(verdict) =
180            verdict.filter(|v| v.is_valid() && !v.abstain && v.confidence >= self.min_confidence)
181        else {
182            return Classification::Ambiguous(vec![]);
183        };
184        // A usable verdict below the capability threshold is still a decision: the judge
185        // does not trust the efficient tier with this task.
186        let target = if verdict.p_solve >= self.threshold(verdict) {
187            &self.efficient_target
188        } else {
189            &self.capable_target
190        };
191        Classification::Scores(vec![Score {
192            target: target.clone(),
193            confidence: 1.0,
194        }])
195    }
196}
197
198#[derive(Clone, Debug)]
199/// Settings that control capability classifier prompting and routing.
200pub struct TaskClassifierConfig {
201    /// Lowest solve probability that routes a supported task to the efficient target.
202    pub base_threshold: f64,
203    /// Lowest judge confidence that permits efficient routing.
204    pub min_confidence: f64,
205    /// Higher solve-probability floor for uncertain, unmatched, and unsupported tasks.
206    pub capability_elevated_floor: Option<f64>,
207    /// Enables session affinity before the judge-backed classifier.
208    pub session_affinity: bool,
209    /// Uses the first user message as the SessionKey for sticky routing when session metadata is unavailable.
210    pub message_hash_fallback: bool,
211    /// Trailing conversation turns the judge sees on top of the client
212    /// instructions and the opening task.
213    ///
214    /// `None` (the default) judges the newest user message alone — the task, with
215    /// no history. `Some(n)` widens that to the client instructions, the opening
216    /// task, and the last `n` turns after it.
217    pub recent_turn_window: Option<usize>,
218    /// Prompt and verdict contract settings for the classifier judge.
219    pub contract: ClassifierContractConfig,
220    /// Maximum completion tokens available to the classifier verdict.
221    pub max_output_tokens: u64,
222}
223
224/// Flat serialized shape that maps prompt settings into the runtime contract.
225#[derive(Deserialize)]
226#[serde(deny_unknown_fields)]
227struct TaskClassifierConfigWire {
228    base_threshold: f64,
229    #[serde(default)]
230    min_confidence: f64,
231    #[serde(default)]
232    capability_elevated_floor: Option<f64>,
233    #[serde(default)]
234    session_affinity: bool,
235    #[serde(default)]
236    message_hash_fallback: bool,
237    #[serde(default)]
238    recent_turn_window: Option<usize>,
239    #[serde(default)]
240    prompt: Option<String>,
241    #[serde(default = "default_judge_max_output_tokens")]
242    max_output_tokens: u64,
243}
244
245impl<'de> Deserialize<'de> for TaskClassifierConfig {
246    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
247    where
248        D: Deserializer<'de>,
249    {
250        let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
251        let mut contract = ClassifierContractConfig::default();
252        if let Some(prompt) = wire.prompt {
253            contract = contract.with_prompt(prompt);
254        }
255        Ok(Self {
256            base_threshold: wire.base_threshold,
257            min_confidence: wire.min_confidence,
258            capability_elevated_floor: wire.capability_elevated_floor,
259            session_affinity: wire.session_affinity,
260            message_hash_fallback: wire.message_hash_fallback,
261            recent_turn_window: wire.recent_turn_window,
262            contract,
263            max_output_tokens: wire.max_output_tokens,
264        })
265    }
266}
267
268const fn default_judge_max_output_tokens() -> u64 {
269    DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
270}
271
272impl Default for TaskClassifierConfig {
273    fn default() -> Self {
274        Self {
275            base_threshold: 0.0,
276            min_confidence: 0.0,
277            capability_elevated_floor: None,
278            session_affinity: false,
279            message_hash_fallback: false,
280            recent_turn_window: None,
281            contract: ClassifierContractConfig::default(),
282            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
283        }
284    }
285}
286
287impl TaskClassifierConfig {
288    /// Validates routing thresholds before the classifier is constructed.
289    fn validate(&self) -> Result<()> {
290        if !(0.0..=1.0).contains(&self.base_threshold) {
291            return Err(LibsyError::AlgorithmError {
292                message: format!(
293                    "base_threshold must be between 0 and 1, got {}",
294                    self.base_threshold
295                ),
296            });
297        }
298        if !(0.0..=1.0).contains(&self.min_confidence) {
299            return Err(LibsyError::AlgorithmError {
300                message: format!(
301                    "min_confidence must be between 0 and 1, got {}",
302                    self.min_confidence
303                ),
304            });
305        }
306        if let Some(floor) = self.capability_elevated_floor {
307            if !(0.0..=1.0).contains(&floor) {
308                return Err(LibsyError::AlgorithmError {
309                    message: format!(
310                        "capability_elevated_floor must be between 0 and 1, got {floor}"
311                    ),
312                });
313            }
314            if floor <= self.base_threshold {
315                return Err(LibsyError::AlgorithmError {
316                    message: format!(
317                        "capability_elevated_floor must be greater than base_threshold, got {floor}"
318                    ),
319                });
320            }
321        }
322        if self.max_output_tokens == 0 {
323            return Err(LibsyError::AlgorithmError {
324                message: "max_output_tokens must be at least 1".to_string(),
325            });
326        }
327        if self.message_hash_fallback && !self.session_affinity {
328            return Err(LibsyError::AlgorithmError {
329                message: "message_hash_fallback requires session_affinity".to_string(),
330            });
331        }
332        Ok(())
333    }
334}
335
336struct TaskClassifier {
337    classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
338    efficient_target: String,
339    capable_target: String,
340}
341
342// ── Escalation classifier ──────────────────────────────────────────────────
343
344/// Session-state key holding the consecutive-escalate streak.
345const STREAK_KEY: &str = "escalation_streak";
346
347fn streak(state: &State) -> u32 {
348    match state.extra.get(STREAK_KEY) {
349        Some(StateValue::Count(n)) => *n,
350        _ => 0,
351    }
352}
353
354fn decisive(target: &str) -> Classification {
355    Classification::Scores(vec![Score {
356        target: target.to_string(),
357        confidence: 1.0,
358    }])
359}
360
361fn assistant_message(response: &AggLlmResponse) -> Message {
362    Message {
363        role: Role::Assistant,
364        content: response
365            .first_output()
366            .map(|output| output.content.clone())
367            .unwrap_or_default(),
368    }
369}
370
371/// Calls the efficient model, judges its response, and latches to capable once the streak
372/// confirms. Returns the efficient response directly when not escalating so the caller does
373/// not pay for a second model call.
374struct EscalationClassifier {
375    judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
376    capable: LlmTarget,
377    efficient: LlmTarget,
378    /// Consecutive escalate verdicts required to latch.
379    confirmations: u32,
380}
381
382#[async_trait]
383impl Classifier<State> for EscalationClassifier {
384    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
385        if self.capable.semantic_name == self.efficient.semantic_name {
386            None
387        } else if selected_model == self.capable.semantic_name {
388            Some("strong")
389        } else if selected_model == self.efficient.semantic_name {
390            Some("weak")
391        } else {
392            None
393        }
394    }
395
396    async fn score(
397        &self,
398        state: &mut State,
399        request: &mut Request,
400        driver: Option<&Driver>,
401    ) -> Result<(Classification, Option<Response>)> {
402        let Some(driver) = driver else {
403            return Err(LibsyError::AlgorithmError {
404                message: "escalation classifier requires a driver".into(),
405            });
406        };
407
408        // A confirmed session stays capable without a judge call.
409        if streak(state) >= self.confirmations {
410            return Ok((decisive(&self.capable.semantic_name), None));
411        }
412
413        // Call efficient model and buffer the response so the judge can read it.
414        // `Classifier::score` takes no `ctx`, so inner calls use Context::default() and their
415        // spans carry algorithm="" rather than the algorithm name. Known gap shared with the
416        // task classifier's judge consultation.
417        let efficient_response = driver
418            .call_llm_target(
419                Context::default(),
420                &self.efficient,
421                request.clone(),
422                Arc::new(SimpleDecision {
423                    selected_model: self.efficient.semantic_name.clone(),
424                    reasoning: Some("escalation classifier: efficient tier".into()),
425                }),
426            )
427            .await?;
428        let agg = efficient_response
429            .llm_response
430            .into_agg()
431            .await
432            .map_err(|e| LibsyError::AlgorithmError {
433                message: format!("failed to aggregate efficient response: {e}"),
434            })?;
435        // Append the efficient reply so the judge reads this turn's completed trajectory.
436        let mut judge_request = request.clone();
437        judge_request
438            .llm_request
439            .messages
440            .push(assistant_message(&agg));
441        let efficient_response = Response {
442            llm_response: if request.llm_request.stream {
443                LlmResponse::Stream(agg.into_stream())
444            } else {
445                LlmResponse::Agg(agg)
446            },
447            metadata: efficient_response.metadata,
448        };
449
450        let (classification, _) = self
451            .judge
452            .score(state, &mut judge_request, Some(driver))
453            .await?;
454
455        let held = streak(state);
456        let best = classification.argmax(false)?;
457        let (escalate, pending) = match &best {
458            Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
459            Some(_) => (false, 0),
460            None => (false, held),
461        };
462        state
463            .extra
464            .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
465
466        if escalate && pending >= self.confirmations {
467            // Streak confirmed: drop the efficient response, caller will serve capable.
468            return Ok((decisive(&self.capable.semantic_name), None));
469        }
470
471        Ok((
472            decisive(&self.efficient.semantic_name),
473            Some(efficient_response),
474        ))
475    }
476}
477
478/// Routes each task between an efficient and capable target using an LLM judge.
479///
480/// The judge is consulted before the routed model call. Valid, confident output
481/// selects a tier; invalid output, abstention, or a judge failure falls back to the
482/// capable target. Optional session affinity can retain a prior assignment and skip
483/// the judge on later turns.
484pub struct LlmTaskClassifier {
485    route: FallThrough<State>,
486    /// The active classifier — either capability-based or escalation-based.
487    inner: Arc<dyn Classifier<State>>,
488}
489
490impl LlmTaskClassifier {
491    /// Builds task-level capability routing over `efficient_target` and `capable_target`.
492    ///
493    /// `judge_target` serves the classification call and is not itself a routing
494    /// destination. When enabled, session affinity runs before the judge and can
495    /// short-circuit it with a retained assignment.
496    ///
497    /// # Errors
498    ///
499    /// Returns an error when thresholds are outside `[0, 1]`, the elevated floor is
500    /// not above the base threshold, the output-token budget is zero, message-hash
501    /// fallback is enabled without affinity, or the packaged judge prompt/schema
502    /// cannot be loaded.
503    pub fn new(
504        judge_target: LlmTarget,
505        efficient_target: LlmTarget,
506        capable_target: LlmTarget,
507        config: TaskClassifierConfig,
508    ) -> Result<Self> {
509        config.validate()?;
510        let contract = Self::load_capability_contract(&config.contract)?;
511        let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
512        let session_affinity = config.session_affinity;
513        let message_hash_fallback = config.message_hash_fallback;
514        let classifier = Arc::new(TaskClassifier {
515            classifier: JudgeClassifier::new(
516                CapabilityJudge {
517                    contract,
518                    max_output_tokens: config.max_output_tokens,
519                    recent_turn_window: config.recent_turn_window,
520                },
521                judge_target.clone(),
522                TaskClassifierPolicy::new(
523                    efficient_target.semantic_name.clone(),
524                    capable_target.semantic_name.clone(),
525                    &config,
526                ),
527            ),
528            efficient_target: efficient_target.semantic_name.clone(),
529            capable_target: capable_target.semantic_name.clone(),
530        });
531        let inner: Arc<dyn Classifier<State>> = classifier.clone();
532
533        // Affinity comes first so a retained assignment short-circuits the judge call.
534        // Note: when this classifier is embedded inside another cascade (e.g. StageRouter)
535        // the affinity processor never fires — only the inner score() is called.
536        let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
537        if session_affinity {
538            let affinity = if message_hash_fallback {
539                AffinityRouter::new().with_message_hash_fallback()
540            } else {
541                AffinityRouter::new()
542            };
543            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
544            let affinity = Arc::new(affinity);
545            route = route
546                .with_processor(affinity.clone())
547                .with_classifier(affinity);
548        }
549        // The judge abstains when it cannot tell; the capable tier catches those turns
550        // rather than letting the cascade come back empty-handed.
551        let capable_fallback = DefaultTarget::new(classifier.capable_target.clone());
552        Ok(Self {
553            route: route
554                .with_classifier(inner.clone())
555                .with_classifier(Arc::new(capable_fallback)),
556            inner,
557        })
558    }
559
560    /// Constructs an escalation variant that calls the efficient model each turn, judges its
561    /// response, and latches to the capable tier once the streak confirms.
562    ///
563    /// Every unlatched turn calls the efficient model, buffers its reply, and consults the
564    /// trajectory judge. Once `config.confirmations` consecutive escalate verdicts accumulate
565    /// the session latches to the capable tier for its remainder. A judge outage always stays
566    /// efficient.
567    pub fn new_with_escalation(
568        judge_target: LlmTarget,
569        efficient_target: LlmTarget,
570        capable_target: LlmTarget,
571        config: EscalationJudgeConfig,
572        max_output_tokens: u64,
573    ) -> Result<Self> {
574        Self::new_with_escalation_contract(
575            judge_target,
576            efficient_target,
577            capable_target,
578            ClassifierContractConfig::default(),
579            config,
580            max_output_tokens,
581        )
582    }
583
584    /// Constructs an escalation variant with a configurable classifier contract.
585    pub fn new_with_escalation_contract(
586        judge_target: LlmTarget,
587        efficient_target: LlmTarget,
588        capable_target: LlmTarget,
589        contract_config: ClassifierContractConfig,
590        config: EscalationJudgeConfig,
591        max_output_tokens: u64,
592    ) -> Result<Self> {
593        let capable_name = capable_target.semantic_name.clone();
594        let efficient_name = efficient_target.semantic_name.clone();
595        let confirmations = config.confirmations;
596        let esc = Arc::new(EscalationClassifier {
597            judge: escalation::build_judge(
598                judge_target,
599                capable_name,
600                efficient_name,
601                &contract_config,
602                config,
603                max_output_tokens,
604            )?,
605            capable: capable_target.clone(),
606            efficient: efficient_target.clone(),
607            confirmations,
608        });
609        let inner: Arc<dyn Classifier<State>> = esc.clone();
610        let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
611        Ok(Self {
612            route: FallThrough::<State>::new_with_state(targets)
613                .with_name(ALGORITHM_NAME)
614                .with_classifier(esc),
615            inner,
616        })
617    }
618
619    /// Loads the packaged capability-classifier contract.
620    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
621        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
622    }
623}
624
625#[async_trait]
626impl Classifier<State> for TaskClassifier {
627    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
628        if self.efficient_target == self.capable_target {
629            None
630        } else if selected_model == self.efficient_target {
631            Some("weak")
632        } else if selected_model == self.capable_target {
633            Some("strong")
634        } else {
635            None
636        }
637    }
638
639    async fn score(
640        &self,
641        state: &mut State,
642        request: &mut Request,
643        driver: Option<&Driver>,
644    ) -> Result<(Classification, Option<Response>)> {
645        self.classifier.score(state, request, driver).await
646    }
647}
648
649#[async_trait]
650impl Classifier<State> for LlmTaskClassifier {
651    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
652        self.inner.routing_tier(selected_model)
653    }
654
655    async fn score(
656        &self,
657        state: &mut State,
658        request: &mut Request,
659        driver: Option<&Driver>,
660    ) -> Result<(Classification, Option<Response>)> {
661        self.inner.score(state, request, driver).await
662    }
663}
664
665#[async_trait]
666impl Algorithm for LlmTaskClassifier {
667    fn name(&self) -> &str {
668        "llm_task_classifier"
669    }
670
671    fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
672        self.route.count_tokens_client()
673    }
674
675    async fn create_run_task(
676        self: Arc<Self>,
677        ctx: Context,
678        driver: Driver,
679        request: Request,
680    ) -> Result<Response> {
681        self.route.execute(ctx, driver, request).await
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use std::sync::Arc;
688
689    use parking_lot::Mutex;
690    use serde_json::Value;
691
692    use super::*;
693    use switchyard_protocol::{
694        LlmClientError, Metadata, completion_text, text_request, text_response,
695    };
696
697    use crate::core::algorithm::Algorithm;
698    use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
699
700    const TEST_THRESHOLD: f64 = 0.5;
701
702    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
703        TaskClassifierConfig {
704            base_threshold,
705            ..TaskClassifierConfig::default()
706        }
707    }
708
709    fn policy() -> TaskClassifierPolicy {
710        TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
711    }
712
713    /// A verdict whose non-routing fields are fixed — only the three the policy reads vary.
714    fn verdict(p_solve: f64, confidence: f64, abstain: bool) -> TaskClassifierVerdict {
715        TaskClassifierVerdict {
716            _recommended_route: "efficient".to_string(),
717            p_solve,
718            confidence,
719            abstain,
720            capability_boundary: "supported".to_string(),
721            _primary_rule: "SUP-1".to_string(),
722            _crux: "test crux".to_string(),
723        }
724    }
725
726    fn selected(
727        policy: &TaskClassifierPolicy,
728        verdict: Option<&TaskClassifierVerdict>,
729    ) -> Result<String> {
730        policy
731            .to_classification(verdict)
732            .argmax(false)?
733            .map(|score| score.target)
734            .ok_or_else(|| LibsyError::AlgorithmError {
735                message: "policy abstained".to_string(),
736            })
737    }
738
739    #[derive(Default)]
740    struct PerRequestClient {
741        calls: Mutex<Vec<String>>,
742        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
743        judge_system_prompts: Mutex<Vec<String>>,
744    }
745
746    impl PerRequestClient {
747        fn calls(&self) -> Vec<String> {
748            self.calls.lock().clone()
749        }
750
751        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
752            self.judge_max_output_tokens.lock().clone()
753        }
754
755        fn judge_system_prompts(&self) -> Vec<String> {
756            self.judge_system_prompts.lock().clone()
757        }
758    }
759
760    #[async_trait]
761    impl RoutedLlmClient for PerRequestClient {
762        async fn call(
763            &self,
764            _ctx: Context,
765            request: Request,
766            decision: Arc<dyn Decision>,
767        ) -> std::result::Result<Response, LlmClientError> {
768            let model = decision.selected_model().to_string();
769            self.calls.lock().push(model.clone());
770            let completion = if model == "judge" {
771                self.judge_max_output_tokens
772                    .lock()
773                    .push(request.llm_request.output.max_output_tokens);
774                self.judge_system_prompts.lock().extend(
775                    request
776                        .llm_request
777                        .messages
778                        .first()
779                        .and_then(|message| message.text_content("\n")),
780                );
781                r#"{"recommended_route":"efficient","p_solve":0.9,"confidence":0.9,"abstain":false,"capability_boundary":"supported","primary_rule":"SUP-1","crux":"bounded task"}"#.to_string()
782            } else {
783                format!("answer from {model}")
784            };
785            Ok(Response {
786                llm_response: LlmResponse::Agg(text_response(None, completion)),
787                metadata: request.metadata,
788            })
789        }
790    }
791
792    struct UnreachableJudgeClient;
793
794    #[async_trait]
795    impl RoutedLlmClient for UnreachableJudgeClient {
796        async fn call(
797            &self,
798            _ctx: Context,
799            request: Request,
800            decision: Arc<dyn Decision>,
801        ) -> std::result::Result<Response, LlmClientError> {
802            let model = decision.selected_model().to_string();
803            if model == "judge" {
804                return Err(LlmClientError::Timeout {
805                    source: Box::new(std::io::Error::other("judge unreachable")),
806                });
807            }
808            Ok(Response {
809                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
810                metadata: request.metadata,
811            })
812        }
813    }
814
815    fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
816        let target = |name: &str| LlmTarget {
817            semantic_name: name.to_string(),
818            llm_client: Some(client.clone()),
819        };
820        Ok(Arc::new(LlmTaskClassifier::new(
821            target("judge"),
822            target("efficient"),
823            target("capable"),
824            test_config(TEST_THRESHOLD),
825        )?))
826    }
827
828    fn classify_request() -> Request {
829        Request {
830            llm_request: text_request(Some("auto".to_string()), "classify this task"),
831            raw_request: None,
832            metadata: None,
833        }
834    }
835
836    fn classify_session_request() -> Request {
837        Request {
838            metadata: Some(Metadata {
839                session_id: Some("session-1".to_string()),
840                ..Metadata::default()
841            }),
842            ..classify_request()
843        }
844    }
845
846    fn classify_follow_up_request() -> Request {
847        let mut request = classify_request();
848        request
849            .llm_request
850            .messages
851            .push(Message::text(Role::Assistant, "I will add the test."));
852        request.llm_request.messages.push(Message::text(
853            Role::User,
854            "Now run the test suite and report the result.",
855        ));
856        request
857    }
858
859    #[tokio::test]
860    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
861        let router = router(Arc::new(UnreachableJudgeClient))?;
862
863        let (trace, response) = router.run(Context::default(), classify_request()).await?;
864
865        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
866        assert_eq!(
867            response.llm_response.as_agg().map(completion_text),
868            Some("answer from capable".to_string())
869        );
870        Ok(())
871    }
872
873    #[tokio::test]
874    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
875        let client = Arc::new(PerRequestClient::default());
876        let router = router(client.clone())?;
877        let request = classify_request;
878
879        router.clone().run(Context::default(), request()).await?;
880        router.clone().run(Context::default(), request()).await?;
881
882        assert_eq!(
883            client.calls(),
884            vec!["judge", "efficient", "judge", "efficient"]
885        );
886        Ok(())
887    }
888
889    #[tokio::test]
890    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
891        let client = Arc::new(PerRequestClient::default());
892        let target = |name: &str| LlmTarget {
893            semantic_name: name.to_string(),
894            llm_client: Some(client.clone()),
895        };
896        let router = Arc::new(LlmTaskClassifier::new(
897            target("judge"),
898            target("efficient"),
899            target("capable"),
900            TaskClassifierConfig {
901                max_output_tokens: 512,
902                ..test_config(TEST_THRESHOLD)
903            },
904        )?);
905
906        router.run(Context::default(), classify_request()).await?;
907
908        assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
909        Ok(())
910    }
911
912    #[tokio::test]
913    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
914        let client = Arc::new(PerRequestClient::default());
915        let target = |name: &str| LlmTarget {
916            semantic_name: name.to_string(),
917            llm_client: Some(client.clone()),
918        };
919        let router = Arc::new(LlmTaskClassifier::new(
920            target("judge"),
921            target("efficient"),
922            target("capable"),
923            TaskClassifierConfig {
924                contract: ClassifierContractConfig::default()
925                    .with_prompt("Custom capability rubric:\n{{RESPONSE_SCHEMA}}"),
926                ..test_config(TEST_THRESHOLD)
927            },
928        )?);
929
930        router.run(Context::default(), classify_request()).await?;
931
932        let prompts = client.judge_system_prompts();
933        assert_eq!(prompts.len(), 1);
934        assert!(prompts[0].starts_with("Custom capability rubric:"));
935        assert!(prompts[0].contains("\"recommended_route\""));
936        assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
937        Ok(())
938    }
939
940    #[tokio::test]
941    async fn classifier_config_enables_session_affinity() -> Result<()> {
942        let client = Arc::new(PerRequestClient::default());
943        let target = |name: &str| LlmTarget {
944            semantic_name: name.to_string(),
945            llm_client: Some(client.clone()),
946        };
947        let router = Arc::new(LlmTaskClassifier::new(
948            target("judge"),
949            target("efficient"),
950            target("capable"),
951            TaskClassifierConfig {
952                session_affinity: true,
953                ..test_config(TEST_THRESHOLD)
954            },
955        )?);
956
957        router
958            .clone()
959            .run(Context::default(), classify_session_request())
960            .await?;
961        router
962            .clone()
963            .run(Context::default(), classify_session_request())
964            .await?;
965
966        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
967        Ok(())
968    }
969
970    #[tokio::test]
971    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
972        let client = Arc::new(PerRequestClient::default());
973        let target = |name: &str| LlmTarget {
974            semantic_name: name.to_string(),
975            llm_client: Some(client.clone()),
976        };
977        let router = Arc::new(LlmTaskClassifier::new(
978            target("judge"),
979            target("efficient"),
980            target("capable"),
981            TaskClassifierConfig {
982                session_affinity: true,
983                message_hash_fallback: true,
984                recent_turn_window: None,
985                ..test_config(TEST_THRESHOLD)
986            },
987        )?);
988
989        router
990            .clone()
991            .run(Context::default(), classify_request())
992            .await?;
993        router
994            .clone()
995            .run(Context::default(), classify_follow_up_request())
996            .await?;
997
998        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
999        Ok(())
1000    }
1001
1002    #[test]
1003    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1004        let policy = policy();
1005        let at_threshold = verdict(0.5, 0.0, false);
1006        let below_threshold = verdict(0.49, 1.0, false);
1007        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1008        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1009        Ok(())
1010    }
1011
1012    #[test]
1013    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1014        let borderline = verdict(0.5, 1.0, false);
1015        let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1016        let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1017        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1018        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1019        Ok(())
1020    }
1021
1022    #[test]
1023    fn classifier_config_rejects_unknown_fields() {
1024        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1025            "base_threshold": 0.5,
1026            "classifier_magic": true,
1027        }))
1028        .expect_err("unknown classifier fields must be rejected");
1029
1030        assert!(
1031            error
1032                .to_string()
1033                .contains("unknown field `classifier_magic`"),
1034            "{error}"
1035        );
1036    }
1037
1038    #[test]
1039    fn invalid_classifier_config_is_rejected() -> Result<()> {
1040        let target = |name: &str| LlmTarget {
1041            semantic_name: name.to_string(),
1042            llm_client: None,
1043        };
1044        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1045            assert!(
1046                LlmTaskClassifier::new(
1047                    target("judge"),
1048                    target("e"),
1049                    target("c"),
1050                    test_config(bad),
1051                )
1052                .is_err(),
1053                "base threshold {bad} should be rejected"
1054            );
1055        }
1056        for config in [
1057            TaskClassifierConfig {
1058                base_threshold: 0.5,
1059                min_confidence: 1.1,
1060                ..TaskClassifierConfig::default()
1061            },
1062            TaskClassifierConfig {
1063                base_threshold: 0.5,
1064                capability_elevated_floor: Some(0.5),
1065                ..TaskClassifierConfig::default()
1066            },
1067            TaskClassifierConfig {
1068                base_threshold: 0.5,
1069                message_hash_fallback: true,
1070                ..TaskClassifierConfig::default()
1071            },
1072            TaskClassifierConfig {
1073                base_threshold: 0.5,
1074                max_output_tokens: 0,
1075                ..TaskClassifierConfig::default()
1076            },
1077        ] {
1078            assert!(
1079                LlmTaskClassifier::new(target("judge"), target("e"), target("c"), config).is_err()
1080            );
1081        }
1082        LlmTaskClassifier::new(target("judge"), target("e"), target("c"), test_config(0.0))?;
1083        LlmTaskClassifier::new(target("judge"), target("e"), target("c"), test_config(1.0))?;
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn an_unusable_verdict_abstains() -> Result<()> {
1089        // Invalid, abstained, unintelligible, or absent: the judge could not tell,
1090        // so it declines to decide and leaves the fallback to whoever composed the
1091        // cascade.
1092        let policy = policy();
1093        let invalid_boundary = TaskClassifierVerdict {
1094            capability_boundary: "unknown".to_string(),
1095            ..verdict(1.0, 1.0, false)
1096        };
1097        let unusable = [
1098            Some(verdict(1.1, 1.0, false)),
1099            Some(verdict(1.0, 1.0, true)),
1100            Some(invalid_boundary),
1101            None,
1102        ];
1103        for verdict in unusable {
1104            let classification = policy.to_classification(verdict.as_ref());
1105            assert!(matches!(classification, Classification::Ambiguous(_)));
1106            assert!(classification.argmax(false)?.is_none());
1107            assert!(classification.argmax(true)?.is_none());
1108        }
1109        Ok(())
1110    }
1111
1112    #[test]
1113    fn elevated_capability_floor_is_a_targeted_safety_brake() -> Result<()> {
1114        let policy = TaskClassifierPolicy::new(
1115            "efficient",
1116            "capable",
1117            &TaskClassifierConfig {
1118                capability_elevated_floor: Some(0.45),
1119                ..test_config(0.25)
1120            },
1121        );
1122        let supported = verdict(0.30, 1.0, false);
1123        let elevated = TaskClassifierVerdict {
1124            capability_boundary: "uncertain".to_string(),
1125            ..verdict(0.30, 1.0, false)
1126        };
1127        let strong_elevated = TaskClassifierVerdict {
1128            capability_boundary: "unsupported".to_string(),
1129            ..verdict(0.50, 1.0, false)
1130        };
1131
1132        assert_eq!(selected(&policy, Some(&supported))?, "efficient");
1133        assert_eq!(selected(&policy, Some(&elevated))?, "capable");
1134        assert_eq!(selected(&policy, Some(&strong_elevated))?, "efficient");
1135        Ok(())
1136    }
1137
1138    /// The text of each message a judge with `recent_turn_window` would be sent.
1139    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1140    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1141        let judge = CapabilityJudge {
1142            contract: LlmTaskClassifier::load_capability_contract(
1143                &ClassifierContractConfig::default(),
1144            )?,
1145            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1146            recent_turn_window: Some(recent_turn_window),
1147        };
1148        let request = Request {
1149            llm_request: LlmRequest {
1150                messages: vec![
1151                    Message::text(Role::System, "client instructions"),
1152                    Message::text(Role::User, "initial task"),
1153                    Message::text(Role::Assistant, "old response"),
1154                    Message::text(Role::User, "old follow-up"),
1155                    Message::text(Role::Assistant, "recent 1"),
1156                    Message::text(Role::User, "recent 2"),
1157                ],
1158                ..LlmRequest::default()
1159            },
1160            raw_request: None,
1161            metadata: None,
1162        };
1163        Ok(judge
1164            .build_request(&State::default(), &request)
1165            .llm_request
1166            .messages
1167            .iter()
1168            .filter_map(|message| message.text_content("\n"))
1169            .collect())
1170    }
1171
1172    #[test]
1173    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1174        // Client instructions and the opening task, plus the last two turns.
1175        let contents = judged_contents(2)?;
1176        assert!(contents.contains(&"client instructions".to_string()));
1177        assert!(contents.contains(&"initial task".to_string()));
1178        assert!(contents.contains(&"recent 1".to_string()));
1179        assert!(contents.contains(&"recent 2".to_string()));
1180        assert!(!contents.contains(&"old response".to_string()));
1181        Ok(())
1182    }
1183
1184    #[test]
1185    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1186        let contents = judged_contents(0)?;
1187        assert!(contents.contains(&"client instructions".to_string()));
1188        assert!(contents.contains(&"initial task".to_string()));
1189        assert!(!contents.contains(&"recent 2".to_string()));
1190        Ok(())
1191    }
1192
1193    #[test]
1194    fn capability_judge_builds_a_structured_request() -> Result<()> {
1195        let judge = CapabilityJudge {
1196            contract: LlmTaskClassifier::load_capability_contract(
1197                &ClassifierContractConfig::default(),
1198            )?,
1199            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1200            recent_turn_window: None,
1201        };
1202        let request = Request {
1203            llm_request: LlmRequest {
1204                model: Some("inbound".to_string()),
1205                messages: vec![
1206                    Message::text(Role::System, "client instructions"),
1207                    Message::text(Role::Developer, "client developer instructions"),
1208                    Message::text(Role::User, "initial task"),
1209                    Message::text(Role::Assistant, "old response"),
1210                    Message::text(Role::User, "old follow-up"),
1211                    Message::text(Role::Assistant, "recent 1"),
1212                    Message::text(Role::User, "recent 2"),
1213                    Message::text(Role::Assistant, "recent 3"),
1214                    Message::text(Role::User, "recent 4"),
1215                    Message::text(Role::Assistant, "recent 5"),
1216                ],
1217                ..LlmRequest::default()
1218            },
1219            raw_request: None,
1220            metadata: None,
1221        };
1222        let judge_request = judge.build_request(&State::default(), &request);
1223
1224        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1225        assert_eq!(judge_request.llm_request.messages.len(), 2);
1226        let contents = judge_request
1227            .llm_request
1228            .messages
1229            .iter()
1230            .filter_map(|message| message.text_content("\n"))
1231            .collect::<Vec<_>>();
1232        assert!(contents.contains(&"recent 4".to_string()));
1233        assert!(!contents.contains(&"initial task".to_string()));
1234        assert!(!contents.contains(&"recent 5".to_string()));
1235        assert!(!contents.contains(&"client instructions".to_string()));
1236        assert_eq!(
1237            judge_request.llm_request.output.response_format,
1238            Some(judge.contract.response_format().clone())
1239        );
1240        assert_eq!(
1241            judge_request.llm_request.output.max_output_tokens,
1242            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1243        );
1244        Ok(())
1245    }
1246
1247    fn sample_value(spec: &Value) -> Value {
1248        if let Some(first) = spec
1249            .get("enum")
1250            .and_then(Value::as_array)
1251            .and_then(|values| values.first())
1252        {
1253            return first.clone();
1254        }
1255        match spec.get("type").and_then(Value::as_str) {
1256            Some("number") => serde_json::json!(0.5),
1257            Some("boolean") => serde_json::json!(false),
1258            _ => serde_json::json!("sample"),
1259        }
1260    }
1261
1262    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1263        let properties = schema
1264            .pointer("/json_schema/schema/properties")
1265            .and_then(Value::as_object)
1266            .ok_or_else(|| LibsyError::AlgorithmError {
1267                message: "packaged schema declares no properties".to_string(),
1268            })?;
1269        Ok(Value::Object(
1270            properties
1271                .iter()
1272                .map(|(name, spec)| (name.clone(), sample_value(spec)))
1273                .collect(),
1274        )
1275        .to_string())
1276    }
1277
1278    /// Built from the schema so a property added there fails here rather than silently
1279    /// rejecting every production verdict.
1280    #[test]
1281    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1282        let contract =
1283            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1284        let schema = contract.response_format();
1285        let reply = schema_shaped_verdict(schema)?;
1286        let judge = CapabilityJudge {
1287            contract,
1288            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1289            recent_turn_window: None,
1290        };
1291
1292        let verdict = judge.parse(&text_response(None, reply))?;
1293
1294        assert!(verdict.is_valid());
1295        assert!(!verdict.abstain);
1296        Ok(())
1297    }
1298
1299    #[test]
1300    fn prompt_includes_concrete_rules_and_schema() -> Result<()> {
1301        let contract =
1302            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1303        let prompt = contract.system_prompt();
1304        assert!(prompt.contains("SUP-1 [supported]"));
1305        assert!(!prompt.contains("{{CAPABILITY_RULES}}"));
1306        assert!(!prompt.contains("{{PRIMARY_RULE_VALUES}}"));
1307        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1308        assert!(prompt.contains("\"type\": \"object\""));
1309        assert!(!prompt.contains("\"json_schema\""));
1310        assert!(!prompt.contains("\"CapabilityClassifierDecision\""));
1311        let rule_values = contract
1312            .response_format()
1313            .pointer("/json_schema/schema/properties/primary_rule/enum")
1314            .and_then(Value::as_array)
1315            .ok_or_else(|| LibsyError::AlgorithmError {
1316                message: "rendered response schema has no primary rule enum".to_string(),
1317            })?;
1318        assert!(
1319            rule_values
1320                .iter()
1321                .any(|value| value.as_str() == Some("SUP-1"))
1322        );
1323        assert!(
1324            rule_values
1325                .iter()
1326                .any(|value| value.as_str() == Some("none"))
1327        );
1328        Ok(())
1329    }
1330
1331    // ── with_escalation tests ──────────────────────────────────────────────
1332
1333    use std::collections::VecDeque;
1334
1335    use switchyard_protocol::LlmClientError as ClientError;
1336
1337    use switchyard_protocol::Decision;
1338
1339    /// Serves each call with the next queued reply.
1340    struct QueuedClient {
1341        replies: Mutex<VecDeque<String>>,
1342    }
1343
1344    impl QueuedClient {
1345        fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1346            Arc::new(Self {
1347                replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1348            })
1349        }
1350    }
1351
1352    #[async_trait]
1353    impl RoutedLlmClient for QueuedClient {
1354        async fn call(
1355            &self,
1356            _ctx: Context,
1357            request: Request,
1358            _decision: Arc<dyn Decision>,
1359        ) -> std::result::Result<Response, ClientError> {
1360            let reply = self
1361                .replies
1362                .lock()
1363                .pop_front()
1364                .unwrap_or_else(|| "unexpected call".to_string());
1365            Ok(Response {
1366                llm_response: LlmResponse::Agg(text_response(None, reply)),
1367                metadata: request.metadata,
1368            })
1369        }
1370    }
1371
1372    /// Builds a router with escalation enabled (`confirmations=1` latches on the first verdict).
1373    fn escalation_router(
1374        client: Arc<dyn RoutedLlmClient>,
1375        judge_client: Arc<dyn RoutedLlmClient>,
1376    ) -> Result<Arc<LlmTaskClassifier>> {
1377        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1378            semantic_name: name.to_string(),
1379            llm_client: Some(c),
1380        };
1381        Ok(Arc::new(LlmTaskClassifier::new_with_escalation(
1382            target("judge", judge_client),
1383            target("efficient", client.clone()),
1384            target("capable", client),
1385            EscalationJudgeConfig {
1386                confirmations: 1,
1387                ..EscalationJudgeConfig::default()
1388            },
1389            DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1390        )?))
1391    }
1392
1393    #[tokio::test]
1394    async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1395        // Judge: no escalation. Expect the efficient response to be returned directly.
1396        let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1397        let model_client = QueuedClient::new(["efficient answer"]);
1398        let router = escalation_router(model_client, judge_client)?;
1399        let request = classify_request();
1400
1401        let (trace, response) = router.run(Context::default(), request).await?;
1402
1403        // The efficient model is the serving target, and the response comes from its call.
1404        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1405        assert_eq!(
1406            response.llm_response.as_agg().map(completion_text),
1407            Some("efficient answer".to_string())
1408        );
1409        Ok(())
1410    }
1411
1412    #[tokio::test]
1413    async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1414        let client = Arc::new(PerRequestClient::default());
1415        let target = |name: &str| LlmTarget {
1416            semantic_name: name.to_string(),
1417            llm_client: Some(client.clone()),
1418        };
1419        let router = Arc::new(LlmTaskClassifier::new_with_escalation_contract(
1420            target("judge"),
1421            target("efficient"),
1422            target("capable"),
1423            ClassifierContractConfig::default()
1424                .with_prompt("Custom trajectory rubric:\n{{RESPONSE_SCHEMA}}"),
1425            EscalationJudgeConfig {
1426                confirmations: 1,
1427                ..EscalationJudgeConfig::default()
1428            },
1429            DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1430        )?);
1431
1432        router.run(Context::default(), classify_request()).await?;
1433
1434        let prompts = client.judge_system_prompts();
1435        assert_eq!(prompts.len(), 1);
1436        assert!(prompts[0].starts_with("Custom trajectory rubric:"));
1437        assert!(prompts[0].contains("\"escalate\""));
1438        assert!(!prompts[0].contains("{{RESPONSE_SCHEMA}}"));
1439        Ok(())
1440    }
1441
1442    #[tokio::test]
1443    async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1444        // Judge: escalate. After the efficient call, the streak confirms and capable is served.
1445        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1446        // Efficient is called first (by the classifier), then capable is called by FallThrough.
1447        let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1448        let router = escalation_router(model_client, judge_client)?;
1449        let request = classify_request();
1450
1451        let (trace, response) = router.run(Context::default(), request).await?;
1452
1453        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1454        assert_eq!(
1455            response.llm_response.as_agg().map(completion_text),
1456            Some("capable answer".to_string())
1457        );
1458        Ok(())
1459    }
1460
1461    #[tokio::test]
1462    async fn escalation_router_stays_capable_after_latch() -> Result<()> {
1463        // First turn: judge escalates and the streak latches.
1464        // Second turn: judge is not called again; capable is served directly.
1465        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
1466        let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
1467        let router = escalation_router(model_client, judge_client)?;
1468
1469        let session_request = classify_session_request();
1470        router
1471            .clone()
1472            .run(Context::default(), session_request.clone())
1473            .await?;
1474        let (trace, _) = router
1475            .clone()
1476            .run(Context::default(), session_request)
1477            .await?;
1478
1479        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1480        Ok(())
1481    }
1482}