switchyard_libsy/algorithms/
rand.rs1use 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
24pub struct RandomClassifier {
26 distribution: Option<WeightedIndex<f64>>,
27 weights: Option<Vec<f64>>,
28 rng: Mutex<StdRng>,
29}
30
31impl RandomClassifier {
32 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 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 distribution.sample(&mut *rng)
107 } else {
108 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
123pub struct Random {
125 inner: FallThrough<()>,
126 classifier: Arc<RandomClassifier>,
127}
128
129impl Random {
130 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 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 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}