use serde::{Deserialize, Serialize};
use super::protocol::Outcome;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RequestSample {
pub index: usize,
pub worker: usize,
pub start_s: f64,
pub end_s: f64,
#[serde(default)]
pub token_times_s: Vec<f64>,
pub generated_tokens: u32,
#[serde(default)]
pub prompt_tokens: u32,
pub outcome: Outcome,
#[serde(default)]
pub in_flight_at_start: usize,
#[serde(default)]
pub drained: bool,
}
impl RequestSample {
#[must_use]
pub fn ttft_ms(&self) -> Option<f64> {
self.token_times_s
.first()
.map(|t| (t - self.start_s) * 1000.0)
}
#[must_use]
pub fn decode_tok_s(&self) -> Option<f64> {
if self.token_times_s.len() < 2 || self.generated_tokens < 2 {
return None;
}
let first = self.token_times_s[0];
let last = self.token_times_s[self.token_times_s.len() - 1];
let span = last - first;
if span <= 0.0 {
return None;
}
Some(f64::from(self.generated_tokens - 1) / span)
}
#[must_use]
pub fn itl_gaps_ms(&self) -> Vec<f64> {
self.token_times_s
.windows(2)
.map(|w| (w[1] - w[0]) * 1000.0)
.collect()
}
#[must_use]
pub fn counts_toward_aggregate(&self) -> bool {
self.outcome == Outcome::Completed
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BandMetrics {
pub concurrency: usize,
pub agg_tok_s: f64,
pub decode_tok_s: f64,
pub ttft_p50_ms: f64,
pub ttft_p95_ms: f64,
pub itl_p50_ms: f64,
pub itl_p95_ms: f64,
pub requested: usize,
pub completed: usize,
pub timeouts: usize,
pub truncated: usize,
pub errors: usize,
pub tokens_total: u64,
pub span_s: f64,
}
pub use super::drain::percentile;
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v
}
fn median(values: Vec<f64>) -> f64 {
percentile(&sorted(values), 0.50).unwrap_or(0.0)
}
#[must_use]
pub fn agg_tok_s(samples: &[RequestSample]) -> f64 {
let (tokens, span) = aggregate_terms(samples);
if span <= 0.0 {
return 0.0;
}
tokens as f64 / span
}
#[must_use]
pub fn aggregate_terms(samples: &[RequestSample]) -> (u64, f64) {
if samples.is_empty() {
return (0, 0.0);
}
let tokens: u64 = samples
.iter()
.filter(|s| s.counts_toward_aggregate())
.map(|s| u64::from(s.generated_tokens))
.sum();
let first_start = samples
.iter()
.map(|s| s.start_s)
.fold(f64::INFINITY, f64::min);
let last_end = samples
.iter()
.filter(|s| s.counts_toward_aggregate())
.map(|s| s.end_s)
.fold(f64::NEG_INFINITY, f64::max);
if !first_start.is_finite() || !last_end.is_finite() {
return (tokens, 0.0);
}
(tokens, last_end - first_start)
}
#[must_use]
pub fn mean_of_rates(samples: &[RequestSample]) -> f64 {
let rates: Vec<f64> = samples
.iter()
.filter(|s| s.counts_toward_aggregate())
.filter_map(|s| {
let dur = s.end_s - s.start_s;
if dur > 0.0 {
Some(f64::from(s.generated_tokens) / dur)
} else {
None
}
})
.collect();
if rates.is_empty() {
return 0.0;
}
rates.iter().sum::<f64>() / rates.len() as f64
}
impl BandMetrics {
#[must_use]
pub fn from_samples(concurrency: usize, samples: &[RequestSample]) -> Self {
let (tokens_total, span_s) = aggregate_terms(samples);
let agg = if span_s > 0.0 {
tokens_total as f64 / span_s
} else {
0.0
};
let ttfts = sorted(samples.iter().filter_map(RequestSample::ttft_ms).collect());
let itls = sorted(
samples
.iter()
.flat_map(RequestSample::itl_gaps_ms)
.collect::<Vec<f64>>(),
);
let decodes: Vec<f64> = samples
.iter()
.filter_map(RequestSample::decode_tok_s)
.collect();
let count = |o: Outcome| samples.iter().filter(|s| s.outcome == o).count();
Self {
concurrency,
agg_tok_s: agg,
decode_tok_s: median(decodes),
ttft_p50_ms: percentile(&ttfts, 0.50).unwrap_or(0.0),
ttft_p95_ms: percentile(&ttfts, 0.95).unwrap_or(0.0),
itl_p50_ms: percentile(&itls, 0.50).unwrap_or(0.0),
itl_p95_ms: percentile(&itls, 0.95).unwrap_or(0.0),
requested: samples.len(),
completed: count(Outcome::Completed),
timeouts: count(Outcome::Timeout),
truncated: count(Outcome::AbandonedAtDrain),
errors: count(Outcome::Failed),
tokens_total,
span_s,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(index: usize, start_s: f64, end_s: f64, tokens: u32) -> RequestSample {
let n = tokens as usize;
let token_times_s = if n == 0 {
Vec::new()
} else {
let step = (end_s - start_s) / n as f64;
(1..=n).map(|i| start_s + step * i as f64).collect()
};
RequestSample {
index,
worker: 0,
start_s,
end_s,
token_times_s,
generated_tokens: tokens,
prompt_tokens: 0,
outcome: Outcome::Completed,
in_flight_at_start: 1,
drained: false,
}
}
#[test]
fn agg_tok_s_is_wall_clock_not_the_mean_of_rates() {
let s = vec![
sample(0, 0.0, 1.0, 100),
sample(1, 0.0, 2.0, 100),
sample(2, 1.0, 3.0, 100),
sample(3, 2.0, 4.0, 100),
];
let (tokens, span) = aggregate_terms(&s);
assert_eq!(tokens, 400);
assert!((span - 4.0).abs() < 1e-12, "span={span}");
let agg = agg_tok_s(&s);
let mean = mean_of_rates(&s);
assert!((agg - 100.0).abs() < 1e-9, "agg={agg}, want 100.0");
assert!((mean - 62.5).abs() < 1e-9, "mean={mean}, want 62.5");
assert!(
(agg - mean).abs() > 1.0,
"agg {agg} and mean-of-rates {mean} must not coincide"
);
}
#[test]
fn mean_of_rates_overstates_a_serialising_server() {
let s = vec![
sample(0, 0.0, 1.0, 100),
sample(1, 2.0, 3.0, 100),
sample(2, 4.0, 5.0, 100),
sample(3, 6.0, 7.0, 100),
];
let agg = agg_tok_s(&s);
let mean = mean_of_rates(&s);
assert!((agg - 400.0 / 7.0).abs() < 1e-9, "agg={agg}");
assert!((mean - 100.0).abs() < 1e-9, "mean={mean}");
assert!(mean > agg * 1.7, "mean {mean} must overstate agg {agg}");
}
#[test]
fn timeouts_lengthen_the_span_but_add_no_tokens() {
let mut timed_out = sample(0, 0.0, 1.0, 100);
timed_out.outcome = Outcome::Timeout;
timed_out.generated_tokens = 100; let s = vec![timed_out, sample(1, 0.5, 2.5, 100)];
let (tokens, span) = aggregate_terms(&s);
assert_eq!(tokens, 100, "a timed-out request contributes no tokens");
assert!(
(span - 2.5).abs() < 1e-12,
"span must start at 0.0, got {span}"
);
let m = BandMetrics::from_samples(2, &s);
assert_eq!(m.requested, 2);
assert_eq!(m.completed, 1);
assert_eq!(m.timeouts, 1);
assert!((m.agg_tok_s - 40.0).abs() < 1e-9, "{}", m.agg_tok_s);
}
#[test]
fn decode_tok_s_is_the_median_of_per_request_rates() {
let one = sample(0, 0.0, 1.0, 100);
assert!((one.decode_tok_s().expect("two+ tokens") - 100.0).abs() < 1e-9);
let s = vec![
sample(0, 0.0, 1.0, 100),
sample(1, 0.0, 2.0, 100),
sample(2, 0.0, 4.0, 100),
];
let m = BandMetrics::from_samples(1, &s);
assert!((m.decode_tok_s - 50.0).abs() < 1e-9, "{}", m.decode_tok_s);
}
#[test]
fn single_token_request_has_no_decode_rate_and_no_gaps() {
let s = sample(0, 0.0, 1.0, 1);
assert_eq!(s.decode_tok_s(), None);
assert!(s.itl_gaps_ms().is_empty());
assert!(s.ttft_ms().is_some(), "one token still has a TTFT");
}
#[test]
fn ttft_is_start_to_first_token() {
let s = sample(0, 10.0, 11.0, 4); assert!((s.ttft_ms().expect("has tokens") - 250.0).abs() < 1e-9);
}
#[test]
fn itl_gaps_are_pooled_across_requests() {
let s = vec![sample(0, 0.0, 1.0, 4), sample(1, 0.0, 3.0, 3)];
let pooled: Vec<f64> = s.iter().flat_map(RequestSample::itl_gaps_ms).collect();
assert_eq!(
pooled.len(),
5,
"3 + 2 gaps pooled, not 2 per-request means"
);
let m = BandMetrics::from_samples(2, &s);
assert!((m.itl_p50_ms - 250.0).abs() < 1e-9, "{}", m.itl_p50_ms);
assert!(m.itl_p95_ms > 900.0, "{}", m.itl_p95_ms);
}
#[test]
fn percentile_of_nothing_is_none_not_zero() {
assert_eq!(percentile(&[], 0.5), None);
assert_eq!(percentile(&[7.0], 0.95), Some(7.0));
}
#[test]
fn percentile_interpolates_between_order_statistics() {
let v = vec![0.0, 10.0, 20.0, 30.0];
assert_eq!(percentile(&v, 0.0), Some(0.0));
assert_eq!(percentile(&v, 1.0), Some(30.0));
assert_eq!(percentile(&v, 0.5), Some(15.0));
}
#[test]
fn empty_band_is_zero_not_infinite() {
let m = BandMetrics::from_samples(4, &[]);
assert_eq!(m.agg_tok_s, 0.0);
assert_eq!(m.requested, 0);
assert_eq!(m.span_s, 0.0);
}
#[test]
fn samples_round_trip_as_jsonl_rows() {
let s = sample(3, 1.5, 2.5, 8);
let line = serde_json::to_string(&s).expect("serialize");
let back: RequestSample = serde_json::from_str(&line).expect("deserialize");
assert_eq!(back, s);
}
}