switchyard_libsy/algorithms/vgr/config.rs
1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Operator configuration for a verification-gated route.
5//!
6//! Everything an operator chooses lives here: which tiers to route between,
7//! which verifiers to consult, how long the decision may take, and — most
8//! consequentially — whether decisions are allowed to route traffic at all.
9
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use async_trait::async_trait;
14use switchyard_protocol::ModelId;
15
16use super::policy::Policy;
17use super::safety::{BreakerConfig, KillSwitch};
18use crate::{LibsyError, Result};
19
20/// How much authority a decision has over the traffic it decides.
21///
22/// The ladder exists because a routing decision and *acting* on that decision
23/// are separable, and the gap between them is where a new router earns trust: a
24/// deployment can measure what VGR would have done for as long as it likes
25/// before letting it do anything.
26#[derive(Clone, Debug, Default, Eq, PartialEq)]
27pub enum ServingMode {
28 /// Decisions are not made. Every request goes to the capable tier.
29 ///
30 /// The default, so a route that is configured but not yet consciously
31 /// enabled cannot commit anything locally.
32 #[default]
33 Off,
34 /// Decisions are made and reported, and the *decided* route is served.
35 ///
36 /// For isolated measurement only: readiness gates are not applied, so this
37 /// serves routes a production deployment would refuse.
38 Evaluate,
39 /// Decisions are made and reported, but the capable tier is always served.
40 ///
41 /// The safe observation mode — full decision records at no routing risk.
42 Shadow,
43 /// Decisions route traffic, subject to the readiness gates.
44 ///
45 /// Requires an explicit attestation string, so enabling live local commits
46 /// is a deliberate act rather than a config default someone inherited.
47 Active {
48 /// The operator's recorded approval to serve local commits.
49 approval: String,
50 },
51}
52
53/// One invocation of an operator-supplied sandboxed checker.
54///
55/// The absolute deadline and remaining budget describe the same decision
56/// deadline. The manifest identity is pinned by [`ValidatedChecker`], not chosen
57/// by a request or by the checker implementation.
58#[derive(Clone, Copy, Debug)]
59pub struct CheckerRequest<'a> {
60 /// The task whose locally produced attempt is being checked.
61 pub task_text: &'a str,
62 /// The locally produced attempt to check.
63 pub attempt: &'a str,
64 /// Absolute monotonic deadline for the check.
65 pub deadline: Instant,
66 /// Budget remaining when the checker was invoked.
67 pub remaining: Duration,
68 /// Operator-authenticated identity of the immutable check manifest.
69 pub manifest_identity: &'a str,
70}
71
72/// An asynchronous, cancellation-safe sandboxed checker.
73///
74/// Dropping the returned future must cancel the check or terminate its isolated
75/// worker. VGR also enforces [`CheckerRequest::deadline`] around every call.
76#[async_trait]
77pub trait Checker: Send + Sync {
78 /// Runs the task's checks against an attempt, reporting whether they passed.
79 ///
80 /// `None` means the checks could not be run to a conclusion — a timeout, a
81 /// sandbox failure, a missing manifest. It is not a failure verdict, but it
82 /// commits nothing either.
83 async fn check(&self, request: CheckerRequest<'_>) -> Option<bool>;
84}
85
86/// A checker bound to validation evidence for one pinned manifest.
87///
88/// Keeping the checker handle and authenticated manifest identity in one value
89/// makes it impossible for route configuration to claim checker validation
90/// without also supplying the checker that was validated.
91#[derive(Clone)]
92pub struct ValidatedChecker {
93 checker: Arc<dyn Checker>,
94 manifest_identity: Arc<str>,
95}
96
97impl ValidatedChecker {
98 /// Binds a checker to an operator-authenticated immutable manifest identity.
99 ///
100 /// The host authenticates the identity before construction. Empty,
101 /// whitespace-padded, or control-character identities are rejected so the
102 /// pinned value is unambiguous when passed to the checker.
103 pub fn new(
104 checker: Arc<dyn Checker>,
105 authenticated_manifest_identity: impl Into<String>,
106 ) -> Result<Self> {
107 let identity = authenticated_manifest_identity.into();
108 if identity.is_empty()
109 || identity.trim() != identity
110 || identity.chars().any(char::is_control)
111 {
112 return Err(LibsyError::AlgorithmError {
113 message: "vgr checker requires an authenticated manifest identity".to_string(),
114 });
115 }
116 Ok(Self {
117 checker,
118 manifest_identity: Arc::from(identity),
119 })
120 }
121
122 /// Runs the bound checker with its pinned manifest identity.
123 pub(super) async fn check(
124 &self,
125 task_text: &str,
126 attempt: &str,
127 deadline: Instant,
128 ) -> Option<bool> {
129 let remaining = deadline.saturating_duration_since(Instant::now());
130 if remaining.is_zero() {
131 return None;
132 }
133 let request = CheckerRequest {
134 task_text,
135 attempt,
136 deadline,
137 remaining,
138 manifest_identity: &self.manifest_identity,
139 };
140 tokio::time::timeout_at(
141 tokio::time::Instant::from_std(deadline),
142 self.checker.check(request),
143 )
144 .await
145 .ok()
146 .flatten()
147 }
148
149 pub(super) fn manifest_identity(&self) -> &str {
150 &self.manifest_identity
151 }
152}
153
154/// The tiers a verification-gated route moves between.
155#[derive(Clone, Debug)]
156pub struct Targets {
157 /// The tier that produces the attempt being verified.
158 pub local: ModelId,
159 /// The tier a request escalates to when the attempt is not licensed.
160 pub cloud: ModelId,
161 /// The tier that answers verification questions.
162 ///
163 /// Defaults to the local tier, since the readout and deliberation rungs are
164 /// deliberately cheap and local.
165 pub judge: Option<ModelId>,
166 /// The tier that answers the cloud confirmation rungs, when configured.
167 ///
168 /// Leaving this unset removes those rungs, which the decision rules treat as
169 /// signals never produced rather than as indeterminate ones.
170 pub cloud_judge: Option<ModelId>,
171}
172
173/// A complete verification-gated route.
174#[derive(Clone)]
175pub struct VgrConfig {
176 /// The tiers to route between and consult.
177 pub targets: Targets,
178 /// Whether the local tier accepts image content.
179 ///
180 /// False by default because an undeclared local capability must not turn an
181 /// image-bearing request into a failed speculative call.
182 pub local_supports_images: bool,
183 /// The decision constants. Defaults to the current policy.
184 pub policy: Policy,
185 /// How much authority decisions have.
186 pub mode: ServingMode,
187 /// A checker bound to its authenticated pinned-manifest evidence.
188 pub checker: Option<ValidatedChecker>,
189 /// Whether the operator declares this surface enforces a schema-validated
190 /// terse final-answer format, on which typed agreement is checkable.
191 pub structured_answer: bool,
192 /// Whether an escalation carries the local attempt forward as context.
193 ///
194 /// Per-route rather than global: carrying the attempt was measured to help
195 /// substantially on research-style work and to hurt on conversational work.
196 pub speculation_carry: bool,
197 /// The budget for the whole decision, verification included.
198 ///
199 /// Exceeding it does not fail the request; it ends evidence gathering, and
200 /// whatever was not established stays unestablished — which escalates.
201 pub deadline: Duration,
202 /// An operator handle that stops local commits without rebuilding the route.
203 ///
204 /// Unset means no runtime stop exists, which is not the same as one that is
205 /// never engaged: the operator must hold a handle for there to be anything
206 /// to engage.
207 pub kill_switch: Option<KillSwitch>,
208 /// Tuning for the local-endpoint circuit breaker.
209 pub breaker: BreakerConfig,
210 /// Whether the router types the request before deriving capabilities.
211 ///
212 /// Typing costs one cheap local call per turn and is what makes the
213 /// answer and conversational regimes reachable at all; abstaining selects
214 /// the default regime, which is more conservative rather than weaker.
215 pub task_typing: bool,
216 /// Whether a session that escalated stays on the capable tier.
217 ///
218 /// Off by default: holding a session on the capable tier is a cost decision
219 /// an operator makes, not one this router should make for them.
220 pub latch_escalation: bool,
221}
222
223impl VgrConfig {
224 /// A route between two tiers, with everything else at its default.
225 pub fn new(local: ModelId, cloud: ModelId) -> Self {
226 Self {
227 targets: Targets {
228 local,
229 cloud,
230 judge: None,
231 cloud_judge: None,
232 },
233 local_supports_images: false,
234 policy: Policy::CURRENT,
235 mode: ServingMode::Off,
236 checker: None,
237 structured_answer: false,
238 speculation_carry: false,
239 deadline: Duration::from_secs(30),
240 kill_switch: None,
241 breaker: BreakerConfig::default(),
242 task_typing: true,
243 latch_escalation: false,
244 }
245 }
246
247 /// The tier that answers verification questions, defaulting to local.
248 pub(super) fn judge_target(&self) -> &ModelId {
249 match self.targets.judge.as_ref() {
250 Some(judge) => judge,
251 None => &self.targets.local,
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 struct NoopChecker;
261
262 #[async_trait]
263 impl Checker for NoopChecker {
264 async fn check(&self, _request: CheckerRequest<'_>) -> Option<bool> {
265 None
266 }
267 }
268
269 #[test]
270 fn checker_validation_binds_a_handle_to_one_manifest() -> Result<()> {
271 let checker = ValidatedChecker::new(
272 Arc::new(NoopChecker),
273 "sha256:0123456789abcdef0123456789abcdef",
274 )?;
275
276 assert_eq!(
277 checker.manifest_identity(),
278 "sha256:0123456789abcdef0123456789abcdef"
279 );
280 Ok(())
281 }
282
283 #[test]
284 fn checker_validation_rejects_ambiguous_manifest_identity() {
285 for identity in ["", " manifest", "manifest ", "manifest\nother"] {
286 assert!(
287 ValidatedChecker::new(Arc::new(NoopChecker), identity).is_err(),
288 "{identity:?} must not become checker evidence"
289 );
290 }
291 }
292}