Skip to main content

switchyard_libsy/algorithms/
infra.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional queue-based moves from the efficient model to the capable model.
5
6use 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/// Measured service times for a representative, bounded workload on one efficient worker.
15#[derive(Clone, Debug, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct QueueCalibration {
18    /// Number of background requests served per measured batch.
19    pub batch_size: u64,
20    /// Duration of a background batch, measured under the expected contention.
21    pub batch_time_ms: f64,
22    /// Duration of the foreground request without a background queue.
23    pub request_time_ms: f64,
24}
25
26/// Queue thresholds and measured service times. No production defaults are assumed.
27#[derive(Clone, Debug, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct InfraPolicyConfig {
30    /// Maximum age of every required worker observation.
31    pub max_signal_age_ms: u64,
32    /// Every efficient worker must have at least this many waiting requests.
33    pub min_waiting_requests: u64,
34    /// Stop moving requests when observed running or locally tracked capable work reaches this limit.
35    pub max_capable_inflight: u64,
36    /// Limit concurrent moved requests through the host.
37    pub max_spillovers: u64,
38    /// Required predicted saving over the capable model's service time.
39    pub min_saving_ms: f64,
40    /// All efficient workers, with their measured batch and foreground service times.
41    pub efficient_workers: BTreeMap<String, QueueCalibration>,
42    /// All capable workers, with their measured foreground service time in milliseconds.
43    pub capable_workers: BTreeMap<String, f64>,
44}
45
46/// Applies a queue policy after task routing; the host owns collection and request lifetimes.
47pub struct InfraPolicy {
48    config: InfraPolicyConfig,
49}
50
51impl InfraPolicy {
52    /// Validate the policy once at configuration load.
53    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        // Use the fastest small-worker estimate: EPP may still find a better small worker.
128        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    /// Adjust a decision-only task result. Returns true only when the model changes.
158    ///
159    /// Hosts must serialize this check with their local in-flight increment, then decrement
160    /// when the response ends or is cancelled. Worker selection remains in the serving layer.
161    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}