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, FallThroughDecision};
19use crate::core::algorithm::{Algorithm, Driver, LlmTargetSet};
20use crate::core::classifier::{Classification, Classifier, Score};
21use crate::{LibsyError, Result};
22use switchyard_protocol::{Context, Request, Response};
23
24/// Compatibility name for the decision produced by [`Random`].
25pub type RandomDecision = FallThroughDecision;
26
27/// Stateless weighted classifier used by random fall-through routing.
28pub struct RandomClassifier {
29    targets: Vec<String>,
30    distribution: WeightedIndex<f64>,
31    rng: Mutex<StdRng>,
32}
33
34impl RandomClassifier {
35    /// Creates a classifier over ordered target names.
36    ///
37    /// Missing weights default to one per target. Explicit weights are relative,
38    /// follow target order, and need not sum to one. Zero disables a target.
39    /// Missing `seed` uses entropy-backed randomness.
40    ///
41    /// # Errors
42    ///
43    /// Returns an error when targets are empty or duplicated, or when explicit
44    /// weights have the wrong length, are negative or non-finite, or contain no
45    /// positive value.
46    pub fn new(targets: Vec<String>, weights: Option<Vec<f64>>, seed: Option<u64>) -> Result<Self> {
47        let target_count = targets.len();
48        if target_count == 0 {
49            return Err(LibsyError::NoTargets);
50        }
51        let unique_targets = targets.iter().map(String::as_str).collect::<BTreeSet<_>>();
52        if unique_targets.len() != target_count {
53            return Err(LibsyError::AlgorithmError {
54                message: "random targets must be unique".to_string(),
55            });
56        }
57
58        let weights = weights.unwrap_or_else(|| vec![1.0; target_count]);
59        if weights.len() != target_count {
60            return Err(invalid_weights(format!(
61                "expected {target_count} weights, got {}",
62                weights.len()
63            )));
64        }
65        if weights
66            .iter()
67            .any(|weight| !weight.is_finite() || *weight < 0.0)
68        {
69            return Err(invalid_weights(
70                "weights must be finite and nonnegative".to_string(),
71            ));
72        }
73        if !weights.iter().any(|weight| *weight > 0.0) {
74            return Err(invalid_weights(
75                "at least one weight must be positive".to_string(),
76            ));
77        }
78        let distribution =
79            WeightedIndex::new(weights).map_err(|error| invalid_weights(error.to_string()))?;
80        let rng = match seed {
81            Some(seed) => StdRng::seed_from_u64(seed),
82            None => rand::make_rng(),
83        };
84        Ok(Self {
85            targets,
86            distribution,
87            rng: Mutex::new(rng),
88        })
89    }
90
91    fn select_target(&self) -> String {
92        let mut rng = self.rng.lock();
93        let index = self.distribution.sample(&mut *rng);
94        self.targets[index].clone()
95    }
96}
97
98fn invalid_weights(message: String) -> LibsyError {
99    LibsyError::AlgorithmError {
100        message: format!("invalid random weights: {message}"),
101    }
102}
103
104#[async_trait]
105impl<S> Classifier<S> for RandomClassifier
106where
107    S: Send + 'static,
108{
109    async fn score(
110        &self,
111        _state: &mut S,
112        _request: &mut Request,
113        _driver: Option<&Driver>,
114    ) -> Result<(Classification, Option<Response>)> {
115        Ok((
116            Classification::Scores(vec![Score {
117                confidence: 1.0,
118                target: self.select_target(),
119            }]),
120            None,
121        ))
122    }
123}
124
125/// Random router implemented as a stateless fall-through composition.
126pub struct Random {
127    inner: FallThrough<()>,
128}
129
130impl Random {
131    /// Creates a router over `target_set`.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error when targets or weights are invalid for [`RandomClassifier`].
136    pub fn new(
137        target_set: LlmTargetSet,
138        weights: Option<Vec<f64>>,
139        seed: Option<u64>,
140    ) -> Result<Self> {
141        let target_names = target_set
142            .targets()
143            .iter()
144            .map(|target| target.semantic_name.clone())
145            .collect();
146        let classifier = Arc::new(RandomClassifier::new(target_names, weights, seed)?);
147        let inner = FallThrough::<()>::new(target_set)
148            .with_name("random")
149            .with_decision_reason(random_decision_reason)
150            .with_classifier(classifier);
151        Ok(Self { inner })
152    }
153}
154
155fn random_decision_reason(_name: &str, winner: &Score) -> String {
156    format!("random routing selected target '{}'", winner.target)
157}
158
159#[async_trait]
160impl Algorithm for Random {
161    fn name(&self) -> &str {
162        "random"
163    }
164
165    async fn create_run_task(
166        self: Arc<Self>,
167        ctx: Context,
168        driver: Driver,
169        request: Request,
170    ) -> Result<Response> {
171        self.inner.execute(ctx, driver, request).await
172    }
173}
174
175#[cfg(test)]
176#[path = "rand_tests.rs"]
177mod tests;