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