Skip to main content

switchyard_libsy/algorithms/
vgr.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Verification-gated routing: fail-closed capability derivation.
5//!
6//! Capabilities are never client-declared. They come from the router's own
7//! typing of the request, the attempt it produced on the efficient tier, and its own reading
8//! of the tool results in the conversation. No client-reachable input selects
9//! a weaker regime than the default one; unsupported content and missing
10//! evidence select [`Branch::Unknown`], which never commits.
11
12use std::sync::Arc;
13
14use switchyard_protocol::Request;
15
16use self::text::ToolRecord;
17use crate::Result;
18use crate::algorithms::fall_through::FallThrough;
19use crate::algorithms::util::affinity::AffinityRouter;
20use crate::core::algorithm::{Algorithm, AnswerSummary, Driver, RoutingOutcome};
21
22mod compaction;
23mod config;
24mod decide;
25mod readout;
26mod render;
27mod rungs;
28mod runtime;
29mod safety;
30mod text;
31
32pub use config::{ACTIVE_APPROVAL, ServingMode, Targets, VgrConfig};
33pub use safety::{BreakerConfig, KillSwitch};
34
35#[cfg(test)]
36mod tests;
37
38/// A verification-gated route between an efficient and a capable tier.
39pub struct Vgr {
40    route: FallThrough,
41    classifier: Arc<runtime::VgrClassifier>,
42}
43
44impl Vgr {
45    /// Validates and constructs a verification-gated route.
46    pub fn new(config: VgrConfig) -> Result<Self> {
47        config.validate()?;
48        let mut affinity = AffinityRouter::new().with_latch_only([config.targets.capable.clone()]);
49        let reason = if config.latch_escalation && config.mode != ServingMode::Shadow {
50            "session_latched"
51        } else {
52            affinity = affinity.with_release_on_user_turn();
53            "turn_verification_latched"
54        };
55        let affinity = Arc::new(affinity);
56        let mut route = FallThrough::new_with_state().with_name("vgr");
57        if config.compact_handoff {
58            route = route.with_classifier(Arc::new(compaction::Compactor));
59        }
60        let classifier = Arc::new(runtime::VgrClassifier {
61            breaker: safety::CircuitBreaker::new(config.breaker),
62            config,
63        });
64        // Shadow always serves the capable tier; only a VGR escalation should hold it there.
65        if classifier.config.mode != ServingMode::Shadow {
66            route = route
67                .with_processor(affinity.clone())
68                .with_classifier(Arc::new(runtime::Held(affinity, reason)));
69        }
70        let route = route.with_classifier(classifier.clone());
71        Ok(Self { route, classifier })
72    }
73}
74
75#[async_trait::async_trait]
76impl Algorithm for Vgr {
77    fn name(&self) -> &str {
78        "vgr"
79    }
80
81    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
82        // The handoff note is written for the capable tier, so an efficient stand-in gets the request
83        // as it arrived.
84        let arrived = self
85            .classifier
86            .efficient_may_stand_in(&request)
87            .then(|| request.clone());
88        let mut outcome = self.route.execute(driver, request).await?;
89        // Efficient answers are already buffered and have no fallback.
90        outcome.selected_model_ids.truncate(1);
91        let config = &self.classifier.config;
92        if let Some(arrived) = arrived
93            && outcome.response.is_none()
94            && outcome.selected_model_ids.first() == Some(&config.targets.capable)
95            // The efficient tier may have failed while this request was routed.
96            && self.classifier.efficient_may_stand_in(&arrived)
97        {
98            outcome
99                .selected_model_ids
100                .push(config.targets.efficient.clone());
101            outcome.fallback_request = Some(arrived);
102        }
103        Ok(outcome)
104    }
105
106    fn tracks_answers(&self) -> bool {
107        self.route.tracks_answers()
108    }
109
110    async fn observe_answer(&self, session_id: &str, answer: AnswerSummary) {
111        self.route.observe_answer(session_id, answer).await;
112    }
113}
114
115/// The verification regime a request's capabilities license.
116#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
117enum Branch {
118    /// Code or test activity, with no sandboxed checker to appeal to.
119    Coding,
120    /// A single-turn request typed as answer-seeking.
121    Answer,
122    /// Conversational traffic.
123    Chat,
124    /// A tool-using session verified from its trajectory.
125    Agentic,
126    /// Anything else with an attempt to verify.
127    DefaultVerified,
128    /// Nothing to verify.
129    #[default]
130    Unknown,
131}
132
133/// A task type produced by the router's own typing call; `None` abstains.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135enum TaskType {
136    Coding,
137    Agentic,
138    Answer,
139    Chat,
140}
141
142/// The complete input the decision core sees.
143#[derive(Default)]
144struct Capabilities {
145    branch: Branch,
146    /// The judged view of the request and attempt.
147    transcript: Option<String>,
148    /// The router's own tool-result record; unused by the answer regime.
149    tools: Option<ToolRecord>,
150}
151
152/// Derives capabilities from the router's own evidence, failing closed.
153fn derive_capabilities(
154    request: &Request,
155    attempt: &str,
156    task_type: Option<TaskType>,
157) -> Capabilities {
158    let (turns, unsupported) = text::turns(request);
159    if unsupported || text::user_task_text(&turns).trim().is_empty() || attempt.trim().is_empty() {
160        return Capabilities::default();
161    }
162    let caps = |branch, transcript| Capabilities {
163        branch,
164        transcript,
165        tools: Some(ToolRecord::from_request(request)),
166    };
167
168    // Typing abstains on a request that leans on earlier context, so a typed answer stands
169    // on its own; only tool use in the current user turn takes it off this branch.
170    if task_type == Some(TaskType::Answer) && !text::turn_has_tool_trajectory(request) {
171        return Capabilities {
172            tools: None,
173            ..caps(
174                Branch::Answer,
175                Some(render::render_session(&turns, attempt)),
176            )
177        };
178    }
179    // A tool trajectory is judged as agentic work unless typed conversational.
180    if task_type == Some(TaskType::Agentic)
181        || (text::has_tool_trajectory(request)
182            && !matches!(task_type, Some(TaskType::Answer | TaskType::Chat)))
183    {
184        return caps(
185            Branch::Agentic,
186            Some(render::render_agentic_view(request, &turns, attempt)),
187        );
188    }
189    let transcript = Some(render::render_session(&turns, attempt));
190    if text::observed_hardening(attempt) || task_type == Some(TaskType::Coding) {
191        return caps(Branch::Coding, transcript);
192    }
193    if text::observed_tool_activity(attempt) {
194        caps(Branch::Agentic, transcript)
195    } else if matches!(task_type, Some(TaskType::Chat | TaskType::Answer)) {
196        caps(Branch::Chat, transcript)
197    } else {
198        caps(Branch::DefaultVerified, transcript)
199    }
200}