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                        responses: None,
518                        text: prompt.into()
519                    }]
520                );
521                state = outcome.state;
522            }
523            assert_eq!(recorder.judge_calls(), 4);
524        }
525        Ok(())
526    }
527
528    #[test]
529    fn rejects_every_request_as_a_trigger() {
530        for judge in [
531            CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
532            CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
533                "Choose a tier",
534                serde_json::json!({"type": "object"}),
535                crate::CustomClassifierPolicy::target_selector("/decision/target"),
536            )),
537        ] {
538            let error = CompositeRouter::new(CompositeRouterConfig {
539                judge,
540                stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
541            })
542            .err()
543            .expect("every_request must be rejected for both classifier modes");
544            assert!(matches!(error, LibsyError::AlgorithmError { message }
545                if message == "composite: classify_trigger must be user_turn or new_session"));
546        }
547    }
548
549    fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
550        let mut config = CustomClassifierConfig::new(
551            "Select a tier",
552            serde_json::json!({"type": "object"}),
553            crate::CustomClassifierPolicy::target_selector("/decision/target"),
554        );
555        config.classify_trigger = trigger;
556        CompositeJudgeConfig::Custom(config)
557    }
558
559    #[tokio::test]
560    async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
561        for custom in [false, true] {
562            for (mode, expected) in [
563                (PickerMode::EfficientFirst, "weak"),
564                (PickerMode::CapableFirst, "strong"),
565            ] {
566                let judge = if custom {
567                    custom_judge(ClassifyTrigger::NewSession)
568                } else {
569                    CompositeJudgeConfig::Capability(TaskClassifierConfig {
570                        classify_trigger: ClassifyTrigger::NewSession,
571                        ..Default::default()
572                    })
573                };
574                let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
575                    judge,
576                    stage: StageRouterConfig::new(mode, 0.5),
577                })?);
578                let calls = Arc::new(Mutex::new(0));
579                let serve = {
580                    let calls = calls.clone();
581                    move |target: ModelId, _request: Request| {
582                        let completion = if target.as_str() == JUDGE {
583                            *calls.lock() += 1;
584                            "not JSON".to_string()
585                        } else {
586                            target.to_string()
587                        };
588                        async move { Ok(crate::core::testing::reply(completion)) }
589                    }
590                };
591                for request in [
592                    user_turn_request(),
593                    turn_request(false),
594                    user_turn_request(),
595                ] {
596                    let (selected, _) = test_drive_with_models(
597                        router.clone(),
598                        request,
599                        runtime_models(),
600                        serve.clone(),
601                    )
602                    .await?;
603                    assert_eq!(selected.as_str(), expected);
604                }
605                assert_eq!(
606                    *calls.lock(),
607                    1,
608                    "new_session must retain a failed judgment"
609                );
610            }
611        }
612        Ok(())
613    }
614
615    #[tokio::test]
616    async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
617        let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
618            judge: custom_judge(ClassifyTrigger::UserTurn),
619            stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
620        })?);
621        let replies = Arc::new(Mutex::new(
622            [
623                Some(r#"{"decision":{"target":"capable"}}"#),
624                None,
625                Some("not JSON"),
626                Some(r#"{"decision":{"target":"efficient"}}"#),
627            ]
628            .into_iter(),
629        ));
630        let serve = {
631            let replies = replies.clone();
632            move |target: ModelId, _request: Request| {
633                let completion = if target.as_str() == JUDGE {
634                    replies
635                        .lock()
636                        .next()
637                        .expect("unexpected judge call")
638                        .map(str::to_string)
639                } else {
640                    Some(target.to_string())
641                };
642                async move {
643                    completion
644                        .map(crate::core::testing::reply)
645                        .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
646                }
647            }
648        };
649        for (request, expected) in [
650            (user_turn_request(), "strong"),
651            (turn_request(false), "strong"),
652            (user_turn_request(), "strong"),
653            (user_turn_request(), "strong"),
654            (user_turn_request(), "weak"),
655        ] {
656            let (selected, _) =
657                test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
658                    .await?;
659            assert_eq!(selected.as_str(), expected);
660        }
661        assert!(replies.lock().next().is_none());
662        Ok(())
663    }
664
665    /// A route may point its judge at the same target it serves capable turns on.
666    /// The tier then cannot be recovered by looking the served model up in the
667    /// runtime groups — one id, two categories — so it comes off the verdict itself.
668    #[tokio::test]
669    async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
670        let models: HashMap<Category, Vec<ModelId>> = [
671            (Category::Judge, vec![ModelId::from("strong")]),
672            (Category::Capable, vec![ModelId::from("strong")]),
673            (Category::Efficient, vec![ModelId::from("weak")]),
674            (
675                Category::Any,
676                vec![ModelId::from("strong"), ModelId::from("weak")],
677            ),
678        ]
679        .into();
680        // The judge runs first as a request-side processor, so the opening call is its own.
681        let calls = Arc::new(Mutex::new(0u32));
682        let serve = {
683            let calls = Arc::clone(&calls);
684            move |target: ModelId, _request: Request| {
685                let calls = Arc::clone(&calls);
686                async move {
687                    let mut calls = calls.lock();
688                    *calls += 1;
689                    let completion = if *calls == 1 {
690                        // p_solve below the threshold: the judge does not trust the
691                        // efficient tier, so the verdict is capable.
692                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
693                    } else {
694                        target.to_string()
695                    };
696                    Ok(crate::core::testing::reply(completion))
697                }
698            }
699        };
700        let router = router()?;
701
702        test_drive_with_models(
703            router.clone(),
704            user_turn_request(),
705            models.clone(),
706            serve.clone(),
707        )
708        .await?;
709        // A tool step is not a user turn, so nothing re-judges and the latched tier decides.
710        let (selected, _) =
711            test_drive_with_models(router, turn_request(false), models, serve).await?;
712
713        assert_eq!(selected, ModelId::from("strong"));
714        Ok(())
715    }
716
717    #[tokio::test]
718    async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
719        let recorder = Arc::new(Recorder::default());
720        *recorder.judge_p_solve.lock() = 0.1;
721        let router = hash_keyed_router()?;
722
723        test_drive_with_models(
724            router.clone(),
725            unkeyed(user_turn_request()),
726            runtime_models(),
727            recorder.serve(),
728        )
729        .await?;
730        test_drive_with_models(
731            router.clone(),
732            unkeyed(turn_request(false)),
733            runtime_models(),
734            recorder.serve(),
735        )
736        .await?;
737
738        assert_eq!(
739            recorder.judge_calls(),
740            1,
741            "a tool step is not a user turn, session id or not"
742        );
743        assert_eq!(
744            recorder.routed()[1].target,
745            "strong",
746            "and the tier survives the tool step"
747        );
748        Ok(())
749    }
750
751    #[tokio::test]
752    async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
753        let recorder = Arc::new(Recorder::default());
754        *recorder.judge_p_solve.lock() = 0.1;
755        let router = router()?;
756
757        test_drive_with_models(
758            router.clone(),
759            user_turn_request(),
760            runtime_models(),
761            recorder.serve(),
762        )
763        .await?;
764        test_drive_with_models(
765            router.clone(),
766            turn_request(false),
767            runtime_models(),
768            recorder.serve(),
769        )
770        .await?;
771
772        let routed = recorder.routed();
773        assert_eq!(
774            routed[0].target, "strong",
775            "a quiet turn falls open to the verdict"
776        );
777        assert_eq!(
778            routed[1].target, "strong",
779            "which holds across the tool steps after it"
780        );
781        assert_eq!(
782            recorder.judge_calls(),
783            1,
784            "a tool step is not a new user turn"
785        );
786        Ok(())
787    }
788
789    #[tokio::test]
790    async fn caller_state_carries_the_judged_tier_without_a_session_id() -> Result<()> {
791        for trigger in [ClassifyTrigger::UserTurn, ClassifyTrigger::NewSession] {
792            let recorder = Arc::new(Recorder::default());
793            *recorder.judge_p_solve.lock() = 0.1;
794            let router = router_with_trigger(trigger)?;
795            let run = |request, state| {
796                crate::core::testing::test_drive_with_state(
797                    router.clone(),
798                    request,
799                    runtime_models(),
800                    state,
801                    recorder.serve(),
802                )
803            };
804
805            let first = run(unkeyed(user_turn_request()), State::default()).await?;
806            assert_eq!(first.selected_model_id()?, &ModelId::from("strong"));
807
808            let continued = run(unkeyed(turn_request(false)), first.state).await?;
809            assert_eq!(continued.selected_model_id()?, &ModelId::from("strong"));
810            assert_eq!(
811                continued.metadata.and_then(|m| m.evidence),
812                Some(serde_json::json!({"source": "retained"}))
813            );
814            assert_eq!(recorder.judge_calls(), 1, "{trigger:?}");
815        }
816        Ok(())
817    }
818}