Skip to main content

llm_verify/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One entry point for a whole verification run.
3//!
4//! Everything the CLI does after argument parsing happens here, so an embedder
5//! gets the same run the command line gets — same probes, same order, same
6//! verdict logic — without reimplementing the wiring. That equivalence is the
7//! point: a marketplace that gates listings on this must be able to say its
8//! gate and its published tool agree, and the only way to guarantee that is for
9//! there to be one implementation.
10
11use crate::client::{Client, Endpoint};
12use crate::i18n::Lang;
13use crate::probes::{self, Cancel, Ctx, Depth, Event, Pace, Selection};
14use crate::report::Report;
15use crate::util::Rng;
16use crate::verdict;
17use anyhow::Result;
18
19/// Everything a run needs.
20#[derive(Clone)]
21pub struct RunConfig {
22    pub endpoint: Endpoint,
23    /// The model the vendor claims to serve, when it differs from the id being
24    /// requested. Defaults to `endpoint.model`.
25    pub claimed_model: Option<String>,
26    pub depth: Depth,
27    pub lang: Lang,
28    pub selection: Selection,
29    /// `None` draws one from the clock — see [`Rng::from_seed`] for why an
30    /// embedder should choose its own instead.
31    pub seed: Option<u64>,
32    /// Reuse the caller's HTTP client. See [`Client::with_http`].
33    pub http: Option<reqwest::Client>,
34    /// Spread the run out instead of issuing it as a burst. See [`Pace`].
35    pub pace: Option<Pace>,
36}
37
38impl RunConfig {
39    pub fn new(endpoint: Endpoint) -> Self {
40        RunConfig {
41            endpoint,
42            claimed_model: None,
43            depth: Depth::Balanced,
44            lang: Lang::En,
45            selection: Selection::all(),
46            seed: None,
47            http: None,
48            pace: None,
49        }
50    }
51
52    /// Probe only what survives a relay — see [`probes::Subject`].
53    pub fn model_only(mut self) -> Self {
54        self.selection = Selection::model_only();
55        self
56    }
57
58    pub fn depth(mut self, d: Depth) -> Self {
59        self.depth = d;
60        self
61    }
62
63    pub fn lang(mut self, l: Lang) -> Self {
64        self.lang = l;
65        self
66    }
67
68    pub fn seed(mut self, s: u64) -> Self {
69        self.seed = Some(s);
70        self
71    }
72
73    pub fn claimed_model(mut self, m: impl Into<String>) -> Self {
74        self.claimed_model = Some(m.into());
75        self
76    }
77
78    pub fn http(mut self, c: reqwest::Client) -> Self {
79        self.http = Some(c);
80        self
81    }
82
83    /// Wait a random interval between steps — see [`Pace`].
84    pub fn pace(mut self, min: std::time::Duration, max: std::time::Duration) -> Self {
85        self.pace = Some(Pace { min, max });
86        self
87    }
88}
89
90/// Run the suite and assemble the report.
91///
92/// Progress arrives through `on_event`; pass `&mut |_| {}` to ignore it.
93/// `cancel` is checked between steps — see [`Cancel`].
94pub async fn run(
95    cfg: RunConfig,
96    cancel: &Cancel,
97    on_event: &mut (dyn FnMut(Event<'_>) + Send),
98) -> Result<Report> {
99    let started_at = crate::util::iso8601_utc();
100    let t0 = crate::util::now_ms();
101
102    let seed = cfg.seed.unwrap_or_else(|| {
103        // Same source `Rng::new` uses, surfaced so the report can record it.
104        (crate::util::now_ms() as u64) ^ 0x9E37_79B9_7F4A_7C15
105    });
106    let claimed_model = cfg
107        .claimed_model
108        .clone()
109        .unwrap_or_else(|| cfg.endpoint.model.clone());
110    let protocol = cfg.endpoint.protocol;
111    let model = cfg.endpoint.model.clone();
112    let base_url = cfg.endpoint.base_url.clone();
113    let host = cfg.endpoint.host();
114
115    let client = match cfg.http.clone() {
116        Some(http) => Client::with_http(cfg.endpoint.clone(), http),
117        None => Client::new(cfg.endpoint.clone())?,
118    };
119    let ctx = Ctx::with_rng(
120        client,
121        cfg.depth,
122        cfg.lang,
123        claimed_model.clone(),
124        Rng::from_seed(seed),
125    );
126
127    let specs = cfg.selection.resolve();
128    // Custom probes are named here alongside the built-in steps. A report that
129    // listed only the public suite would understate what the run actually
130    // asked — and the whole point of the private ones is that the list is the
131    // only place they are visible.
132    let steps: Vec<String> = specs
133        .iter()
134        .map(|s| s.id.to_string())
135        .chain(
136            cfg.selection
137                .resolve_extra()
138                .iter()
139                .map(|p| p.id().to_string()),
140        )
141        .collect();
142    let extra = cfg.selection.resolve_extra();
143    let results = probes::run_with_extra(&ctx, &specs, &extra, cancel, cfg.pace, on_event).await;
144
145    let l = cfg.lang;
146    let identity = verdict::build_identity(&results, &claimed_model, l);
147    let billing = verdict::build_billing(&results, &model, l);
148    let channel = verdict::build_channel(&results, l);
149    let v = verdict::decide(&results, &identity, &billing, &channel, protocol, l);
150    let perf = probes::perf::summarize(&ctx.perf.lock().unwrap());
151
152    let skipped = results
153        .iter()
154        .filter(|r| {
155            matches!(
156                r.status,
157                crate::report::Status::Skip | crate::report::Status::Error
158            )
159        })
160        .map(|r| t!(l, "{} ({}) — {}", "{}({}):{}", r.label, r.id, r.summary))
161        .collect();
162
163    Ok(Report {
164        schema_version: crate::report::schema_version(),
165        tool_version: env!("CARGO_PKG_VERSION").to_string(),
166        lang: l,
167        started_at,
168        finished_at: crate::util::iso8601_utc(),
169        duration_ms: (crate::util::now_ms() - t0) as u64,
170        host,
171        base_url,
172        protocol,
173        model,
174        claimed_model,
175        depth: cfg.depth.as_str().to_string(),
176        seed,
177        steps,
178        request_count: ctx.client.requests(),
179        results,
180        verdict: v,
181        identity,
182        billing,
183        channel,
184        perf,
185        skipped,
186    })
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    /// The engine has to be awaitable from a multi-threaded runtime, which is
194    /// the whole reason `Ctx` holds locks instead of `RefCell`s. A regression
195    /// here is a compile error rather than a test failure, which is the point:
196    /// this exists so that reintroducing a `!Send` field cannot pass CI.
197    #[test]
198    fn run_future_is_send() {
199        fn assert_send<T: Send>(_: T) {}
200        let cfg = RunConfig::new(Endpoint {
201            base_url: "https://example.invalid".into(),
202            model: "m".into(),
203            ..Default::default()
204        });
205        let cancel = Cancel::new();
206        let mut sink = |_: Event<'_>| {};
207        assert_send(run(cfg, &cancel, &mut sink));
208    }
209
210    #[test]
211    fn model_only_drops_endpoint_steps_but_keeps_preflight() {
212        let specs = Selection::model_only().resolve();
213        let ids: Vec<&str> = specs.iter().map(|s| s.id).collect();
214        assert!(
215            ids.contains(&"preflight"),
216            "the run is meaningless without it"
217        );
218        assert!(ids.contains(&"identity"));
219        assert!(ids.contains(&"perf"));
220        // These read the endpoint's own contract and accounting, which behind a
221        // relay belong to the relay.
222        assert!(!ids.contains(&"billing"));
223        assert!(!ids.contains(&"channel"));
224        assert!(!ids.contains(&"missing_auth"));
225    }
226
227    #[test]
228    fn skip_wins_over_an_explicit_include() {
229        let sel = Selection {
230            only: vec!["identity".into(), "perf".into()],
231            skip: vec!["perf".into()],
232            ..Default::default()
233        };
234        let ids: Vec<&str> = sel.resolve().iter().map(|s| s.id).collect();
235        assert!(ids.contains(&"identity"));
236        assert!(!ids.contains(&"perf"));
237    }
238}