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`] builds the candidate list.
7//! [`Random`] removes zero-weight targets from that list.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use parking_lot::Mutex;
13use rand::RngExt as _;
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::{Category, Request, Response};
23
24/// Stateless weighted classifier used by random fall-through routing.
25pub struct RandomClassifier {
26    distribution: Option<WeightedIndex<f64>>,
27    weights: Option<Vec<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 if explicit weights are negative or non-finite, or contain no
41    /// positive value.
42    pub fn new(weights: Option<Vec<f64>>, seed: Option<u64>) -> Result<Self> {
43        let distribution = if let Some(weights) = weights.as_ref() {
44            if weights
45                .iter()
46                .any(|weight| !weight.is_finite() || *weight < 0.0)
47            {
48                return Err(invalid_weights(
49                    "weights must be finite and nonnegative".to_string(),
50                ));
51            }
52            if !weights.iter().any(|weight| *weight > 0.0) {
53                return Err(invalid_weights(
54                    "at least one weight must be positive".to_string(),
55                ));
56            }
57            Some(WeightedIndex::new(weights).map_err(|error| invalid_weights(error.to_string()))?)
58        } else {
59            None
60        };
61        let rng = match seed {
62            Some(seed) => StdRng::seed_from_u64(seed),
63            None => rand::make_rng(),
64        };
65        Ok(Self {
66            distribution,
67            weights,
68            rng: Mutex::new(rng),
69        })
70    }
71}
72
73fn invalid_weights(message: String) -> LibsyError {
74    LibsyError::AlgorithmError {
75        message: format!("invalid random weights: {message}"),
76    }
77}
78
79#[async_trait]
80impl<S> Classifier<S> for RandomClassifier
81where
82    S: Send + 'static,
83{
84    async fn score(
85        &self,
86        _state: &mut S,
87        _request: &mut Request,
88        driver: &Driver,
89    ) -> Result<(Classification, Option<Response>)> {
90        // All the available models
91        let options = driver.models_for(&Category::Any);
92        if options.is_empty() {
93            return Err(LibsyError::NoTargets);
94        }
95        if let Some(weight_count) = self.weights.as_ref().map(Vec::len)
96            && weight_count != options.len()
97        {
98            return Err(invalid_weights(format!(
99                "{weight_count} weights were provided but Category::Any has {} runtime models.",
100                options.len()
101            )));
102        }
103        let mut rng = self.rng.lock();
104        let index = if let Some(distribution) = self.distribution.as_ref() {
105            // The user gave us weights
106            distribution.sample(&mut *rng)
107        } else {
108            // No weights, assume equal probability
109            rng.random_range(..options.len())
110        };
111        let target = options[index].clone();
112        Ok((
113            Classification::Scores(vec![Score {
114                confidence: 1.0,
115                target,
116                category: Some(Category::Any),
117            }]),
118            None,
119        ))
120    }
121}
122
123/// Random router with fallbacks restricted to targets with positive weights.
124pub struct Random {
125    inner: FallThrough<()>,
126    classifier: Arc<RandomClassifier>,
127}
128
129impl Random {
130    /// Creates a random router. The models themselves will be passed at runtime.
131    pub fn new(weights: Option<Vec<f64>>, seed: Option<u64>) -> Result<Self> {
132        let classifier = Arc::new(RandomClassifier::new(weights, seed)?);
133        let inner = FallThrough::<()>::new()
134            .with_name("random")
135            .with_classifier(classifier.clone());
136        Ok(Self { inner, classifier })
137    }
138}
139
140#[async_trait]
141impl Algorithm for Random {
142    fn name(&self) -> &str {
143        "random"
144    }
145
146    async fn route(
147        self: Arc<Self>,
148        driver: Driver,
149        request: Request,
150    ) -> Result<crate::RoutingOutcome> {
151        let mut outcome = self.inner.execute(driver.clone(), request).await?;
152        if let Some(weights) = &self.classifier.weights {
153            // FallThrough includes all runtime targets, but zero weights disable fallbacks too.
154            for (model, weight) in driver.models_for(&Category::Any).iter().zip(weights) {
155                if *weight == 0.0 {
156                    outcome
157                        .selected_model_ids
158                        .retain(|candidate| candidate != model);
159                }
160            }
161        }
162        Ok(outcome)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use std::collections::{HashMap, HashSet};
170
171    use switchyard_protocol::{Metadata, ModelId, completion_text, text_request};
172
173    use crate::algorithms::util::affinity::AffinityRouter;
174    use crate::core::testing::{category_models, echo, test_drive_with_models};
175    use switchyard_protocol::Request;
176
177    fn request() -> Request {
178        Request {
179            llm_request: text_request(Some("auto".to_string()), "hi"),
180            raw_request: None,
181            metadata: None,
182        }
183    }
184
185    fn request_for_session(session_id: &str) -> Request {
186        Request {
187            metadata: Some(Metadata {
188                session_id: Some(session_id.to_string()),
189                ..Metadata::default()
190            }),
191            ..request()
192        }
193    }
194
195    fn algorithm(weights: Option<Vec<f64>>, seed: Option<u64>) -> Result<Random> {
196        Random::new(weights, seed)
197    }
198
199    fn shared_algorithm() -> Result<Arc<dyn Algorithm>> {
200        Ok(Arc::new(algorithm(None, None)?))
201    }
202
203    async fn selected_models(
204        algorithm: Arc<dyn Algorithm>,
205        count: usize,
206        models: HashMap<Category, Vec<ModelId>>,
207    ) -> Result<Vec<String>> {
208        let mut selected = Vec::with_capacity(count);
209        for _ in 0..count {
210            let (_, response) =
211                test_drive_with_models(algorithm.clone(), request(), models.clone(), echo())
212                    .await?;
213            selected.push(
214                response
215                    .llm_response
216                    .as_agg()
217                    .map(completion_text)
218                    .unwrap_or_default(),
219            );
220        }
221        Ok(selected)
222    }
223
224    #[tokio::test]
225    async fn single_target_is_always_selected_and_called() -> Result<()> {
226        let algorithm = shared_algorithm()?;
227        let models = category_models(Category::Any, &["only/model"]);
228        let (selected_model, response) =
229            test_drive_with_models(algorithm, request(), models, echo()).await?;
230
231        assert_eq!(
232            response
233                .llm_response
234                .as_agg()
235                .map(completion_text)
236                .unwrap_or_default(),
237            "only/model"
238        );
239        assert_eq!(selected_model, "only/model");
240        Ok(())
241    }
242
243    #[tokio::test]
244    async fn selection_covers_all_targets_over_many_runs() -> Result<()> {
245        let algorithm = shared_algorithm()?;
246        let models = category_models(Category::Any, &["a/model", "b/model"]);
247        let mut seen = HashSet::new();
248
249        for _ in 0..100 {
250            let (selected_model, response) =
251                test_drive_with_models(algorithm.clone(), request(), models.clone(), echo())
252                    .await?;
253            let served_model = response
254                .llm_response
255                .as_agg()
256                .map(completion_text)
257                .unwrap_or_default();
258            assert_eq!(selected_model, served_model.as_str());
259            seen.insert(served_model);
260        }
261
262        // Missing either target after 100 uniform draws has probability about 2^-99.
263        assert_eq!(
264            seen.len(),
265            2,
266            "expected both targets to be selected, saw {seen:?}"
267        );
268        Ok(())
269    }
270
271    #[tokio::test]
272    async fn weighted_seeded_selection_is_reproducible() -> Result<()> {
273        let models = category_models(Category::Any, &["a/model", "b/model"]);
274        let first: Arc<dyn Algorithm> = Arc::new(algorithm(Some(vec![1.0, 3.0]), Some(42))?);
275        let second: Arc<dyn Algorithm> = Arc::new(algorithm(Some(vec![1.0, 3.0]), Some(42))?);
276
277        let first_selections = selected_models(first, 1_000, models.clone()).await?;
278        let second_selections = selected_models(second, 1_000, models).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 models = category_models(Category::Any, &names);
296        let affinity = Arc::new(AffinityRouter::new());
297        let random = Arc::new(RandomClassifier::new(None, Some(42))?);
298        let algorithm: Arc<dyn Algorithm> = Arc::new(
299            FallThrough::<()>::new()
300                .with_name("affinity_random")
301                .with_processor(affinity.clone())
302                .with_classifier(affinity.clone())
303                .with_classifier(random),
304        );
305
306        let (_, first) = test_drive_with_models(
307            algorithm.clone(),
308            request_for_session("session-1"),
309            models.clone(),
310            echo(),
311        )
312        .await?;
313        let selected = first
314            .llm_response
315            .as_agg()
316            .map(completion_text)
317            .unwrap_or_default();
318
319        let mut state = ();
320        let mut request = request_for_session("session-1");
321        let driver = Driver::new("test", Arc::new(models.clone().into())).0;
322        let retained = affinity
323            .score(&mut state, &mut request, &driver)
324            .await?
325            .0
326            .argmax(false)?;
327        assert_eq!(
328            retained.map(|score| score.target),
329            Some(ModelId::from(selected.clone()))
330        );
331
332        let (_, second) =
333            test_drive_with_models(algorithm, request_for_session("session-1"), models, echo())
334                .await?;
335        assert_eq!(
336            second
337                .llm_response
338                .as_agg()
339                .map(completion_text)
340                .unwrap_or_default(),
341            selected
342        );
343        Ok(())
344    }
345
346    #[test]
347    fn rejects_invalid_weights() {
348        let cases = [
349            (vec![1.0, -1.0], "finite and nonnegative"),
350            (vec![0.0, 0.0], "at least one weight must be positive"),
351            (vec![1.0, f64::INFINITY], "finite and nonnegative"),
352        ];
353
354        for (weights, expected) in cases {
355            let error = algorithm(Some(weights), None)
356                .err()
357                .map(|error| error.to_string())
358                .unwrap_or_default();
359            assert!(error.contains(expected), "unexpected error: {error}");
360        }
361    }
362
363    #[tokio::test]
364    async fn decision_is_inspectable() -> Result<()> {
365        let algorithm = shared_algorithm()?;
366        let models = category_models(Category::Any, &["only/model"]);
367        let (selected_model, _) =
368            test_drive_with_models(algorithm, request(), models, echo()).await?;
369        assert_eq!(selected_model, "only/model");
370        Ok(())
371    }
372}