Skip to main content

switchyard_libsy/algorithms/
rand.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Random routing as a stateless [`FallThrough`] composition.
5//!
6//! [`RandomClassifier`] selects one target; [`FallThrough`] owns the common
7//! processor/classifier/target-call orchestration.
8
9use std::collections::BTreeSet;
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use parking_lot::Mutex;
14use rand::SeedableRng;
15use rand::distr::{Distribution, weighted::WeightedIndex};
16use rand::rngs::StdRng;
17
18use crate::algorithms::fall_through::FallThrough;
19use crate::core::algorithm::{Algorithm, Driver};
20use crate::core::classifier::{Classification, Classifier, Score};
21use crate::{LibsyError, Result};
22use switchyard_protocol::{ModelId, Request, Response};
23
24/// Stateless weighted classifier used by random fall-through routing.
25pub struct RandomClassifier {
26    targets: Vec<ModelId>,
27    distribution: WeightedIndex<f64>,
28    rng: Mutex<StdRng>,
29}
30
31impl RandomClassifier {
32    /// Creates a classifier over ordered target names.
33    ///
34    /// Missing weights default to one per target. Explicit weights are relative,
35    /// follow target order, and need not sum to one. Zero disables a target.
36    /// Missing `seed` uses entropy-backed randomness.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error when targets are empty or duplicated, or when explicit
41    /// weights have the wrong length, are negative or non-finite, or contain no
42    /// positive value.
43    pub fn new(
44        targets: Vec<ModelId>,
45        weights: Option<Vec<f64>>,
46        seed: Option<u64>,
47    ) -> Result<Self> {
48        let target_count = targets.len();
49        if target_count == 0 {
50            return Err(LibsyError::NoTargets);
51        }
52        let unique_targets = targets.iter().map(ModelId::as_str).collect::<BTreeSet<_>>();
53        if unique_targets.len() != target_count {
54            return Err(LibsyError::AlgorithmError {
55                message: "random targets must be unique".to_string(),
56            });
57        }
58
59        let weights = weights.unwrap_or_else(|| vec![1.0; target_count]);
60        if weights.len() != target_count {
61            return Err(invalid_weights(format!(
62                "expected {target_count} weights, got {}",
63                weights.len()
64            )));
65        }
66        if weights
67            .iter()
68            .any(|weight| !weight.is_finite() || *weight < 0.0)
69        {
70            return Err(invalid_weights(
71                "weights must be finite and nonnegative".to_string(),
72            ));
73        }
74        if !weights.iter().any(|weight| *weight > 0.0) {
75            return Err(invalid_weights(
76                "at least one weight must be positive".to_string(),
77            ));
78        }
79        let distribution =
80            WeightedIndex::new(weights).map_err(|error| invalid_weights(error.to_string()))?;
81        let rng = match seed {
82            Some(seed) => StdRng::seed_from_u64(seed),
83            None => rand::make_rng(),
84        };
85        Ok(Self {
86            targets,
87            distribution,
88            rng: Mutex::new(rng),
89        })
90    }
91
92    fn select_target(&self) -> ModelId {
93        let mut rng = self.rng.lock();
94        let index = self.distribution.sample(&mut *rng);
95        self.targets[index].clone()
96    }
97}
98
99fn invalid_weights(message: String) -> LibsyError {
100    LibsyError::AlgorithmError {
101        message: format!("invalid random weights: {message}"),
102    }
103}
104
105#[async_trait]
106impl<S> Classifier<S> for RandomClassifier
107where
108    S: Send + 'static,
109{
110    async fn score(
111        &self,
112        _state: &mut S,
113        _request: &mut Request,
114        _driver: Option<&Driver>,
115    ) -> Result<(Classification, Option<Response>)> {
116        Ok((
117            Classification::Scores(vec![Score {
118                confidence: 1.0,
119                target: self.select_target(),
120            }]),
121            None,
122        ))
123    }
124}
125
126/// Random router implemented as a stateless fall-through composition.
127pub struct Random {
128    inner: FallThrough<()>,
129}
130
131impl Random {
132    /// Creates a router over `targets`.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error when targets or weights are invalid for [`RandomClassifier`].
137    pub fn new(
138        targets: Vec<ModelId>,
139        weights: Option<Vec<f64>>,
140        seed: Option<u64>,
141    ) -> Result<Self> {
142        let classifier = Arc::new(RandomClassifier::new(targets.clone(), weights, seed)?);
143        let inner = FallThrough::<()>::new(targets)
144            .with_name("random")
145            .with_classifier(classifier);
146        Ok(Self { inner })
147    }
148}
149
150#[async_trait]
151impl Algorithm for Random {
152    fn name(&self) -> &str {
153        "random"
154    }
155
156    async fn route(
157        self: Arc<Self>,
158        driver: Driver,
159        request: Request,
160    ) -> Result<crate::RoutingOutcome> {
161        self.inner.execute(driver, request).await
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::collections::HashSet;
169
170    use switchyard_protocol::{Metadata, completion_text, text_request};
171
172    use crate::algorithms::util::affinity::AffinityRouter;
173    use crate::core::testing::{echo, test_drive};
174    use switchyard_protocol::Request;
175
176    fn request() -> Request {
177        Request {
178            llm_request: text_request(Some("auto".to_string()), "hi"),
179            raw_request: None,
180            metadata: None,
181        }
182    }
183
184    fn request_for_session(session_id: &str) -> Request {
185        Request {
186            metadata: Some(Metadata {
187                session_id: Some(session_id.to_string()),
188                ..Metadata::default()
189            }),
190            ..request()
191        }
192    }
193
194    fn target_set(names: &[&str]) -> Vec<ModelId> {
195        names.iter().map(|name| ModelId::from(*name)).collect()
196    }
197
198    fn algorithm(names: &[&str], weights: Option<Vec<f64>>, seed: Option<u64>) -> Result<Random> {
199        Random::new(target_set(names), weights, seed)
200    }
201
202    fn shared_algorithm(names: &[&str]) -> Result<Arc<dyn Algorithm>> {
203        Ok(Arc::new(algorithm(names, None, None)?))
204    }
205
206    async fn selected_models(algorithm: Arc<dyn Algorithm>, count: usize) -> Result<Vec<String>> {
207        let mut selected = Vec::with_capacity(count);
208        for _ in 0..count {
209            let (_, response) = test_drive(algorithm.clone(), request(), echo()).await?;
210            selected.push(
211                response
212                    .llm_response
213                    .as_agg()
214                    .map(completion_text)
215                    .unwrap_or_default(),
216            );
217        }
218        Ok(selected)
219    }
220
221    #[tokio::test]
222    async fn single_target_is_always_selected_and_called() -> Result<()> {
223        let algorithm = shared_algorithm(&["only/model"])?;
224        let (selected_model, response) = test_drive(algorithm, request(), echo()).await?;
225
226        assert_eq!(
227            response
228                .llm_response
229                .as_agg()
230                .map(completion_text)
231                .unwrap_or_default(),
232            "only/model"
233        );
234        assert_eq!(selected_model, "only/model");
235        Ok(())
236    }
237
238    #[tokio::test]
239    async fn selection_covers_all_targets_over_many_runs() -> Result<()> {
240        let algorithm = shared_algorithm(&["a/model", "b/model"])?;
241        let mut seen = HashSet::new();
242
243        for _ in 0..100 {
244            let (selected_model, response) =
245                test_drive(algorithm.clone(), request(), echo()).await?;
246            let served_model = response
247                .llm_response
248                .as_agg()
249                .map(completion_text)
250                .unwrap_or_default();
251            assert_eq!(selected_model, served_model.as_str());
252            seen.insert(served_model);
253        }
254
255        // Missing either target after 100 uniform draws has probability about 2^-99.
256        assert_eq!(
257            seen.len(),
258            2,
259            "expected both targets to be selected, saw {seen:?}"
260        );
261        Ok(())
262    }
263
264    #[tokio::test]
265    async fn weighted_seeded_selection_is_reproducible() -> Result<()> {
266        let first: Arc<dyn Algorithm> = Arc::new(algorithm(
267            &["a/model", "b/model"],
268            Some(vec![1.0, 3.0]),
269            Some(42),
270        )?);
271        let second: Arc<dyn Algorithm> = Arc::new(algorithm(
272            &["a/model", "b/model"],
273            Some(vec![1.0, 3.0]),
274            Some(42),
275        )?);
276
277        let first_selections = selected_models(first, 1_000).await?;
278        let second_selections = selected_models(second, 1_000).await?;
279        assert_eq!(first_selections, second_selections);
280
281        let second_count = first_selections
282            .iter()
283            .filter(|model| model.as_str() == "b/model")
284            .count();
285        assert!(
286            (700..=800).contains(&second_count),
287            "expected a roughly 25/75 split, selected b/model {second_count} times"
288        );
289        Ok(())
290    }
291
292    #[tokio::test]
293    async fn affinity_reuses_the_initial_random_selection() -> Result<()> {
294        let names = ["a/model", "b/model"];
295        let affinity = Arc::new(AffinityRouter::new());
296        let random = Arc::new(RandomClassifier::new(
297            names.iter().map(|name| ModelId::from(*name)).collect(),
298            None,
299            Some(42),
300        )?);
301        let algorithm: Arc<dyn Algorithm> = Arc::new(
302            FallThrough::<()>::new(target_set(&names))
303                .with_name("affinity_random")
304                .with_processor(affinity.clone())
305                .with_classifier(affinity.clone())
306                .with_classifier(random),
307        );
308
309        let (_, first) =
310            test_drive(algorithm.clone(), request_for_session("session-1"), echo()).await?;
311        let selected = first
312            .llm_response
313            .as_agg()
314            .map(completion_text)
315            .unwrap_or_default();
316
317        let mut state = ();
318        let mut request = request_for_session("session-1");
319        let retained = affinity
320            .score(&mut state, &mut request, None)
321            .await?
322            .0
323            .argmax(false)?;
324        assert_eq!(
325            retained.map(|score| score.target),
326            Some(ModelId::from(selected.clone()))
327        );
328
329        let (_, second) = test_drive(algorithm, request_for_session("session-1"), echo()).await?;
330        assert_eq!(
331            second
332                .llm_response
333                .as_agg()
334                .map(completion_text)
335                .unwrap_or_default(),
336            selected
337        );
338        Ok(())
339    }
340
341    #[test]
342    fn rejects_invalid_weights() {
343        let cases = [
344            (vec![1.0], "expected 2 weights"),
345            (vec![1.0, -1.0], "finite and nonnegative"),
346            (vec![0.0, 0.0], "at least one weight must be positive"),
347            (vec![1.0, f64::INFINITY], "finite and nonnegative"),
348        ];
349
350        for (weights, expected) in cases {
351            let error = algorithm(&["a/model", "b/model"], Some(weights), None)
352                .err()
353                .map(|error| error.to_string())
354                .unwrap_or_default();
355            assert!(error.contains(expected), "unexpected error: {error}");
356        }
357    }
358
359    #[test]
360    fn rejects_invalid_targets() {
361        let error = algorithm(&[], None, None).err();
362        assert!(matches!(error, Some(LibsyError::NoTargets)));
363
364        let error = algorithm(&["same/model", "same/model"], None, None)
365            .err()
366            .map(|error| error.to_string())
367            .unwrap_or_default();
368        assert!(error.contains("random targets must be unique"));
369    }
370
371    #[tokio::test]
372    async fn decision_is_inspectable() -> Result<()> {
373        let algorithm = shared_algorithm(&["only/model"])?;
374        let (selected_model, _) = test_drive(algorithm, request(), echo()).await?;
375        assert_eq!(selected_model, "only/model");
376        Ok(())
377    }
378}