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, Role, SimpleDecision};
13
14use super::fall_through::{DefaultTarget, FallThrough};
15use super::util::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS;
16use super::util::affinity::AffinityRouter;
17use super::util::classifier_contract::{ClassifierContract, ClassifierContractConfig};
18use super::util::escalation::{self, EscalationJudge, EscalationJudgeConfig, EscalationPolicy};
19use super::util::llm_judge::{
20    ClassifierInput, JsonSchemaDecoder, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig,
21    SerdeDecoder, StructuredJudge,
22};
23use super::util::target_selector::TargetSelectorPolicy;
24use crate::core::algorithm::{Algorithm, Driver, LlmTarget, LlmTargetSet};
25use crate::core::classifier::{Classification, Classifier, Score};
26use crate::core::state::{State, StateValue};
27use crate::{LibsyError, Result};
28use switchyard_protocol::{
29    AggLlmResponse, Context, LlmClientError, LlmResponse, Request, Response,
30};
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: String,
171    capable_target: String,
172    base_threshold: f64,
173    threshold_step: f64,
174}
175
176impl TaskClassifierPolicy {
177    fn new(
178        efficient_target: impl Into<String>,
179        capable_target: impl Into<String>,
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    /// Enables session affinity before the judge-backed classifier.
235    pub session_affinity: bool,
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    session_affinity: bool,
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 = "default_judge_max_output_tokens")]
267    max_output_tokens: u64,
268}
269
270impl<'de> Deserialize<'de> for TaskClassifierConfig {
271    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
272    where
273        D: Deserializer<'de>,
274    {
275        let wire = TaskClassifierConfigWire::deserialize(deserializer)?;
276        let mut contract = ClassifierContractConfig::default();
277        if let Some(prompt) = wire.prompt {
278            contract = contract.with_prompt(prompt);
279        }
280        Ok(Self {
281            base_threshold: wire.base_threshold,
282            threshold_step: wire.threshold_step,
283            session_affinity: wire.session_affinity,
284            message_hash_fallback: wire.message_hash_fallback,
285            recent_turn_window: wire.recent_turn_window,
286            contract,
287            max_output_tokens: wire.max_output_tokens,
288        })
289    }
290}
291
292const fn default_judge_max_output_tokens() -> u64 {
293    DEFAULT_JUDGE_MAX_OUTPUT_TOKENS
294}
295
296impl Default for TaskClassifierConfig {
297    fn default() -> Self {
298        Self {
299            base_threshold: 0.0,
300            threshold_step: 0.0,
301            session_affinity: false,
302            message_hash_fallback: false,
303            recent_turn_window: None,
304            contract: ClassifierContractConfig::default(),
305            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
306        }
307    }
308}
309
310impl TaskClassifierConfig {
311    /// Validates routing thresholds before the classifier is constructed.
312    fn validate(&self) -> Result<()> {
313        if !(0.0..=1.0).contains(&self.base_threshold) {
314            return Err(LibsyError::AlgorithmError {
315                message: format!(
316                    "base_threshold must be between 0 and 1, got {}",
317                    self.base_threshold
318                ),
319            });
320        }
321        if !self.threshold_step.is_finite() || self.threshold_step < 0.0 {
322            return Err(LibsyError::AlgorithmError {
323                message: format!(
324                    "threshold_step must be finite and greater than or equal to 0, got {}",
325                    self.threshold_step
326                ),
327            });
328        }
329        let unsupported_threshold = self.base_threshold + 2.0 * self.threshold_step;
330        if unsupported_threshold > 1.0 && unsupported_threshold - 1.0 > f64::EPSILON {
331            return Err(LibsyError::AlgorithmError {
332                message: format!(
333                    "base_threshold + 2 * threshold_step must be at most 1, got {unsupported_threshold}"
334                ),
335            });
336        }
337        if self.max_output_tokens == 0 {
338            return Err(LibsyError::AlgorithmError {
339                message: "max_output_tokens must be at least 1".to_string(),
340            });
341        }
342        if self.message_hash_fallback && !self.session_affinity {
343            return Err(LibsyError::AlgorithmError {
344                message: "message_hash_fallback requires session_affinity".to_string(),
345            });
346        }
347        Ok(())
348    }
349}
350
351/// Policy that maps a custom classifier verdict to a routing target.
352#[derive(Clone, Debug)]
353pub enum CustomClassifierPolicy {
354    /// Resolves a JSON Pointer and treats its string value as a configured target label.
355    TargetSelector {
356        /// JSON Pointer evaluated against each schema-validated verdict.
357        selector: String,
358    },
359}
360
361impl CustomClassifierPolicy {
362    /// Creates a policy that selects a target label through a JSON Pointer.
363    pub fn target_selector(selector: impl Into<String>) -> Self {
364        Self::TargetSelector {
365            selector: selector.into(),
366        }
367    }
368}
369
370/// Settings for a classifier whose JSON Schema and target-selection policy are user supplied.
371#[derive(Clone, Debug)]
372pub struct CustomClassifierConfig {
373    /// System prompt sent to the classifier judge.
374    pub prompt: String,
375    /// Inner JSON Schema placed inside the provider's structured-output wrapper.
376    pub response_schema: Value,
377    /// Deterministic policy applied after the verdict passes schema validation.
378    pub policy: CustomClassifierPolicy,
379    /// Enables session affinity before the judge-backed classifier.
380    pub session_affinity: bool,
381    /// Uses the first user message when session metadata is unavailable.
382    pub message_hash_fallback: bool,
383    /// Trailing conversation turns shown to the classifier judge.
384    pub recent_turn_window: Option<usize>,
385    /// Maximum completion tokens available to the classifier verdict.
386    pub max_output_tokens: u64,
387}
388
389impl CustomClassifierConfig {
390    /// Creates a custom-schema classifier contract with conservative runtime defaults.
391    pub fn new(
392        prompt: impl Into<String>,
393        response_schema: Value,
394        policy: CustomClassifierPolicy,
395    ) -> Self {
396        Self {
397            prompt: prompt.into(),
398            response_schema,
399            policy,
400            session_affinity: false,
401            message_hash_fallback: false,
402            recent_turn_window: None,
403            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
404        }
405    }
406
407    fn validate(&self) -> Result<()> {
408        if self.max_output_tokens == 0 {
409            return Err(LibsyError::AlgorithmError {
410                message: "max_output_tokens must be at least 1".to_string(),
411            });
412        }
413        if self.message_hash_fallback && !self.session_affinity {
414            return Err(LibsyError::AlgorithmError {
415                message: "message_hash_fallback requires session_affinity".to_string(),
416            });
417        }
418        Ok(())
419    }
420}
421
422enum CustomPolicyRuntime {
423    TargetSelector(TargetSelectorPolicy),
424}
425
426impl JudgePolicy for CustomPolicyRuntime {
427    type Verdict = Value;
428
429    fn to_classification(&self, verdict: Option<&Self::Verdict>) -> Classification {
430        match self {
431            Self::TargetSelector(policy) => policy.to_classification(verdict),
432        }
433    }
434}
435
436struct TaskClassifier {
437    classifier: JudgeClassifier<CapabilityJudge, TaskClassifierPolicy>,
438    efficient_target: String,
439    capable_target: String,
440}
441
442// ── Escalation classifier ──────────────────────────────────────────────────
443
444/// Session-state key holding the consecutive-escalate streak.
445const STREAK_KEY: &str = "escalation_streak";
446
447/// Session-state key holding the consecutive-clear streak while latched.
448const RECOVERY_STREAK_KEY: &str = "escalation_recovery_streak";
449
450/// Session-state key marking that the session's one recovery has been used.
451const RECOVERY_SPENT_KEY: &str = "escalation_recovery_spent";
452
453fn streak(state: &State) -> u32 {
454    match state.extra.get(STREAK_KEY) {
455        Some(StateValue::Count(n)) => *n,
456        _ => 0,
457    }
458}
459
460fn recovery_streak(state: &State) -> u32 {
461    match state.extra.get(RECOVERY_STREAK_KEY) {
462        Some(StateValue::Count(n)) => *n,
463        _ => 0,
464    }
465}
466
467fn recovery_spent(state: &State) -> bool {
468    matches!(
469        state.extra.get(RECOVERY_SPENT_KEY),
470        Some(StateValue::Count(n)) if *n > 0
471    )
472}
473
474fn decisive(target: &str) -> Classification {
475    Classification::Scores(vec![Score {
476        target: target.to_string(),
477        confidence: 1.0,
478    }])
479}
480
481fn assistant_message(response: &AggLlmResponse) -> Message {
482    Message {
483        role: Role::Assistant,
484        content: response
485            .first_output()
486            .map(|output| output.content.clone())
487            .unwrap_or_default(),
488    }
489}
490
491/// Calls the efficient model, judges its response, and latches to capable once the streak
492/// confirms. Returns the efficient response directly when not escalating so the caller does
493/// not pay for a second model call.
494struct EscalationClassifier {
495    judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
496    /// Hand-back judge consulted on latched turns. Uses the packaged recovery prompt, which
497    /// asks whether the efficient tier could carry the *remaining* work — the trouble
498    /// rubric would misread the capable tier's own healthy-looking turns as recovery.
499    recovery_judge: JudgeClassifier<EscalationJudge, EscalationPolicy>,
500    capable: LlmTarget,
501    efficient: LlmTarget,
502    /// Consecutive escalate verdicts required to latch.
503    confirmations: u32,
504    /// Consecutive clear verdicts, while latched, required to de-latch. `0` disables recovery.
505    recovery_confirmations: u32,
506}
507
508#[async_trait]
509impl Classifier<State> for EscalationClassifier {
510    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
511        if self.capable.semantic_name == self.efficient.semantic_name {
512            None
513        } else if selected_model == self.capable.semantic_name {
514            Some("strong")
515        } else if selected_model == self.efficient.semantic_name {
516            Some("weak")
517        } else {
518            None
519        }
520    }
521
522    async fn score(
523        &self,
524        state: &mut State,
525        request: &mut Request,
526        driver: Option<&Driver>,
527    ) -> Result<(Classification, Option<Response>)> {
528        let Some(driver) = driver else {
529            return Err(LibsyError::AlgorithmError {
530                message: "escalation classifier requires a driver".into(),
531            });
532        };
533
534        // A confirmed session stays capable. Without recovery the latch is permanent and
535        // latched turns skip the judge; with it, the hand-back judge keeps reading the
536        // trajectory and returns the session to efficient once the remaining work no longer
537        // needs the capable tier. A session gets one recovery: after it has been handed back
538        // and re-latched, the second latch is permanent, so a wrong hand-back cannot oscillate
539        // for the rest of the session.
540        if streak(state) >= self.confirmations {
541            if self.recovery_confirmations == 0 || recovery_spent(state) {
542                return Ok((decisive(&self.capable.semantic_name), None));
543            }
544            // Ask the hand-back judge; the capable tier's prior replies are already in the
545            // history. A clear verdict scores the efficient target.
546            let mut judge_request = request.clone();
547            let (classification, _) = self
548                .recovery_judge
549                .score(state, &mut judge_request, Some(driver))
550                .await?;
551            let recovered = match &classification.argmax(false)? {
552                Some(score) if score.target == self.efficient.semantic_name => {
553                    recovery_streak(state) + 1
554                }
555                Some(_) => 0,
556                // Judge outage: hold the latch and the streak — recovery needs live verdicts.
557                None => recovery_streak(state),
558            };
559            if recovered < self.recovery_confirmations {
560                state.extra.insert(
561                    RECOVERY_STREAK_KEY.to_string(),
562                    StateValue::Count(recovered),
563                );
564                return Ok((decisive(&self.capable.semantic_name), None));
565            }
566            // De-latch: spend the session's one recovery, clear both streaks, and fall
567            // through to the ordinary efficient-first path below, which judges this turn's
568            // fresh reply and can re-escalate as usual.
569            tracing::info!(
570                recovery_confirmations = self.recovery_confirmations,
571                "escalation recovery: session handed back to the efficient tier"
572            );
573            state
574                .extra
575                .insert(RECOVERY_SPENT_KEY.to_string(), StateValue::Count(1));
576            state
577                .extra
578                .insert(STREAK_KEY.to_string(), StateValue::Count(0));
579            state
580                .extra
581                .insert(RECOVERY_STREAK_KEY.to_string(), StateValue::Count(0));
582        }
583
584        // Call efficient model and buffer the response so the judge can read it.
585        // `Classifier::score` takes no `ctx`, so inner calls use Context::default() and their
586        // spans carry algorithm="" rather than the algorithm name. Known gap shared with the
587        // task classifier's judge consultation.
588        //
589        // If the efficient model exceeds its context window, fall through to capable: returning
590        // `(decisive(capable), None)` tells FallThrough::execute to call
591        // call_llm_with_overflow_fallback with the capable target instead of surfacing the error.
592        let efficient_response = match driver
593            .call_llm_target(
594                Context::default(),
595                &self.efficient,
596                request.clone(),
597                Arc::new(SimpleDecision {
598                    selected_model: self.efficient.semantic_name.clone(),
599                    reasoning: Some("escalation classifier: efficient tier".into()),
600                }),
601            )
602            .await
603        {
604            Ok(r) => r,
605            Err(LibsyError::ClientCall {
606                source: LlmClientError::ContextWindowExceeded { .. },
607                ..
608            }) => {
609                // A handed-back conversation that has outgrown the efficient tier's context
610                // window can never be served weak again, and this arm skips the judge, so
611                // probation would otherwise never fire: re-latch permanently instead of
612                // paying a doomed efficient call plus a capable fallback on every turn.
613                if recovery_spent(state) {
614                    tracing::info!(
615                        "escalation recovery: efficient tier overflowed after hand-back, \
616                         re-latching permanently"
617                    );
618                    state.extra.insert(
619                        STREAK_KEY.to_string(),
620                        StateValue::Count(self.confirmations),
621                    );
622                }
623                return Ok((decisive(&self.capable.semantic_name), None));
624            }
625            Err(e) => return Err(e),
626        };
627        let agg = efficient_response
628            .llm_response
629            .into_agg()
630            .await
631            .map_err(|e| LibsyError::AlgorithmError {
632                message: format!("failed to aggregate efficient response: {e}"),
633            })?;
634        // Append the efficient reply so the judge reads this turn's completed trajectory.
635        let mut judge_request = request.clone();
636        judge_request
637            .llm_request
638            .messages
639            .push(assistant_message(&agg));
640        let efficient_response = Response {
641            llm_response: if request.llm_request.stream {
642                LlmResponse::Stream(agg.into_stream())
643            } else {
644                LlmResponse::Agg(agg)
645            },
646            metadata: efficient_response.metadata,
647        };
648
649        let (classification, _) = self
650            .judge
651            .score(state, &mut judge_request, Some(driver))
652            .await?;
653
654        let held = streak(state);
655        let best = classification.argmax(false)?;
656        let (escalate, pending) = match &best {
657            Some(score) if score.target == self.capable.semantic_name => (true, held + 1),
658            Some(_) => (false, 0),
659            None => (false, held),
660        };
661        state
662            .extra
663            .insert(STREAK_KEY.to_string(), StateValue::Count(pending));
664
665        // Probation: a session that has already been handed back once re-latches on a single
666        // escalate verdict, so a wrong hand-back costs one efficient turn instead of a full
667        // re-confirmation cycle of thrash.
668        let required = if recovery_spent(state) {
669            1
670        } else {
671            self.confirmations
672        };
673        if escalate && pending >= required {
674            if required < self.confirmations {
675                tracing::info!("escalation recovery: probation re-latch, latch is now permanent");
676            }
677            // Record a full streak so the latched check above recognizes the latch even when
678            // probation confirmed it early. Drop the efficient response, caller serves capable.
679            state.extra.insert(
680                STREAK_KEY.to_string(),
681                StateValue::Count(self.confirmations),
682            );
683            return Ok((decisive(&self.capable.semantic_name), None));
684        }
685
686        Ok((
687            decisive(&self.efficient.semantic_name),
688            Some(efficient_response),
689        ))
690    }
691}
692
693/// Routes requests through a capability, escalation, or custom classifier mode.
694pub struct LlmTaskClassifier {
695    route: FallThrough<State>,
696    /// Classifier used when this router is embedded in another cascade.
697    inner: Arc<dyn Classifier<State>>,
698}
699
700struct ClassifierRouteConfig {
701    default_target: String,
702    session_affinity: bool,
703    message_hash_fallback: bool,
704}
705
706/// Complete construction settings for one LLM classifier mode.
707#[non_exhaustive]
708pub enum LlmClassifierConfig {
709    /// Routes between efficient and capable targets from a task-level verdict.
710    Capability {
711        /// Target that produces classifier verdicts.
712        judge_target: LlmTarget,
713        /// Target used when the efficient tier can handle the task.
714        efficient_target: LlmTarget,
715        /// Target used when the task needs the capable tier.
716        capable_target: LlmTarget,
717        /// Capability classifier settings.
718        config: TaskClassifierConfig,
719    },
720    /// Judges efficient responses and escalates after a confirmed streak.
721    Escalation {
722        /// Target that produces escalation verdicts.
723        judge_target: LlmTarget,
724        /// Target called before each escalation decision.
725        efficient_target: LlmTarget,
726        /// Target used after escalation is confirmed.
727        capable_target: LlmTarget,
728        /// Prompt and verdict contract settings for the escalation judge.
729        contract: ClassifierContractConfig,
730        /// Escalation policy settings.
731        config: EscalationJudgeConfig,
732        /// Maximum completion tokens available to the escalation verdict.
733        max_output_tokens: u64,
734    },
735    /// Routes among named targets using a user-supplied schema and policy.
736    Custom {
737        /// Target that produces classifier verdicts.
738        judge_target: LlmTarget,
739        /// User-facing labels paired with their resolved routing targets.
740        targets: Vec<(String, LlmTarget)>,
741        /// Label selected when the judge does not produce a usable verdict.
742        default_target: String,
743        /// Custom classifier settings.
744        config: CustomClassifierConfig,
745    },
746}
747
748impl LlmTaskClassifier {
749    /// Builds the classifier mode described by `config`.
750    ///
751    /// # Errors
752    ///
753    /// Returns an error when the selected mode's targets, contract, policy, or runtime
754    /// settings are invalid.
755    pub fn new(config: LlmClassifierConfig) -> Result<Self> {
756        match config {
757            LlmClassifierConfig::Capability {
758                judge_target,
759                efficient_target,
760                capable_target,
761                config,
762            } => Self::build_capability(judge_target, efficient_target, capable_target, config),
763            LlmClassifierConfig::Escalation {
764                judge_target,
765                efficient_target,
766                capable_target,
767                contract,
768                config,
769                max_output_tokens,
770            } => Self::build_escalation(
771                judge_target,
772                efficient_target,
773                capable_target,
774                contract,
775                config,
776                max_output_tokens,
777            ),
778            LlmClassifierConfig::Custom {
779                judge_target,
780                targets,
781                default_target,
782                config,
783            } => Self::build_custom(judge_target, targets, default_target, config),
784        }
785    }
786
787    fn build_capability(
788        judge_target: LlmTarget,
789        efficient_target: LlmTarget,
790        capable_target: LlmTarget,
791        config: TaskClassifierConfig,
792    ) -> Result<Self> {
793        config.validate()?;
794        let contract = Self::load_capability_contract(&config.contract)?;
795        let targets = LlmTargetSet::new(vec![efficient_target.clone(), capable_target.clone()]);
796        let session_affinity = config.session_affinity;
797        let message_hash_fallback = config.message_hash_fallback;
798        let classifier = Arc::new(TaskClassifier {
799            classifier: JudgeClassifier::new(
800                StructuredJudge::new(
801                    TaskInput {
802                        recent_turn_window: config.recent_turn_window,
803                    },
804                    contract,
805                    SerdeDecoder::new(),
806                    JudgeRuntimeConfig::new(config.max_output_tokens)?,
807                ),
808                judge_target.clone(),
809                TaskClassifierPolicy::new(
810                    efficient_target.semantic_name.clone(),
811                    capable_target.semantic_name.clone(),
812                    &config,
813                ),
814            ),
815            efficient_target: efficient_target.semantic_name.clone(),
816            capable_target: capable_target.semantic_name.clone(),
817        });
818        let inner: Arc<dyn Classifier<State>> = classifier.clone();
819        Self::from_classifier(
820            targets,
821            inner,
822            ClassifierRouteConfig {
823                default_target: classifier.capable_target.clone(),
824                session_affinity,
825                message_hash_fallback,
826            },
827        )
828    }
829
830    fn build_custom(
831        judge_target: LlmTarget,
832        targets: Vec<(String, LlmTarget)>,
833        default_target: String,
834        config: CustomClassifierConfig,
835    ) -> Result<Self> {
836        config.validate()?;
837        if targets.len() < 2 {
838            return Err(LibsyError::AlgorithmError {
839                message: "custom classifier requires at least two targets".to_string(),
840            });
841        }
842
843        let mut labels = BTreeSet::new();
844        let mut semantic_names = BTreeSet::new();
845        let mut target_map = BTreeMap::new();
846        let mut resolved_targets = Vec::with_capacity(targets.len());
847        for (label, target) in targets {
848            if label.trim().is_empty() || label.trim() != label {
849                return Err(LibsyError::AlgorithmError {
850                    message: "custom classifier target labels must be non-empty and have no surrounding whitespace"
851                        .to_string(),
852                });
853            }
854            if !labels.insert(label.clone()) {
855                return Err(LibsyError::AlgorithmError {
856                    message: format!("custom classifier target label {label:?} is duplicated"),
857                });
858            }
859            if !semantic_names.insert(target.semantic_name.clone()) {
860                return Err(LibsyError::AlgorithmError {
861                    message: format!(
862                        "custom classifier resolved target {:?} is duplicated",
863                        target.semantic_name
864                    ),
865                });
866            }
867            target_map.insert(label, target.semantic_name.clone());
868            resolved_targets.push(target);
869        }
870        let default_semantic_name =
871            target_map
872                .get(&default_target)
873                .cloned()
874                .ok_or_else(|| LibsyError::AlgorithmError {
875                    message: format!(
876                        "default_target {default_target:?} must be one of the configured targets"
877                    ),
878                })?;
879
880        let CustomClassifierConfig {
881            prompt,
882            response_schema,
883            policy,
884            session_affinity,
885            message_hash_fallback,
886            recent_turn_window,
887            max_output_tokens,
888        } = config;
889        let contract = ClassifierContract::from_inner_schema(&prompt, response_schema)?;
890        let policy = match policy {
891            CustomClassifierPolicy::TargetSelector { selector } => {
892                CustomPolicyRuntime::TargetSelector(TargetSelectorPolicy::new(
893                    selector, target_map,
894                )?)
895            }
896        };
897        let classifier: Arc<dyn Classifier<State>> = Arc::new(JudgeClassifier::new(
898            StructuredJudge::new(
899                TaskInput { recent_turn_window },
900                contract,
901                JsonSchemaDecoder::new(),
902                JudgeRuntimeConfig::new(max_output_tokens)?,
903            ),
904            judge_target,
905            policy,
906        ));
907
908        Self::from_classifier(
909            LlmTargetSet::new(resolved_targets),
910            classifier,
911            ClassifierRouteConfig {
912                default_target: default_semantic_name,
913                session_affinity,
914                message_hash_fallback,
915            },
916        )
917    }
918
919    fn build_escalation(
920        judge_target: LlmTarget,
921        efficient_target: LlmTarget,
922        capable_target: LlmTarget,
923        contract_config: ClassifierContractConfig,
924        config: EscalationJudgeConfig,
925        max_output_tokens: u64,
926    ) -> Result<Self> {
927        let capable_name = capable_target.semantic_name.clone();
928        let efficient_name = efficient_target.semantic_name.clone();
929        let confirmations = config.confirmations;
930        let recovery_confirmations = config.recovery_confirmations;
931        let esc = Arc::new(EscalationClassifier {
932            judge: escalation::build_judge(
933                judge_target.clone(),
934                capable_name.clone(),
935                efficient_name.clone(),
936                &contract_config,
937                config.clone(),
938                max_output_tokens,
939            )?,
940            recovery_judge: escalation::build_recovery_judge(
941                judge_target,
942                capable_name,
943                efficient_name,
944                config,
945                max_output_tokens,
946            )?,
947            capable: capable_target.clone(),
948            efficient: efficient_target.clone(),
949            confirmations,
950            recovery_confirmations,
951        });
952        let inner: Arc<dyn Classifier<State>> = esc.clone();
953        let targets = LlmTargetSet::new(vec![capable_target, efficient_target]);
954        Ok(Self {
955            route: FallThrough::<State>::new_with_state(targets)
956                .with_name(ALGORITHM_NAME)
957                .with_classifier(esc),
958            inner,
959        })
960    }
961
962    /// Loads the packaged capability-classifier contract.
963    fn load_capability_contract(config: &ClassifierContractConfig) -> Result<ClassifierContract> {
964        ClassifierContract::from_config(config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
965    }
966
967    /// Keeps affinity and fallback ordering identical across judge-backed modes.
968    fn from_classifier(
969        targets: LlmTargetSet,
970        inner: Arc<dyn Classifier<State>>,
971        config: ClassifierRouteConfig,
972    ) -> Result<Self> {
973        targets.get_target(&config.default_target)?;
974        if config.message_hash_fallback && !config.session_affinity {
975            return Err(LibsyError::AlgorithmError {
976                message: "message_hash_fallback requires session_affinity".to_string(),
977            });
978        }
979        // Affinity comes first so a retained assignment short-circuits the judge call.
980        // Note: when this classifier is embedded inside another cascade (e.g. StageRouter)
981        // the affinity processor never fires — only the inner score() is called.
982        let mut route = FallThrough::<State>::new_with_state(targets).with_name(ALGORITHM_NAME);
983        if config.session_affinity {
984            let affinity = if config.message_hash_fallback {
985                AffinityRouter::new().with_message_hash_fallback()
986            } else {
987                AffinityRouter::new()
988            };
989            // Both roles must share one `Arc` so the classifier reads what the processor wrote.
990            let affinity = Arc::new(affinity);
991            route = route
992                .with_processor(affinity.clone())
993                .with_classifier(affinity);
994        }
995        let fallback = DefaultTarget::new(config.default_target);
996        Ok(Self {
997            route: route
998                .with_classifier(inner.clone())
999                .with_classifier(Arc::new(fallback)),
1000            inner,
1001        })
1002    }
1003}
1004
1005#[async_trait]
1006impl Classifier<State> for TaskClassifier {
1007    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
1008        if self.efficient_target == self.capable_target {
1009            None
1010        } else if selected_model == self.efficient_target {
1011            Some("weak")
1012        } else if selected_model == self.capable_target {
1013            Some("strong")
1014        } else {
1015            None
1016        }
1017    }
1018
1019    async fn score(
1020        &self,
1021        state: &mut State,
1022        request: &mut Request,
1023        driver: Option<&Driver>,
1024    ) -> Result<(Classification, Option<Response>)> {
1025        self.classifier.score(state, request, driver).await
1026    }
1027}
1028
1029#[async_trait]
1030impl Classifier<State> for LlmTaskClassifier {
1031    fn routing_tier(&self, selected_model: &str) -> Option<&'static str> {
1032        self.inner.routing_tier(selected_model)
1033    }
1034
1035    async fn score(
1036        &self,
1037        state: &mut State,
1038        request: &mut Request,
1039        driver: Option<&Driver>,
1040    ) -> Result<(Classification, Option<Response>)> {
1041        self.inner.score(state, request, driver).await
1042    }
1043}
1044
1045#[async_trait]
1046impl Algorithm for LlmTaskClassifier {
1047    fn name(&self) -> &str {
1048        "llm_task_classifier"
1049    }
1050
1051    async fn create_run_task(
1052        self: Arc<Self>,
1053        ctx: Context,
1054        driver: Driver,
1055        request: Request,
1056    ) -> Result<Response> {
1057        self.route.execute(ctx, driver, request).await
1058    }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use std::sync::Arc;
1064
1065    use parking_lot::Mutex;
1066    use serde_json::Value;
1067
1068    use super::*;
1069    use switchyard_protocol::{
1070        ContentBlock, InstructionBlock, LlmClientError, LlmRequest, Metadata, ToolCall, ToolResult,
1071        completion_text, text_request, text_response,
1072    };
1073
1074    use crate::algorithms::util::llm_judge::Judge;
1075    use crate::core::algorithm::Algorithm;
1076    use switchyard_protocol::{Context, LlmResponse, Response, RoutedLlmClient};
1077
1078    const TEST_THRESHOLD: f64 = 0.5;
1079
1080    fn test_config(base_threshold: f64) -> TaskClassifierConfig {
1081        TaskClassifierConfig {
1082            base_threshold,
1083            ..TaskClassifierConfig::default()
1084        }
1085    }
1086
1087    fn policy() -> TaskClassifierPolicy {
1088        TaskClassifierPolicy::new("efficient", "capable", &test_config(TEST_THRESHOLD))
1089    }
1090
1091    fn verdict(
1092        p_solve: f64,
1093        capability_boundary: &str,
1094        primary_rule: &str,
1095    ) -> TaskClassifierVerdict {
1096        TaskClassifierVerdict {
1097            crux: "test crux".to_string(),
1098            primary_rule: primary_rule.to_string(),
1099            capability_boundary: capability_boundary.to_string(),
1100            p_solve,
1101        }
1102    }
1103
1104    fn selected(
1105        policy: &TaskClassifierPolicy,
1106        verdict: Option<&TaskClassifierVerdict>,
1107    ) -> Result<String> {
1108        policy
1109            .to_classification(verdict)
1110            .argmax(false)?
1111            .map(|score| score.target)
1112            .ok_or_else(|| LibsyError::AlgorithmError {
1113                message: "policy abstained".to_string(),
1114            })
1115    }
1116
1117    #[derive(Default)]
1118    struct PerRequestClient {
1119        calls: Mutex<Vec<String>>,
1120        judge_max_output_tokens: Mutex<Vec<Option<u64>>>,
1121        judge_system_prompts: Mutex<Vec<String>>,
1122    }
1123
1124    impl PerRequestClient {
1125        fn calls(&self) -> Vec<String> {
1126            self.calls.lock().clone()
1127        }
1128
1129        fn judge_max_output_tokens(&self) -> Vec<Option<u64>> {
1130            self.judge_max_output_tokens.lock().clone()
1131        }
1132
1133        fn judge_system_prompts(&self) -> Vec<String> {
1134            self.judge_system_prompts.lock().clone()
1135        }
1136    }
1137
1138    #[async_trait]
1139    impl RoutedLlmClient for PerRequestClient {
1140        async fn call(
1141            &self,
1142            _ctx: Context,
1143            request: Request,
1144            decision: Arc<dyn Decision>,
1145        ) -> std::result::Result<Response, LlmClientError> {
1146            let model = decision.selected_model().to_string();
1147            self.calls.lock().push(model.clone());
1148            let completion = if model == "judge" {
1149                self.judge_max_output_tokens
1150                    .lock()
1151                    .push(request.llm_request.output.max_output_tokens);
1152                self.judge_system_prompts.lock().extend(
1153                    request
1154                        .llm_request
1155                        .instructions
1156                        .first()
1157                        .and_then(|instruction| {
1158                            instruction.content.iter().find_map(|b| {
1159                                if let ContentBlock::Text { text } = b {
1160                                    Some(text.clone())
1161                                } else {
1162                                    None
1163                                }
1164                            })
1165                        }),
1166                );
1167                r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#.to_string()
1168            } else {
1169                format!("answer from {model}")
1170            };
1171            Ok(Response {
1172                llm_response: LlmResponse::Agg(text_response(None, completion)),
1173                metadata: request.metadata,
1174            })
1175        }
1176    }
1177
1178    struct UnreachableJudgeClient;
1179
1180    #[async_trait]
1181    impl RoutedLlmClient for UnreachableJudgeClient {
1182        async fn call(
1183            &self,
1184            _ctx: Context,
1185            request: Request,
1186            decision: Arc<dyn Decision>,
1187        ) -> std::result::Result<Response, LlmClientError> {
1188            let model = decision.selected_model().to_string();
1189            if model == "judge" {
1190                return Err(LlmClientError::Timeout {
1191                    source: Box::new(std::io::Error::other("judge unreachable")),
1192                });
1193            }
1194            Ok(Response {
1195                llm_response: LlmResponse::Agg(text_response(None, format!("answer from {model}"))),
1196                metadata: request.metadata,
1197            })
1198        }
1199    }
1200
1201    fn router(client: Arc<dyn RoutedLlmClient>) -> Result<Arc<LlmTaskClassifier>> {
1202        let target = |name: &str| LlmTarget {
1203            semantic_name: name.to_string(),
1204            llm_client: Some(client.clone()),
1205        };
1206        Ok(Arc::new(LlmTaskClassifier::new(
1207            LlmClassifierConfig::Capability {
1208                judge_target: target("judge"),
1209                efficient_target: target("efficient"),
1210                capable_target: target("capable"),
1211                config: test_config(TEST_THRESHOLD),
1212            },
1213        )?))
1214    }
1215
1216    fn classify_request() -> Request {
1217        Request {
1218            llm_request: text_request(Some("auto".to_string()), "classify this task"),
1219            raw_request: None,
1220            metadata: None,
1221        }
1222    }
1223
1224    fn classify_session_request() -> Request {
1225        Request {
1226            metadata: Some(Metadata {
1227                session_id: Some("session-1".to_string()),
1228                ..Metadata::default()
1229            }),
1230            ..classify_request()
1231        }
1232    }
1233
1234    fn classify_follow_up_request() -> Request {
1235        let mut request = classify_request();
1236        request
1237            .llm_request
1238            .messages
1239            .push(Message::text(Role::Assistant, "I will add the test."));
1240        request.llm_request.messages.push(Message::text(
1241            Role::User,
1242            "Now run the test suite and report the result.",
1243        ));
1244        request
1245    }
1246
1247    #[tokio::test]
1248    async fn an_unreachable_judge_routes_capable_instead_of_failing_the_request() -> Result<()> {
1249        let router = router(Arc::new(UnreachableJudgeClient))?;
1250
1251        let (trace, response) = router.run(Context::default(), classify_request()).await?;
1252
1253        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1254        assert_eq!(
1255            response.llm_response.as_agg().map(completion_text),
1256            Some("answer from capable".to_string())
1257        );
1258        Ok(())
1259    }
1260
1261    #[tokio::test]
1262    async fn classifier_judges_each_request_without_affinity() -> Result<()> {
1263        let client = Arc::new(PerRequestClient::default());
1264        let router = router(client.clone())?;
1265        let request = classify_request;
1266
1267        router.clone().run(Context::default(), request()).await?;
1268        router.clone().run(Context::default(), request()).await?;
1269
1270        assert_eq!(
1271            client.calls(),
1272            vec!["judge", "efficient", "judge", "efficient"]
1273        );
1274        Ok(())
1275    }
1276
1277    #[tokio::test]
1278    async fn classifier_config_sets_the_judge_completion_cap() -> Result<()> {
1279        let client = Arc::new(PerRequestClient::default());
1280        let target = |name: &str| LlmTarget {
1281            semantic_name: name.to_string(),
1282            llm_client: Some(client.clone()),
1283        };
1284        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1285            judge_target: target("judge"),
1286            efficient_target: target("efficient"),
1287            capable_target: target("capable"),
1288            config: TaskClassifierConfig {
1289                max_output_tokens: 512,
1290                ..test_config(TEST_THRESHOLD)
1291            },
1292        })?);
1293
1294        router.run(Context::default(), classify_request()).await?;
1295
1296        assert_eq!(client.judge_max_output_tokens(), vec![Some(512)]);
1297        Ok(())
1298    }
1299
1300    #[tokio::test]
1301    async fn classifier_config_overrides_the_packaged_prompt() -> Result<()> {
1302        let client = Arc::new(PerRequestClient::default());
1303        let target = |name: &str| LlmTarget {
1304            semantic_name: name.to_string(),
1305            llm_client: Some(client.clone()),
1306        };
1307        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1308            judge_target: target("judge"),
1309            efficient_target: target("efficient"),
1310            capable_target: target("capable"),
1311            config: TaskClassifierConfig {
1312                contract: ClassifierContractConfig::default()
1313                    .with_prompt("Custom capability rubric."),
1314                ..test_config(TEST_THRESHOLD)
1315            },
1316        })?);
1317
1318        router.run(Context::default(), classify_request()).await?;
1319
1320        let prompts = client.judge_system_prompts();
1321        assert_eq!(prompts.len(), 1);
1322        assert_eq!(prompts[0], "Custom capability rubric.");
1323        Ok(())
1324    }
1325
1326    #[tokio::test]
1327    async fn classifier_config_enables_session_affinity() -> Result<()> {
1328        let client = Arc::new(PerRequestClient::default());
1329        let target = |name: &str| LlmTarget {
1330            semantic_name: name.to_string(),
1331            llm_client: Some(client.clone()),
1332        };
1333        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1334            judge_target: target("judge"),
1335            efficient_target: target("efficient"),
1336            capable_target: target("capable"),
1337            config: TaskClassifierConfig {
1338                session_affinity: true,
1339                ..test_config(TEST_THRESHOLD)
1340            },
1341        })?);
1342
1343        router
1344            .clone()
1345            .run(Context::default(), classify_session_request())
1346            .await?;
1347        router
1348            .clone()
1349            .run(Context::default(), classify_session_request())
1350            .await?;
1351
1352        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1353        Ok(())
1354    }
1355
1356    #[tokio::test]
1357    async fn classifier_config_reuses_message_hash_affinity_for_a_follow_up() -> Result<()> {
1358        let client = Arc::new(PerRequestClient::default());
1359        let target = |name: &str| LlmTarget {
1360            semantic_name: name.to_string(),
1361            llm_client: Some(client.clone()),
1362        };
1363        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1364            judge_target: target("judge"),
1365            efficient_target: target("efficient"),
1366            capable_target: target("capable"),
1367            config: TaskClassifierConfig {
1368                session_affinity: true,
1369                message_hash_fallback: true,
1370                recent_turn_window: None,
1371                ..test_config(TEST_THRESHOLD)
1372            },
1373        })?);
1374
1375        router
1376            .clone()
1377            .run(Context::default(), classify_request())
1378            .await?;
1379        router
1380            .clone()
1381            .run(Context::default(), classify_follow_up_request())
1382            .await?;
1383
1384        assert_eq!(client.calls(), vec!["judge", "efficient", "efficient"]);
1385        Ok(())
1386    }
1387
1388    #[test]
1389    fn the_threshold_boundary_is_inclusive() -> Result<()> {
1390        let policy = policy();
1391        let at_threshold = verdict(0.5, "supported", "SUP-1");
1392        let below_threshold = verdict(0.49, "supported", "SUP-1");
1393        assert_eq!(selected(&policy, Some(&at_threshold))?, "efficient");
1394        assert_eq!(selected(&policy, Some(&below_threshold))?, "capable");
1395        Ok(())
1396    }
1397
1398    #[test]
1399    fn the_threshold_moves_the_routing_boundary() -> Result<()> {
1400        let borderline = verdict(0.5, "supported", "SUP-1");
1401        let strict = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.9));
1402        let lenient = TaskClassifierPolicy::new("efficient", "capable", &test_config(0.1));
1403        assert_eq!(selected(&strict, Some(&borderline))?, "capable");
1404        assert_eq!(selected(&lenient, Some(&borderline))?, "efficient");
1405        Ok(())
1406    }
1407
1408    #[test]
1409    fn classifier_config_rejects_unknown_fields() {
1410        let error = serde_json::from_value::<TaskClassifierConfig>(serde_json::json!({
1411            "base_threshold": 0.5,
1412            "classifier_magic": true,
1413        }))
1414        .expect_err("unknown classifier fields must be rejected");
1415
1416        assert!(
1417            error
1418                .to_string()
1419                .contains("unknown field `classifier_magic`"),
1420            "{error}"
1421        );
1422    }
1423
1424    #[test]
1425    fn invalid_classifier_config_is_rejected() -> Result<()> {
1426        let target = |name: &str| LlmTarget {
1427            semantic_name: name.to_string(),
1428            llm_client: None,
1429        };
1430        for bad in [1.5, -0.1, f64::NAN, f64::INFINITY] {
1431            assert!(
1432                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1433                    judge_target: target("judge"),
1434                    efficient_target: target("e"),
1435                    capable_target: target("c"),
1436                    config: test_config(bad),
1437                })
1438                .is_err(),
1439                "base threshold {bad} should be rejected"
1440            );
1441        }
1442        for config in [
1443            TaskClassifierConfig {
1444                base_threshold: 0.5,
1445                threshold_step: -0.1,
1446                ..TaskClassifierConfig::default()
1447            },
1448            TaskClassifierConfig {
1449                base_threshold: 0.8,
1450                threshold_step: 0.11,
1451                ..TaskClassifierConfig::default()
1452            },
1453            TaskClassifierConfig {
1454                base_threshold: 0.5,
1455                message_hash_fallback: true,
1456                ..TaskClassifierConfig::default()
1457            },
1458            TaskClassifierConfig {
1459                base_threshold: 0.5,
1460                max_output_tokens: 0,
1461                ..TaskClassifierConfig::default()
1462            },
1463        ] {
1464            assert!(
1465                LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1466                    judge_target: target("judge"),
1467                    efficient_target: target("e"),
1468                    capable_target: target("c"),
1469                    config,
1470                })
1471                .is_err()
1472            );
1473        }
1474        for base_threshold in [0.0, 1.0] {
1475            LlmTaskClassifier::new(LlmClassifierConfig::Capability {
1476                judge_target: target("judge"),
1477                efficient_target: target("e"),
1478                capable_target: target("c"),
1479                config: test_config(base_threshold),
1480            })?;
1481        }
1482        Ok(())
1483    }
1484
1485    #[test]
1486    fn an_unusable_verdict_is_ambiguous() -> Result<()> {
1487        let policy = policy();
1488        let inconsistent_rule = TaskClassifierVerdict {
1489            capability_boundary: "uncertain".to_string(),
1490            ..verdict(1.0, "supported", "SUP-1")
1491        };
1492        let empty_crux = TaskClassifierVerdict {
1493            crux: "  ".to_string(),
1494            ..verdict(1.0, "supported", "SUP-1")
1495        };
1496        let unusable = [
1497            Some(verdict(1.1, "supported", "SUP-1")),
1498            Some(inconsistent_rule),
1499            Some(empty_crux),
1500            None,
1501        ];
1502        for verdict in unusable {
1503            let classification = policy.to_classification(verdict.as_ref());
1504            assert!(matches!(classification, Classification::Ambiguous(_)));
1505            assert!(classification.argmax(false)?.is_none());
1506            assert!(classification.argmax(true)?.is_none());
1507        }
1508        Ok(())
1509    }
1510
1511    #[test]
1512    fn capability_boundaries_apply_monotonic_threshold_steps() -> Result<()> {
1513        let policy = TaskClassifierPolicy::new(
1514            "efficient",
1515            "capable",
1516            &TaskClassifierConfig {
1517                threshold_step: 0.1,
1518                ..test_config(0.4)
1519            },
1520        );
1521
1522        assert_eq!(
1523            selected(&policy, Some(&verdict(0.4, "supported", "SUP-2")))?,
1524            "efficient"
1525        );
1526        assert_eq!(
1527            selected(&policy, Some(&verdict(0.49, "uncertain", "UNC-1")))?,
1528            "capable"
1529        );
1530        assert_eq!(
1531            selected(&policy, Some(&verdict(0.5, "uncertain", "UNC-1")))?,
1532            "efficient"
1533        );
1534        assert_eq!(
1535            selected(&policy, Some(&verdict(0.5, "unmatched", "none")))?,
1536            "efficient"
1537        );
1538        assert_eq!(
1539            selected(&policy, Some(&verdict(0.59, "unsupported", "LIM-1")))?,
1540            "capable"
1541        );
1542        assert_eq!(
1543            selected(&policy, Some(&verdict(0.6, "unsupported", "LIM-1")))?,
1544            "efficient"
1545        );
1546        Ok(())
1547    }
1548
1549    /// The text of each message a judge with `recent_turn_window` would be sent.
1550    /// The no-window case is covered by `capability_judge_builds_a_structured_request`.
1551    fn capability_judge(recent_turn_window: Option<usize>) -> Result<CapabilityJudge> {
1552        Ok(StructuredJudge::new(
1553            TaskInput { recent_turn_window },
1554            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?,
1555            SerdeDecoder::new(),
1556            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1557        ))
1558    }
1559
1560    fn judged_contents(recent_turn_window: usize) -> Result<Vec<String>> {
1561        let judge = capability_judge(Some(recent_turn_window))?;
1562        let request = Request {
1563            llm_request: LlmRequest {
1564                messages: vec![
1565                    Message::text(Role::System, "client instructions"),
1566                    Message::text(Role::User, "initial task"),
1567                    Message::text(Role::Assistant, "old response"),
1568                    Message::text(Role::User, "old follow-up"),
1569                    Message::text(Role::Assistant, "recent 1"),
1570                    Message::text(Role::User, "recent 2"),
1571                ],
1572                ..LlmRequest::default()
1573            },
1574            raw_request: None,
1575            metadata: None,
1576        };
1577        Ok(judge
1578            .build_request(&State::default(), &request)
1579            .llm_request
1580            .messages
1581            .iter()
1582            .filter_map(|message| message.text_content("\n"))
1583            .collect())
1584    }
1585
1586    #[test]
1587    fn a_window_widens_the_judge_to_the_surrounding_conversation() -> Result<()> {
1588        // Client instructions and the opening task, plus the last two turns.
1589        let contents = judged_contents(2)?;
1590        assert!(contents.contains(&"client instructions".to_string()));
1591        assert!(contents.contains(&"initial task".to_string()));
1592        assert!(contents.contains(&"recent 1".to_string()));
1593        assert!(contents.contains(&"recent 2".to_string()));
1594        assert!(!contents.contains(&"old response".to_string()));
1595        Ok(())
1596    }
1597
1598    #[test]
1599    fn a_zero_window_keeps_only_the_instructions_and_the_task() -> Result<()> {
1600        let contents = judged_contents(0)?;
1601        assert!(contents.contains(&"client instructions".to_string()));
1602        assert!(contents.contains(&"initial task".to_string()));
1603        assert!(!contents.contains(&"recent 2".to_string()));
1604        Ok(())
1605    }
1606
1607    fn tool_call(id: &str) -> Message {
1608        Message {
1609            role: Role::Assistant,
1610            content: vec![ContentBlock::ToolCall(ToolCall {
1611                id: id.to_string(),
1612                name: "search".to_string(),
1613                arguments: Value::Null,
1614            })],
1615        }
1616    }
1617
1618    fn tool_result(id: &str) -> Message {
1619        Message {
1620            role: Role::Tool,
1621            content: vec![ContentBlock::ToolResult(ToolResult {
1622                tool_call_id: id.to_string(),
1623                content: vec![ContentBlock::Text {
1624                    text: "tool output".to_string(),
1625                }],
1626                is_error: None,
1627            })],
1628        }
1629    }
1630
1631    /// A count-based window can begin on a tool result, which leaves the call that
1632    /// introduced its id outside the window and the classifier history invalid.
1633    #[test]
1634    fn trimming_keeps_the_call_that_introduced_a_kept_tool_result() {
1635        let messages = vec![
1636            Message::text(Role::System, "client instructions"),
1637            Message::text(Role::User, "initial task"),
1638            Message::text(Role::Assistant, "old response"),
1639            tool_call("call-1"),
1640            tool_result("call-1"),
1641            Message::text(Role::Assistant, "recent 1"),
1642            Message::text(Role::User, "recent 2"),
1643            Message::text(Role::Assistant, "recent 3"),
1644            Message::text(Role::User, "recent 4"),
1645        ];
1646
1647        // The five-message tail begins exactly on the tool result.
1648        let kept = trim_messages(&messages, 5);
1649
1650        assert_eq!(
1651            kept,
1652            vec![
1653                Message::text(Role::System, "client instructions"),
1654                Message::text(Role::User, "initial task"),
1655                tool_call("call-1"),
1656                tool_result("call-1"),
1657                Message::text(Role::Assistant, "recent 1"),
1658                Message::text(Role::User, "recent 2"),
1659                Message::text(Role::Assistant, "recent 3"),
1660                Message::text(Role::User, "recent 4"),
1661            ]
1662        );
1663    }
1664
1665    /// Ids repeat across a conversation, so a later call must not stand in for the one that
1666    /// answers an earlier result.
1667    #[test]
1668    fn trimming_pairs_a_repeated_id_with_the_call_that_precedes_it() {
1669        let messages = vec![
1670            Message::text(Role::System, "client instructions"),
1671            Message::text(Role::User, "initial task"),
1672            tool_call("x"),
1673            tool_result("x"),
1674            Message::text(Role::Assistant, "later"),
1675            tool_call("x"),
1676            tool_result("x"),
1677        ];
1678
1679        // The four-message tail begins on the first result, whose own call sits one earlier.
1680        let kept = trim_messages(&messages, 4);
1681
1682        assert_eq!(
1683            kept,
1684            vec![
1685                Message::text(Role::System, "client instructions"),
1686                Message::text(Role::User, "initial task"),
1687                tool_call("x"),
1688                tool_result("x"),
1689                Message::text(Role::Assistant, "later"),
1690                tool_call("x"),
1691                tool_result("x"),
1692            ]
1693        );
1694    }
1695
1696    /// A result whose call precedes the opening task can never be paired, because trimming
1697    /// never reaches behind the task. The window must not widen hunting for it.
1698    #[test]
1699    fn trimming_keeps_the_counted_window_when_a_result_cannot_be_paired() {
1700        let messages = vec![
1701            Message::text(Role::System, "client instructions"),
1702            tool_call("orphan"),
1703            Message::text(Role::User, "initial task"),
1704            Message::text(Role::Assistant, "old response"),
1705            tool_result("orphan"),
1706            Message::text(Role::Assistant, "recent 1"),
1707            Message::text(Role::User, "recent 2"),
1708        ];
1709
1710        let kept = trim_messages(&messages, 3);
1711
1712        assert_eq!(
1713            kept,
1714            vec![
1715                Message::text(Role::System, "client instructions"),
1716                Message::text(Role::User, "initial task"),
1717                tool_result("orphan"),
1718                Message::text(Role::Assistant, "recent 1"),
1719                Message::text(Role::User, "recent 2"),
1720            ]
1721        );
1722    }
1723
1724    #[test]
1725    fn capability_judge_builds_a_structured_request() -> Result<()> {
1726        let judge = capability_judge(None)?;
1727        let request = Request {
1728            llm_request: LlmRequest {
1729                model: Some("inbound".to_string()),
1730                messages: vec![
1731                    Message::text(Role::System, "client instructions"),
1732                    Message::text(Role::Developer, "client developer instructions"),
1733                    Message::text(Role::User, "initial task"),
1734                    Message::text(Role::Assistant, "old response"),
1735                    Message::text(Role::User, "old follow-up"),
1736                    Message::text(Role::Assistant, "recent 1"),
1737                    Message::text(Role::User, "recent 2"),
1738                    Message::text(Role::Assistant, "recent 3"),
1739                    Message::text(Role::User, "recent 4"),
1740                    Message::text(Role::Assistant, "recent 5"),
1741                ],
1742                ..LlmRequest::default()
1743            },
1744            raw_request: None,
1745            metadata: None,
1746        };
1747        let judge_request = judge.build_request(&State::default(), &request);
1748
1749        assert_eq!(judge_request.llm_request.model, request.llm_request.model);
1750        assert_eq!(judge_request.llm_request.instructions.len(), 1);
1751        assert_eq!(judge_request.llm_request.instructions[0].role, Role::System);
1752        assert_eq!(
1753            judge_request.llm_request.instructions[0].content,
1754            InstructionBlock {
1755                role: Role::System,
1756                content: Message::text(Role::System, judge.contract().system_prompt()).content,
1757            }
1758            .content,
1759        );
1760        assert_eq!(judge_request.llm_request.messages.len(), 2);
1761        let contents = judge_request
1762            .llm_request
1763            .messages
1764            .iter()
1765            .filter_map(|message| message.text_content("\n"))
1766            .collect::<Vec<_>>();
1767        assert!(contents.contains(&"recent 4".to_string()));
1768        assert!(contents.contains(&"initial task".to_string()));
1769        assert!(!contents.contains(&"recent 5".to_string()));
1770        assert!(!contents.contains(&"client instructions".to_string()));
1771        assert_eq!(
1772            judge_request.llm_request.output.response_format,
1773            Some(judge.contract().response_format().clone())
1774        );
1775        assert_eq!(
1776            judge_request.llm_request.output.max_output_tokens,
1777            Some(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
1778        );
1779        Ok(())
1780    }
1781
1782    fn sample_value(spec: &Value) -> Value {
1783        if let Some(first) = spec
1784            .get("enum")
1785            .and_then(Value::as_array)
1786            .and_then(|values| values.first())
1787        {
1788            return first.clone();
1789        }
1790        match spec.get("type").and_then(Value::as_str) {
1791            Some("number") => serde_json::json!(0.5),
1792            Some("boolean") => serde_json::json!(false),
1793            _ => serde_json::json!("sample"),
1794        }
1795    }
1796
1797    fn schema_shaped_verdict(schema: &Value) -> Result<String> {
1798        let properties = schema
1799            .pointer("/json_schema/schema/properties")
1800            .and_then(Value::as_object)
1801            .ok_or_else(|| LibsyError::AlgorithmError {
1802                message: "packaged schema declares no properties".to_string(),
1803            })?;
1804        Ok(Value::Object(
1805            properties
1806                .iter()
1807                .map(|(name, spec)| (name.clone(), sample_value(spec)))
1808                .collect(),
1809        )
1810        .to_string())
1811    }
1812
1813    /// Built from the schema so a property added there fails here rather than silently
1814    /// rejecting every production verdict.
1815    #[test]
1816    fn every_schema_property_round_trips_through_the_judge_parser() -> Result<()> {
1817        let contract =
1818            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1819        let schema = contract.response_format();
1820        let reply = schema_shaped_verdict(schema)?;
1821        let judge: CapabilityJudge = StructuredJudge::new(
1822            TaskInput {
1823                recent_turn_window: None,
1824            },
1825            contract,
1826            SerdeDecoder::new(),
1827            JudgeRuntimeConfig::new(DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)?,
1828        );
1829
1830        let verdict = judge.parse(&text_response(None, reply))?;
1831
1832        assert!(verdict.is_valid());
1833        assert!((0.0..=1.0).contains(&verdict.p_solve));
1834        Ok(())
1835    }
1836
1837    #[test]
1838    fn packaged_prompt_keeps_the_schema_in_the_structured_request() -> Result<()> {
1839        let contract =
1840            LlmTaskClassifier::load_capability_contract(&ClassifierContractConfig::default())?;
1841        let prompt = contract.system_prompt();
1842        let schema_name = contract
1843            .response_format()
1844            .pointer("/json_schema/name")
1845            .and_then(Value::as_str)
1846            .ok_or_else(|| LibsyError::AlgorithmError {
1847                message: "packaged response schema has no name".to_string(),
1848            })?;
1849        assert_eq!(schema_name, "CapabilityClassifierDecision");
1850        assert!(prompt.contains("SUP-1 [supported]"));
1851        assert!(prompt.contains("SUP-5 [supported]"));
1852        assert!(!prompt.contains("{{RESPONSE_SCHEMA}}"));
1853        assert!(!prompt.contains("\"type\": \"object\""));
1854        assert!(!prompt.contains("\"json_schema\""));
1855        assert!(!prompt.contains(schema_name));
1856        let rule_values = contract
1857            .response_format()
1858            .pointer("/json_schema/schema/properties/primary_rule/enum")
1859            .and_then(Value::as_array)
1860            .ok_or_else(|| LibsyError::AlgorithmError {
1861                message: "rendered response schema has no primary rule enum".to_string(),
1862            })?;
1863        assert!(
1864            rule_values
1865                .iter()
1866                .any(|value| value.as_str() == Some("SUP-1"))
1867        );
1868        assert!(
1869            rule_values
1870                .iter()
1871                .any(|value| value.as_str() == Some("none"))
1872        );
1873        Ok(())
1874    }
1875
1876    // ── with_escalation tests ──────────────────────────────────────────────
1877
1878    use std::collections::VecDeque;
1879
1880    use switchyard_protocol::LlmClientError as ClientError;
1881
1882    use switchyard_protocol::Decision;
1883
1884    /// Serves each call with the next queued reply.
1885    struct QueuedClient {
1886        replies: Mutex<VecDeque<String>>,
1887    }
1888
1889    impl QueuedClient {
1890        fn new(replies: impl IntoIterator<Item = &'static str>) -> Arc<Self> {
1891            Arc::new(Self {
1892                replies: Mutex::new(replies.into_iter().map(String::from).collect()),
1893            })
1894        }
1895    }
1896
1897    #[async_trait]
1898    impl RoutedLlmClient for QueuedClient {
1899        async fn call(
1900            &self,
1901            _ctx: Context,
1902            request: Request,
1903            _decision: Arc<dyn Decision>,
1904        ) -> std::result::Result<Response, ClientError> {
1905            let reply = self
1906                .replies
1907                .lock()
1908                .pop_front()
1909                .unwrap_or_else(|| "unexpected call".to_string());
1910            Ok(Response {
1911                llm_response: LlmResponse::Agg(text_response(None, reply)),
1912                metadata: request.metadata,
1913            })
1914        }
1915    }
1916
1917    /// Builds a router with escalation enabled (`confirmations=1` latches on the first verdict).
1918    fn escalation_router(
1919        client: Arc<dyn RoutedLlmClient>,
1920        judge_client: Arc<dyn RoutedLlmClient>,
1921    ) -> Result<Arc<LlmTaskClassifier>> {
1922        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
1923            semantic_name: name.to_string(),
1924            llm_client: Some(c),
1925        };
1926        Ok(Arc::new(LlmTaskClassifier::new(
1927            LlmClassifierConfig::Escalation {
1928                judge_target: target("judge", judge_client),
1929                efficient_target: target("efficient", client.clone()),
1930                capable_target: target("capable", client),
1931                contract: ClassifierContractConfig::default(),
1932                config: EscalationJudgeConfig {
1933                    confirmations: 1,
1934                    ..EscalationJudgeConfig::default()
1935                },
1936                max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1937            },
1938        )?))
1939    }
1940
1941    #[tokio::test]
1942    async fn escalation_router_serves_efficient_when_judge_declines() -> Result<()> {
1943        // Judge: no escalation. Expect the efficient response to be returned directly.
1944        let judge_client = QueuedClient::new([r#"{"escalate":false,"reason":"progressing"}"#]);
1945        let model_client = QueuedClient::new(["efficient answer"]);
1946        let router = escalation_router(model_client, judge_client)?;
1947        let request = classify_request();
1948
1949        let (trace, response) = router.run(Context::default(), request).await?;
1950
1951        // The efficient model is the serving target, and the response comes from its call.
1952        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
1953        assert_eq!(
1954            response.llm_response.as_agg().map(completion_text),
1955            Some("efficient answer".to_string())
1956        );
1957        Ok(())
1958    }
1959
1960    #[tokio::test]
1961    async fn escalation_config_overrides_the_packaged_prompt() -> Result<()> {
1962        let client = Arc::new(PerRequestClient::default());
1963        let target = |name: &str| LlmTarget {
1964            semantic_name: name.to_string(),
1965            llm_client: Some(client.clone()),
1966        };
1967        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
1968            judge_target: target("judge"),
1969            efficient_target: target("efficient"),
1970            capable_target: target("capable"),
1971            contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
1972            config: EscalationJudgeConfig {
1973                confirmations: 1,
1974                ..EscalationJudgeConfig::default()
1975            },
1976            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
1977        })?);
1978
1979        router.run(Context::default(), classify_request()).await?;
1980
1981        let prompts = client.judge_system_prompts();
1982        assert_eq!(prompts.len(), 1);
1983        assert_eq!(prompts[0], "Custom trajectory rubric.");
1984        Ok(())
1985    }
1986
1987    #[tokio::test]
1988    async fn escalation_router_upgrades_to_capable_when_judge_escalates() -> Result<()> {
1989        // Judge: escalate. After the efficient call, the streak confirms and capable is served.
1990        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck in a loop"}"#]);
1991        // Efficient is called first (by the classifier), then capable is called by FallThrough.
1992        let model_client = QueuedClient::new(["efficient draft", "capable answer"]);
1993        let router = escalation_router(model_client, judge_client)?;
1994        let request = classify_request();
1995
1996        let (trace, response) = router.run(Context::default(), request).await?;
1997
1998        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
1999        assert_eq!(
2000            response.llm_response.as_agg().map(completion_text),
2001            Some("capable answer".to_string())
2002        );
2003        Ok(())
2004    }
2005
2006    #[tokio::test]
2007    async fn escalation_router_stays_capable_after_latch() -> Result<()> {
2008        // First turn: judge escalates and the streak latches.
2009        // Second turn: judge is not called again; capable is served directly.
2010        let judge_client = QueuedClient::new([r#"{"escalate":true,"reason":"stuck"}"#]);
2011        let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
2012        let router = escalation_router(model_client, judge_client)?;
2013
2014        let session_request = classify_session_request();
2015        router
2016            .clone()
2017            .run(Context::default(), session_request.clone())
2018            .await?;
2019        let (trace, _) = router
2020            .clone()
2021            .run(Context::default(), session_request)
2022            .await?;
2023
2024        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2025        Ok(())
2026    }
2027
2028    /// Builds an escalation router that latches after `confirmations` escalate verdicts and
2029    /// de-latches after `recovery_confirmations` clear verdicts.
2030    fn escalation_router_with_recovery(
2031        client: Arc<dyn RoutedLlmClient>,
2032        judge_client: Arc<dyn RoutedLlmClient>,
2033        confirmations: u32,
2034        recovery_confirmations: u32,
2035    ) -> Result<Arc<LlmTaskClassifier>> {
2036        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
2037            semantic_name: name.to_string(),
2038            llm_client: Some(c),
2039        };
2040        Ok(Arc::new(LlmTaskClassifier::new(
2041            LlmClassifierConfig::Escalation {
2042                judge_target: target("judge", judge_client),
2043                efficient_target: target("efficient", client.clone()),
2044                capable_target: target("capable", client),
2045                contract: ClassifierContractConfig::default(),
2046                config: EscalationJudgeConfig {
2047                    confirmations,
2048                    recovery_confirmations,
2049                    ..EscalationJudgeConfig::default()
2050                },
2051                max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
2052            },
2053        )?))
2054    }
2055
2056    #[tokio::test]
2057    async fn escalation_router_delatches_after_recovery_streak() -> Result<()> {
2058        // Turn 1: judge escalates and the session latches.
2059        // Turn 2: the latched-turn judge rules clear, the recovery streak confirms, and the
2060        // turn falls through to the efficient-first path (whose own judge also rules clear),
2061        // so efficient is served again.
2062        let judge_client = QueuedClient::new([
2063            r#"{"escalate":true,"reason":"stuck"}"#,
2064            r#"{"escalate":false,"reason":"blocker resolved"}"#,
2065            r#"{"escalate":false,"reason":"progressing"}"#,
2066        ]);
2067        let model_client = QueuedClient::new(["efficient draft", "capable t1", "efficient t2"]);
2068        let router = escalation_router_with_recovery(model_client, judge_client, 1, 1)?;
2069
2070        let session_request = classify_session_request();
2071        router
2072            .clone()
2073            .run(Context::default(), session_request.clone())
2074            .await?;
2075        let (trace, response) = router.run(Context::default(), session_request).await?;
2076
2077        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
2078        assert_eq!(
2079            response.llm_response.as_agg().map(completion_text),
2080            Some("efficient t2".to_string())
2081        );
2082        Ok(())
2083    }
2084
2085    #[tokio::test]
2086    async fn escalation_router_holds_latch_until_recovery_confirms() -> Result<()> {
2087        // With recovery_confirmations=2, one clear verdict while latched is not enough:
2088        // the session stays capable and the streak carries to the next turn.
2089        let judge_client = QueuedClient::new([
2090            r#"{"escalate":true,"reason":"stuck"}"#,
2091            r#"{"escalate":false,"reason":"looks better"}"#,
2092        ]);
2093        let model_client = QueuedClient::new(["efficient draft", "capable t1", "capable t2"]);
2094        let router = escalation_router_with_recovery(model_client, judge_client, 1, 2)?;
2095
2096        let session_request = classify_session_request();
2097        router
2098            .clone()
2099            .run(Context::default(), session_request.clone())
2100            .await?;
2101        let (trace, _) = router.run(Context::default(), session_request).await?;
2102
2103        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2104        Ok(())
2105    }
2106
2107    #[tokio::test]
2108    async fn escalation_router_probation_relatches_on_one_verdict_then_latch_is_permanent()
2109    -> Result<()> {
2110        // Turn 1: trouble judge escalates; streak 1 of 2, efficient still serves.
2111        // Turn 2: trouble judge escalates; streak confirms, capable serves (first latch).
2112        // Turn 3: hand-back judge clears, the session de-latches, and the fall-through
2113        // trouble judge escalates — probation confirms on that single verdict, so capable
2114        // serves again (second latch) instead of restarting the two-verdict streak.
2115        // Turn 4: the second latch is permanent: no judge runs, capable serves. A clear
2116        // verdict is queued as a tripwire — consuming it would de-latch and serve efficient.
2117        let judge_client = QueuedClient::new([
2118            r#"{"escalate":true,"reason":"stuck"}"#,
2119            r#"{"escalate":true,"reason":"still stuck"}"#,
2120            r#"{"escalate":false,"reason":"remaining work is routine"}"#,
2121            r#"{"escalate":true,"reason":"weak model thrashing again"}"#,
2122            r#"{"escalate":false,"reason":"tripwire: must never be consumed"}"#,
2123        ]);
2124        let model_client = QueuedClient::new([
2125            "efficient t1",
2126            "efficient d2",
2127            "capable t2",
2128            "efficient d3",
2129            "capable t3",
2130            "capable t4",
2131        ]);
2132        let router = escalation_router_with_recovery(model_client, judge_client, 2, 1)?;
2133
2134        let session_request = classify_session_request();
2135        let (trace, _) = router
2136            .clone()
2137            .run(Context::default(), session_request.clone())
2138            .await?;
2139        assert_eq!(trace.last().map(|d| d.selected_model()), Some("efficient"));
2140        let (trace, _) = router
2141            .clone()
2142            .run(Context::default(), session_request.clone())
2143            .await?;
2144        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2145        let (trace, response) = router
2146            .clone()
2147            .run(Context::default(), session_request.clone())
2148            .await?;
2149        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2150        assert_eq!(
2151            response.llm_response.as_agg().map(completion_text),
2152            Some("capable t3".to_string())
2153        );
2154        let (trace, response) = router.run(Context::default(), session_request).await?;
2155        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2156        assert_eq!(
2157            response.llm_response.as_agg().map(completion_text),
2158            Some("capable t4".to_string())
2159        );
2160        Ok(())
2161    }
2162
2163    #[tokio::test]
2164    async fn escalation_relatches_permanently_when_efficient_overflows_after_hand_back()
2165    -> Result<()> {
2166        // Turn 1: trouble judge escalates; capable serves (first latch).
2167        // Turn 2: hand-back judge clears and the session de-latches, but the efficient call
2168        // overflows its context window — the turn falls through to capable and the session
2169        // re-latches permanently, because a conversation the efficient tier cannot even read
2170        // will overflow on every future turn and this arm never reaches the judge.
2171        // Turn 3: capable serves with no judge call; a clear verdict is queued as a tripwire.
2172        struct OverflowMarkerClient {
2173            replies: Mutex<VecDeque<String>>,
2174        }
2175        #[async_trait]
2176        impl RoutedLlmClient for OverflowMarkerClient {
2177            async fn call(
2178                &self,
2179                _ctx: Context,
2180                request: Request,
2181                decision: Arc<dyn Decision>,
2182            ) -> std::result::Result<Response, ClientError> {
2183                let reply = self.replies.lock().pop_front().expect("queued reply");
2184                if reply == "OVERFLOW" {
2185                    return Err(ClientError::ContextWindowExceeded {
2186                        model: decision.selected_model().to_string(),
2187                        message: "prompt is too long".to_string(),
2188                    });
2189                }
2190                Ok(Response {
2191                    llm_response: LlmResponse::Agg(text_response(None, reply)),
2192                    metadata: request.metadata,
2193                })
2194            }
2195        }
2196        let model_client = Arc::new(OverflowMarkerClient {
2197            replies: Mutex::new(
2198                [
2199                    "efficient d1",
2200                    "capable t1",
2201                    "OVERFLOW",
2202                    "capable t2",
2203                    "capable t3",
2204                ]
2205                .into_iter()
2206                .map(String::from)
2207                .collect(),
2208            ),
2209        });
2210        let judge_client = QueuedClient::new([
2211            r#"{"escalate":true,"reason":"stuck"}"#,
2212            r#"{"escalate":false,"reason":"remaining work is routine"}"#,
2213            r#"{"escalate":false,"reason":"tripwire: must never be consumed"}"#,
2214        ]);
2215        let router = escalation_router_with_recovery(model_client, judge_client, 1, 1)?;
2216
2217        let session_request = classify_session_request();
2218        let (trace, _) = router
2219            .clone()
2220            .run(Context::default(), session_request.clone())
2221            .await?;
2222        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2223        let (trace, response) = router
2224            .clone()
2225            .run(Context::default(), session_request.clone())
2226            .await?;
2227        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2228        assert_eq!(
2229            response.llm_response.as_agg().map(completion_text),
2230            Some("capable t2".to_string())
2231        );
2232        let (trace, response) = router.run(Context::default(), session_request).await?;
2233        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2234        assert_eq!(
2235            response.llm_response.as_agg().map(completion_text),
2236            Some("capable t3".to_string())
2237        );
2238        Ok(())
2239    }
2240
2241    #[tokio::test]
2242    async fn escalation_recovery_judge_uses_the_packaged_recovery_prompt() -> Result<()> {
2243        // The route-level prompt override replaces the trouble rubric only; the hand-back
2244        // judge consulted on latched turns must keep the packaged recovery prompt, which
2245        // asks about the remaining work rather than trouble in the recent turns.
2246        struct RecordingJudge {
2247            replies: Mutex<VecDeque<String>>,
2248            prompts: Mutex<Vec<String>>,
2249        }
2250        #[async_trait]
2251        impl RoutedLlmClient for RecordingJudge {
2252            async fn call(
2253                &self,
2254                _ctx: Context,
2255                request: Request,
2256                _decision: Arc<dyn Decision>,
2257            ) -> std::result::Result<Response, ClientError> {
2258                self.prompts
2259                    .lock()
2260                    .extend(
2261                        request
2262                            .llm_request
2263                            .instructions
2264                            .first()
2265                            .and_then(|instruction| {
2266                                instruction.content.iter().find_map(|b| {
2267                                    if let ContentBlock::Text { text } = b {
2268                                        Some(text.clone())
2269                                    } else {
2270                                        None
2271                                    }
2272                                })
2273                            }),
2274                    );
2275                let reply = self.replies.lock().pop_front().expect("queued verdict");
2276                Ok(Response {
2277                    llm_response: LlmResponse::Agg(text_response(None, reply)),
2278                    metadata: request.metadata,
2279                })
2280            }
2281        }
2282        let judge_client = Arc::new(RecordingJudge {
2283            replies: Mutex::new(
2284                [
2285                    r#"{"escalate":true,"reason":"stuck"}"#,
2286                    r#"{"escalate":false,"reason":"remaining work is routine"}"#,
2287                    r#"{"escalate":false,"reason":"progressing"}"#,
2288                ]
2289                .into_iter()
2290                .map(String::from)
2291                .collect(),
2292            ),
2293            prompts: Mutex::new(Vec::new()),
2294        });
2295        let model_client = QueuedClient::new(["efficient d1", "capable t1", "efficient t2"]);
2296        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
2297            semantic_name: name.to_string(),
2298            llm_client: Some(c),
2299        };
2300        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
2301            judge_target: target("judge", judge_client.clone()),
2302            efficient_target: target("efficient", model_client.clone()),
2303            capable_target: target("capable", model_client),
2304            contract: ClassifierContractConfig::default().with_prompt("Custom trajectory rubric."),
2305            config: EscalationJudgeConfig {
2306                confirmations: 1,
2307                recovery_confirmations: 1,
2308                ..EscalationJudgeConfig::default()
2309            },
2310            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
2311        })?);
2312
2313        let session_request = classify_session_request();
2314        router
2315            .clone()
2316            .run(Context::default(), session_request.clone())
2317            .await?;
2318        router.run(Context::default(), session_request).await?;
2319
2320        let prompts = judge_client.prompts.lock().clone();
2321        assert_eq!(prompts.len(), 3);
2322        assert_eq!(prompts[0], "Custom trajectory rubric.");
2323        assert!(prompts[1].contains("hand-back judge"));
2324        assert_eq!(prompts[2], "Custom trajectory rubric.");
2325        Ok(())
2326    }
2327
2328    #[tokio::test]
2329    async fn escalation_classifier_falls_back_to_capable_when_efficient_overflows() -> Result<()> {
2330        // When the efficient model exceeds its context window inside score(), the classifier
2331        // must return capable rather than propagating the error — otherwise the client sees
2332        // HTTP 400 instead of a response from the strong model.
2333        struct OverflowClient;
2334        #[async_trait]
2335        impl RoutedLlmClient for OverflowClient {
2336            async fn call(
2337                &self,
2338                _ctx: Context,
2339                _request: Request,
2340                decision: Arc<dyn Decision>,
2341            ) -> std::result::Result<Response, LlmClientError> {
2342                Err(LlmClientError::ContextWindowExceeded {
2343                    model: decision.selected_model().to_string(),
2344                    message: "prompt is too long".to_string(),
2345                })
2346            }
2347        }
2348
2349        let target = |name: &str, c: Arc<dyn RoutedLlmClient>| LlmTarget {
2350            semantic_name: name.to_string(),
2351            llm_client: Some(c),
2352        };
2353        let router = Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Escalation {
2354            judge_target: target("judge", QueuedClient::new([])), // must not be called
2355            efficient_target: target("efficient", Arc::new(OverflowClient)),
2356            capable_target: target("capable", QueuedClient::new(["capable answer"])),
2357            contract: ClassifierContractConfig::default(),
2358            config: EscalationJudgeConfig {
2359                confirmations: 1,
2360                ..EscalationJudgeConfig::default()
2361            },
2362            max_output_tokens: DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
2363        })?);
2364
2365        let (trace, response) = router.run(Context::default(), classify_request()).await?;
2366
2367        assert_eq!(trace.last().map(|d| d.selected_model()), Some("capable"));
2368        assert_eq!(
2369            response.llm_response.as_agg().map(completion_text),
2370            Some("capable answer".to_string())
2371        );
2372        Ok(())
2373    }
2374}