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