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, 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
24pub type RandomDecision = FallThroughDecision;
26
27pub struct RandomClassifier {
29 targets: Vec<String>,
30 distribution: WeightedIndex<f64>,
31 rng: Mutex<StdRng>,
32}
33
34impl RandomClassifier {
35 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
125pub struct Random {
127 inner: FallThrough<()>,
128}
129
130impl Random {
131 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;