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::core::state::State;
22use crate::{LibsyError, Result};
23use switchyard_protocol::{Category, Request, Response};
24
25pub struct RandomClassifier {
27 distribution: Option<WeightedIndex<f64>>,
28 weights: Option<Vec<f64>>,
29 rng: Mutex<StdRng>,
30}
31
32impl RandomClassifier {
33 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 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 distribution.sample(&mut *rng)
105 } else {
106 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
121pub struct Random {
123 inner: FallThrough,
124 classifier: Arc<RandomClassifier>,
125}
126
127impl Random {
128 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 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 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}