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: capability derivation and branch selection.
5//!
6//! The router decides whether to commit a locally produced attempt or escalate
7//! it, based on evidence about that specific attempt. This module holds the
8//! decision core: everything that is pure and does no I/O, so that what the
9//! router concludes is separable from the calls that gathered the evidence.
10//!
11//! Three stages, in order. [`derive_capabilities`] builds the [`Capabilities`]
12//! the decision core is allowed to see. [`select_branch`] picks the verification
13//! regime those capabilities license. [`decide::decide_from_signals`] applies
14//! that regime's rule to the evidence and returns a decision. The rules
15//! themselves live in [`rules`], and the constants they read in [`policy`].
16//!
17//! # Trust model
18//!
19//! [`Capabilities`] are **never client-declared**, and never read from
20//! client-supplied structure such as tool schemas or third-party classifier
21//! output. [`derive_capabilities`] builds them from exactly four sources:
22//!
23//! 1. **Operator route configuration** — whether a checker is configured.
24//! 2. **The router's own typing of the request** — a [`TaskType`] the router
25//! produced itself. Anything outside the known set is an abstention.
26//! 3. **Locally produced attempt evidence** — the attempt this router generated,
27//! and only that attempt.
28//! 4. **Request-derived tool summaries** — the native runtime deliberately
29//! trusts normalized transcript tool results as Host evidence. A client that
30//! can submit conversation history can therefore authorize an agentic commit
31//! by reporting a clean tool record.
32//!
33//! The derivation lattice is **monotone**: no input reachable by a client
34//! selects a weaker verification regime than the default one. Evidence found in
35//! the attempt can only harden the branch. Unsupported content and incomplete
36//! context fail closed, to capabilities that select [`Branch::Unknown`].
37
38#![allow(dead_code)]
39
40use std::sync::Arc;
41
42use switchyard_protocol::Request;
43
44use crate::core::algorithm::{Algorithm, Driver};
45use crate::core::state::State;
46use crate::{Result, RoutingOutcome};
47
48use self::config::VgrConfig;
49use self::fall_through::FallThrough;
50use crate::algorithms::fall_through;
51use crate::algorithms::util::affinity::AffinityRouter;
52
53// Unix process groups and Windows Job Objects preserve the same cancellation
54// contract, while platform mutation stamps detect edit-and-restore attempts.
55#[cfg(any(unix, windows))]
56pub mod checker;
57pub mod config;
58mod decide;
59mod matching;
60pub mod mode;
61mod policy;
62mod readout;
63mod render;
64mod rules;
65mod rungs;
66mod runtime;
67pub mod safety;
68mod telemetry;
69mod text;
70
71#[cfg(test)]
72mod conformance_tests;
73#[cfg(test)]
74mod decide_tests;
75#[cfg(test)]
76mod matching_tests;
77#[cfg(test)]
78mod tests;
79
80/// A verification-gated route.
81///
82/// Calls the local tier, gathers evidence about the answer it produced, and
83/// either releases that answer or escalates to the capable tier. Composed on the
84/// shared fall-through shell like every other algorithm here, with a single
85/// classifier: the whole decision is one unit of work, not a cascade of
86/// independent recommendations.
87pub struct Vgr {
88 route: FallThrough<State>,
89 local: switchyard_protocol::ModelId,
90 cloud: switchyard_protocol::ModelId,
91 cloud_breaker: Arc<safety::CircuitBreaker>,
92}
93
94impl Vgr {
95 /// Builds a verification-gated route.
96 ///
97 /// Errors when the serving mode is configured incoherently — the one
98 /// setting whose misconfiguration would otherwise be silent.
99 pub fn new(config: VgrConfig) -> Result<Self> {
100 mode::validate(&config.mode)
101 .map_err(|message| crate::LibsyError::AlgorithmError { message })?;
102 let local = config.targets.local.clone();
103 let cloud = config.targets.cloud.clone();
104 // A local turn must re-enter VGR so every proposed tool call is verified.
105 // Retain only cloud to keep an escalation stable through the current user turn.
106 let turn_affinity = Arc::new(
107 AffinityRouter::new()
108 .with_release_on_user_turn()
109 .with_latch_only([cloud.clone()]),
110 );
111 let latch = config.latch_escalation.then(|| {
112 // Retaining only the capable tier is what makes this an escalation
113 // latch rather than plain affinity: a local commit leaves the
114 // session free to be verified afresh next turn, while an escalation
115 // sticks. Registered ahead of the verification classifier, so a
116 // latched session does not even pay for the local attempt.
117 Arc::new(AffinityRouter::new().with_latch_only([config.targets.cloud.clone()]))
118 });
119 let local_breaker = Arc::new(safety::CircuitBreaker::new(config.breaker));
120 let cloud_breaker = Arc::new(safety::CircuitBreaker::new(config.breaker));
121 let classifier = Arc::new(runtime::VgrClassifier {
122 config,
123 local_breaker,
124 cloud_breaker: cloud_breaker.clone(),
125 });
126
127 let mut route = FallThrough::new_with_state().with_name("vgr");
128 if let Some(latch) = latch {
129 route = route.with_processor(latch.clone()).with_classifier(latch);
130 }
131 route = route
132 .with_processor(turn_affinity.clone())
133 .with_classifier(turn_affinity);
134 Ok(Self {
135 route: route.with_classifier(classifier),
136 local,
137 cloud,
138 cloud_breaker,
139 })
140 }
141}
142
143#[async_trait::async_trait]
144impl Algorithm for Vgr {
145 fn name(&self) -> &str {
146 "vgr"
147 }
148
149 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome> {
150 let mut outcome = self.route.execute(driver.clone(), request).await?;
151 let selected = outcome.selected_model_id()?.clone();
152 telemetry::annotate_retained_route(
153 &mut outcome.request,
154 if selected == self.local {
155 decide::Route::Local
156 } else {
157 decide::Route::Cloud
158 },
159 );
160 if selected == self.cloud {
161 // A cloud decision is terminal. Falling backward to local would
162 // bypass the verification decision that selected cloud.
163 outcome.selected_model_ids.truncate(1);
164 if outcome.response.is_none() {
165 outcome.response = Some(
166 runtime::complete_cloud(
167 &driver,
168 &outcome.request,
169 &self.cloud,
170 &self.cloud_breaker,
171 None,
172 )
173 .await?,
174 );
175 }
176 } else if selected == self.local && outcome.response.is_none() {
177 // Local may fail forward to cloud on the host's eligible-failure
178 // policy. Current local commits carry their buffered response, but
179 // retain the directional contract if that implementation changes.
180 outcome.selected_model_ids = vec![self.local.clone(), self.cloud.clone()];
181 }
182 Ok(outcome)
183 }
184}
185
186/// The verification regime a request's capabilities license.
187///
188/// Ordered by the priority [`select_branch`] applies, strongest evidence first.
189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190pub enum Branch {
191 /// An operator-configured sandboxed checker can execute tests.
192 Checks,
193 /// Code or test activity, with no checker configured.
194 CodingNoChecks,
195 /// A single-turn request with a typed final answer to agree against.
196 Answer,
197 /// Conversational traffic.
198 Chat,
199 /// An agentic session with an operator-declared prior.
200 AgenticRecognized,
201 /// An agentic session verified from evidence alone.
202 AgenticVerified,
203 /// Anything else with an attempt to verify.
204 DefaultVerified,
205 /// Nothing to verify.
206 Unknown,
207}
208
209/// A task type the router itself produced by typing the request.
210///
211/// This is never a client-declared field. Any value the router's typing step
212/// cannot resolve to one of these is an abstention, represented as `None`, which
213/// derives to the default verification regime rather than a weaker one.
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum TaskType {
216 /// Code-producing work.
217 Coding,
218 /// Tool-operating work.
219 Agentic,
220 /// Answer-seeking work with a checkable final answer.
221 Answer,
222 /// Conversational work.
223 Chat,
224}
225
226/// A reported tool-error count together with its provenance.
227///
228/// Only [`ToolErrorCount::Host`] — evidence the routing deployment trusts — can
229/// authorize a commit. An untrusted report may veto a commit when it reports
230/// errors, but never authorize one when it reports none.
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232pub enum ToolErrorCount {
233 /// A count trusted by the routing host.
234 Host(i32),
235 /// A count from any other provenance.
236 Untrusted(i32),
237}
238
239impl ToolErrorCount {
240 /// Returns the reported number of tool errors.
241 pub fn count(self) -> i32 {
242 match self {
243 Self::Host(count) | Self::Untrusted(count) => count,
244 }
245 }
246
247 /// Whether the routing host trusts this count.
248 pub fn is_host(self) -> bool {
249 matches!(self, Self::Host(_))
250 }
251
252 /// Low-cardinality provenance label for telemetry.
253 pub fn source_label(self) -> &'static str {
254 match self {
255 Self::Host(_) => "host",
256 Self::Untrusted(_) => "untrusted",
257 }
258 }
259}
260
261/// The complete input the decision core sees.
262///
263/// Every field is derived, never client-declared — see the module-level trust
264/// model. An all-default `Capabilities` selects [`Branch::Unknown`], which is the
265/// fail-closed outcome.
266#[derive(Clone, Debug, Default, PartialEq)]
267pub struct Capabilities {
268 /// The latest user instruction — the only outside datum branching consults.
269 pub task_text: Option<String>,
270 /// The attempt's final answer, when the request was typed as answer-seeking
271 /// and is single-turn.
272 pub final_answer: Option<String>,
273 /// The judged view of the request and attempt, as rendered for a verifier.
274 pub transcript: Option<String>,
275 /// An operator-configured sandboxed checker is available for this route.
276 pub has_checks: bool,
277 /// Typed as coding, or the attempt shows code or test activity.
278 pub is_coding: bool,
279 /// Typed as conversational, or typed answer-seeking on a multi-turn request.
280 pub is_chat: bool,
281 /// An operator-declared prior for this surface. Never derived, so
282 /// [`derive_capabilities`] always leaves it unset.
283 pub prior_local: Option<f64>,
284 /// No confident type, but there is an attempt to verify.
285 pub default_verified: bool,
286 /// The locally produced attempt text. Scoped to the attempt only: request
287 /// content must never mint evidence.
288 pub attempt: Option<String>,
289 /// The operator declared that this surface enforces a schema-validated terse
290 /// final-answer format. Never derived.
291 pub structured_answer: bool,
292 /// Typed as agentic, or the attempt shows tool activity.
293 pub is_agentic: bool,
294 /// A tool-error count derived from normalized tool-result history.
295 pub tool_errors: Option<ToolErrorCount>,
296 /// Total tool results in the execution log summarized with `tool_errors`.
297 pub tool_results: Option<i32>,
298 /// Whether the final result in that execution log was clean.
299 pub tool_tail_clean: Option<bool>,
300}
301
302/// Selects the verification regime a request's capabilities license.
303///
304/// Structural and priority-ordered: the first regime whose evidence is present
305/// wins. Pure — no I/O, no dependence on anything but its argument.
306pub fn select_branch(caps: &Capabilities) -> Branch {
307 if caps.has_checks {
308 return Branch::Checks;
309 }
310 if caps.is_coding {
311 return Branch::CodingNoChecks;
312 }
313 if caps.final_answer.is_some() && caps.task_text.is_some() {
314 return Branch::Answer;
315 }
316 if caps.is_chat {
317 return Branch::Chat;
318 }
319 if caps.transcript.is_some() && caps.prior_local.is_some() {
320 return Branch::AgenticRecognized;
321 }
322 if caps.is_agentic && caps.transcript.is_some() {
323 return Branch::AgenticVerified;
324 }
325 if caps.default_verified && caps.transcript.is_some() {
326 return Branch::DefaultVerified;
327 }
328 Branch::Unknown
329}
330
331/// Builds [`Capabilities`] from the three trusted sources, failing closed.
332///
333/// `task_type` must be the router's own typing output, never a client field;
334/// `None` is an abstention and derives to the default regime. `attempt` is the
335/// text this router generated locally. `tool_errors` carries a tool-execution
336/// error count together with its provenance.
337///
338/// Returns capabilities selecting [`Branch::Unknown`] when the request carries
339/// content the router cannot faithfully judge, when there is no user text or no
340/// attempt to verify, or when the judged view would silently drop a requirement
341/// the verifier needs to see. In each of those cases there is nothing to commit,
342/// so the request must not take a local-committing regime.
343pub fn derive_capabilities(
344 request: &Request,
345 attempt: &str,
346 checker_configured: bool,
347 task_type: Option<TaskType>,
348 tool_errors: Option<ToolErrorCount>,
349 tool_results: Option<i32>,
350 tool_tail_clean: Option<bool>,
351) -> Capabilities {
352 let (turns, unsupported) = text::turns(request);
353 if unsupported {
354 // Media or unrecognized provider blocks the router cannot faithfully
355 // judge: fail closed with no task text at all.
356 return Capabilities::default();
357 }
358
359 let task_text = text::user_task_text(&turns);
360 if task_text.trim().is_empty() || attempt.trim().is_empty() {
361 // Request validation runs before every regime, the checker included: a
362 // request with no user text, or no local attempt, has nothing to commit.
363 return Capabilities {
364 task_text: (!task_text.is_empty()).then(|| task_text.clone()),
365 ..Default::default()
366 };
367 }
368
369 // Beyond this point every regime carries the same derived evidence; only the
370 // regime-selecting flags and the judged view differ.
371 let observed = Capabilities {
372 task_text: Some(task_text.clone()),
373 attempt: Some(attempt.to_string()),
374 tool_errors,
375 tool_results,
376 tool_tail_clean,
377 ..Default::default()
378 };
379
380 if checker_configured {
381 return Capabilities {
382 transcript: Some(render::render_session(&turns, attempt)),
383 has_checks: true,
384 // The checker is the evidence; a tool-error count plays no part.
385 tool_errors: None,
386 tool_results: None,
387 tool_tail_clean: None,
388 ..observed
389 };
390 }
391
392 if task_type == Some(TaskType::Answer) && !text::has_assistant_turn(&turns) {
393 return Capabilities {
394 final_answer: Some(attempt.to_string()),
395 transcript: Some(render::render_session(&turns, attempt)),
396 tool_errors: None,
397 tool_results: None,
398 tool_tail_clean: None,
399 ..observed
400 };
401 }
402
403 let has_tool_trajectory = text::has_tool_trajectory(request);
404 let agentic_trajectory = task_type == Some(TaskType::Agentic)
405 || (has_tool_trajectory && !matches!(task_type, Some(TaskType::Answer | TaskType::Chat)));
406 if agentic_trajectory {
407 if !render::agentic_context_complete(&turns) {
408 return Capabilities {
409 task_text: Some(task_text),
410 ..Default::default()
411 };
412 }
413 return Capabilities {
414 transcript: Some(render::render_agentic_view(request, &turns, attempt)),
415 is_agentic: true,
416 ..observed
417 };
418 }
419
420 if text::observed_hardening(attempt) || task_type == Some(TaskType::Coding) {
421 if !render::coding_context_complete(&turns) {
422 // A coding conversation whose earlier user constraints cannot all be
423 // represented is not judged at all — never judge against partial
424 // requirements.
425 return Capabilities {
426 task_text: Some(task_text),
427 ..Default::default()
428 };
429 }
430 return Capabilities {
431 transcript: Some(render::render_coding_view(&turns, attempt)),
432 is_coding: true,
433 ..observed
434 };
435 }
436
437 if !render::session_context_complete(&turns, attempt) {
438 // A judged view that would silently drop a system or user requirement
439 // never judges.
440 return Capabilities {
441 task_text: Some(task_text),
442 ..Default::default()
443 };
444 }
445
446 let transcript = Some(render::render_session(&turns, attempt));
447 if task_type == Some(TaskType::Agentic) || text::observed_tool_activity(attempt) {
448 return Capabilities {
449 transcript,
450 is_agentic: true,
451 ..observed
452 };
453 }
454 if matches!(task_type, Some(TaskType::Chat | TaskType::Answer)) {
455 // A typed answer reaching here is multi-turn answer-seeking: the answer
456 // regime's instruments are context-blind, so they are invalid here.
457 return Capabilities {
458 transcript,
459 is_chat: true,
460 ..observed
461 };
462 }
463 Capabilities {
464 transcript,
465 default_verified: true,
466 ..observed
467 }
468}