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, 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}