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}