Skip to main content

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}