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, 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/// Probes run sequentially and nothing here is ever contended, so the lock is
100/// not buying mutual exclusion — it is buying `Sync`. This used to be
101/// `RefCell`, which made every probe future `!Send` and therefore impossible to
102/// `await` from a multi-threaded runtime: an embedded caller (a request handler
103/// spawning a verification) could not hold the future at all. Nothing may hold
104/// one of these guards across an `.await`; each site below takes it, reads or
105/// pushes, and drops it in the same expression.
106pub struct Ctx {
107 pub client: Client,
108 pub depth: Depth,
109 pub lang: Lang,
110 pub claimed_model: String,
111 pub rng: Mutex<Rng>,
112 pub perf: Mutex<Vec<PerfSample>>,
113 pub billing: Mutex<Vec<BillingRound>>,
114 /// Response headers from every successful call, for channel classification.
115 pub headers: Mutex<Vec<BTreeMap<String, String>>>,
116 pub message_ids: Mutex<Vec<String>>,
117 pub raw_bodies: Mutex<Vec<String>>,
118 /// Set by the preflight probe; when false the rest of the run is pointless.
119 pub reachable: Mutex<bool>,
120}
121
122impl Ctx {
123 pub fn new(client: Client, depth: Depth, lang: Lang, claimed_model: String) -> Self {
124 Self::with_rng(client, depth, lang, claimed_model, Rng::new())
125 }
126
127 /// The same, on a caller-chosen random source — see [`Rng::from_seed`].
128 pub fn with_rng(
129 client: Client,
130 depth: Depth,
131 lang: Lang,
132 claimed_model: String,
133 rng: Rng,
134 ) -> Self {
135 Self {
136 client,
137 depth,
138 lang,
139 claimed_model,
140 rng: Mutex::new(rng),
141 perf: Mutex::new(Vec::new()),
142 billing: Mutex::new(Vec::new()),
143 headers: Mutex::new(Vec::new()),
144 message_ids: Mutex::new(Vec::new()),
145 raw_bodies: Mutex::new(Vec::new()),
146 reachable: Mutex::new(true),
147 }
148 }
149
150 /// Record everything a later probe might want from a raw response.
151 pub fn observe(&self, raw: &crate::client::RawResponse, id: &str) {
152 self.headers.lock().unwrap().push(raw.headers.clone());
153 if !id.is_empty() {
154 self.message_ids.lock().unwrap().push(id.to_string());
155 }
156 let mut bodies = self.raw_bodies.lock().unwrap();
157 if bodies.len() < 12 {
158 bodies.push(crate::util::truncate(&raw.body, 4000));
159 }
160 }
161
162 pub fn add_perf(&self, sample: PerfSample) {
163 self.perf.lock().unwrap().push(sample);
164 }
165
166 /// Whether the endpoint answered the preflight probe at all.
167 pub fn is_reachable(&self) -> bool {
168 *self.reachable.lock().unwrap()
169 }
170
171 pub fn set_reachable(&self, v: bool) {
172 *self.reachable.lock().unwrap() = v;
173 }
174}
175
176/// What a step is actually measuring — and therefore whether its answer
177/// survives a relay.
178///
179/// This is the distinction that matters to anyone probing an endpoint that is
180/// not the vendor's own. Ask "is this endpoint's error envelope well formed"
181/// through three hops and you have measured the hop nearest you; ask "does the
182/// text coming back read like the model it claims to be" and you have measured
183/// whatever generated the tokens, however many hops away it sits, because the
184/// tokens themselves are the evidence.
185///
186/// A marketplace verifying a seller reachable only through its own gateway must
187/// run [`Subject::Model`] steps and skip the rest: the endpoint steps would all
188/// be describing the gateway, identically for every seller, at the cost of a
189/// real request each. Running the whole suite there is not merely wasteful —
190/// the contract steps deliberately send malformed and unauthenticated requests,
191/// which is exactly the traffic pattern that gets a seller's upstream account
192/// flagged for abuse.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Subject {
195 /// The HTTP endpoint itself: its contract, its error shapes, its headers,
196 /// and the usage numbers *it* reports. Behind a relay, these describe the
197 /// relay.
198 Endpoint,
199 /// The model generating the text. Survives any number of relay hops.
200 Model,
201}
202
203pub type ProbeFuture<'a> = futures_util::future::BoxFuture<'a, Vec<ProbeResult>>;
204
205/// One entry in the suite.
206///
207/// A step may emit several [`ProbeResult`]s — `identity` alone produces seven —
208/// so the unit of *selection* is coarser than the unit of *reporting*. That is
209/// deliberate: the multi-result steps share state and sampling between their
210/// parts, and letting a caller pick half of one would silently change what the
211/// other half means.
212pub struct ProbeSpec {
213 /// Stable across releases. Callers persist these, filter on them, and
214 /// localise labels from them, so renaming one is a breaking change.
215 pub id: &'static str,
216 pub subject: Subject,
217 /// How many results this step contributes, for progress totals. Steps whose
218 /// output depends on what earlier steps observed report their maximum.
219 pub results: usize,
220 /// Runs regardless of the caller's subject filter, because the rest of the
221 /// suite is meaningless without it.
222 pub always: bool,
223 pub run: for<'a> fn(&'a Ctx) -> ProbeFuture<'a>,
224}
225
226/// A probe supplied by the caller rather than by this crate.
227///
228/// # Why this exists
229///
230/// Everything in [`registry`] is published. That is right for a tool whose
231/// value is that anyone can audit what it asks — and it is a problem for
232/// anyone using it to police a marketplace, because the endpoint being probed
233/// can read the suite too. Timing can be disguised (see [`Pace`]); the
234/// questions themselves cannot be, once they are in a public repository.
235///
236/// So a caller in that position keeps its own bank of probes, out of the
237/// public tree, rotated often enough that a fingerprint of last month's
238/// questions is worth nothing. This trait is where those plug in. They share
239/// the run's [`Ctx`] — the same client, the same seeded RNG, the same
240/// observation buffers — so a custom probe is indistinguishable from a
241/// built-in one in the report and in the traffic.
242///
243/// The engine never inspects them beyond scheduling. A custom probe that
244/// returns [`Status::Fail`](crate::report::Status::Fail) moves the score
245/// exactly as a built-in one does; if that is not wanted, mark the result
246/// neutral.
247pub trait Probe: Send + Sync {
248 /// Stable id, as [`ProbeSpec::id`]. Namespace it (`acme.tokenizer`) so it
249 /// cannot collide with a step this crate adds later.
250 fn id(&self) -> &str;
251 /// Whether this probe's evidence survives a relay. See [`Subject`].
252 fn subject(&self) -> Subject {
253 Subject::Model
254 }
255 /// Results this probe contributes, for progress totals.
256 fn results(&self) -> usize {
257 1
258 }
259 fn run<'a>(&'a self, ctx: &'a Ctx) -> ProbeFuture<'a>;
260}
261
262/// Wrap a single-result step so it fits the registry's shape.
263macro_rules! one {
264 ($f:path) => {
265 (|ctx: &Ctx| Box::pin(async move { vec![$f(ctx).await] }) as ProbeFuture<'_>)
266 as for<'a> fn(&'a Ctx) -> ProbeFuture<'a>
267 };
268}
269
270/// Wrap a step that already returns several results.
271macro_rules! many {
272 ($f:path) => {
273 (|ctx: &Ctx| Box::pin($f(ctx)) as ProbeFuture<'_>) as for<'a> fn(&'a Ctx) -> ProbeFuture<'a>
274 };
275}
276
277/// Every step in the suite, in execution order.
278///
279/// Contract first: if the channel is rewriting requests, later fingerprint
280/// results are unreliable and the verdict layer needs to know that before it
281/// reads them. Perf and channel last, because both read what every earlier step
282/// observed rather than issuing much of their own.
283pub fn registry() -> Vec<ProbeSpec> {
284 use Subject::{Endpoint, Model};
285 let spec = |id, subject, results, run| ProbeSpec {
286 id,
287 subject,
288 results,
289 always: false,
290 run,
291 };
292 vec![
293 ProbeSpec {
294 id: "preflight",
295 subject: Endpoint,
296 results: 1,
297 always: true,
298 run: one!(contract::preflight),
299 },
300 spec("model_catalog", Endpoint, 1, one!(contract::model_catalog)),
301 spec(
302 "response_schema",
303 Endpoint,
304 1,
305 one!(contract::response_schema),
306 ),
307 spec("model_echo", Endpoint, 1, one!(contract::model_echo)),
308 spec(
309 "missing_version",
310 Endpoint,
311 1,
312 one!(contract::missing_version),
313 ),
314 spec("missing_auth", Endpoint, 1, one!(contract::missing_auth)),
315 spec("invalid_model", Endpoint, 1, one!(contract::invalid_model)),
316 spec(
317 "error_envelope",
318 Endpoint,
319 1,
320 one!(contract::error_envelope),
321 ),
322 spec(
323 "stop_reason_enum",
324 Endpoint,
325 1,
326 one!(contract::stop_reason_enum),
327 ),
328 // Truncation, stop sequences and system-prompt adherence are asked of
329 // the generator, not of the transport: a relay forwards the parameter
330 // and it is the model that honours or ignores it.
331 spec(
332 "max_tokens_truncation",
333 Model,
334 1,
335 one!(contract::max_tokens_truncation),
336 ),
337 spec("stop_sequence", Model, 1, one!(contract::stop_sequence)),
338 spec(
339 "system_adherence",
340 Model,
341 1,
342 one!(contract::system_adherence),
343 ),
344 spec("sse_format", Endpoint, 1, one!(stream::sse_format)),
345 spec(
346 "stream_not_empty",
347 Endpoint,
348 1,
349 one!(stream::stream_not_empty),
350 ),
351 spec("stream_usage", Endpoint, 1, one!(stream::stream_usage)),
352 // Every billing signal is read out of the `usage` block the endpoint
353 // reports. Behind a relay that block is the relay's accounting.
354 spec("billing", Endpoint, 7, many!(billing::run)),
355 spec("identity", Model, 8, many!(identity::run)),
356 spec(
357 "signature_drift",
358 Model,
359 1,
360 one!(consistency::signature_drift),
361 ),
362 spec("cache_replay", Model, 1, one!(consistency::cache_replay)),
363 spec(
364 "request_id_unique",
365 Endpoint,
366 1,
367 one!(consistency::request_id_unique),
368 ),
369 spec("perf", Model, 4, many!(perf::run)),
370 spec("channel", Endpoint, 3, many!(channel::run)),
371 ]
372}
373
374/// Which steps to run.
375#[derive(Clone, Default)]
376pub struct Selection {
377 /// Subjects to keep. Empty means all of them.
378 pub subjects: Vec<Subject>,
379 /// Step ids to keep. Empty means all of them; applied after `subjects`.
380 pub only: Vec<String>,
381 /// Step ids to drop, applied last and winning over both fields above.
382 pub skip: Vec<String>,
383 /// Caller-supplied probes, appended after the built-in steps. See [`Probe`].
384 ///
385 /// Not filtered by `subjects`/`only`/`skip`: the caller assembled this list
386 /// itself and already decided what belongs in it. `skip` still removes one
387 /// by id, so a probe found to be misbehaving can be turned off without a
388 /// deploy.
389 pub extra: Vec<std::sync::Arc<dyn Probe>>,
390}
391
392impl std::fmt::Debug for Selection {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 f.debug_struct("Selection")
395 .field("subjects", &self.subjects)
396 .field("only", &self.only)
397 .field("skip", &self.skip)
398 .field(
399 "extra",
400 &self.extra.iter().map(|p| p.id()).collect::<Vec<_>>(),
401 )
402 .finish()
403 }
404}
405
406impl Selection {
407 /// Everything — what the CLI runs against a vendor's own endpoint.
408 pub fn all() -> Self {
409 Self::default()
410 }
411
412 /// Only what survives a relay. See [`Subject`].
413 pub fn model_only() -> Self {
414 Selection {
415 subjects: vec![Subject::Model],
416 ..Default::default()
417 }
418 }
419
420 /// Append a caller-supplied probe.
421 pub fn with(mut self, p: std::sync::Arc<dyn Probe>) -> Self {
422 self.extra.push(p);
423 self
424 }
425
426 fn keeps(&self, spec: &ProbeSpec) -> bool {
427 if spec.always {
428 return true;
429 }
430 if self.skip.iter().any(|s| s == spec.id) {
431 return false;
432 }
433 if !self.subjects.is_empty() && !self.subjects.contains(&spec.subject) {
434 return false;
435 }
436 if !self.only.is_empty() && !self.only.iter().any(|s| s == spec.id) {
437 return false;
438 }
439 true
440 }
441
442 /// The registry, filtered.
443 pub fn resolve(&self) -> Vec<ProbeSpec> {
444 registry().into_iter().filter(|s| self.keeps(s)).collect()
445 }
446
447 /// Caller-supplied probes that survived `skip`.
448 pub fn resolve_extra(&self) -> Vec<std::sync::Arc<dyn Probe>> {
449 self.extra
450 .iter()
451 .filter(|p| !self.skip.iter().any(|s| s == p.id()))
452 .cloned()
453 .collect()
454 }
455}
456
457/// What the caller is told as the run proceeds.
458///
459/// Both variants carry the running total so a progress bar can be drawn without
460/// the caller tracking state. `total` is an upper bound — several steps skip
461/// parts of themselves when the endpoint does not offer what they need — so a
462/// run legitimately finishes below it.
463pub enum Event<'a> {
464 /// A step is about to issue its requests. This is the one a UI wants: the
465 /// gap before a slow step's first result is where a progress display
466 /// otherwise looks frozen.
467 Started {
468 id: &'a str,
469 done: usize,
470 total: usize,
471 },
472 Finished {
473 result: &'a ProbeResult,
474 done: usize,
475 total: usize,
476 },
477}
478
479/// Cooperative cancellation.
480///
481/// Checked between steps rather than inside them: a step that has already paid
482/// for its requests may as well report what they showed, and tearing one down
483/// mid-flight would leave the shared context half-written for whatever runs
484/// next. Worst-case latency is therefore one step, not one request.
485#[derive(Clone)]
486pub struct Cancel {
487 flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
488 /// Wakes a paced run that is asleep between steps. Without it, cancelling
489 /// a run spread over half an hour would take up to one full interval to
490 /// take effect.
491 notify: std::sync::Arc<tokio::sync::Notify>,
492}
493
494impl Default for Cancel {
495 fn default() -> Self {
496 Cancel {
497 flag: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
498 notify: std::sync::Arc::new(tokio::sync::Notify::new()),
499 }
500 }
501}
502
503impl Cancel {
504 pub fn new() -> Self {
505 Self::default()
506 }
507
508 pub fn cancel(&self) {
509 self.flag.store(true, std::sync::atomic::Ordering::Relaxed);
510 self.notify.notify_waiters();
511 }
512
513 pub fn is_cancelled(&self) -> bool {
514 self.flag.load(std::sync::atomic::Ordering::Relaxed)
515 }
516
517 /// Resolves once cancelled, or immediately if it already is.
518 pub async fn wait(&self) {
519 if self.is_cancelled() {
520 return;
521 }
522 self.notify.notified().await;
523 }
524}
525
526/// How long to wait between steps, drawn uniformly from the range.
527///
528/// The CLI leaves this unset and the suite runs as fast as the endpoint
529/// answers, which is what somebody probing their own endpoint wants.
530///
531/// It exists for the caller probing *somebody else's*. Back to back, a run is a
532/// recognisable object: a fixed number of requests, in a fixed order, arriving
533/// in a burst that looks like nothing else that endpoint serves. An operator
534/// who wants a run to be hard to pick out of ordinary traffic has to spread it,
535/// and spreading it is the caller's decision because only the caller knows what
536/// ordinary traffic there looks like.
537///
538/// This raises the cost of recognition; it does not eliminate it. The payloads
539/// are still drawn from a published suite.
540#[derive(Debug, Clone, Copy)]
541pub struct Pace {
542 pub min: std::time::Duration,
543 pub max: std::time::Duration,
544}
545
546/// Run a selected suite.
547pub async fn run_selected(
548 ctx: &Ctx,
549 specs: &[ProbeSpec],
550 cancel: &Cancel,
551 on_event: &mut (dyn FnMut(Event<'_>) + Send),
552) -> Vec<ProbeResult> {
553 run_paced(ctx, specs, cancel, None, on_event).await
554}
555
556/// Run a selected suite, optionally spacing the steps out. See [`Pace`].
557pub async fn run_paced(
558 ctx: &Ctx,
559 specs: &[ProbeSpec],
560 cancel: &Cancel,
561 pace: Option<Pace>,
562 on_event: &mut (dyn FnMut(Event<'_>) + Send),
563) -> Vec<ProbeResult> {
564 run_with_extra(ctx, specs, &[], cancel, pace, on_event).await
565}
566
567/// One step, whether it came from the registry or from the caller.
568///
569/// The two are deliberately indistinguishable from here on: same context, same
570/// pacing, same events, same place in the report. A custom probe that ran
571/// differently from a built-in one would be a custom probe the endpoint could
572/// pick out, which defeats the reason for having private ones at all.
573enum Step<'a> {
574 Built(&'a ProbeSpec),
575 Custom(&'a std::sync::Arc<dyn Probe>),
576}
577
578impl Step<'_> {
579 fn id(&self) -> &str {
580 match self {
581 Step::Built(s) => s.id,
582 Step::Custom(p) => p.id(),
583 }
584 }
585 fn results(&self) -> usize {
586 match self {
587 Step::Built(s) => s.results,
588 Step::Custom(p) => p.results(),
589 }
590 }
591 fn run<'a>(&'a self, ctx: &'a Ctx) -> ProbeFuture<'a> {
592 match self {
593 Step::Built(s) => (s.run)(ctx),
594 Step::Custom(p) => p.run(ctx),
595 }
596 }
597}
598
599/// Run the registry steps and then the caller's own. See [`Probe`].
600pub async fn run_with_extra(
601 ctx: &Ctx,
602 specs: &[ProbeSpec],
603 extra: &[std::sync::Arc<dyn Probe>],
604 cancel: &Cancel,
605 pace: Option<Pace>,
606 on_event: &mut (dyn FnMut(Event<'_>) + Send),
607) -> Vec<ProbeResult> {
608 let steps: Vec<Step<'_>> = specs
609 .iter()
610 .map(Step::Built)
611 .chain(extra.iter().map(Step::Custom))
612 .collect();
613 run_steps(ctx, &steps, cancel, pace, on_event).await
614}
615
616async fn run_steps(
617 ctx: &Ctx,
618 specs: &[Step<'_>],
619 cancel: &Cancel,
620 pace: Option<Pace>,
621 on_event: &mut (dyn FnMut(Event<'_>) + Send),
622) -> Vec<ProbeResult> {
623 let total: usize = specs.iter().map(|s| s.results()).sum();
624 let mut out: Vec<ProbeResult> = Vec::new();
625 let mut first = true;
626 for spec in specs {
627 if cancel.is_cancelled() {
628 break;
629 }
630 // Before the step, never after the last one: a run that ended minutes
631 // ago but has not returned is a run whose caller thinks it is still
632 // going.
633 if let (false, Some(p)) = (first, pace) {
634 let span = p.max.saturating_sub(p.min);
635 let jitter = if span.is_zero() {
636 std::time::Duration::ZERO
637 } else {
638 let r = ctx.rng.lock().unwrap().next_u64();
639 std::time::Duration::from_millis(r % (span.as_millis() as u64).max(1))
640 };
641 let wait = p.min + jitter;
642 // Cancellation has to win over the wait, or cancelling a paced run
643 // takes as long as letting it finish.
644 tokio::select! {
645 _ = tokio::time::sleep(wait) => {}
646 _ = cancel.wait() => break,
647 }
648 }
649 first = false;
650 on_event(Event::Started {
651 id: spec.id(),
652 done: out.len(),
653 total,
654 });
655 for r in spec.run(ctx).await {
656 out.push(r);
657 let done = out.len();
658 on_event(Event::Finished {
659 result: out.last().unwrap(),
660 done,
661 total,
662 });
663 }
664 // Everything downstream would just report the same connection failure.
665 if !ctx.is_reachable() {
666 break;
667 }
668 }
669 out
670}
671
672/// Upper bound on results from the full suite. Derived, so adding a step cannot
673/// leave it stale — it used to be a hand-maintained `40`.
674pub fn probe_count() -> usize {
675 registry().iter().map(|s| s.results).sum()
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn depth_parses_and_scales_repeats_monotonically() {
684 assert_eq!(Depth::parse("FAST"), Some(Depth::Fast));
685 assert_eq!(Depth::parse("deep"), Some(Depth::Forensic));
686 assert_eq!(Depth::parse("nonsense"), None);
687 assert!(Depth::Fast.repeats() < Depth::Balanced.repeats());
688 assert!(Depth::Balanced.repeats() < Depth::Forensic.repeats());
689 }
690
691 #[test]
692 fn tps_excludes_the_wait_for_first_token() {
693 let s = PerfSample {
694 probe: "p".into(),
695 ttft_ms: Some(1000),
696 latency_ms: 3000,
697 output_tokens: 100,
698 };
699 // 100 tokens over the 2s of actual generation, not the full 3s.
700 assert_eq!(s.tps(), Some(50.0));
701 }
702
703 #[test]
704 fn tps_is_none_when_the_sample_cannot_support_it() {
705 let base = PerfSample {
706 probe: "p".into(),
707 ttft_ms: Some(500),
708 latency_ms: 1500,
709 output_tokens: 10,
710 };
711 assert!(base.tps().is_some());
712
713 // No streaming, so no TTFT to subtract.
714 let mut s = base.clone();
715 s.ttft_ms = None;
716 assert!(s.tps().is_none());
717
718 // Zero output tokens would divide by a meaningless numerator.
719 let mut s = base.clone();
720 s.output_tokens = 0;
721 assert!(s.tps().is_none());
722
723 // Whole response arrived in the first frame: no generation window.
724 let mut s = base.clone();
725 s.latency_ms = 500;
726 assert!(s.tps().is_none());
727 }
728}