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: 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}