switchyard_libsy/algorithms/
infra.rs1use crate::{LibsyError, Result, RoutingOutcome, RuntimeModels};
7use serde::Deserialize;
8use std::{
9 collections::BTreeMap,
10 time::{Duration, Instant},
11};
12use switchyard_protocol::{Category, InfraSignals, ModelInfraSignals};
13
14#[derive(Clone, Debug, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct QueueCalibration {
18 pub batch_size: u64,
20 pub batch_time_ms: f64,
22 pub request_time_ms: f64,
24}
25
26#[derive(Clone, Debug, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct InfraPolicyConfig {
30 pub max_signal_age_ms: u64,
32 pub min_waiting_requests: u64,
34 pub max_capable_inflight: u64,
36 pub max_spillovers: u64,
38 pub min_saving_ms: f64,
40 pub efficient_workers: BTreeMap<String, QueueCalibration>,
42 pub capable_workers: BTreeMap<String, f64>,
44}
45
46pub struct InfraPolicy {
48 config: InfraPolicyConfig,
49}
50
51impl InfraPolicy {
52 pub fn new(config: InfraPolicyConfig) -> Result<Self> {
54 let positive = |v: f64| v.is_finite() && v > 0.0;
55 if config.max_signal_age_ms == 0
56 || config.min_waiting_requests == 0
57 || config.max_capable_inflight == 0
58 || config.max_spillovers == 0
59 || !config.min_saving_ms.is_finite()
60 || config.min_saving_ms < 0.0
61 || config.efficient_workers.is_empty()
62 || config.capable_workers.is_empty()
63 || config.efficient_workers.iter().any(|(id, c)| {
64 id.is_empty()
65 || c.batch_size == 0
66 || !positive(c.batch_time_ms)
67 || !positive(c.request_time_ms)
68 || config.capable_workers.contains_key(id)
69 })
70 || config
71 .capable_workers
72 .iter()
73 .any(|(id, ms)| id.is_empty() || !positive(*ms))
74 {
75 return Err(LibsyError::AlgorithmError { message: "infra requires positive limits and service times, distinct nonempty worker IDs, and a finite nonnegative saving margin".into() });
76 }
77 Ok(Self { config })
78 }
79
80 fn fresh<T>(
81 &self,
82 model: &ModelInfraSignals,
83 expected: &BTreeMap<String, T>,
84 now: Instant,
85 ) -> bool {
86 expected.len() == model.workers.len()
87 && expected.keys().all(|id| {
88 model.workers.get(id).is_some_and(|worker| {
89 now.checked_duration_since(worker.sampled_at)
90 .is_some_and(|age| {
91 age <= Duration::from_millis(self.config.max_signal_age_ms)
92 })
93 })
94 })
95 }
96
97 fn evaluate(
98 &self,
99 source: &ModelInfraSignals,
100 destination: &ModelInfraSignals,
101 ) -> (bool, &'static str, Option<(f64, f64)>) {
102 let now = Instant::now();
103 if !self.fresh(source, &self.config.efficient_workers, now)
104 || !self.fresh(destination, &self.config.capable_workers, now)
105 {
106 return (false, "unknown_or_stale_signals", None);
107 }
108 if source
109 .workers
110 .values()
111 .any(|w| w.waiting < self.config.min_waiting_requests)
112 {
113 return (false, "small_workers_not_all_queued", None);
114 }
115 let running = destination
116 .workers
117 .values()
118 .try_fold(0u64, |sum, w| sum.checked_add(w.running));
119 if destination.workers.values().any(|w| w.waiting > 0)
120 || running.is_none_or(|n| {
121 n.max(destination.local_inflight) >= self.config.max_capable_inflight
122 })
123 || destination.local_spillovers >= self.config.max_spillovers
124 {
125 return (false, "protect_capable_capacity", None);
126 }
127 let small_ms = source
129 .workers
130 .iter()
131 .map(|(id, worker)| {
132 let c = &self.config.efficient_workers[id];
133 worker.waiting.div_ceil(c.batch_size) as f64 * c.batch_time_ms + c.request_time_ms
134 })
135 .fold(f64::INFINITY, f64::min);
136 let large_ms = self
137 .config
138 .capable_workers
139 .values()
140 .copied()
141 .fold(0.0, f64::max);
142 if !small_ms.is_finite() {
143 return (false, "invalid_queue_estimate", None);
144 }
145 let move_request = small_ms > large_ms + self.config.min_saving_ms;
146 (
147 move_request,
148 if move_request {
149 "queue_cost_exceeds_capable_service"
150 } else {
151 "insufficient_predicted_saving"
152 },
153 Some((small_ms, large_ms)),
154 )
155 }
156
157 pub fn apply(
162 &self,
163 outcome: &mut RoutingOutcome,
164 models: &RuntimeModels,
165 signals: &InfraSignals,
166 ) -> Result<bool> {
167 if outcome.response.is_some()
168 || outcome
169 .request
170 .metadata
171 .as_ref()
172 .is_some_and(|m| m.is_subagent_work())
173 {
174 return Ok(false);
175 }
176 let ([efficient], [capable]) = (
177 models.models_for(&Category::Efficient),
178 models.models_for(&Category::Capable),
179 ) else {
180 return Err(LibsyError::AlgorithmError {
181 message: "infra requires one efficient and one capable model".into(),
182 });
183 };
184 if outcome.selected_model_id()? != efficient || efficient == capable {
185 return Ok(false);
186 }
187 let (spillover, reason, estimates) = match (signals.get(efficient), signals.get(capable)) {
188 (Some(source), Some(destination)) => self.evaluate(source, destination),
189 _ => (false, "unknown_or_stale_signals", None),
190 };
191 if let Some(metadata) = &mut outcome.metadata {
192 let evidence = metadata
193 .evidence
194 .get_or_insert_with(|| serde_json::json!({}));
195 if let Some(object) = evidence.as_object_mut() {
196 object.insert(
197 "infra".into(),
198 serde_json::json!({
199 "task_model": efficient, "alternate_model": capable, "spillover": spillover,
200 "reason": reason, "estimated_small_ms": estimates.map(|v| v.0),
201 "estimated_capable_ms": estimates.map(|v| v.1),
202 }),
203 );
204 }
205 }
206 tracing::info!(task_model = %efficient, alternate_model = %capable, spillover, reason,
207 estimated_small_ms = estimates.map(|v| v.0), estimated_capable_ms = estimates.map(|v| v.1),
208 "queue routing decision");
209 if spillover {
210 outcome.selected_model_ids.retain(|id| id != capable);
211 outcome.selected_model_ids.insert(0, capable.clone());
212 outcome.request.llm_request.model = Some(capable.to_string());
213 }
214 Ok(spillover)
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use switchyard_protocol::{ModelId, Request, WorkerLoad, text_request};
222
223 fn config() -> InfraPolicyConfig {
224 InfraPolicyConfig {
225 max_signal_age_ms: 1000,
226 min_waiting_requests: 4,
227 max_capable_inflight: 2,
228 max_spillovers: 2,
229 min_saving_ms: 500.0,
230 efficient_workers: ["a", "b"]
231 .into_iter()
232 .map(|id| {
233 (
234 id.into(),
235 QueueCalibration {
236 batch_size: 4,
237 batch_time_ms: 2000.0,
238 request_time_ms: 1000.0,
239 },
240 )
241 })
242 .collect(),
243 capable_workers: [("c".into(), 4000.0)].into(),
244 }
245 }
246
247 #[test]
248 fn queue_policy_requires_all_workers_and_preserves_task_choices() {
249 for case in [
250 "busy",
251 "one_quiet",
252 "capable_queued",
253 "capable_running",
254 "local_limit",
255 "spillover_limit",
256 "missing",
257 "stale",
258 "future",
259 "no_saving",
260 "capable_task",
261 ] {
262 let model = |ids: &[&str], waiting| ModelInfraSignals {
263 workers: ids
264 .iter()
265 .map(|id| {
266 (
267 id.to_string(),
268 WorkerLoad {
269 sampled_at: Instant::now(),
270 running: 0,
271 waiting,
272 },
273 )
274 })
275 .collect(),
276 ..Default::default()
277 };
278 let mut source = model(&["a", "b"], 8);
279 let mut destination = model(&["c"], 0);
280 match case {
281 "one_quiet" => source.workers.get_mut("b").unwrap().waiting = 0,
282 "capable_queued" => destination.workers.get_mut("c").unwrap().waiting = 1,
283 "capable_running" => destination.workers.get_mut("c").unwrap().running = 2,
284 "local_limit" => destination.local_inflight = 2,
285 "spillover_limit" => destination.local_spillovers = 2,
286 "missing" => {
287 source.workers.remove("a");
288 }
289 "stale" => {
290 source.workers.get_mut("a").unwrap().sampled_at -= Duration::from_secs(2)
291 }
292 "future" => {
293 destination.workers.get_mut("c").unwrap().sampled_at += Duration::from_secs(2)
294 }
295 "no_saving" => source.workers.get_mut("b").unwrap().waiting = 4,
296 _ => {}
297 }
298 let signals = [
299 (ModelId::from("small"), source),
300 (ModelId::from("large"), destination),
301 ]
302 .into();
303 let models = RuntimeModels::new(
304 [
305 (Category::Efficient, vec!["small".into()]),
306 (Category::Capable, vec!["large".into()]),
307 ]
308 .into(),
309 );
310 let request = Request {
311 llm_request: text_request(None, "hello"),
312 ..Default::default()
313 };
314 let mut outcome = RoutingOutcome::route_to(
315 if case == "capable_task" {
316 "large"
317 } else {
318 "small"
319 }
320 .into(),
321 vec![],
322 request.clone(),
323 );
324 let moved = InfraPolicy::new(config())
325 .unwrap()
326 .apply(&mut outcome, &models, &signals)
327 .unwrap();
328 assert_eq!(moved, case == "busy", "{case}");
329 let expected = if matches!(case, "busy" | "capable_task") {
330 "large"
331 } else {
332 "small"
333 };
334 assert_eq!(
335 outcome.selected_model_id().unwrap().as_str(),
336 expected,
337 "{case}"
338 );
339 outcome.request.llm_request.model = None;
340 assert_eq!(outcome.request.llm_request, request.llm_request);
341 }
342 let mut invalid = config();
343 invalid
344 .efficient_workers
345 .get_mut("a")
346 .unwrap()
347 .batch_time_ms = f64::NAN;
348 assert!(InfraPolicy::new(invalid).is_err());
349 }
350}