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::time::{Duration, Instant};
9use switchyard_protocol::{Category, InfraSignals, ModelId};
10
11/// Queue and capacity limits for moving requests to the capable model.
12#[derive(Clone, Debug, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct InfraPolicy {
15    /// Maximum age of the pool observation.
16    pub max_signal_age_ms: u64,
17    /// Every efficient worker must have at least this many waiting requests.
18    pub min_waiting_requests: u64,
19    /// Stop moving requests when observed running or locally tracked capable work reaches this limit.
20    pub max_capable_inflight: u64,
21}
22
23impl InfraPolicy {
24    /// Validate the policy once at configuration load.
25    pub fn validate(&self) -> Result<()> {
26        if self.max_signal_age_ms == 0
27            || self.min_waiting_requests == 0
28            || self.max_capable_inflight == 0
29        {
30            return Err(LibsyError::AlgorithmError {
31                message: "infra requires positive limits".into(),
32            });
33        }
34        Ok(())
35    }
36
37    fn evaluate(
38        &self,
39        signals: &InfraSignals,
40        source: &ModelId,
41        destination: &ModelId,
42    ) -> Option<(bool, &'static str)> {
43        let source = signals.get(source)?.load.as_ref()?;
44        let destination = signals.get(destination)?;
45        let load = destination.load.as_ref()?;
46        let now = Instant::now();
47        if [source, load].iter().any(|pool| {
48            now.checked_duration_since(pool.sampled_at)
49                .is_none_or(|age| age > Duration::from_millis(self.max_signal_age_ms))
50        }) {
51            return None;
52        }
53        if source.min_waiting < self.min_waiting_requests {
54            return Some((false, "small_pool_not_queued"));
55        }
56        if load.max_waiting > 0
57            || load.running.max(destination.local_inflight) >= self.max_capable_inflight
58        {
59            return Some((false, "protect_capable_capacity"));
60        }
61        Some((true, "small_pool_queued"))
62    }
63
64    /// Adjust a decision-only task result. Returns true only when the model changes.
65    ///
66    /// Hosts must serialize this check with their local in-flight increment, then decrement
67    /// when the response ends or is cancelled. Worker selection remains in the serving layer.
68    pub fn apply(
69        &self,
70        outcome: &mut RoutingOutcome,
71        models: &RuntimeModels,
72        signals: &InfraSignals,
73    ) -> Result<bool> {
74        if outcome.response.is_some()
75            || outcome
76                .request
77                .metadata
78                .as_ref()
79                .is_some_and(|m| m.is_subagent_work())
80        {
81            return Ok(false);
82        }
83        let ([efficient], [capable]) = (
84            models.models_for(&Category::Efficient),
85            models.models_for(&Category::Capable),
86        ) else {
87            return Err(LibsyError::AlgorithmError {
88                message: "infra requires one efficient and one capable model".into(),
89            });
90        };
91        if outcome.selected_model_id()? != efficient || efficient == capable {
92            return Ok(false);
93        }
94        let (is_spillover, reason) = self
95            .evaluate(signals, efficient, capable)
96            .unwrap_or((false, "unknown_or_stale_signals"));
97        tracing::info!(task_model = %efficient, alternate_model = %capable, spillover = is_spillover, reason,
98            "queue routing decision");
99        if is_spillover {
100            outcome.selected_model_ids.retain(|id| id != capable);
101            outcome.selected_model_ids.insert(0, capable.clone());
102            outcome.request.llm_request.model = Some(capable.to_string());
103        }
104        Ok(is_spillover)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use switchyard_protocol::{ModelInfraSignals, PoolLoad, Request, text_request};
112
113    #[test]
114    fn queue_policy_preserves_task_choices() {
115        let policy = InfraPolicy {
116            max_signal_age_ms: 1000,
117            min_waiting_requests: 4,
118            max_capable_inflight: 2,
119        };
120        policy.validate().unwrap();
121        let models = RuntimeModels::new(
122            [
123                (Category::Efficient, vec!["small".into()]),
124                (Category::Capable, vec!["large".into()]),
125            ]
126            .into(),
127        );
128        let request = Request {
129            llm_request: text_request(None, "hello"),
130            ..Default::default()
131        };
132        for case in [
133            "busy",
134            "one_quiet",
135            "capable_queued",
136            "capable_running",
137            "local_limit",
138            "missing",
139            "stale",
140            "future",
141            "below_threshold",
142            "at_threshold",
143            "capable_task",
144        ] {
145            let model = |waiting| ModelInfraSignals {
146                load: Some(PoolLoad {
147                    sampled_at: Instant::now(),
148                    running: 0,
149                    min_waiting: waiting,
150                    max_waiting: waiting,
151                }),
152                ..Default::default()
153            };
154            let mut source = model(8);
155            let mut destination = model(0);
156            match case {
157                "one_quiet" => source.load.as_mut().unwrap().min_waiting = 0,
158                "capable_queued" => destination.load.as_mut().unwrap().max_waiting = 1,
159                "capable_running" => destination.load.as_mut().unwrap().running = 2,
160                "local_limit" => destination.local_inflight = 2,
161                "missing" => source.load = None,
162                "stale" => source.load.as_mut().unwrap().sampled_at -= Duration::from_secs(2),
163                "future" => destination.load.as_mut().unwrap().sampled_at += Duration::from_secs(2),
164                "below_threshold" => source.load.as_mut().unwrap().min_waiting = 3,
165                "at_threshold" => source.load.as_mut().unwrap().min_waiting = 4,
166                _ => {}
167            }
168            let signals = [
169                (ModelId::from("small"), source),
170                (ModelId::from("large"), destination),
171            ]
172            .into();
173            let initial = if case == "capable_task" {
174                "large"
175            } else {
176                "small"
177            };
178            let mut outcome = RoutingOutcome::route_to(initial.into(), vec![], request.clone());
179            let is_spillover = matches!(case, "busy" | "at_threshold");
180            assert_eq!(
181                policy.apply(&mut outcome, &models, &signals).unwrap(),
182                is_spillover,
183                "{case}"
184            );
185            assert_eq!(
186                outcome.selected_model_id().unwrap().as_str(),
187                if is_spillover { "large" } else { initial },
188                "{case}"
189            );
190            outcome.request.llm_request.model = None;
191            assert_eq!(outcome.request.llm_request, request.llm_request);
192        }
193        let mut invalid = policy;
194        invalid.min_waiting_requests = 0;
195        assert!(invalid.validate().is_err());
196    }
197}