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::verdict;
16use anyhow::Result;
17
18/// Everything a run needs.
19#[derive(Clone)]
20pub struct RunConfig {
21    pub endpoint: Endpoint,
22    /// The model the vendor claims to serve, when it differs from the id being
23    /// requested. Defaults to `endpoint.model`.
24    pub claimed_model: Option<String>,
25    pub depth: Depth,
26    pub lang: Lang,
27    pub selection: Selection,
28    /// `None` draws one from the clock — see [`Rng::from_seed`] for why an
29    /// embedder should choose its own instead.
30    pub seed: Option<u64>,
31    /// Reuse the caller's HTTP client. See [`Client::with_http`].
32    pub http: Option<reqwest::Client>,
33    /// Spread the run out instead of issuing it as a burst. See [`Pace`].
34    pub pace: Option<Pace>,
35    /// Overlap the steps instead of running them one at a time. See
36    /// [`RunConfig::concurrency`].
37    pub concurrency: usize,
38    /// Requests this run may have in flight at once, whatever the schedule.
39    /// See [`RunConfig::max_in_flight`].
40    pub max_in_flight: usize,
41}
42
43impl RunConfig {
44    pub fn new(endpoint: Endpoint) -> Self {
45        RunConfig {
46            endpoint,
47            claimed_model: None,
48            depth: Depth::Balanced,
49            lang: Lang::En,
50            selection: Selection::all(),
51            seed: None,
52            http: None,
53            pace: None,
54            concurrency: 1,
55            max_in_flight: 0,
56        }
57    }
58
59    /// Probe only what survives a relay — see [`probes::Subject`].
60    pub fn model_only(mut self) -> Self {
61        self.selection = Selection::model_only();
62        self
63    }
64
65    /// The cheapest run that still supports a verdict about the model.
66    ///
67    /// [`Selection::turbo`] with the steps overlapped and the traffic capped.
68    /// Twelve requests at [`Depth::Fast`], in roughly four round trips instead
69    /// of twenty-one, which is the difference between a run somebody can watch
70    /// finish and one they give up on.
71    ///
72    /// `in_flight` is the only number here worth thinking about, and it is a
73    /// statement about the endpoint rather than about this crate: how many
74    /// simultaneous requests it will answer without queueing or refusing. Set
75    /// it too high and the run measures the endpoint's saturation instead of
76    /// its behaviour — and, on anything with a per-account concurrency budget,
77    /// spends that budget on being examined. When in doubt, three.
78    pub fn turbo(mut self, in_flight: usize) -> Self {
79        self.selection = Selection::turbo();
80        self.depth = Depth::Fast;
81        self.concurrency = in_flight.max(1);
82        self.max_in_flight = in_flight;
83        self
84    }
85
86    /// How many steps may be in flight at once. `1` is sequential, the default,
87    /// and what every release before 0.5.0 did.
88    ///
89    /// Ignored while a [`Pace`] is set: pacing exists to make a run hard to
90    /// pick out of ordinary traffic, and overlapping exists to compress it into
91    /// the smallest possible burst. A caller asking for both is asking to be
92    /// unobtrusive quickly, and pacing wins.
93    ///
94    /// Steps marked [`exclusive`](probes::ProbeSpec::exclusive) still run alone
95    /// — `preflight` because everything after it depends on its answer, `perf`
96    /// because a latency measurement taken alongside this run's own traffic
97    /// measures this run.
98    pub fn concurrency(mut self, n: usize) -> Self {
99        self.concurrency = n.max(1);
100        self
101    }
102
103    /// Cap the requests in flight, independently of how many steps are.
104    ///
105    /// Steps are uneven — the capability battery is a dozen requests and
106    /// `stop_sequence` is one — so a step count does not bound what the
107    /// endpoint sees. This does. `0` leaves it uncapped.
108    pub fn max_in_flight(mut self, n: usize) -> Self {
109        self.max_in_flight = n;
110        self
111    }
112
113    pub fn depth(mut self, d: Depth) -> Self {
114        self.depth = d;
115        self
116    }
117
118    pub fn lang(mut self, l: Lang) -> Self {
119        self.lang = l;
120        self
121    }
122
123    pub fn seed(mut self, s: u64) -> Self {
124        self.seed = Some(s);
125        self
126    }
127
128    pub fn claimed_model(mut self, m: impl Into<String>) -> Self {
129        self.claimed_model = Some(m.into());
130        self
131    }
132
133    pub fn http(mut self, c: reqwest::Client) -> Self {
134        self.http = Some(c);
135        self
136    }
137
138    /// Wait a random interval between steps — see [`Pace`].
139    pub fn pace(mut self, min: std::time::Duration, max: std::time::Duration) -> Self {
140        self.pace = Some(Pace { min, max });
141        self
142    }
143}
144
145/// Run the suite and assemble the report.
146///
147/// Progress arrives through `on_event`; pass `&mut |_| {}` to ignore it.
148/// `cancel` is checked between steps — see [`Cancel`].
149pub async fn run(
150    cfg: RunConfig,
151    cancel: &Cancel,
152    on_event: &mut (dyn FnMut(Event<'_>) + Send),
153) -> Result<Report> {
154    let started_at = crate::util::iso8601_utc();
155    let t0 = crate::util::now_ms();
156
157    let seed = cfg.seed.unwrap_or_else(|| {
158        // Same source `Rng::new` uses, surfaced so the report can record it.
159        (crate::util::now_ms() as u64) ^ 0x9E37_79B9_7F4A_7C15
160    });
161    let claimed_model = cfg
162        .claimed_model
163        .clone()
164        .unwrap_or_else(|| cfg.endpoint.model.clone());
165    let protocol = cfg.endpoint.protocol;
166    let model = cfg.endpoint.model.clone();
167    let base_url = cfg.endpoint.base_url.clone();
168    let host = cfg.endpoint.host();
169
170    let client = match cfg.http.clone() {
171        Some(http) => Client::with_http(cfg.endpoint.clone(), http),
172        None => Client::new(cfg.endpoint.clone())?,
173    };
174    let client = client.with_limit(cfg.max_in_flight);
175    let ctx = Ctx::with_seed(client, cfg.depth, cfg.lang, claimed_model.clone(), seed);
176
177    let specs = cfg.selection.resolve();
178    // Custom probes are named here alongside the built-in steps. A report that
179    // listed only the public suite would understate what the run actually
180    // asked — and the whole point of the private ones is that the list is the
181    // only place they are visible.
182    let steps: Vec<String> = specs
183        .iter()
184        .map(|s| s.id.to_string())
185        .chain(
186            cfg.selection
187                .resolve_extra()
188                .iter()
189                .map(|p| p.id().to_string()),
190        )
191        .collect();
192    let extra = cfg.selection.resolve_extra();
193    let schedule = probes::Schedule {
194        pace: cfg.pace,
195        concurrency: cfg.concurrency,
196    };
197    let results = probes::run_with_extra(&ctx, &specs, &extra, cancel, schedule, on_event).await;
198
199    let l = cfg.lang;
200    let identity = verdict::build_identity(&results, &claimed_model, l);
201    let billing = verdict::build_billing(&results, &model, l);
202    let channel = verdict::build_channel(&results, l);
203    let v = verdict::decide(&results, &identity, &billing, &channel, protocol, l);
204    let perf = probes::perf::summarize(&ctx.perf.lock().unwrap());
205
206    let skipped = results
207        .iter()
208        .filter(|r| {
209            matches!(
210                r.status,
211                crate::report::Status::Skip | crate::report::Status::Error
212            )
213        })
214        .map(|r| t!(l, "{} ({}) — {}", "{}({}):{}", r.label, r.id, r.summary))
215        .collect();
216
217    Ok(Report {
218        schema_version: crate::report::schema_version(),
219        tool_version: env!("CARGO_PKG_VERSION").to_string(),
220        lang: l,
221        started_at,
222        finished_at: crate::util::iso8601_utc(),
223        duration_ms: (crate::util::now_ms() - t0) as u64,
224        host,
225        base_url,
226        protocol,
227        model,
228        claimed_model,
229        depth: cfg.depth.as_str().to_string(),
230        seed,
231        steps,
232        request_count: ctx.client.requests(),
233        results,
234        verdict: v,
235        identity,
236        billing,
237        channel,
238        perf,
239        skipped,
240    })
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    /// The engine has to be awaitable from a multi-threaded runtime, which is
248    /// the whole reason `Ctx` holds locks instead of `RefCell`s. A regression
249    /// here is a compile error rather than a test failure, which is the point:
250    /// this exists so that reintroducing a `!Send` field cannot pass CI.
251    #[test]
252    fn run_future_is_send() {
253        fn assert_send<T: Send>(_: T) {}
254        let cfg = RunConfig::new(Endpoint {
255            base_url: "https://example.invalid".into(),
256            model: "m".into(),
257            ..Default::default()
258        });
259        let cancel = Cancel::new();
260        let mut sink = |_: Event<'_>| {};
261        assert_send(run(cfg, &cancel, &mut sink));
262    }
263
264    fn ids(sel: Selection) -> Vec<&'static str> {
265        sel.resolve().iter().map(|s| s.id).collect()
266    }
267
268    #[test]
269    fn model_only_drops_endpoint_steps_but_keeps_preflight() {
270        let ids = ids(Selection::model_only());
271        assert!(
272            ids.contains(&"preflight"),
273            "the run is meaningless without it"
274        );
275        assert!(ids.contains(&"self_id"));
276        assert!(ids.contains(&"capability"));
277        assert!(ids.contains(&"perf"));
278        // These read the endpoint's own contract and accounting, which behind a
279        // relay belong to the relay.
280        assert!(!ids.contains(&"billing"));
281        assert!(!ids.contains(&"channel"));
282        assert!(!ids.contains(&"missing_auth"));
283    }
284
285    #[test]
286    fn skip_wins_over_an_explicit_include() {
287        let sel = Selection {
288            only: vec!["identity".into(), "perf".into()],
289            skip: vec!["perf".into()],
290            ..Default::default()
291        };
292        let ids = ids(sel);
293        assert!(ids.contains(&"self_id"));
294        assert!(!ids.contains(&"perf"));
295    }
296
297    /// Splitting the identity step into seven must not have taken away the way
298    /// callers already addressed it. `skip: ["identity"]` meant "no identity
299    /// probing" before 0.5.0 and has to keep meaning that, because the
300    /// alternative is every existing caller silently starting to run steps they
301    /// had turned off.
302    #[test]
303    fn a_group_key_still_addresses_the_whole_family() {
304        let without = ids(Selection {
305            skip: vec!["identity".into()],
306            ..Default::default()
307        });
308        for gone in ["self_id", "meta_creator", "world_knowledge", "capability"] {
309            assert!(!without.contains(&gone), "{gone} survived skip: [identity]");
310        }
311        assert!(without.contains(&"preflight"));
312
313        let only_identity = ids(Selection {
314            only: vec!["identity".into()],
315            ..Default::default()
316        });
317        assert!(only_identity.contains(&"self_id") && only_identity.contains(&"verbosity"));
318        assert!(!only_identity.contains(&"cache_replay"));
319    }
320
321    #[test]
322    fn turbo_keeps_every_probe_a_verdict_rests_on() {
323        let kept_ids = ids(Selection::turbo());
324        // The family gate, the capability measurement and the tier it implies,
325        // the two population axes, and the one hard gate that survives a relay.
326        for kept in [
327            "preflight",
328            "self_id",
329            "capability",
330            "verbosity",
331            "perf",
332            "cache_replay",
333        ] {
334            assert!(kept_ids.contains(&kept), "turbo dropped {kept}");
335        }
336        // Corroboration, description and a fan-out check that describes the
337        // relay rather than the model.
338        for dropped in [
339            "meta_creator",
340            "context_claim",
341            "cutoff_claim",
342            "world_knowledge",
343            "signature_drift",
344        ] {
345            assert!(!kept_ids.contains(&dropped), "turbo kept {dropped}");
346        }
347        // And nothing whose subject is the endpoint, which turbo inherits from
348        // being a strict subset of `model_only`.
349        let relayed = ids(Selection::model_only());
350        for id in &kept_ids {
351            assert!(relayed.contains(id), "{id} is not in model_only");
352        }
353    }
354
355    #[test]
356    fn plus_puts_the_endpoint_contract_checks_back() {
357        let ids = ids(Selection::turbo().plus(["stop_sequence", "system_adherence"]));
358        assert!(ids.contains(&"stop_sequence"));
359        assert!(ids.contains(&"system_adherence"));
360        assert!(ids.contains(&"self_id"), "and keeps what turbo had");
361    }
362
363    /// `plus` on a selection with no `only` list must not narrow it. An empty
364    /// `only` means "everything", so extending it would turn a full run into a
365    /// two-step one — the exact opposite of what the name promises.
366    #[test]
367    fn plus_on_an_unfiltered_selection_is_a_no_op() {
368        let before = ids(Selection::all()).len();
369        let after = ids(Selection::all().plus(["self_id"])).len();
370        assert_eq!(before, after);
371    }
372}