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::{Tier, set_fall_open};
22use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity};
23use crate::core::classifier::Classifier;
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26use crate::{LibsyError, Result};
27use switchyard_protocol::{Category, Request};
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<State>>,
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>, request: &Request) -> bool {
56        match self.trigger {
57            ClassifyTrigger::UserTurn => has_new_user_turn(&request.llm_request.messages),
58            // Unkeyed requests cannot be told apart, so every one is a new session.
59            ClassifyTrigger::NewSession => {
60                identity.is_none_or(|identity| !self.tiers.lock().contains_key(identity))
61            }
62            // Rejected by the constructor, and only in the enum for the standalone route.
63            ClassifyTrigger::EveryRequest => true,
64        }
65    }
66
67    fn retain(&self, identity: RoutingIdentity, tier: Tier) {
68        let mut tiers = self.tiers.lock();
69        // A user turn re-decides, so it overwrites. A session keeps its first verdict,
70        // matching how affinity retains an assignment.
71        let writable = self.trigger == ClassifyTrigger::UserTurn || !tiers.contains_key(&identity);
72        if writable {
73            evict_if_full(&mut tiers);
74            tiers.insert(identity, tier);
75        }
76    }
77}
78
79#[async_trait]
80impl Processor<State> for TierSetter {
81    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
82        let Event::Request { request, driver } = event else {
83            return Ok(());
84        };
85        let identity = retention_key(request, self.message_hash_fallback);
86        if self.is_due(identity.as_ref(), request) {
87            let (classification, _) = self.judge.score(state, request, driver).await?;
88            if let Some(winner) = classification.argmax(false)?
89                && let Some(tier) = Self::tier_for(winner.category)
90            {
91                set_fall_open(state, tier);
92                if let Some(identity) = identity {
93                    self.retain(identity, tier);
94                }
95                return Ok(());
96            }
97            if self.trigger == ClassifyTrigger::NewSession
98                && let Some(identity) = identity.as_ref()
99            {
100                // A failed opening judgment is still the session's one judgment.
101                self.retain(identity.clone(), self.default_tier);
102            }
103        }
104        // Either not this request's turn or the judge had no verdict, so the last
105        // tier stands rather than dropping back to the picker default.
106        if let Some(tier) = identity.and_then(|identity| self.tiers.lock().get(&identity).copied())
107        {
108            set_fall_open(state, tier);
109            driver.set_evidence_if_empty(serde_json::json!({"source": "retained"}));
110        }
111        Ok(())
112    }
113}
114
115/// Classifier settings for the composite router's retained tier.
116pub enum CompositeJudgeConfig {
117    /// Selects a tier from a capability verdict.
118    Capability(TaskClassifierConfig),
119    /// Selects a tier using a custom schema and policy, retaining the fallback on failure.
120    Custom(CustomClassifierConfig),
121}
122
123/// A judge stacked over a stage router.
124pub struct CompositeRouterConfig {
125    /// Judge settings, including how often `classify_trigger` runs it.
126    pub judge: CompositeJudgeConfig,
127    /// Serves the turns, with the tier the judge picked as its fall-open default.
128    pub stage: StageRouterConfig,
129}
130
131/// Runs a stage router with a tier the judge picks.
132pub struct CompositeRouter {
133    route: FallThrough<State>,
134}
135
136impl CompositeRouter {
137    /// Stacks the judge over a stage router across the same tier pair.
138    ///
139    /// Errors on a configuration either algorithm rejects and on `every_request`.
140    ///
141    /// A stage router carrying its own judge is allowed, but that judge sits ahead
142    /// of the fall-open tier and so answers most of the turns this one set a tier for.
143    pub fn new(config: CompositeRouterConfig) -> Result<Self> {
144        let (trigger, message_hash_fallback) = match &config.judge {
145            CompositeJudgeConfig::Capability(judge) => {
146                (judge.classify_trigger, judge.message_hash_fallback)
147            }
148            CompositeJudgeConfig::Custom(config) => {
149                (config.classify_trigger, config.message_hash_fallback)
150            }
151        };
152        if trigger == ClassifyTrigger::EveryRequest {
153            return Err(LibsyError::AlgorithmError {
154                message: "composite: classify_trigger must be user_turn or new_session".to_string(),
155            });
156        }
157        let judge: Arc<dyn Classifier<State>> = match config.judge {
158            CompositeJudgeConfig::Capability(judge) => {
159                // Composite owns the trigger and retention; standalone affinity never runs.
160                Arc::new(LlmTaskClassifier::new(LlmClassifierConfig::Capability {
161                    config: TaskClassifierConfig {
162                        classify_trigger: ClassifyTrigger::EveryRequest,
163                        message_hash_fallback: false,
164                        ..judge
165                    },
166                })?)
167            }
168            CompositeJudgeConfig::Custom(config) => {
169                LlmTaskClassifier::custom_classifier(&config, true)?
170            }
171        };
172        let setter = TierSetter {
173            judge,
174            trigger,
175            message_hash_fallback,
176            default_tier: config.stage.mode.default_tier(),
177            tiers: Mutex::new(HashMap::new()),
178        };
179        let route = build_stage_route(config.stage)?
180            .with_name(COMPOSITE)
181            .with_processor(Arc::new(setter));
182        Ok(Self { route })
183    }
184}
185
186#[async_trait]
187impl Algorithm for CompositeRouter {
188    fn name(&self) -> &str {
189        COMPOSITE
190    }
191
192    async fn route(
193        self: Arc<Self>,
194        driver: Driver,
195        request: Request,
196    ) -> Result<crate::RoutingOutcome> {
197        self.route.execute(driver, request).await
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use crate::{CapabilityJudgeConfig, LlmCapabilityConfig};
204    use std::collections::HashMap;
205    use std::sync::Arc;
206
207    use switchyard_protocol::{Category, LlmClientError, Message, ModelId, Role};
208
209    use super::*;
210    use crate::algorithms::util::stage::PickerMode;
211    use crate::algorithms::util::tier_fixtures::{JUDGE, Recorder, turn_request};
212    use crate::core::testing::test_drive_with_models;
213
214    fn runtime_models() -> HashMap<Category, Vec<ModelId>> {
215        [
216            (Category::Judge, vec![ModelId::from(JUDGE)]),
217            (Category::Efficient, vec![ModelId::from("weak")]),
218            (Category::Capable, vec![ModelId::from("strong")]),
219            (
220                Category::Any,
221                vec![ModelId::from("strong"), ModelId::from("weak")],
222            ),
223        ]
224        .into()
225    }
226
227    fn user_turn_request() -> Request {
228        let mut request = turn_request(false);
229        request
230            .llm_request
231            .messages
232            .push(Message::text(Role::User, "now rewrite the parser"));
233        request
234    }
235
236    /// The same request shape with no session ID, so only the hash can key it.
237    fn unkeyed(mut request: Request) -> Request {
238        if let Some(metadata) = request.metadata.as_mut() {
239            metadata.session_id = None;
240        }
241        request
242    }
243
244    fn hash_keyed_router() -> Result<Arc<CompositeRouter>> {
245        Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
246            judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
247                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
248                    base_threshold: 0.5,
249                    ..LlmCapabilityConfig::default()
250                }),
251                classify_trigger: ClassifyTrigger::UserTurn,
252                message_hash_fallback: true,
253                ..Default::default()
254            }),
255            stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
256        })?))
257    }
258
259    fn router() -> Result<Arc<CompositeRouter>> {
260        Ok(Arc::new(CompositeRouter::new(CompositeRouterConfig {
261            judge: CompositeJudgeConfig::Capability(TaskClassifierConfig {
262                judge: CapabilityJudgeConfig::Llm(LlmCapabilityConfig {
263                    base_threshold: 0.5,
264                    ..LlmCapabilityConfig::default()
265                }),
266                classify_trigger: ClassifyTrigger::UserTurn,
267                ..Default::default()
268            }),
269            stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
270        })?))
271    }
272
273    #[test]
274    fn rejects_every_request_as_a_trigger() {
275        for judge in [
276            CompositeJudgeConfig::Capability(TaskClassifierConfig::default()),
277            CompositeJudgeConfig::Custom(CustomClassifierConfig::new(
278                "Choose a tier",
279                serde_json::json!({"type": "object"}),
280                crate::CustomClassifierPolicy::target_selector("/decision/target"),
281            )),
282        ] {
283            let error = CompositeRouter::new(CompositeRouterConfig {
284                judge,
285                stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
286            })
287            .err()
288            .expect("every_request must be rejected for both classifier modes");
289            assert!(matches!(error, LibsyError::AlgorithmError { message }
290                if message == "composite: classify_trigger must be user_turn or new_session"));
291        }
292    }
293
294    fn custom_judge(trigger: ClassifyTrigger) -> CompositeJudgeConfig {
295        let mut config = CustomClassifierConfig::new(
296            "Select a tier",
297            serde_json::json!({"type": "object"}),
298            crate::CustomClassifierPolicy::target_selector("/decision/target"),
299        );
300        config.classify_trigger = trigger;
301        CompositeJudgeConfig::Custom(config)
302    }
303
304    #[tokio::test]
305    async fn a_failed_session_judgment_retains_the_configured_stage_default() -> Result<()> {
306        for custom in [false, true] {
307            for (mode, expected) in [
308                (PickerMode::EfficientFirst, "weak"),
309                (PickerMode::CapableFirst, "strong"),
310            ] {
311                let judge = if custom {
312                    custom_judge(ClassifyTrigger::NewSession)
313                } else {
314                    CompositeJudgeConfig::Capability(TaskClassifierConfig {
315                        classify_trigger: ClassifyTrigger::NewSession,
316                        ..Default::default()
317                    })
318                };
319                let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
320                    judge,
321                    stage: StageRouterConfig::new(mode, 0.5),
322                })?);
323                let calls = Arc::new(Mutex::new(0));
324                let serve = {
325                    let calls = calls.clone();
326                    move |target: ModelId, _request: Request| {
327                        let completion = if target.as_str() == JUDGE {
328                            *calls.lock() += 1;
329                            "not JSON".to_string()
330                        } else {
331                            target.to_string()
332                        };
333                        async move { Ok(crate::core::testing::reply(completion)) }
334                    }
335                };
336                for request in [
337                    user_turn_request(),
338                    turn_request(false),
339                    user_turn_request(),
340                ] {
341                    let (selected, _) = test_drive_with_models(
342                        router.clone(),
343                        request,
344                        runtime_models(),
345                        serve.clone(),
346                    )
347                    .await?;
348                    assert_eq!(selected.as_str(), expected);
349                }
350                assert_eq!(
351                    *calls.lock(),
352                    1,
353                    "new_session must retain a failed judgment"
354                );
355            }
356        }
357        Ok(())
358    }
359
360    #[tokio::test]
361    async fn a_custom_judge_sets_the_tier_and_retains_it_on_failure() -> Result<()> {
362        let router = Arc::new(CompositeRouter::new(CompositeRouterConfig {
363            judge: custom_judge(ClassifyTrigger::UserTurn),
364            stage: StageRouterConfig::new(PickerMode::EfficientFirst, 0.5),
365        })?);
366        let replies = Arc::new(Mutex::new(
367            [
368                Some(r#"{"decision":{"target":"capable"}}"#),
369                None,
370                Some("not JSON"),
371                Some(r#"{"decision":{"target":"efficient"}}"#),
372            ]
373            .into_iter(),
374        ));
375        let serve = {
376            let replies = replies.clone();
377            move |target: ModelId, _request: Request| {
378                let completion = if target.as_str() == JUDGE {
379                    replies
380                        .lock()
381                        .next()
382                        .expect("unexpected judge call")
383                        .map(str::to_string)
384                } else {
385                    Some(target.to_string())
386                };
387                async move {
388                    completion
389                        .map(crate::core::testing::reply)
390                        .ok_or_else(|| LlmClientError::General("judge unavailable".into()))
391                }
392            }
393        };
394        for (request, expected) in [
395            (user_turn_request(), "strong"),
396            (turn_request(false), "strong"),
397            (user_turn_request(), "strong"),
398            (user_turn_request(), "strong"),
399            (user_turn_request(), "weak"),
400        ] {
401            let (selected, _) =
402                test_drive_with_models(router.clone(), request, runtime_models(), serve.clone())
403                    .await?;
404            assert_eq!(selected.as_str(), expected);
405        }
406        assert!(replies.lock().next().is_none());
407        Ok(())
408    }
409
410    /// A route may point its judge at the same target it serves capable turns on.
411    /// The tier then cannot be recovered by looking the served model up in the
412    /// runtime groups — one id, two categories — so it comes off the verdict itself.
413    #[tokio::test]
414    async fn a_judge_sharing_the_capable_model_still_latches_the_tier() -> Result<()> {
415        let models: HashMap<Category, Vec<ModelId>> = [
416            (Category::Judge, vec![ModelId::from("strong")]),
417            (Category::Capable, vec![ModelId::from("strong")]),
418            (Category::Efficient, vec![ModelId::from("weak")]),
419            (
420                Category::Any,
421                vec![ModelId::from("strong"), ModelId::from("weak")],
422            ),
423        ]
424        .into();
425        // The judge runs first as a request-side processor, so the opening call is its own.
426        let calls = Arc::new(Mutex::new(0u32));
427        let serve = {
428            let calls = Arc::clone(&calls);
429            move |target: ModelId, _request: Request| {
430                let calls = Arc::clone(&calls);
431                async move {
432                    let mut calls = calls.lock();
433                    *calls += 1;
434                    let completion = if *calls == 1 {
435                        // p_solve below the threshold: the judge does not trust the
436                        // efficient tier, so the verdict is capable.
437                        r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.1}"#.to_string()
438                    } else {
439                        target.to_string()
440                    };
441                    Ok(crate::core::testing::reply(completion))
442                }
443            }
444        };
445        let router = router()?;
446
447        test_drive_with_models(
448            router.clone(),
449            user_turn_request(),
450            models.clone(),
451            serve.clone(),
452        )
453        .await?;
454        // A tool step is not a user turn, so nothing re-judges and the latched tier decides.
455        let (selected, _) =
456            test_drive_with_models(router, turn_request(false), models, serve).await?;
457
458        assert_eq!(selected, ModelId::from("strong"));
459        Ok(())
460    }
461
462    #[tokio::test]
463    async fn a_session_without_an_id_keys_on_the_message_hash() -> Result<()> {
464        let recorder = Arc::new(Recorder::default());
465        *recorder.judge_p_solve.lock() = 0.1;
466        let router = hash_keyed_router()?;
467
468        test_drive_with_models(
469            router.clone(),
470            unkeyed(user_turn_request()),
471            runtime_models(),
472            recorder.serve(),
473        )
474        .await?;
475        test_drive_with_models(
476            router.clone(),
477            unkeyed(turn_request(false)),
478            runtime_models(),
479            recorder.serve(),
480        )
481        .await?;
482
483        assert_eq!(
484            recorder.judge_calls(),
485            1,
486            "a tool step is not a user turn, session id or not"
487        );
488        assert_eq!(
489            recorder.routed()[1].target,
490            "strong",
491            "and the tier survives the tool step"
492        );
493        Ok(())
494    }
495
496    #[tokio::test]
497    async fn the_judge_sets_the_tier_once_a_turn_and_the_signals_run_within_it() -> Result<()> {
498        let recorder = Arc::new(Recorder::default());
499        *recorder.judge_p_solve.lock() = 0.1;
500        let router = router()?;
501
502        test_drive_with_models(
503            router.clone(),
504            user_turn_request(),
505            runtime_models(),
506            recorder.serve(),
507        )
508        .await?;
509        test_drive_with_models(
510            router.clone(),
511            turn_request(false),
512            runtime_models(),
513            recorder.serve(),
514        )
515        .await?;
516
517        let routed = recorder.routed();
518        assert_eq!(
519            routed[0].target, "strong",
520            "a quiet turn falls open to the verdict"
521        );
522        assert_eq!(
523            routed[1].target, "strong",
524            "which holds across the tool steps after it"
525        );
526        assert_eq!(
527            recorder.judge_calls(),
528            1,
529            "a tool step is not a new user turn"
530        );
531        Ok(())
532    }
533}