Skip to main content

switchyard_libsy/algorithms/
composite.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Routing that stacks a judge over a stage router.
5//!
6//! The judge runs as a [`Processor`]: it sets configuration the stage router reads,
7//! and picks no target itself.
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use parking_lot::Mutex;
14
15use super::fall_through::FallThrough;
16use super::llm_class::{
17    CustomClassifierConfig, LlmClassifierConfig, LlmTaskClassifier, TaskClassifierConfig,
18};
19use super::stage::{StageRouterConfig, build_stage_route};
20use super::util::affinity::{ClassifyTrigger, evict_if_full, has_new_user_turn, retention_key};
21use super::util::stage::{PickerMode, Tier, fall_open_tier, set_fall_open};
22use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity};
23use crate::core::classifier::{Classification, Classifier};
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26use crate::{LibsyError, Result};
27use switchyard_protocol::{Category, Request, Response};
28
29const COMPOSITE: &str = "composite";
30
31/// Sets the stage router's fall-open tier from a judge verdict.
32///
33/// Retains the tier per routing identity, so it survives requests that carry no
34/// session ID when `message_hash_fallback` is on. The retained tier is replayed
35/// into state on every request so the cascade below reads it.
36struct TierSetter {
37    judge: Arc<dyn Classifier>,
38    trigger: ClassifyTrigger,
39    message_hash_fallback: bool,
40    default_tier: Tier,
41    tiers: Mutex<HashMap<RoutingIdentity, Tier>>,
42}
43
44impl TierSetter {
45    fn tier_for(category: Option<Category>) -> Option<Tier> {
46        match category {
47            Some(Category::Capable) => Some(Tier::Capable),
48            Some(Category::Efficient) => Some(Tier::Efficient),
49            _ => None,
50        }
51    }
52
53    /// Two requests for one identity can both pass this and both judge, since a
54    /// judge call sits between here and [`retain`](Self::retain). The later wins.
55    fn is_due(&self, identity: Option<&RoutingIdentity>, state: &State, request: &Request) -> bool {
56        match self.trigger {
57            ClassifyTrigger::UserTurn => {
58                !request.llm_request.is_compaction
59                    && has_new_user_turn(&request.llm_request.messages)
60            }
61            // Unkeyed requests with no carried tier cannot be told apart, so every one
62            // is a new session.
63            ClassifyTrigger::NewSession => {
64                identity.is_none_or(|identity| !self.tiers.lock().contains_key(identity))
65                    && fall_open_tier(state).is_none()
66            }
67            // Rejected by the constructor, and only in the enum for the standalone route.
68            ClassifyTrigger::EveryRequest => true,
69        }
70    }
71
72    fn retain(&self, identity: RoutingIdentity, tier: Tier) {
73        let mut tiers = self.tiers.lock();
74        // A user turn re-decides, so it overwrites. A session keeps its first verdict,
75        // matching how affinity retains an assignment.
76        let writable = self.trigger == ClassifyTrigger::UserTurn || !tiers.contains_key(&identity);
77        if writable {
78            evict_if_full(&mut tiers);
79            tiers.insert(identity, tier);
80        }
81    }
82}
83
84#[async_trait]
85impl Processor for TierSetter {
86    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
87        let Event::Request { request, driver } = event else {
88            return Ok(());
89        };
90        let identity = retention_key(request, self.message_hash_fallback);
91        if self.is_due(identity.as_ref(), state, request) {
92            let (classification, _) = self.judge.score(state, request, driver).await?;
93            if let Some(winner) = classification.argmax(false)?
94                && let Some(tier) = Self::tier_for(winner.category)
95            {
96                set_fall_open(state, tier);
97                if let Some(identity) = identity {
98                    self.retain(identity, tier);
99                }
100                return Ok(());
101            }
102            if self.trigger == ClassifyTrigger::NewSession
103                && let Some(identity) = identity.as_ref()
104            {
105                // A failed opening judgment is still the session's one judgment.
106                self.retain(identity.clone(), self.default_tier);
107            }
108        }
109        // Either not this request's turn or the judge had no verdict, so the last
110        // tier stands rather than dropping back to the picker default.
111        if let Some(tier) = identity
112            .and_then(|identity| self.tiers.lock().get(&identity).copied())
113            .or_else(|| fall_open_tier(state))
114        {
115            set_fall_open(state, tier);
116            driver.set_evidence_if_empty(serde_json::json!({"source": "retained"}));
117        }
118        Ok(())
119    }
120}
121
122/// Classifier settings for the composite router's retained tier.
123pub enum CompositeJudgeConfig {
124    /// Selects a tier from a capability verdict.
125    Capability(TaskClassifierConfig),
126    /// Selects a tier using a custom schema and policy, retaining the fallback on failure.
127    Custom(CustomClassifierConfig),
128}
129
130/// A judge stacked over a stage router.
131pub struct CompositeRouterConfig {
132    /// Judge settings, including how often `classify_trigger` runs it.
133    pub judge: CompositeJudgeConfig,
134    /// Serves the turns, with the tier the judge picked as its fall-open default.
135    pub stage: StageRouterConfig,
136}
137
138/// Runs a stage router with a tier the judge picks.
139pub struct CompositeRouter {
140    route: FallThrough,
141}
142
143impl CompositeRouter {
144    /// Stacks the judge over a stage router across the same tier pair.
145    ///
146    /// Errors on a configuration either algorithm rejects and on `every_request`.
147    ///
148    /// A stage router carrying its own judge is allowed, but that judge sits ahead
149    /// of the fall-open tier and so answers most of the turns this one set a tier for.
150    pub fn new(config: CompositeRouterConfig) -> Result<Self> {
151        let setter = Self::tier_setter(config.judge, config.stage.mode.default_tier())?;
152        let route = build_stage_route(config.stage, setter.trigger == ClassifyTrigger::UserTurn)?
153            .with_name(COMPOSITE)
154            .with_processor(Arc::new(setter));
155        Ok(Self { route })
156    }
157
158    /// Builds two independent stage profiles selected by the judge.
159    pub fn new_with_profiles(
160        judge: CompositeJudgeConfig,
161        mut profile_0: StageRouterConfig,
162        mut profile_1: StageRouterConfig,
163    ) -> Result<Self> {
164        for profile in [&mut profile_0, &mut profile_1] {
165            if profile.mode == PickerMode::CapableFirst {
166                profile.efficient_hold_turns.get_or_insert(-1);
167            }
168        }
169        let setter = Self::tier_setter(judge, Tier::Efficient)?;
170        let reset_on_user_turn = setter.trigger == ClassifyTrigger::UserTurn;
171        let profiles = StageProfiles([
172            build_stage_route(profile_0, reset_on_user_turn)?,
173            build_stage_route(profile_1, reset_on_user_turn)?,
174        ]);
175        let route = FallThrough::new_with_state()
176            .with_name(COMPOSITE)
177            .with_processor(Arc::new(setter))
178            .with_classifier(Arc::new(profiles));
179        Ok(Self { route })
180    }
181
182    fn tier_setter(judge: CompositeJudgeConfig, default_tier: Tier) -> Result<TierSetter> {
183        let (trigger, message_hash_fallback) = match &judge {
184            CompositeJudgeConfig::Capability(judge) => {
185                (judge.classify_trigger, judge.message_hash_fallback)
186            }
187            CompositeJudgeConfig::Custom(config) => {
188                (config.classify_trigger, config.message_hash_fallback)
189            }
190        };
191        if trigger == ClassifyTrigger::EveryRequest {
192            return Err(LibsyError::AlgorithmError {
193                message: "composite: classify_trigger must be user_turn or new_session".to_string(),
194            });
195        }
196        let judge: Arc<dyn Classifier> = match judge {
197            CompositeJudgeConfig::Capability(judge) => {
198                // Composite owns the trigger and retained verdict.
199                Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
200                    config: TaskClassifierConfig {
201                        classify_trigger: ClassifyTrigger::EveryRequest,
202                        message_hash_fallback: false,
203                        ..judge
204                    },
205                })?)
206            }
207            CompositeJudgeConfig::Custom(config) => {
208                LlmTaskClassifier::custom_classifier(&config, true)?
209            }
210        };
211        Ok(TierSetter {
212            judge,
213            trigger,
214            message_hash_fallback,
215            default_tier,
216            tiers: Mutex::new(HashMap::new()),
217        })
218    }
219}
220
221struct StageProfiles([FallThrough; 2]);
222
223#[async_trait]
224impl Classifier for StageProfiles {
225    async fn score(
226        &self,
227        state: &mut State,
228        request: &mut Request,
229        driver: &Driver,
230    ) -> Result<(Classification, Option<Response>)> {
231        let (profile, prefix) = match fall_open_tier(state) {
232            Some(Tier::Capable) => (&self.0[1], "composite.profile_1."),
233            _ => (&self.0[0], "composite.profile_0."),
234        };
235        // Each profile retains its own holds, including in caller-carried state.
236        let mut profile_state = State {
237            extra: state
238                .extra
239                .iter()
240                .filter_map(|(key, value)| {
241                    key.strip_prefix(prefix)
242                        .map(|key| (key.to_string(), value.clone()))
243                })
244                .collect(),
245            ..State::default()
246        };
247        let result = profile.route(&mut profile_state, driver, request).await;
248        state.extra.retain(|key, _| !key.starts_with(prefix));
249        state.extra.extend(
250            profile_state
251                .extra
252                .into_iter()
253                .map(|(key, value)| (format!("{prefix}{key}"), value)),
254        );
255        state.tool_signals = profile_state.tool_signals;
256        state.target_prompts = profile_state.target_prompts;
257        let (score, response) = result?;
258        Ok((Classification::Scores(vec![score]), response))
259    }
260}
261
262#[async_trait]
263impl Algorithm for CompositeRouter {
264    fn name(&self) -> &str {
265        COMPOSITE
266    }
267
268    async fn route(
269        self: Arc<Self>,
270        driver: Driver,
271        request: Request,
272    ) -> Result<crate::RoutingOutcome> {
273        self.route.execute(driver, request).await
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use crate::{CapabilityJudgeConfig, LlmCapabilityConfig};
280    use std::collections::HashMap;
281    use std::sync::Arc;
282
283    use switchyard_protocol::{Category, LlmClientError, Message, ModelId, Role};
284
285    use super::*;
286    use crate::algorithms::util::tier_fixtures::{JUDGE, Recorder, turn_request};
287    use crate::core::testing::test_drive_with_models;
288
289    fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
290        [
291            (Category::Judge, vec![ModelId::from(JUDGE)]),
292            (Category::Efficient, vec![ModelId::from("weak")]),
293            (Category::Capable, vec![ModelId::from("strong")]),
294            (
295                Category::Any,
296                vec![ModelId::from("strong"), ModelId::from("weak")],
297            ),
298        ]
299        .into()
300    }
301
302    fn user_turn_request() -> Request {
303        let mut request = turn_request(false);
304        request
305            .llm_request
306            .messages
307            .push(Message::text(Role::User, "now rewrite the parser"));
308        request
309    }
310
311    /// The same request shape with no session ID, so only the hash can key it.
312    fn unkeyed(mut request: Request) -> Request {
313        if let Some(metadata) = request.metadata.as_mut() {
314            metadata.session_id = None;
315        }
316        request
317    }
318
319    fn hash_keyed_router() -> Result<Arc<CompositeRouter>> {
320        Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
321            judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
322                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
323                    base_threshold: 0.5,
324                    ..LlmCapabilityConfig::default()
325                }),
326                classify_trigger: ClassifyTrigger::UserTurn,
327                message_hash_fallback: true,
328                ..Default::default()
329            }),
330            stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
331        })?))
332    }
333
334    fn router() -> Result<Arc<CompositeRouter>> {
335        router_with_trigger(ClassifyTrigger::UserTurn)
336    }
337
338    #[tokio::test]
339    async fn compaction_retains_the_profile_until_a_real_user_turn() -> Result<()> {
340        let router = router()?;
341        let recorder = Arc::new(Recorder::default());
342        let mut compacted = user_turn_request();
343        compacted.llm_request.is_compaction = true;
344        for (request, verdict, calls, target) in [
345            (user_turn_request(), 1.0, 1, "weak"),
346            (compacted, 0.0, 1, "weak"),
347            (user_turn_request(), 0.0, 2, "strong"),
348        ] {
349            *recorder.judge_p_solve.lock() = verdict;
350            let (model, _) =
351                test_drive_with_models(router.clone(), request, runtime_models(), recorder.serve())
352                    .await?;
353            assert_eq!(recorder.judge_calls(), calls);
354            assert_eq!(model.as_str(), target);
355        }
356        Ok(())
357    }
358
359    #[tokio::test]
360    async fn capable_first_defaults_to_pinning_efficient_after_a_switch() -> Result<()> {
361        for capable_first in [false, true] {
362            for turns in [None, Some(0), Some(1), Some(-1)] {
363                let mut stage = StageRouterConfig::new(PickerMode::EfficientFirst, 0.5);
364                stage.efficient_hold_turns = turns;
365                let router = router_with_stage(ClassifyTrigger::UserTurn, stage)?;
366                let recorder = Arc::new(Recorder::default());
367                *recorder.judge_p_solve.lock() = if capable_first { 0.0 } else { 1.0 };
368                let mut initial = turn_request(false);
369                initial.llm_request.messages.truncate(1);
370                let mut write = turn_request(false);
371                if let switchyard_protocol::ContentBlock::ToolCall(call) =
372                    &mut write.llm_request.messages[1].content[0]
373                {
374                    call.name = "Write".into();
375                }
376                for request in [
377                    initial,
378                    turn_request(true),
379                    write.clone(),
380                    write.clone(),
381                    write,
382                    turn_request(true),
383                    turn_request(true),
384                    turn_request(true),
385                ] {
386                    test_drive_with_models(
387                        router.clone(),
388                        request,
389                        runtime_models(),
390                        recorder.serve(),
391                    )
392                    .await?;
393                }
394                let actual: Vec<_> = recorder
395                    .routed()
396                    .into_iter()
397                    .map(|call| call.target)
398                    .collect();
399                let initial = if capable_first { "strong" } else { "weak" };
400                let mut expected = vec![initial, "strong", "strong", "strong", "weak"];
401                let count = turns.unwrap_or(if capable_first { -1 } else { 0 });
402                expected.extend((0..3).map(|i| {
403                    if count == -1 || i < count {
404                        "weak"
405                    } else {
406                        "strong"
407                    }
408                }));
409                assert_eq!(
410                    actual, expected,
411                    "capable_first={capable_first}, turns={turns:?}"
412                );
413                assert_eq!(recorder.judge_calls(), 1);
414            }
415        }
416        Ok(())
417    }
418
419    fn router_with_trigger(classify_trigger: ClassifyTrigger) -> Result<Arc<CompositeRouter>> {
420        router_with_stage(
421            classify_trigger,
422            StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
423        )
424    }
425
426    fn router_with_stage(
427        classify_trigger: ClassifyTrigger,
428        stage: StageRouterConfig,
429    ) -> Result<Arc<CompositeRouter>> {
430        Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
431            judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
432                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
433                    base_threshold: 0.5,
434                    ..LlmCapabilityConfig::default()
435                }),
436                classify_trigger,
437                ..Default::default()
438            }),
439            stage,
440        })?))
441    }
442
443    #[tokio::test]
444    async fn profiles_reset_holds_and_keep_prompts_when_the_judge_switches() -> Result<()> {
445        for (has_session_id, picker, first_target, first_prompt) in [
446            (false, PickerMode::EfficientFirst, "weak", "EF efficient"),
447            (true, PickerMode::EfficientFirst, "weak", "EF efficient"),
448            (true, PickerMode::CapableFirst, "strong", "EF capable"),
449        ] {
450            let mut efficient = StageRouterConfig::new(picker, 0.5);
451            efficient.capable_hold_turns = -1;
452            efficient.capable_system_prompt = Some("EF capable".into());
453            efficient.efficient_system_prompt = Some("EF efficient".into());
454            let mut capable = StageRouterConfig::new(PickerMode::CapableFirst, 0.0);
455            capable.capable_hold_turns = 0;
456            capable.capable_system_prompt = Some("CF capable".into());
457            capable.efficient_system_prompt = Some("CF efficient".into());
458            for stage in [&mut efficient, &mut capable] {
459                stage.llm_fallback = Some(crate::algorithms::stage::LlmFallback {
460                    target: None,
461                    config: Default::default(),
462                });
463            }
464            let router = Arc::new(CompositeRouter::new_with_profiles(
465                CompositeJudgeConfig::Capability(TaskClassifierConfig {
466                    judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
467                        base_threshold: 0.5,
468                        ..Default::default()
469                    }),
470                    classify_trigger: ClassifyTrigger::UserTurn,
471                    ..Default::default()
472                }),
473                efficient,
474                capable,
475            )?);
476            let recorder = Arc::new(Recorder::default());
477            let mut write = turn_request(false);
478            if let switchyard_protocol::ContentBlock::ToolCall(call) =
479                &mut write.llm_request.messages[1].content[0]
480            {
481                call.name = "Write".into();
482            }
483            let mut context_update = write.clone();
484            context_update.llm_request.messages.push(Message::text(
485                Role::User,
486                "<environment_context><current_date>2026-10-31</current_date></environment_context>",
487            ));
488            let mut state = State::default();
489            for (p_solve, request, target, prompt) in [
490                (1.0, user_turn_request(), first_target, first_prompt),
491                (1.0, turn_request(true), "strong", "EF capable"),
492                (0.0, user_turn_request(), "strong", "CF capable"),
493                (0.0, write, "weak", "CF efficient"),
494                (1.0, context_update, "weak", "CF efficient"),
495                (1.0, user_turn_request(), first_target, first_prompt),
496                (0.0, user_turn_request(), "strong", "CF capable"),
497            ] {
498                *recorder.judge_p_solve.lock() = p_solve;
499                let request = if has_session_id {
500                    request
501                } else {
502                    unkeyed(request)
503                };
504                let outcome = crate::core::testing::test_drive_with_state(
505                    router.clone(),
506                    request,
507                    runtime_models(),
508                    state,
509                    recorder.serve(),
510                )
511                .await?;
512                assert_eq!(outcome.selected_model_id()?.as_str(), target);
513                assert_eq!(outcome.request.llm_request.instructions.len(), 1);
514                assert_eq!(
515                    outcome.request.llm_request.instructions[0].content,
516                    vec![switchyard_protocol::ContentBlock::Text {
517                        text: prompt.into()
518                    }]
519                );
520                state = outcome.state;
521            }
522            assert_eq!(recorder.judge_calls(), 4);
523        }
524        Ok(())
525    }
526
527    #[test]
528    fn rejects_every_request_as_a_trigger() {
529        for judge in [
530            CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
531            CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
532                "Choose a tier",
533                serde_json::json!({"type": "object"}),
534                crate::CustomClassifierPolicy::target_selector("/decision/target"),
535            )),
536        ] {
537            let error = CompositeRouter::new(CompositeRouterConfig {
538                judge,
539                stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
540            })
541            .err()
542            .expect("every_request must be rejected for both classifier modes");
543            assert!(matches!(error, LibsyError::AlgorithmError { message }
544                if message == "composite: classify_trigger must be user_turn or new_session"));
545        }
546    }
547
548    fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
549        let mut config = CustomClassifierConfig::new(
550            "Select a tier",
551            serde_json::json!({"type": "object"}),
552            crate::CustomClassifierPolicy::target_selector("/decision/target"),
553        );
554        config.classify_trigger = trigger;
555        CompositeJudgeConfig::Custom(config)
556    }
557
558    #[tokio::test]
559    async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
560        for custom in [false, true] {
561            for (mode, expected) in [
562                (PickerMode::EfficientFirst, "weak"),
563                (PickerMode::CapableFirst, "strong"),
564            ] {
565                let judge = if custom {
566                    custom_judge(ClassifyTrigger::NewSession)
567                } else {
568                    CompositeJudgeConfig::Capability(TaskClassifierConfig {
569                        classify_trigger: ClassifyTrigger::NewSession,
570                        ..Default::default()
571                    })
572                };
573                let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
574                    judge,
575                    stage: StageRouterConfig::new(mode, 0.5),
576                })?);
577                let calls = Arc::new(Mutex::new(0));
578                let serve = {
579                    let calls = calls.clone();
580                    move |target: ModelId, _request: Request| {
581                        let completion = if target.as_str() == JUDGE {
582                            *calls.lock() += 1;
583                            "not JSON".to_string()
584                        } else {
585                            target.to_string()
586                        };
587                        async move { Ok(crate::core::testing::reply(completion)) }
588                    }
589                };
590                for request in [
591                    user_turn_request(),
592                    turn_request(false),
593                    user_turn_request(),
594                ] {
595                    let (selected, _) = test_drive_with_models(
596                        router.clone(),
597                        request,
598                        runtime_models(),
599                        serve.clone(),
600                    )
601                    .await?;
602                    assert_eq!(selected.as_str(), expected);
603                }
604                assert_eq!(
605                    *calls.lock(),
606                    1,
607                    "new_session must retain a failed judgment"
608                );
609            }
610        }
611        Ok(())
612    }
613
614    #[tokio::test]
615    async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
616        let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
617            judge: custom_judge(ClassifyTrigger::UserTurn),
618            stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
619        })?);
620        let replies = Arc::new(Mutex::new(
621            [
622                Some(r#"{"decision":{"target":"capable"}}"#),
623                None,
624                Some("not JSON"),
625                Some(r#"{"decision":{"target":"efficient"}}"#),
626            ]
627            .into_iter(),
628        ));
629        let serve = {
630            let replies = replies.clone();
631            move |target: ModelId, _request: Request| {
632                let completion = if target.as_str() == JUDGE {
633                    replies
634                        .lock()
635                        .next()
636                        .expect("unexpected judge call")
637                        .map(str::to_string)
638                } else {
639                    Some(target.to_string())
640                };
641                async move {
642                    completion
643                        .map(crate::core::testing::reply)
644                        .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
645                }
646            }
647        };
648        for (request, expected) in [
649            (user_turn_request(), "strong"),
650            (turn_request(false), "strong"),
651            (user_turn_request(), "strong"),
652            (user_turn_request(), "strong"),
653            (user_turn_request(), "weak"),
654        ] {
655            let (selected, _) =
656                test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
657                    .await?;
658            assert_eq!(selected.as_str(), expected);
659        }
660        assert!(replies.lock().next().is_none());
661        Ok(())
662    }
663
664    /// A route may point its judge at the same target it serves capable turns on.
665    /// The tier then cannot be recovered by looking the served model up in the
666    /// runtime groups — one id, two categories — so it comes off the verdict itself.
667    #[tokio::test]
668    async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
669        let models: HashMap<Category, Vec<ModelId>> = [
670            (Category::Judge, vec![ModelId::from("strong")]),
671            (Category::Capable, vec![ModelId::from("strong")]),
672            (Category::Efficient, vec![ModelId::from("weak")]),
673            (
674                Category::Any,
675                vec![ModelId::from("strong"), ModelId::from("weak")],
676            ),
677        ]
678        .into();
679        // The judge runs first as a request-side processor, so the opening call is its own.
680        let calls = Arc::new(Mutex::new(0u32));
681        let serve = {
682            let calls = Arc::clone(&calls);
683            move |target: ModelId, _request: Request| {
684                let calls = Arc::clone(&calls);
685                async move {
686                    let mut calls = calls.lock();
687                    *calls += 1;
688                    let completion = if *calls == 1 {
689                        // p_solve below the threshold: the judge does not trust the
690                        // efficient tier, so the verdict is capable.
691                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
692                    } else {
693                        target.to_string()
694                    };
695                    Ok(crate::core::testing::reply(completion))
696                }
697            }
698        };
699        let router = router()?;
700
701        test_drive_with_models(
702            router.clone(),
703            user_turn_request(),
704            models.clone(),
705            serve.clone(),
706        )
707        .await?;
708        // A tool step is not a user turn, so nothing re-judges and the latched tier decides.
709        let (selected, _) =
710            test_drive_with_models(router, turn_request(false), models, serve).await?;
711
712        assert_eq!(selected, ModelId::from("strong"));
713        Ok(())
714    }
715
716    #[tokio::test]
717    async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
718        let recorder = Arc::new(Recorder::default());
719        *recorder.judge_p_solve.lock() = 0.1;
720        let router = hash_keyed_router()?;
721
722        test_drive_with_models(
723            router.clone(),
724            unkeyed(user_turn_request()),
725            runtime_models(),
726            recorder.serve(),
727        )
728        .await?;
729        test_drive_with_models(
730            router.clone(),
731            unkeyed(turn_request(false)),
732            runtime_models(),
733            recorder.serve(),
734        )
735        .await?;
736
737        assert_eq!(
738            recorder.judge_calls(),
739            1,
740            "a tool step is not a user turn, session id or not"
741        );
742        assert_eq!(
743            recorder.routed()[1].target,
744            "strong",
745            "and the tier survives the tool step"
746        );
747        Ok(())
748    }
749
750    #[tokio::test]
751    async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
752        let recorder = Arc::new(Recorder::default());
753        *recorder.judge_p_solve.lock() = 0.1;
754        let router = router()?;
755
756        test_drive_with_models(
757            router.clone(),
758            user_turn_request(),
759            runtime_models(),
760            recorder.serve(),
761        )
762        .await?;
763        test_drive_with_models(
764            router.clone(),
765            turn_request(false),
766            runtime_models(),
767            recorder.serve(),
768        )
769        .await?;
770
771        let routed = recorder.routed();
772        assert_eq!(
773            routed[0].target, "strong",
774            "a quiet turn falls open to the verdict"
775        );
776        assert_eq!(
777            routed[1].target, "strong",
778            "which holds across the tool steps after it"
779        );
780        assert_eq!(
781            recorder.judge_calls(),
782            1,
783            "a tool step is not a new user turn"
784        );
785        Ok(())
786    }
787
788    #[tokio::test]
789    async fn caller_state_carries_the_judged_tier_without_a_session_id() -> Result<()> {
790        for trigger in [ClassifyTrigger::UserTurn, ClassifyTrigger::NewSession] {
791            let recorder = Arc::new(Recorder::default());
792            *recorder.judge_p_solve.lock() = 0.1;
793            let router = router_with_trigger(trigger)?;
794            let run = |request, state| {
795                crate::core::testing::test_drive_with_state(
796                    router.clone(),
797                    request,
798                    runtime_models(),
799                    state,
800                    recorder.serve(),
801                )
802            };
803
804            let first = run(unkeyed(user_turn_request()), State::default()).await?;
805            assert_eq!(first.selected_model_id()?, &ModelId::from("strong"));
806
807            let continued = run(unkeyed(turn_request(false)), first.state).await?;
808            assert_eq!(continued.selected_model_id()?, &ModelId::from("strong"));
809            assert_eq!(
810                continued.metadata.and_then(|m| m.evidence),
811                Some(serde_json::json!({"source": "retained"}))
812            );
813            assert_eq!(recorder.judge_calls(), 1, "{trigger:?}");
814        }
815        Ok(())
816    }
817}