Skip to main content

llm_verify/probes/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Probe registry and the shared context every probe writes into.
3
4pub mod billing;
5pub mod channel;
6pub mod consistency;
7pub mod contract;
8pub mod identity;
9pub mod perf;
10pub mod stream;
11
12use crate::client::Client;
13use crate::i18n::Lang;
14use crate::report::{BillingRound, Group, ProbeResult};
15use crate::util::Rng;
16use std::collections::BTreeMap;
17use std::sync::Mutex;
18
19/// How hard to push. Repeat-count driven: more samples buy tighter
20/// consistency and jitter signals at linear cost.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Depth {
23    /// Cheapest useful pass. Single samples, no repeat probes.
24    Fast,
25    /// Default. Enough repeats to see jitter and cache replay.
26    Balanced,
27    /// Consistency-heavy. Use when building a case against a provider.
28    Forensic,
29}
30
31impl Depth {
32    pub fn parse(s: &str) -> Option<Self> {
33        match s.trim().to_ascii_lowercase().as_str() {
34            "fast" => Some(Self::Fast),
35            "balanced" | "default" => Some(Self::Balanced),
36            "forensic" | "deep" => Some(Self::Forensic),
37            _ => None,
38        }
39    }
40
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            Self::Fast => "fast",
44            Self::Balanced => "balanced",
45            Self::Forensic => "forensic",
46        }
47    }
48
49    /// Repeats for consistency and jitter sampling.
50    pub fn repeats(&self) -> usize {
51        match self {
52            Self::Fast => 1,
53            Self::Balanced => 3,
54            Self::Forensic => 6,
55        }
56    }
57
58    /// Questions per difficulty band in the tier estimator.
59    ///
60    /// Measured on a live endpoint, two per band was not enough: the same
61    /// model scored `hard 0/2` on one run and `1/2` on the next, which moved
62    /// the fitted tier by a whole step. The abstention gates caught it — the
63    /// second run degraded to a warning instead of accusing — but a real
64    /// downgrade can be missed that way. Three narrows the swing at the cost
65    /// of three extra requests; `forensic` is still the setting to reach for
66    /// when the answer has to hold up.
67    pub fn tier_questions(&self) -> usize {
68        match self {
69            Self::Fast => 1,
70            Self::Balanced => 3,
71            Self::Forensic => 5,
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct PerfSample {
78    pub probe: String,
79    pub ttft_ms: Option<u64>,
80    pub latency_ms: u64,
81    pub output_tokens: u32,
82}
83
84impl PerfSample {
85    /// Generation throughput, excluding the wait for the first token.
86    /// Returns `None` when the sample cannot support the calculation.
87    pub fn tps(&self) -> Option<f64> {
88        let ttft = self.ttft_ms? as f64;
89        let gen_ms = self.latency_ms as f64 - ttft;
90        if gen_ms <= 0.0 || self.output_tokens == 0 {
91            return None;
92        }
93        Some(self.output_tokens as f64 / (gen_ms / 1000.0))
94    }
95}
96
97/// Shared state.
98///
99/// The locks are not buying mutual exclusion so much as `Sync`. This used to be
100/// `RefCell`, which made every probe future `!Send` and therefore impossible to
101/// `await` from a multi-threaded runtime: an embedded caller (a request handler
102/// spawning a verification) could not hold the future at all. Nothing may hold
103/// one of these guards across an `.await`; each site below takes it, reads or
104/// pushes, and drops it in the same expression. That rule was a tidiness
105/// convention while steps ran one at a time and is load bearing now that they
106/// may not — see [`run_steps`].
107pub struct Ctx {
108    pub client: Client,
109    pub depth: Depth,
110    pub lang: Lang,
111    pub claimed_model: String,
112    /// The run's seed. Every random payload is derived from it and from the id
113    /// of the step that asks — see [`Ctx::rng_for`].
114    pub seed: u64,
115    pub perf: Mutex<Vec<PerfSample>>,
116    pub billing: Mutex<Vec<BillingRound>>,
117    /// Response headers from every successful call, for channel classification.
118    pub headers: Mutex<Vec<BTreeMap<String, String>>>,
119    pub message_ids: Mutex<Vec<String>>,
120    pub raw_bodies: Mutex<Vec<String>>,
121    /// Set by the preflight probe; when false the rest of the run is pointless.
122    pub reachable: Mutex<bool>,
123}
124
125impl Ctx {
126    pub fn new(client: Client, depth: Depth, lang: Lang, claimed_model: String) -> Self {
127        Self::with_seed(client, depth, lang, claimed_model, Rng::new().next_u64())
128    }
129
130    /// The same, on a caller-chosen seed.
131    ///
132    /// Replaces the `with_rng` of earlier releases, which handed the whole run
133    /// one shared generator. That worked exactly as long as the steps ran in a
134    /// fixed order: draw order *was* step order, so a seed reproduced a run.
135    /// Under [`RunConfig::concurrency`](crate::engine::RunConfig::concurrency)
136    /// it does not — whichever step wins the race draws first — and a seed that
137    /// reproduces a different set of questions each time is not a seed, it is
138    /// decoration. The generator is therefore per step now, and the id is half
139    /// of what seeds it.
140    pub fn with_seed(
141        client: Client,
142        depth: Depth,
143        lang: Lang,
144        claimed_model: String,
145        seed: u64,
146    ) -> Self {
147        Self {
148            client,
149            depth,
150            lang,
151            claimed_model,
152            seed,
153            perf: Mutex::new(Vec::new()),
154            billing: Mutex::new(Vec::new()),
155            headers: Mutex::new(Vec::new()),
156            message_ids: Mutex::new(Vec::new()),
157            raw_bodies: Mutex::new(Vec::new()),
158            reachable: Mutex::new(true),
159        }
160    }
161
162    /// A generator belonging to one step, and to one run.
163    ///
164    /// Two steps never share a stream, so the payloads a seed produces do not
165    /// depend on the order the scheduler happened to run them in, or on whether
166    /// an earlier step was skipped. Same seed and same step id rebuild the same
167    /// questions on any machine — which is what makes the seed in the report
168    /// worth recording, and a contested verdict answerable question by question.
169    ///
170    /// A step that draws more than once must therefore hold on to what this
171    /// returns rather than calling again per draw, or every draw is the first
172    /// draw. Steps that fan their requests out concurrently have to generate
173    /// everything up front for the same reason.
174    pub fn rng_for(&self, step_id: &str) -> Rng {
175        // FNV-1a, inlined: the requirement is that the mixing never changes
176        // between releases, which no hasher from the standard library promises.
177        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
178        for b in step_id.as_bytes() {
179            h ^= *b as u64;
180            h = h.wrapping_mul(0x0000_0100_0000_01b3);
181        }
182        Rng::from_seed(self.seed ^ h)
183    }
184
185    /// Record everything a later probe might want from a raw response.
186    pub fn observe(&self, raw: &crate::client::RawResponse, id: &str) {
187        self.headers.lock().unwrap().push(raw.headers.clone());
188        if !id.is_empty() {
189            self.message_ids.lock().unwrap().push(id.to_string());
190        }
191        let mut bodies = self.raw_bodies.lock().unwrap();
192        if bodies.len() < 12 {
193            bodies.push(crate::util::truncate(&raw.body, 4000));
194        }
195    }
196
197    pub fn add_perf(&self, sample: PerfSample) {
198        self.perf.lock().unwrap().push(sample);
199    }
200
201    /// Whether the endpoint answered the preflight probe at all.
202    pub fn is_reachable(&self) -> bool {
203        *self.reachable.lock().unwrap()
204    }
205
206    pub fn set_reachable(&self, v: bool) {
207        *self.reachable.lock().unwrap() = v;
208    }
209}
210
211/// What a step is actually measuring — and therefore whether its answer
212/// survives a relay.
213///
214/// This is the distinction that matters to anyone probing an endpoint that is
215/// not the vendor's own. Ask "is this endpoint's error envelope well formed"
216/// through three hops and you have measured the hop nearest you; ask "does the
217/// text coming back read like the model it claims to be" and you have measured
218/// whatever generated the tokens, however many hops away it sits, because the
219/// tokens themselves are the evidence.
220///
221/// A marketplace verifying a seller reachable only through its own gateway must
222/// run [`Subject::Model`] steps and skip the rest: the endpoint steps would all
223/// be describing the gateway, identically for every seller, at the cost of a
224/// real request each. Running the whole suite there is not merely wasteful —
225/// the contract steps deliberately send malformed and unauthenticated requests,
226/// which is exactly the traffic pattern that gets a seller's upstream account
227/// flagged for abuse.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum Subject {
230    /// The HTTP endpoint itself: its contract, its error shapes, its headers,
231    /// and the usage numbers *it* reports. Behind a relay, these describe the
232    /// relay.
233    Endpoint,
234    /// The model generating the text. Survives any number of relay hops.
235    Model,
236}
237
238pub type ProbeFuture<'a> = futures_util::future::BoxFuture<'a, Vec<ProbeResult>>;
239
240/// One entry in the suite.
241///
242/// A step may emit several [`ProbeResult`]s — `identity` alone produces seven —
243/// so the unit of *selection* is coarser than the unit of *reporting*. That is
244/// deliberate: the multi-result steps share state and sampling between their
245/// parts, and letting a caller pick half of one would silently change what the
246/// other half means.
247pub struct ProbeSpec {
248    /// Stable across releases. Callers persist these, filter on them, and
249    /// localise labels from them, so renaming one is a breaking change.
250    pub id: &'static str,
251    /// Which section of the report this step's results land in. Also selectable
252    /// — see [`Selection::only`] — so `identity` addresses the whole family of
253    /// identity steps without naming each one.
254    pub group: Group,
255    pub subject: Subject,
256    /// How many results this step contributes, for progress totals. Steps whose
257    /// output depends on what earlier steps observed report their maximum.
258    pub results: usize,
259    /// Runs regardless of the caller's subject filter, because the rest of the
260    /// suite is meaningless without it.
261    pub always: bool,
262    /// Must have the run to itself: nothing else may be in flight while it
263    /// runs. See [`run_steps`] for why anything measuring a clock has to be.
264    pub exclusive: bool,
265    pub run: for<'a> fn(&'a Ctx) -> ProbeFuture<'a>,
266}
267
268/// A probe supplied by the caller rather than by this crate.
269///
270/// # Why this exists
271///
272/// Everything in [`registry`] is published. That is right for a tool whose
273/// value is that anyone can audit what it asks — and it is a problem for
274/// anyone using it to police a marketplace, because the endpoint being probed
275/// can read the suite too. Timing can be disguised (see [`Pace`]); the
276/// questions themselves cannot be, once they are in a public repository.
277///
278/// So a caller in that position keeps its own bank of probes, out of the
279/// public tree, rotated often enough that a fingerprint of last month's
280/// questions is worth nothing. This trait is where those plug in. They share
281/// the run's [`Ctx`] — the same client, the same seeded RNG, the same
282/// observation buffers — so a custom probe is indistinguishable from a
283/// built-in one in the report and in the traffic.
284///
285/// The engine never inspects them beyond scheduling. A custom probe that
286/// returns [`Status::Fail`](crate::report::Status::Fail) moves the score
287/// exactly as a built-in one does; if that is not wanted, mark the result
288/// neutral.
289pub trait Probe: Send + Sync {
290    /// Stable id, as [`ProbeSpec::id`]. Namespace it (`acme.tokenizer`) so it
291    /// cannot collide with a step this crate adds later.
292    fn id(&self) -> &str;
293    /// Whether this probe's evidence survives a relay. See [`Subject`].
294    fn subject(&self) -> Subject {
295        Subject::Model
296    }
297    /// Results this probe contributes, for progress totals.
298    fn results(&self) -> usize {
299        1
300    }
301    /// Whether this probe needs the run to itself. See [`ProbeSpec::exclusive`].
302    ///
303    /// `false` is right for anything whose evidence is the content of a reply.
304    /// Say `true` only if the measurement is a clock reading, or if the probe
305    /// reads shared state that a step running beside it could still be writing.
306    fn exclusive(&self) -> bool {
307        false
308    }
309    fn run<'a>(&'a self, ctx: &'a Ctx) -> ProbeFuture<'a>;
310}
311
312/// Wrap a single-result step so it fits the registry's shape.
313macro_rules! one {
314    ($f:path) => {
315        (|ctx: &Ctx| Box::pin(async move { vec![$f(ctx).await] }) as ProbeFuture<'_>)
316            as for<'a> fn(&'a Ctx) -> ProbeFuture<'a>
317    };
318}
319
320/// Wrap a step that already returns several results.
321macro_rules! many {
322    ($f:path) => {
323        (|ctx: &Ctx| Box::pin($f(ctx)) as ProbeFuture<'_>) as for<'a> fn(&'a Ctx) -> ProbeFuture<'a>
324    };
325}
326
327/// Every step in the suite, in execution order.
328///
329/// Contract first: if the channel is rewriting requests, later fingerprint
330/// results are unreliable and the verdict layer needs to know that before it
331/// reads them. Perf and channel last, because both read what every earlier step
332/// observed rather than issuing much of their own.
333///
334/// The identity family is seven steps rather than one. It was one until 0.5.0,
335/// and the cost of that was invisible until somebody wanted a cheaper run: the
336/// unit of selection was the whole family, so a caller who wanted the model's
337/// self-report and its capability profile had to buy four steps of self-reported
338/// trivia along with them — and, because a step is also the unit of scheduling,
339/// had to run all twelve requests one after another. Splitting them changes
340/// nothing about what any of them asks.
341pub fn registry() -> Vec<ProbeSpec> {
342    use Group::{Consistency, Contract, Identity, Perf, Stream};
343    use Subject::{Endpoint, Model};
344    let spec = |id, group, subject, results, run| ProbeSpec {
345        id,
346        group,
347        subject,
348        results,
349        always: false,
350        exclusive: false,
351        run,
352    };
353    vec![
354        ProbeSpec {
355            id: "preflight",
356            group: Contract,
357            subject: Endpoint,
358            results: 1,
359            always: true,
360            // Nothing may run beside it, because nothing may run *before* its
361            // answer: every later step is conditional on the endpoint being
362            // there at all.
363            exclusive: true,
364            run: one!(contract::preflight),
365        },
366        spec(
367            "model_catalog",
368            Contract,
369            Endpoint,
370            1,
371            one!(contract::model_catalog),
372        ),
373        spec(
374            "response_schema",
375            Contract,
376            Endpoint,
377            1,
378            one!(contract::response_schema),
379        ),
380        spec(
381            "model_echo",
382            Contract,
383            Endpoint,
384            1,
385            one!(contract::model_echo),
386        ),
387        spec(
388            "missing_version",
389            Contract,
390            Endpoint,
391            1,
392            one!(contract::missing_version),
393        ),
394        spec(
395            "missing_auth",
396            Contract,
397            Endpoint,
398            1,
399            one!(contract::missing_auth),
400        ),
401        spec(
402            "invalid_model",
403            Contract,
404            Endpoint,
405            1,
406            one!(contract::invalid_model),
407        ),
408        spec(
409            "error_envelope",
410            Contract,
411            Endpoint,
412            1,
413            one!(contract::error_envelope),
414        ),
415        spec(
416            "stop_reason_enum",
417            Contract,
418            Endpoint,
419            1,
420            one!(contract::stop_reason_enum),
421        ),
422        // Truncation, stop sequences and system-prompt adherence are asked of
423        // the generator, not of the transport: a relay forwards the parameter
424        // and it is the model that honours or ignores it.
425        spec(
426            "max_tokens_truncation",
427            Contract,
428            Model,
429            1,
430            one!(contract::max_tokens_truncation),
431        ),
432        spec(
433            "stop_sequence",
434            Contract,
435            Model,
436            1,
437            one!(contract::stop_sequence),
438        ),
439        spec(
440            "system_adherence",
441            Contract,
442            Model,
443            1,
444            one!(contract::system_adherence),
445        ),
446        spec("sse_format", Stream, Endpoint, 1, one!(stream::sse_format)),
447        spec(
448            "stream_not_empty",
449            Stream,
450            Endpoint,
451            1,
452            one!(stream::stream_not_empty),
453        ),
454        spec(
455            "stream_usage",
456            Stream,
457            Endpoint,
458            1,
459            one!(stream::stream_usage),
460        ),
461        // Every billing signal is read out of the `usage` block the endpoint
462        // reports. Behind a relay that block is the relay's accounting.
463        spec("billing", Group::Billing, Endpoint, 7, many!(billing::run)),
464        // ── identity ───────────────────────────────────────────────────────
465        // What it says it is. One request, and the only one of these that can
466        // reach a family verdict on its own.
467        spec("self_id", Identity, Model, 1, one!(identity::self_id)),
468        // A second, differently-phrased ask, purely to corroborate `self_id`.
469        spec(
470            "meta_creator",
471            Identity,
472            Model,
473            1,
474            one!(identity::meta_creator),
475        ),
476        // Self-reported context window and cutoff. Both are fingerprints — the
477        // answers are strikingly consistent within a checkpoint — and neither
478        // is scored: they describe, they do not judge.
479        spec(
480            "context_claim",
481            Identity,
482            Model,
483            1,
484            one!(identity::context_claim),
485        ),
486        spec(
487            "cutoff_claim",
488            Identity,
489            Model,
490            1,
491            one!(identity::cutoff_claim),
492        ),
493        // Four requests, and the most expensive thing in the family that is not
494        // the battery. Cross-checks demonstrated knowledge against the model's
495        // own claimed cutoff.
496        spec(
497            "world_knowledge",
498            Identity,
499            Model,
500            1,
501            one!(identity::world_knowledge),
502        ),
503        // The battery and the tier it implies stay one step: the estimate is a
504        // reading of the battery's own result, and a caller who took one without
505        // the other would get a tier fitted to no measurements.
506        spec(
507            "capability",
508            Identity,
509            Model,
510            2,
511            many!(identity::capability),
512        ),
513        spec("verbosity", Identity, Model, 1, one!(identity::verbosity)),
514        // ── consistency ────────────────────────────────────────────────────
515        spec(
516            "signature_drift",
517            Consistency,
518            Model,
519            1,
520            one!(consistency::signature_drift),
521        ),
522        spec(
523            "cache_replay",
524            Consistency,
525            Model,
526            1,
527            one!(consistency::cache_replay),
528        ),
529        spec(
530            "request_id_unique",
531            Consistency,
532            Endpoint,
533            1,
534            one!(consistency::request_id_unique),
535        ),
536        ProbeSpec {
537            id: "perf",
538            group: Perf,
539            subject: Model,
540            results: 4,
541            always: false,
542            // The one step whose measurement is a clock reading. Anything else
543            // in flight is load this run put there itself, and a latency figure
544            // that includes our own queueing describes the run rather than the
545            // endpoint — then travels on into whatever the caller does with
546            // `PerfSummary`.
547            exclusive: true,
548            run: many!(perf::run),
549        },
550        spec("channel", Group::Channel, Endpoint, 3, many!(channel::run)),
551    ]
552}
553
554/// Which steps to run.
555#[derive(Clone, Default)]
556pub struct Selection {
557    /// Subjects to keep. Empty means all of them.
558    pub subjects: Vec<Subject>,
559    /// Step ids to keep. Empty means all of them; applied after `subjects`.
560    pub only: Vec<String>,
561    /// Step ids to drop, applied last and winning over both fields above.
562    ///
563    /// Drops caller-supplied probes as well as built-in ones, so a custom probe
564    /// found to be misbehaving can be turned off by id without a deploy. That
565    /// is why replacing a built-in step with a custom one of the same id is
566    /// [`replacing`](Self::replacing) rather than a `skip` plus a `with`.
567    pub skip: Vec<String>,
568    /// Built-in step ids a caller-supplied probe is standing in for.
569    ///
570    /// Unlike `skip` this applies to the registry only, which is the whole
571    /// point: the replacement is allowed to answer to the id it replaced.
572    pub replaced: Vec<String>,
573    /// Caller-supplied probes, appended after the built-in steps. See [`Probe`].
574    ///
575    /// Not filtered by `subjects`/`only`/`skip`: the caller assembled this list
576    /// itself and already decided what belongs in it. `skip` still removes one
577    /// by id, so a probe found to be misbehaving can be turned off without a
578    /// deploy.
579    pub extra: Vec<std::sync::Arc<dyn Probe>>,
580}
581
582impl std::fmt::Debug for Selection {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        f.debug_struct("Selection")
585            .field("subjects", &self.subjects)
586            .field("only", &self.only)
587            .field("skip", &self.skip)
588            .field("replaced", &self.replaced)
589            .field(
590                "extra",
591                &self.extra.iter().map(|p| p.id()).collect::<Vec<_>>(),
592            )
593            .finish()
594    }
595}
596
597impl Selection {
598    /// Everything — what the CLI runs against a vendor's own endpoint.
599    pub fn all() -> Self {
600        Self::default()
601    }
602
603    /// Only what survives a relay. See [`Subject`].
604    pub fn model_only() -> Self {
605        Selection {
606            subjects: vec![Subject::Model],
607            ..Default::default()
608        }
609    }
610
611    /// The smallest set that still supports a verdict about the model.
612    ///
613    /// [`model_only`](Self::model_only) with the redundant and the merely
614    /// descriptive taken out. Nine requests at [`Depth::Fast`] against
615    /// twenty-one, and — because the identity family is no longer one
616    /// indivisible step — a shape the scheduler can actually overlap.
617    ///
618    /// # What it keeps, and why those
619    ///
620    /// * `preflight` — without it nothing else means anything.
621    /// * `self_id` — the family check. This is the one that catches a lane sold
622    ///   as one vendor's model and served by another's, which is the cheat that
623    ///   does not need to be subtle.
624    /// * `capability` — graded questions generated per run, and the only thing
625    ///   here that a downgrade cannot answer its way around.
626    /// * `verbosity` and `perf` — how much it writes and how fast it generates.
627    ///   Weak on their own and the two most useful axes there are for anyone
628    ///   comparing one endpoint against many others serving the same model.
629    /// * `cache_replay` — a hard gate, and it catches being charged for
630    ///   inference that never ran.
631    ///
632    /// # What it drops, and what that costs
633    ///
634    /// `meta_creator` only ever corroborated `self_id`; `context_claim` and
635    /// `cutoff_claim` are unscored description; `signature_drift` looks for
636    /// fan-out across backends, which behind a relay describes the relay.
637    /// `world_knowledge` is four requests, asks for the cutoff a second time,
638    /// and measures the training corpus — a cheap model with a large corpus
639    /// passes it and an expensive one having a bad day fails it.
640    ///
641    /// The real loss is the three contract steps that survive a relay
642    /// (`max_tokens_truncation`, `stop_sequence`, `system_adherence`). Each is
643    /// one short request and each is a *strong* reverse-channel signal: they are
644    /// how a reconstructed endpoint, one that forwards a prompt to a web session
645    /// and cannot honour an API parameter it never received, gives itself away.
646    /// A caller who has not otherwise established what is on the far end should
647    /// add them back:
648    ///
649    /// ```
650    /// # use llm_verify::probes::Selection;
651    /// let sel = Selection::turbo().plus(["max_tokens_truncation", "stop_sequence", "system_adherence"]);
652    /// ```
653    ///
654    /// # Replacing the battery rather than paying for two
655    ///
656    /// A caller with a private bank of graded questions — the published ones
657    /// are readable by the endpoint being probed, which is the ceiling on what
658    /// they can prove — should spend the budget there instead of on both:
659    ///
660    /// ```
661    /// # use llm_verify::probes::Selection;
662    /// # fn demo(bank: std::sync::Arc<dyn llm_verify::probes::Probe>) {
663    /// let sel = Selection::turbo().replacing("capability", bank);
664    /// # }
665    /// ```
666    ///
667    /// [`replacing`](Self::replacing) rather than `minus` then `with` — the
668    /// latter reads correctly and silently drops both, for the reason spelled
669    /// out there.
670    ///
671    /// Three of turbo's nine requests are the battery, so that trades them for
672    /// however many the bank asks. The private probe is then responsible for
673    /// emitting a `capability` result and a `tier_estimate` one, or the identity
674    /// view has no capability measurement to read — see
675    /// [`identity::tier_result`](crate::probes::identity::tier_result), which is
676    /// public so that the thresholds stay in one place.
677    pub fn turbo() -> Self {
678        Selection {
679            only: ["self_id", "capability", "verbosity", "cache_replay", "perf"]
680                .iter()
681                .map(|s| s.to_string())
682                .collect(),
683            ..Default::default()
684        }
685    }
686
687    /// Add steps to an `only` list. Ids or group keys, as [`Selection::only`].
688    ///
689    /// A no-op on a selection that has no `only` list, because that one already
690    /// includes everything — adding to it could only ever narrow it, which is
691    /// the opposite of what the name says.
692    pub fn plus<I, S>(mut self, ids: I) -> Self
693    where
694        I: IntoIterator<Item = S>,
695        S: AsRef<str>,
696    {
697        if !self.only.is_empty() {
698            self.only
699                .extend(ids.into_iter().map(|s| s.as_ref().to_string()));
700        }
701        self
702    }
703
704    /// Drop steps. Ids or group keys, as [`Selection::skip`].
705    ///
706    /// Works on any selection, unlike [`plus`](Self::plus): removing is
707    /// unambiguous whether or not there is an `only` list to remove from.
708    pub fn minus<I, S>(mut self, ids: I) -> Self
709    where
710        I: IntoIterator<Item = S>,
711        S: AsRef<str>,
712    {
713        self.skip
714            .extend(ids.into_iter().map(|s| s.as_ref().to_string()));
715        self
716    }
717
718    /// Append a caller-supplied probe.
719    pub fn with(mut self, p: std::sync::Arc<dyn Probe>) -> Self {
720        self.extra.push(p);
721        self
722    }
723
724    /// Drop a built-in step and install a caller's probe in its place.
725    ///
726    /// # Why this is one call
727    ///
728    /// The obvious spelling — `.minus(["capability"]).with(bank)` — is wrong in
729    /// a way that reports success. [`skip`](Self::skip) applies to custom
730    /// probes too, deliberately, so that a misbehaving one can be switched off
731    /// by id; and a probe standing in for a built-in step naturally carries
732    /// that step's id, because carrying it is what makes everything downstream
733    /// keep working. So the skip removes both, the run comes back with the step
734    /// simply absent, and nothing anywhere says so. The verdict still
735    /// assembles, the report still renders, and the graded questions the whole
736    /// exercise existed to ask were never sent.
737    ///
738    /// Found by counting requests against a stub, which is the only way it
739    /// could have been found.
740    pub fn replacing(mut self, id: &str, p: std::sync::Arc<dyn Probe>) -> Self {
741        self.replaced.push(id.to_string());
742        self.extra.push(p);
743        self
744    }
745
746    /// Whether a name in `only`/`skip` addresses this step — by its own id, or
747    /// by the key of the group it belongs to.
748    ///
749    /// Group matching is what keeps `skip: ["identity"]` meaning what it meant
750    /// before 0.5.0 split that step into seven. It is also the more useful thing
751    /// to write: a caller who wants "no identity probing" wants the family, not
752    /// a list they have to keep in step with each release.
753    fn names(spec: &ProbeSpec, pattern: &str) -> bool {
754        pattern == spec.id || pattern == spec.group.key()
755    }
756
757    fn keeps(&self, spec: &ProbeSpec) -> bool {
758        if spec.always {
759            return true;
760        }
761        if self.skip.iter().any(|s| Self::names(spec, s))
762            || self.replaced.iter().any(|s| Self::names(spec, s))
763        {
764            return false;
765        }
766        if !self.subjects.is_empty() && !self.subjects.contains(&spec.subject) {
767            return false;
768        }
769        if !self.only.is_empty() && !self.only.iter().any(|s| Self::names(spec, s)) {
770            return false;
771        }
772        true
773    }
774
775    /// The registry, filtered.
776    pub fn resolve(&self) -> Vec<ProbeSpec> {
777        registry().into_iter().filter(|s| self.keeps(s)).collect()
778    }
779
780    /// Caller-supplied probes that survived `skip`.
781    pub fn resolve_extra(&self) -> Vec<std::sync::Arc<dyn Probe>> {
782        self.extra
783            .iter()
784            .filter(|p| !self.skip.iter().any(|s| s == p.id()))
785            .cloned()
786            .collect()
787    }
788}
789
790/// What the caller is told as the run proceeds.
791///
792/// Both variants carry the running total so a progress bar can be drawn without
793/// the caller tracking state. `total` is an upper bound — several steps skip
794/// parts of themselves when the endpoint does not offer what they need — so a
795/// run legitimately finishes below it.
796pub enum Event<'a> {
797    /// A step is about to issue its requests. This is the one a UI wants: the
798    /// gap before a slow step's first result is where a progress display
799    /// otherwise looks frozen.
800    Started {
801        id: &'a str,
802        done: usize,
803        total: usize,
804    },
805    Finished {
806        result: &'a ProbeResult,
807        done: usize,
808        total: usize,
809    },
810}
811
812/// Cooperative cancellation.
813///
814/// Checked between steps rather than inside them: a step that has already paid
815/// for its requests may as well report what they showed, and tearing one down
816/// mid-flight would leave the shared context half-written for whatever runs
817/// next. Worst-case latency is therefore one step, not one request.
818#[derive(Clone)]
819pub struct Cancel {
820    flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
821    /// Wakes a paced run that is asleep between steps. Without it, cancelling
822    /// a run spread over half an hour would take up to one full interval to
823    /// take effect.
824    notify: std::sync::Arc<tokio::sync::Notify>,
825}
826
827impl Default for Cancel {
828    fn default() -> Self {
829        Cancel {
830            flag: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
831            notify: std::sync::Arc::new(tokio::sync::Notify::new()),
832        }
833    }
834}
835
836impl Cancel {
837    pub fn new() -> Self {
838        Self::default()
839    }
840
841    pub fn cancel(&self) {
842        self.flag.store(true, std::sync::atomic::Ordering::Relaxed);
843        self.notify.notify_waiters();
844    }
845
846    pub fn is_cancelled(&self) -> bool {
847        self.flag.load(std::sync::atomic::Ordering::Relaxed)
848    }
849
850    /// Resolves once cancelled, or immediately if it already is.
851    pub async fn wait(&self) {
852        if self.is_cancelled() {
853            return;
854        }
855        self.notify.notified().await;
856    }
857}
858
859/// How long to wait between steps, drawn uniformly from the range.
860///
861/// The CLI leaves this unset and the suite runs as fast as the endpoint
862/// answers, which is what somebody probing their own endpoint wants.
863///
864/// It exists for the caller probing *somebody else's*. Back to back, a run is a
865/// recognisable object: a fixed number of requests, in a fixed order, arriving
866/// in a burst that looks like nothing else that endpoint serves. An operator
867/// who wants a run to be hard to pick out of ordinary traffic has to spread it,
868/// and spreading it is the caller's decision because only the caller knows what
869/// ordinary traffic there looks like.
870///
871/// This raises the cost of recognition; it does not eliminate it. The payloads
872/// are still drawn from a published suite.
873#[derive(Debug, Clone, Copy)]
874pub struct Pace {
875    pub min: std::time::Duration,
876    pub max: std::time::Duration,
877}
878
879/// Run a selected suite.
880pub async fn run_selected(
881    ctx: &Ctx,
882    specs: &[ProbeSpec],
883    cancel: &Cancel,
884    on_event: &mut (dyn FnMut(Event<'_>) + Send),
885) -> Vec<ProbeResult> {
886    run_paced(ctx, specs, cancel, None, on_event).await
887}
888
889/// Run a selected suite, optionally spacing the steps out. See [`Pace`].
890pub async fn run_paced(
891    ctx: &Ctx,
892    specs: &[ProbeSpec],
893    cancel: &Cancel,
894    pace: Option<Pace>,
895    on_event: &mut (dyn FnMut(Event<'_>) + Send),
896) -> Vec<ProbeResult> {
897    run_with_extra(ctx, specs, &[], cancel, Schedule::paced(pace), on_event).await
898}
899
900/// When the steps run relative to one another.
901///
902/// The two fields are mutually exclusive in practice and the constructors say
903/// so, because they exist for opposite reasons. [`Pace`] spreads a run out to
904/// make it hard to recognise; overlapping compresses it into the smallest burst
905/// the endpoint will tolerate. A caller who asked for both would be asking to be
906/// unobtrusive quickly.
907#[derive(Debug, Clone, Copy, Default)]
908pub struct Schedule {
909    pub pace: Option<Pace>,
910    /// Steps that may be in flight at once. `0` and `1` both mean sequential.
911    ///
912    /// This is a *step* count, and steps are uneven — the capability battery is
913    /// a dozen requests and `stop_sequence` is one — so it does not bound how
914    /// much traffic reaches the endpoint. That bound is
915    /// [`Client::with_limit`](crate::client::Client::with_limit), and a caller
916    /// probing an endpoint it does not own wants both.
917    pub concurrency: usize,
918}
919
920impl Schedule {
921    /// One step at a time, in registry order. What every release before 0.5.0
922    /// did, and still the default.
923    pub fn sequential() -> Self {
924        Schedule::default()
925    }
926
927    pub fn paced(pace: Option<Pace>) -> Self {
928        Schedule {
929            pace,
930            concurrency: 0,
931        }
932    }
933
934    /// Overlap up to `n` steps. Ignored while a [`Pace`] is set.
935    pub fn concurrent(n: usize) -> Self {
936        Schedule {
937            pace: None,
938            concurrency: n,
939        }
940    }
941
942    fn overlaps(&self) -> bool {
943        self.pace.is_none() && self.concurrency > 1
944    }
945}
946
947/// One step, whether it came from the registry or from the caller.
948///
949/// The two are deliberately indistinguishable from here on: same context, same
950/// pacing, same events, same place in the report. A custom probe that ran
951/// differently from a built-in one would be a custom probe the endpoint could
952/// pick out, which defeats the reason for having private ones at all.
953enum Step<'a> {
954    Built(&'a ProbeSpec),
955    Custom(&'a std::sync::Arc<dyn Probe>),
956}
957
958impl Step<'_> {
959    fn id(&self) -> &str {
960        match self {
961            Step::Built(s) => s.id,
962            Step::Custom(p) => p.id(),
963        }
964    }
965    fn results(&self) -> usize {
966        match self {
967            Step::Built(s) => s.results,
968            Step::Custom(p) => p.results(),
969        }
970    }
971    fn exclusive(&self) -> bool {
972        match self {
973            Step::Built(s) => s.exclusive,
974            Step::Custom(p) => p.exclusive(),
975        }
976    }
977    fn run<'a>(&'a self, ctx: &'a Ctx) -> ProbeFuture<'a> {
978        match self {
979            Step::Built(s) => (s.run)(ctx),
980            Step::Custom(p) => p.run(ctx),
981        }
982    }
983}
984
985/// Run the registry steps and then the caller's own. See [`Probe`].
986pub async fn run_with_extra(
987    ctx: &Ctx,
988    specs: &[ProbeSpec],
989    extra: &[std::sync::Arc<dyn Probe>],
990    cancel: &Cancel,
991    schedule: Schedule,
992    on_event: &mut (dyn FnMut(Event<'_>) + Send),
993) -> Vec<ProbeResult> {
994    let steps: Vec<Step<'_>> = specs
995        .iter()
996        .map(Step::Built)
997        .chain(extra.iter().map(Step::Custom))
998        .collect();
999    run_steps(ctx, &steps, cancel, schedule, on_event).await
1000}
1001
1002/// Run the steps, one at a time or several at once.
1003///
1004/// # The two schedules
1005///
1006/// Sequential is what every release before 0.5.0 did and is still the default,
1007/// because it is the only one that can be paced and the only one under which a
1008/// [`Perf`](Group::Perf) reading means anything without further care.
1009///
1010/// Overlapping exists for the run somebody is *waiting on* — a marketplace
1011/// admitting a listing while a seller watches a progress dialog, where thirty
1012/// sequential round trips to a model that thinks before it answers is minutes.
1013/// Two things make it safe to do without changing any probe's answer:
1014///
1015/// * **Exclusive steps.** [`ProbeSpec::exclusive`] marks a step that must have
1016///   the run to itself. `preflight` is one because everything after it is
1017///   conditional on its answer; `perf` is one because its measurement is a
1018///   clock, and a latency figure taken while this run had three other requests
1019///   in the air describes the run rather than the endpoint. Registry order is
1020///   preserved across them: consecutive non-exclusive steps overlap with each
1021///   other and with nothing on the far side of an exclusive one.
1022/// * **Per-step generators.** See [`Ctx::rng_for`]. Draw order no longer
1023///   depends on scheduling, so a seed reproduces a run either way.
1024///
1025/// What does change is [`Event`] timing: a step announces `Started` when it is
1026/// admitted to the window rather than when the one before it finished, so
1027/// several may be outstanding at once and their `Finished` events interleave. A
1028/// display that tracks "the current step" should expect to hold more than one.
1029/// `done`/`total` still advance monotonically, and results are re-ordered into
1030/// registry order before returning, so nothing reading the report can tell.
1031async fn run_steps(
1032    ctx: &Ctx,
1033    specs: &[Step<'_>],
1034    cancel: &Cancel,
1035    schedule: Schedule,
1036    on_event: &mut (dyn FnMut(Event<'_>) + Send),
1037) -> Vec<ProbeResult> {
1038    let total: usize = specs.iter().map(|s| s.results()).sum();
1039    // Indexed by position in `specs`, so an overlapping wave can be flattened
1040    // back into registry order however its steps finished.
1041    let mut collected: Vec<Vec<ProbeResult>> = vec![Vec::new(); specs.len()];
1042    let mut done = 0usize;
1043    let mut first = true;
1044    let mut i = 0usize;
1045
1046    while i < specs.len() {
1047        if cancel.is_cancelled() {
1048            break;
1049        }
1050        // How many steps start together.
1051        //
1052        // A rolling window rather than fixed batches, and the difference is not
1053        // academic: batching in groups of `concurrency` makes every group wait
1054        // for its own slowest member before the next one starts, so a run whose
1055        // steps are uneven — and they are, one of them is a whole battery —
1056        // spends most of its time with idle permits. Taking every consecutive
1057        // non-exclusive step instead lets a step that finishes early free its
1058        // permit for one further down the list. What bounds the actual traffic
1059        // is the client's permit pool, not this number.
1060        let wave = if schedule.overlaps() && !specs[i].exclusive() {
1061            specs[i..]
1062                .iter()
1063                .take_while(|s| !s.exclusive())
1064                .count()
1065                .max(1)
1066        } else {
1067            1
1068        };
1069
1070        // Before the wave, never after the last one: a run that ended minutes
1071        // ago but has not returned is a run whose caller thinks it is still
1072        // going.
1073        if let (false, Some(p)) = (first, schedule.pace) {
1074            let span = p.max.saturating_sub(p.min);
1075            let jitter = if span.is_zero() {
1076                std::time::Duration::ZERO
1077            } else {
1078                // Drawn from a stream of its own so that pacing — which is a
1079                // decision about *when*, made by the caller — cannot shift the
1080                // payloads any probe asks. Before 0.5.0 it shared the run's one
1081                // generator, so the same seed produced different questions
1082                // depending on whether the run was paced.
1083                let r = ctx.rng_for("__pace").next_u64();
1084                std::time::Duration::from_millis(r % (span.as_millis() as u64).max(1))
1085            };
1086            let wait = p.min + jitter;
1087            // Cancellation has to win over the wait, or cancelling a paced run
1088            // takes as long as letting it finish.
1089            tokio::select! {
1090                _ = tokio::time::sleep(wait) => {}
1091                _ = cancel.wait() => break,
1092            }
1093        }
1094        first = false;
1095
1096        // Nothing is spawned: the futures borrow `ctx` and are polled together
1097        // on this task. A step is admitted as soon as a slot frees rather than
1098        // when the whole group finishes, so a short step behind a long one does
1099        // not wait for it.
1100        use futures_util::StreamExt;
1101        let end = i + wave;
1102        let cap = if wave == 1 {
1103            1
1104        } else {
1105            schedule.concurrency.max(1)
1106        };
1107        let mut running = futures_util::stream::FuturesUnordered::new();
1108        let mut next = i;
1109        loop {
1110            while next < end && running.len() < cap {
1111                let at = next;
1112                on_event(Event::Started {
1113                    id: specs[at].id(),
1114                    done,
1115                    total,
1116                });
1117                running.push(async move { (at, specs[at].run(ctx).await) });
1118                next += 1;
1119            }
1120            let Some((at, results)) = running.next().await else {
1121                break;
1122            };
1123            for r in results {
1124                collected[at].push(r);
1125                done += 1;
1126                on_event(Event::Finished {
1127                    result: collected[at].last().unwrap(),
1128                    done,
1129                    total,
1130                });
1131            }
1132        }
1133
1134        // Everything downstream would just report the same connection failure.
1135        if !ctx.is_reachable() {
1136            break;
1137        }
1138        i += wave;
1139    }
1140
1141    collected.into_iter().flatten().collect()
1142}
1143
1144/// Upper bound on results from the full suite. Derived, so adding a step cannot
1145/// leave it stale — it used to be a hand-maintained `40`.
1146pub fn probe_count() -> usize {
1147    registry().iter().map(|s| s.results).sum()
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153
1154    #[test]
1155    fn depth_parses_and_scales_repeats_monotonically() {
1156        assert_eq!(Depth::parse("FAST"), Some(Depth::Fast));
1157        assert_eq!(Depth::parse("deep"), Some(Depth::Forensic));
1158        assert_eq!(Depth::parse("nonsense"), None);
1159        assert!(Depth::Fast.repeats() < Depth::Balanced.repeats());
1160        assert!(Depth::Balanced.repeats() < Depth::Forensic.repeats());
1161    }
1162
1163    #[test]
1164    fn tps_excludes_the_wait_for_first_token() {
1165        let s = PerfSample {
1166            probe: "p".into(),
1167            ttft_ms: Some(1000),
1168            latency_ms: 3000,
1169            output_tokens: 100,
1170        };
1171        // 100 tokens over the 2s of actual generation, not the full 3s.
1172        assert_eq!(s.tps(), Some(50.0));
1173    }
1174
1175    /// The two steps that cannot share a run, and the reason each cannot.
1176    ///
1177    /// `perf` is the one that would fail quietly. Overlap it and its numbers
1178    /// come out worse the more concurrency the caller asked for — and those
1179    /// numbers do not stay in the report, they go on to
1180    /// `PerfSummary::tps_mean`/`ttft_p50`, which anyone comparing endpoints
1181    /// against a population reads as a property of the model.
1182    #[test]
1183    fn the_clock_reading_and_the_gate_run_alone() {
1184        let exclusive: Vec<&str> = registry()
1185            .iter()
1186            .filter(|s| s.exclusive)
1187            .map(|s| s.id)
1188            .collect();
1189        assert_eq!(exclusive, vec!["preflight", "perf"]);
1190    }
1191
1192    /// Whatever else concurrency changed, it must not have changed what a seed
1193    /// asks. The report records the seed so a seller can be shown the questions
1194    /// they were scored on; if scheduling could move them, that record is a
1195    /// fiction.
1196    #[test]
1197    fn a_step_s_questions_depend_on_the_seed_and_its_own_id_only() {
1198        let ctx = |seed| {
1199            Ctx::with_seed(
1200                Client::with_http(crate::client::Endpoint::default(), reqwest::Client::new()),
1201                Depth::Fast,
1202                Lang::En,
1203                "m".into(),
1204                seed,
1205            )
1206        };
1207        let a = ctx(0xC0FFEE);
1208        let b = ctx(0xC0FFEE);
1209        // Same seed, same step: identical, and drawing for some other step in
1210        // between cannot disturb it — which is exactly what a shared generator
1211        // could not promise once the steps stopped running in a fixed order.
1212        let first = a.rng_for("capability").hex(8);
1213        let _ = a.rng_for("cache_replay").hex(8);
1214        let _ = a.rng_for("stop_sequence").hex(8);
1215        assert_eq!(a.rng_for("capability").hex(8), first);
1216        assert_eq!(b.rng_for("capability").hex(8), first);
1217
1218        // Two steps never share a stream, or one of them asking a question
1219        // fewer would shift every question after it.
1220        assert_ne!(a.rng_for("cache_replay").hex(8), first);
1221        // And a different run asks differently.
1222        assert_ne!(ctx(0xC0FFEF).rng_for("capability").hex(8), first);
1223    }
1224
1225    #[test]
1226    fn a_schedule_overlaps_only_when_it_can() {
1227        assert!(!Schedule::sequential().overlaps());
1228        assert!(!Schedule::concurrent(1).overlaps());
1229        assert!(Schedule::concurrent(4).overlaps());
1230        // Pacing wins. Spreading a run out to be hard to spot and compressing
1231        // it into the smallest possible burst cannot both be had.
1232        let paced = Schedule {
1233            pace: Some(Pace {
1234                min: std::time::Duration::from_secs(20),
1235                max: std::time::Duration::from_secs(180),
1236            }),
1237            concurrency: 8,
1238        };
1239        assert!(!paced.overlaps());
1240    }
1241
1242    #[test]
1243    fn tps_is_none_when_the_sample_cannot_support_it() {
1244        let base = PerfSample {
1245            probe: "p".into(),
1246            ttft_ms: Some(500),
1247            latency_ms: 1500,
1248            output_tokens: 10,
1249        };
1250        assert!(base.tps().is_some());
1251
1252        // No streaming, so no TTFT to subtract.
1253        let mut s = base.clone();
1254        s.ttft_ms = None;
1255        assert!(s.tps().is_none());
1256
1257        // Zero output tokens would divide by a meaningless numerator.
1258        let mut s = base.clone();
1259        s.output_tokens = 0;
1260        assert!(s.tps().is_none());
1261
1262        // Whole response arrived in the first frame: no generation window.
1263        let mut s = base.clone();
1264        s.latency_ms = 500;
1265        assert!(s.tps().is_none());
1266    }
1267}