Skip to main content

aisimulate_core/replay/
report.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use ddsketchy::DDSketch;
5use rustc_hash::FxHashMap;
6use serde::Serialize;
7use serde::ser::{SerializeMap, Serializer};
8use serde_json::Value;
9use std::fmt::{Display, Formatter, Result as FmtResult};
10use uuid::Uuid;
11
12// 0.1% relative quantile error. The enlarged store covers latency/rate values
13// spanning roughly 10^28 within one sign while remaining bounded (~512 KiB for
14// the two stores at their maximum size, ~1 MiB for both global sketches).
15const DDSKETCH_RELATIVE_ACCURACY: f64 = 0.001;
16const DDSKETCH_MAX_BINS: usize = 32_768;
17
18/// Canonical replay result returned by [`crate::replay::Replayer`].
19#[derive(Debug, Clone)]
20pub struct ReplayReport {
21    pub request_counts: TraceRequestCounts,
22    pub throughput: TraceThroughputStats,
23    pub prefix_cache_reused_ratio: f64,
24    pub first_admission_prefix_cache_reused_ratio: f64,
25    pub latency: TraceLatencyStats,
26    /// SLA-goodput stats. `Some` only when an SLA was supplied to the collector
27    /// (via `set_sla_thresholds`); `None` otherwise — goodput is undefined
28    /// without an SLA, so the `goodput_*` keys are omitted from the report.
29    pub goodput: Option<TraceGoodputStats>,
30    /// Per-request records, one per admitted request. Populated by
31    /// `TraceCollector::finish`. Intentionally NOT serialized into the summary
32    /// JSON (see custom `Serialize` impl below) — consumers that want per-
33    /// request granularity should access this field directly and serialize it
34    /// themselves.
35    pub per_request: Vec<PerRequestRecord>,
36    /// Execution-owned planner/lifecycle/pressure evidence. This is excluded
37    /// from the compact summary serializer and consumed explicitly by
38    /// canonical-report and planner adapters.
39    pub runtime_evidence: crate::replay::OfflineRuntimeEvidence,
40}
41
42#[derive(Debug, Clone)]
43pub struct TraceRequestCounts {
44    pub num_requests: usize,
45    pub completed_requests: usize,
46    pub total_input_tokens: usize,
47    pub total_output_tokens: usize,
48}
49
50#[derive(Debug, Clone)]
51pub struct TraceThroughputStats {
52    pub duration_ms: f64,
53    pub wall_time_ms: f64,
54    pub request_throughput_rps: f64,
55    pub input_throughput_tok_s: f64,
56    pub output_throughput_tok_s: f64,
57    pub total_throughput_tok_s: f64,
58    /// Provisioned worker-time per role, in **worker-seconds**: the time-integral
59    /// of the *provisioned* worker count over the whole simulated run. The
60    /// provisioned count is every worker physically holding a GPU (active +
61    /// starting-up + draining), so this captures the startup ramp and the
62    /// scale-down drain tail, unlike a snapshot of the active/serving count.
63    /// Populated on the collector by the runtime: `add_worker_seconds` accrues
64    /// the integral each clock advance (agg / disagg), and
65    /// `set_static_worker_count` covers externally clocked runtimes that do not
66    /// integrate worker counts on each clock advance; 0.0 otherwise.
67    /// Multiply by GPUs-per-worker for GPU-seconds (/3600 for GPU-hours).
68    /// Aggregated replay reports through `decode_worker_seconds`, leaving
69    /// `prefill_worker_seconds` at 0.0.
70    pub prefill_worker_seconds: f64,
71    pub decode_worker_seconds: f64,
72    /// GPUs per worker per role, derived from the mocker engine parallelism
73    /// (`MockEngineArgs::aic_gpus_per_worker` = tensor parallelism × materialized
74    /// DP topology); the runtime sets it on the collector. 0 when not set.
75    pub prefill_gpus_per_worker: usize,
76    pub decode_gpus_per_worker: usize,
77    /// GPU-hours = Σ_role `worker_seconds × gpus_per_worker / 3600` — the
78    /// deployment's provisioned GPU-time (already including the startup ramp and
79    /// drain tail, since `*_worker_seconds` do). Computed in `finish()` straight
80    /// from the mocker's own worker parallelism, so it needs no external config.
81    pub gpu_hours: f64,
82}
83
84/// Goodput: throughput restricted to the requests that satisfy the SLA. Present
85/// on the report only when an SLA was supplied to the collector (goodput is
86/// undefined without one). A completed request counts as "good" when it meets
87/// the configured SLA thresholds.
88#[derive(Debug, Clone)]
89pub struct TraceGoodputStats {
90    /// Completed requests that satisfied the SLA.
91    pub completed_requests: usize,
92    /// Good requests per second, over the simulated `duration_s`.
93    pub request_throughput_rps: f64,
94    /// Output tokens from good requests per second, over `duration_s`.
95    pub output_throughput_tok_s: f64,
96}
97
98#[derive(Debug, Clone)]
99pub struct TraceDistributionStats {
100    pub mean_ms: f64,
101    pub min_ms: f64,
102    pub max_ms: f64,
103    pub median_ms: f64,
104    pub p75_ms: f64,
105    pub p90_ms: f64,
106    pub p95_ms: f64,
107    pub p99_ms: f64,
108    pub std_ms: f64,
109}
110
111#[derive(Debug, Clone)]
112pub struct TraceLatencyStats {
113    pub ttft: TraceDistributionStats,
114    pub ttst: TraceDistributionStats,
115    pub tpot: TraceDistributionStats,
116    pub itl: TraceInterTokenLatencyStats,
117    pub e2e: TraceDistributionStats,
118    pub output_token_throughput_per_user: TraceDistributionStats,
119}
120
121#[derive(Debug, Clone)]
122pub struct TraceInterTokenLatencyStats {
123    pub distribution: TraceDistributionStats,
124    pub max_ms: f64,
125}
126
127impl ReplayReport {
128    pub fn with_wall_time_ms(mut self, wall_time_ms: f64) -> Self {
129        self.throughput.wall_time_ms = wall_time_ms;
130        self
131    }
132
133    pub fn processed_tokens(&self) -> usize {
134        self.request_counts.total_input_tokens + self.request_counts.total_output_tokens
135    }
136
137    pub fn processed_tokens_per_s(&self) -> f64 {
138        if self.throughput.wall_time_ms <= 0.0 {
139            return 0.0;
140        }
141        self.processed_tokens() as f64 / self.throughput.wall_time_ms * 1000.0
142    }
143
144    pub fn processed_output_tokens_per_s(&self) -> f64 {
145        if self.throughput.wall_time_ms <= 0.0 {
146            return 0.0;
147        }
148        self.request_counts.total_output_tokens as f64 / self.throughput.wall_time_ms * 1000.0
149    }
150}
151
152impl Display for ReplayReport {
153    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
154        writeln!(
155            f,
156            "  completed_requests: {}",
157            self.request_counts.completed_requests
158        )?;
159        writeln!(
160            f,
161            "  request_throughput_rps: {:.6}",
162            self.throughput.request_throughput_rps
163        )?;
164        writeln!(
165            f,
166            "  output_throughput_tok_s: {:.6}",
167            self.throughput.output_throughput_tok_s
168        )?;
169        writeln!(
170            f,
171            "  total_input_tokens: {}",
172            self.request_counts.total_input_tokens
173        )?;
174        writeln!(
175            f,
176            "  total_output_tokens: {}",
177            self.request_counts.total_output_tokens
178        )?;
179        writeln!(
180            f,
181            "  processed_tokens_per_s: {:.6}",
182            self.processed_tokens_per_s()
183        )?;
184        writeln!(
185            f,
186            "  processed_output_tokens_per_s: {:.6}",
187            self.processed_output_tokens_per_s()
188        )?;
189        writeln!(f, "  mean_ttft_ms: {:.6}", self.latency.ttft.mean_ms)?;
190        writeln!(f, "  mean_e2e_latency_ms: {:.6}", self.latency.e2e.mean_ms)?;
191        writeln!(
192            f,
193            "  prefix_cache_reused_ratio: {:.6}",
194            self.prefix_cache_reused_ratio
195        )?;
196        writeln!(
197            f,
198            "  first_admission_prefix_cache_reused_ratio: {:.6}",
199            self.first_admission_prefix_cache_reused_ratio
200        )?;
201        write!(f, "  wall_time_ms: {:.6}", self.throughput.wall_time_ms)
202    }
203}
204
205impl Serialize for ReplayReport {
206    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
207    where
208        S: Serializer,
209    {
210        let mut map = serializer.serialize_map(Some(70))?;
211        map.serialize_entry("num_requests", &self.request_counts.num_requests)?;
212        map.serialize_entry(
213            "completed_requests",
214            &self.request_counts.completed_requests,
215        )?;
216        map.serialize_entry(
217            "total_input_tokens",
218            &self.request_counts.total_input_tokens,
219        )?;
220        map.serialize_entry(
221            "total_output_tokens",
222            &self.request_counts.total_output_tokens,
223        )?;
224        map.serialize_entry("duration_ms", &self.throughput.duration_ms)?;
225        map.serialize_entry("wall_time_ms", &self.throughput.wall_time_ms)?;
226        map.serialize_entry(
227            "request_throughput_rps",
228            &self.throughput.request_throughput_rps,
229        )?;
230        map.serialize_entry(
231            "input_throughput_tok_s",
232            &self.throughput.input_throughput_tok_s,
233        )?;
234        map.serialize_entry(
235            "output_throughput_tok_s",
236            &self.throughput.output_throughput_tok_s,
237        )?;
238        map.serialize_entry(
239            "total_throughput_tok_s",
240            &self.throughput.total_throughput_tok_s,
241        )?;
242        map.serialize_entry(
243            "prefill_worker_seconds",
244            &self.throughput.prefill_worker_seconds,
245        )?;
246        map.serialize_entry(
247            "decode_worker_seconds",
248            &self.throughput.decode_worker_seconds,
249        )?;
250        map.serialize_entry(
251            "prefill_gpus_per_worker",
252            &self.throughput.prefill_gpus_per_worker,
253        )?;
254        map.serialize_entry(
255            "decode_gpus_per_worker",
256            &self.throughput.decode_gpus_per_worker,
257        )?;
258        map.serialize_entry("gpu_hours", &self.throughput.gpu_hours)?;
259        if let Some(goodput) = &self.goodput {
260            map.serialize_entry("goodput_completed_requests", &goodput.completed_requests)?;
261            map.serialize_entry(
262                "goodput_request_throughput_rps",
263                &goodput.request_throughput_rps,
264            )?;
265            map.serialize_entry(
266                "goodput_output_throughput_tok_s",
267                &goodput.output_throughput_tok_s,
268            )?;
269        }
270        map.serialize_entry("processed_tokens", &self.processed_tokens())?;
271        map.serialize_entry("processed_tokens_per_s", &self.processed_tokens_per_s())?;
272        map.serialize_entry(
273            "processed_output_tokens_per_s",
274            &self.processed_output_tokens_per_s(),
275        )?;
276        map.serialize_entry("prefix_cache_reused_ratio", &self.prefix_cache_reused_ratio)?;
277        map.serialize_entry(
278            "first_admission_prefix_cache_reused_ratio",
279            &self.first_admission_prefix_cache_reused_ratio,
280        )?;
281        serialize_distribution(&mut map, "ttft", &self.latency.ttft)?;
282        serialize_distribution(&mut map, "ttst", &self.latency.ttst)?;
283        serialize_distribution(&mut map, "tpot", &self.latency.tpot)?;
284        serialize_distribution(&mut map, "itl", &self.latency.itl.distribution)?;
285        map.serialize_entry("max_itl_ms", &self.latency.itl.max_ms)?;
286        serialize_distribution(&mut map, "e2e_latency", &self.latency.e2e)?;
287        serialize_rate_distribution(
288            &mut map,
289            "output_token_throughput_per_user",
290            &self.latency.output_token_throughput_per_user,
291        )?;
292        map.end()
293    }
294}
295
296fn serialize_distribution<S>(
297    map: &mut S,
298    prefix: &str,
299    stats: &TraceDistributionStats,
300) -> Result<(), S::Error>
301where
302    S: SerializeMap,
303{
304    map.serialize_entry(&format!("mean_{prefix}_ms"), &stats.mean_ms)?;
305    map.serialize_entry(&format!("min_{prefix}_ms"), &stats.min_ms)?;
306    map.serialize_entry(&format!("max_{prefix}_ms"), &stats.max_ms)?;
307    map.serialize_entry(&format!("median_{prefix}_ms"), &stats.median_ms)?;
308    map.serialize_entry(&format!("p75_{prefix}_ms"), &stats.p75_ms)?;
309    map.serialize_entry(&format!("p90_{prefix}_ms"), &stats.p90_ms)?;
310    map.serialize_entry(&format!("p95_{prefix}_ms"), &stats.p95_ms)?;
311    map.serialize_entry(&format!("p99_{prefix}_ms"), &stats.p99_ms)?;
312    map.serialize_entry(&format!("std_{prefix}_ms"), &stats.std_ms)?;
313    Ok(())
314}
315
316fn serialize_rate_distribution<S>(
317    map: &mut S,
318    prefix: &str,
319    stats: &TraceDistributionStats,
320) -> Result<(), S::Error>
321where
322    S: SerializeMap,
323{
324    map.serialize_entry(&format!("mean_{prefix}"), &stats.mean_ms)?;
325    map.serialize_entry(&format!("min_{prefix}"), &stats.min_ms)?;
326    map.serialize_entry(&format!("max_{prefix}"), &stats.max_ms)?;
327    map.serialize_entry(&format!("median_{prefix}"), &stats.median_ms)?;
328    map.serialize_entry(&format!("p75_{prefix}"), &stats.p75_ms)?;
329    map.serialize_entry(&format!("p90_{prefix}"), &stats.p90_ms)?;
330    map.serialize_entry(&format!("p95_{prefix}"), &stats.p95_ms)?;
331    map.serialize_entry(&format!("p99_{prefix}"), &stats.p99_ms)?;
332    map.serialize_entry(&format!("std_{prefix}"), &stats.std_ms)?;
333    Ok(())
334}
335
336#[derive(Debug)]
337struct TraceRequestStats {
338    arrival_time_ms: f64,
339    first_admit_ms: Option<f64>,
340    terminal_time_ms: Option<f64>,
341    terminal_status: Option<ReplayTerminalStatus>,
342    token_timeline: TokenTimeline,
343    input_length: usize,
344    requested_output_length: usize,
345    reused_input_tokens: usize,
346    first_admission_reused_input_tokens: usize,
347    /// Index of the prefill worker that handled this request, if any.
348    /// `None` in two situations:
349    ///   - Aggregated replay (no separate prefill pool) — meaningless field.
350    ///   - Offline disagg with conditional-prefill bypass — request was
351    ///     routed directly to a decode worker without going through prefill.
352    ///
353    /// Downstream tooling derives "was_bypassed" as `prefill_worker_idx is None`
354    /// in disagg mode.
355    prefill_worker_idx: Option<usize>,
356    /// Index of the decode worker that handled this request, if any.
357    decode_worker_idx: Option<usize>,
358    /// Session / turn metadata copied from the workload driver, when the
359    /// trace source carries it (e.g., multi-turn Mooncake). `None` for raw
360    /// single-shot request lists.
361    session_id: Option<String>,
362    turn_index: Option<usize>,
363    authored_id: Option<String>,
364    metadata: Value,
365    detail: Option<Box<PerRequestDetail>>,
366}
367
368#[derive(Debug)]
369enum TokenTimeline {
370    Recording(Vec<f64>),
371    Finalized(FinalizedTokenTimeline),
372}
373
374impl Default for TokenTimeline {
375    fn default() -> Self {
376        Self::Recording(Vec::new())
377    }
378}
379
380#[derive(Debug, Clone, Copy)]
381struct FinalizedTokenTimeline {
382    first_ms: f64,
383    second_ms: Option<f64>,
384    last_ms: f64,
385    len: usize,
386}
387
388#[derive(Debug)]
389struct StreamingDistribution {
390    sketch: DDSketch,
391    count: u64,
392    mean: f64,
393    sum_squared_deviations: f64,
394    min: f64,
395    max: f64,
396}
397
398impl Default for StreamingDistribution {
399    fn default() -> Self {
400        let sketch = match DDSketch::with_max_bins(DDSKETCH_RELATIVE_ACCURACY, DDSKETCH_MAX_BINS) {
401            Ok(sketch) => sketch,
402            Err(error) => panic!("invalid built-in DDSketch configuration: {error}"),
403        };
404        Self {
405            sketch,
406            count: 0,
407            mean: 0.0,
408            sum_squared_deviations: 0.0,
409            min: f64::INFINITY,
410            max: f64::NEG_INFINITY,
411        }
412    }
413}
414
415impl StreamingDistribution {
416    fn add(&mut self, value: f64) {
417        if !value.is_finite() {
418            return;
419        }
420
421        self.sketch.add(value);
422        self.count += 1;
423        let delta = value - self.mean;
424        self.mean += delta / self.count as f64;
425        let delta_after_mean_update = value - self.mean;
426        self.sum_squared_deviations += delta * delta_after_mean_update;
427        self.min = self.min.min(value);
428        self.max = self.max.max(value);
429    }
430
431    fn finish(&self) -> TraceDistributionStats {
432        if self.count == 0 {
433            return empty_distribution_stats();
434        }
435
436        TraceDistributionStats {
437            mean_ms: self.mean,
438            min_ms: self.min,
439            max_ms: self.max,
440            median_ms: self.percentile(50.0),
441            p75_ms: self.percentile(75.0),
442            p90_ms: self.percentile(90.0),
443            p95_ms: self.percentile(95.0),
444            p99_ms: self.percentile(99.0),
445            std_ms: (self.sum_squared_deviations / self.count as f64).sqrt(),
446        }
447    }
448
449    /// Preserve the report's historical rounded-rank percentile definition;
450    /// DDSketch itself uses a floored rank for its `quantile` input.
451    fn percentile(&self, percentile: f64) -> f64 {
452        let span = self.count.saturating_sub(1);
453        let rank = (span as f64 * percentile / 100.0).round() as u64;
454        let quantile = if span == 0 || rank >= span {
455            1.0
456        } else {
457            (rank as f64 + 0.5) / span as f64
458        };
459        match self.sketch.quantile(quantile) {
460            Ok(value) => value,
461            Err(error) => panic!("invalid built-in DDSketch quantile {quantile}: {error}"),
462        }
463    }
464}
465
466#[derive(Debug, Default)]
467struct PerRequestDetail {
468    prefill_reused_input_tokens: Option<usize>,
469    prefill_admit_ms: Option<f64>,
470    source_held_ms: Option<f64>,
471    destination_reserved_ms: Option<f64>,
472    destination_activated_ms: Option<f64>,
473    decode_admit_ms: Option<f64>,
474    source_released_ms: Option<f64>,
475    decode_reused_input_tokens: Option<usize>,
476    prefill_route_overlap_tokens: Option<usize>,
477    decode_route_overlap_tokens: Option<usize>,
478    routing_history: Vec<PerRequestRoutingRecord>,
479    admission_history: Vec<PerRequestAdmissionRecord>,
480    pool_admission_counts: [usize; 3],
481    pressure_record_ordinals: Vec<u64>,
482}
483
484#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
485#[serde(rename_all = "snake_case")]
486pub enum ReplayRequestPool {
487    Agg,
488    Prefill,
489    Decode,
490}
491
492impl ReplayRequestPool {
493    const fn index(self) -> usize {
494        match self {
495            Self::Agg => 0,
496            Self::Prefill => 1,
497            Self::Decode => 2,
498        }
499    }
500}
501
502#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
503#[serde(rename_all = "snake_case")]
504pub enum ReplayRoutingOutcome {
505    Immediate,
506    Queued,
507}
508
509#[derive(Debug, Clone, Serialize)]
510pub struct PerRequestRoutingRecord {
511    pub pool: ReplayRequestPool,
512    pub outcome: ReplayRoutingOutcome,
513    pub queue_entered_at_ms: Option<f64>,
514    pub released_at_ms: Option<f64>,
515    pub queue_wait_ms: Option<f64>,
516    pub logical_worker_id: Option<usize>,
517    pub scheduler_id: Option<usize>,
518    pub dp_rank: Option<u32>,
519    pub reported_overlap_tokens: Option<usize>,
520}
521
522#[derive(Debug, Clone, Serialize)]
523pub struct PerRequestAdmissionRecord {
524    pub admission_ordinal: usize,
525    pub pool_admission_ordinal: usize,
526    pub pool: ReplayRequestPool,
527    pub at_ms: f64,
528    pub reused_input_tokens: usize,
529    pub is_readmission: bool,
530}
531
532#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
533#[serde(rename_all = "snake_case")]
534pub enum ReplayTerminalStatus {
535    Completed,
536    Rejected,
537    Canceled,
538    Failed,
539}
540
541/// Flat per-request record for optional JSONL emission. One JSON line per
542/// request is consumed by external analysis tools that want per-request
543/// granularity (TTFT vs. ISL scatter, worker-residency analysis, bypass
544/// classification, etc.).
545#[derive(Debug, Clone, Serialize)]
546pub struct PerRequestRecord {
547    /// Authored request identity from ReplaySpec. Legacy runtime inputs that
548    /// only carry an internal UUID leave this unset.
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub request_id: Option<String>,
551    /// Session identifier from the trace, when present. Mirrors AIPerf's
552    /// `conversation_id` field for the same purpose: bucket per-request
553    /// records by multi-turn session. Placed first in the serialized output
554    /// so each JSONL row leads with its session/turn identity, matching
555    /// AIPerf's `profile_export.jsonl` layout.
556    pub session_id: Option<String>,
557    /// Zero-based turn index within `session_id`, when present.
558    pub turn_index: Option<usize>,
559    /// Authored provider-neutral metadata retained for correlation.
560    #[serde(skip_serializing_if = "Value::is_null")]
561    pub metadata: Value,
562    pub uuid: String,
563    pub arrival_time_ms: f64,
564    pub first_admit_ms: Option<f64>,
565    pub terminal_time_ms: f64,
566    pub first_token_ms: Option<f64>,
567    pub last_token_ms: Option<f64>,
568    pub ttft_ms: Option<f64>,
569    pub ttst_ms: Option<f64>,
570    pub e2e_latency_ms: Option<f64>,
571    /// Inter-token latency for this request, in milliseconds. Matches
572    /// AIPerf's `inter_token_latency` field — one scalar per request.
573    pub itl_ms: Option<f64>,
574    pub input_length: usize,
575    /// Number of output tokens requested by the workload trace.
576    pub requested_output_length: usize,
577    /// Number of output tokens actually emitted by the mock engine.
578    pub output_length: usize,
579    pub reused_input_tokens: usize,
580    pub prefill_worker_idx: Option<usize>,
581    pub decode_worker_idx: Option<usize>,
582    pub prefill_admit_ms: Option<f64>,
583    pub source_held_ms: Option<f64>,
584    pub destination_reserved_ms: Option<f64>,
585    pub destination_activated_ms: Option<f64>,
586    pub decode_admit_ms: Option<f64>,
587    pub source_released_ms: Option<f64>,
588    pub decode_reused_input_tokens: Option<usize>,
589    pub prefill_route_overlap_tokens: Option<usize>,
590    pub decode_route_overlap_tokens: Option<usize>,
591    pub routing_history: Vec<PerRequestRoutingRecord>,
592    pub admission_history: Vec<PerRequestAdmissionRecord>,
593    pub admission_count: usize,
594    pub readmission_count: usize,
595    pub pressure_record_ordinals: Vec<u64>,
596    pub terminal_status: ReplayTerminalStatus,
597}
598
599#[cfg(test)]
600#[derive(Debug, Clone, PartialEq)]
601pub(crate) struct TraceRequestStatsSnapshot {
602    pub arrival_time_ms: f64,
603    pub first_admit_ms: Option<f64>,
604    pub first_token_ms: Option<f64>,
605    pub last_token_ms: Option<f64>,
606    pub input_length: usize,
607    pub requested_output_length: usize,
608    pub output_length: usize,
609    pub reused_input_tokens: usize,
610    pub first_admission_reused_input_tokens: usize,
611}
612
613/// SLA thresholds used to classify requests for goodput. Mirrors Sweeper's
614/// `SLATarget` shape: set `ttft_ms` + `itl_ms` together, or `e2e_ms` alone.
615/// Only the thresholds that are set are checked, so an e2e-only SLA gates on
616/// e2e and a ttft+itl SLA gates on both. All-`None` (the default) means "no
617/// SLA", which suppresses goodput entirely.
618#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, serde::Deserialize)]
619pub struct SlaThresholds {
620    pub ttft_ms: Option<f64>,
621    pub itl_ms: Option<f64>,
622    pub e2e_ms: Option<f64>,
623}
624
625impl SlaThresholds {
626    pub fn is_set(&self) -> bool {
627        self.ttft_ms.is_some() || self.itl_ms.is_some() || self.e2e_ms.is_some()
628    }
629
630    pub(crate) fn is_unset(&self) -> bool {
631        !self.is_set()
632    }
633
634    pub(crate) fn validate(&self) -> crate::replay::ReplayResult<()> {
635        for (name, value) in [
636            ("sla.ttft_ms", self.ttft_ms),
637            ("sla.itl_ms", self.itl_ms),
638            ("sla.e2e_ms", self.e2e_ms),
639        ] {
640            if let Some(value) = value
641                && (!value.is_finite() || value < 0.0)
642            {
643                return Err(crate::replay::ReplayError::InvalidSpec(format!(
644                    "{name} must be finite and non-negative, got {value}"
645                )));
646            }
647        }
648        Ok(())
649    }
650
651    /// Whether a completed request satisfies the SLA. Each *set* threshold must
652    /// hold; unset thresholds are ignored.
653    ///
654    /// - `ttft_ms`: time-to-first-token ≤ bound.
655    /// - `e2e_ms`: end-to-end latency ≤ bound.
656    /// - `itl_ms`: the per-request **average inter-token latency** ≤ bound,
657    ///   computed the same way as aiperf / genai-perf:
658    ///   `avg_itl = (e2e_ms − ttft_ms) / (output_length − 1)`. When
659    ///   `output_length ≤ 1` there is no inter-token interval, so the ITL check
660    ///   is skipped (treated as satisfied).
661    fn is_good(&self, ttft_ms: f64, e2e_ms: f64, output_length: usize) -> bool {
662        if let Some(bound) = self.e2e_ms
663            && e2e_ms > bound
664        {
665            return false;
666        }
667        if let Some(bound) = self.ttft_ms
668            && ttft_ms > bound
669        {
670            return false;
671        }
672        if let Some(bound) = self.itl_ms
673            && output_length > 1
674        {
675            let avg_itl_ms = (e2e_ms - ttft_ms) / (output_length as f64 - 1.0);
676            if avg_itl_ms > bound {
677                return false;
678            }
679        }
680        true
681    }
682
683    fn is_good_without_tokens(&self, e2e_ms: f64) -> bool {
684        self.ttft_ms.is_none()
685            && self.itl_ms.is_none()
686            && self.e2e_ms.is_some_and(|bound| e2e_ms <= bound)
687    }
688}
689
690#[doc(hidden)]
691#[derive(Debug, Default)]
692pub struct TraceCollector {
693    requests: FxHashMap<Uuid, TraceRequestStats>,
694    /// Global per-token distributions are folded in as requests terminate, so
695    /// completed requests no longer retain one timestamp per emitted token.
696    itl_distribution: StreamingDistribution,
697    output_token_throughput_per_user: StreamingDistribution,
698    /// Keep completed token timelines until `finish()` instead of folding them
699    /// synchronously in `on_terminal`.
700    defer_token_timeline_finalization: bool,
701    /// When `true`, `finish()` populates `ReplayReport::per_request`.
702    /// Default `false` to skip the ~100ms terminal pass + ~30MB allocation
703    /// when the caller doesn't need per-request granularity.
704    capture_per_request: bool,
705    /// SLA thresholds for goodput classification. All-`None` by default, in
706    /// which case `finish()` leaves `ReplayReport::goodput` as `None`.
707    sla: SlaThresholds,
708    /// Accumulated provisioned worker-seconds per role, integrated by the
709    /// runtime over the sim clock (see `add_worker_seconds`). Used for the
710    /// runtimes that have an event loop (agg / disagg), where the provisioned
711    /// count varies with startup / drain / scaling.
712    prefill_worker_seconds: f64,
713    decode_worker_seconds: f64,
714    /// Static provisioned worker counts `(prefill, decode)` for externally
715    /// clocked runtimes with a fixed deployment size. When `Some`, `finish()`
716    /// derives worker-seconds as `count × duration_s` instead of using the
717    /// accumulator.
718    static_worker_count: Option<(usize, usize)>,
719    /// GPUs per worker per role, from the mocker engine parallelism. Used in
720    /// `finish()` to turn worker-seconds into gpu_hours.
721    prefill_gpus_per_worker: usize,
722    decode_gpus_per_worker: usize,
723    runtime_evidence: crate::replay::OfflineRuntimeEvidence,
724}
725
726impl TraceRequestStats {
727    fn first_token_ms(&self) -> Option<f64> {
728        match &self.token_timeline {
729            TokenTimeline::Recording(times) => times.first().copied(),
730            TokenTimeline::Finalized(summary) => Some(summary.first_ms),
731        }
732    }
733
734    fn last_token_ms(&self) -> Option<f64> {
735        match &self.token_timeline {
736            TokenTimeline::Recording(times) => times.last().copied(),
737            TokenTimeline::Finalized(summary) => Some(summary.last_ms),
738        }
739    }
740
741    fn actual_output_length(&self) -> usize {
742        match &self.token_timeline {
743            TokenTimeline::Recording(times) => times.len(),
744            TokenTimeline::Finalized(summary) => summary.len,
745        }
746    }
747
748    fn mean_tpot_ms(&self) -> Option<f64> {
749        let num_gaps = self.actual_output_length().saturating_sub(1);
750        if num_gaps == 0 {
751            return None;
752        }
753
754        let first_token_ms = self.first_token_ms()?;
755        let last_token_ms = self.last_token_ms()?;
756        Some((last_token_ms - first_token_ms).max(0.0) / num_gaps as f64)
757    }
758
759    fn ttst_ms(&self) -> Option<f64> {
760        let (first_token_ms, second_token_ms) = match &self.token_timeline {
761            TokenTimeline::Recording(times) => {
762                let [first_token_ms, second_token_ms, ..] = times.as_slice() else {
763                    return None;
764                };
765                (*first_token_ms, *second_token_ms)
766            }
767            TokenTimeline::Finalized(summary) => (summary.first_ms, summary.second_ms?),
768        };
769        Some((second_token_ms - first_token_ms).max(0.0))
770    }
771
772    fn finalize_token_timeline(
773        &mut self,
774        include_in_distributions: bool,
775        itl_distribution: &mut StreamingDistribution,
776        output_token_throughput_per_user: &mut StreamingDistribution,
777    ) {
778        let TokenTimeline::Recording(times) = &self.token_timeline else {
779            return;
780        };
781
782        if include_in_distributions {
783            for window in times.windows(2) {
784                let itl_ms = (window[1] - window[0]).max(0.0);
785                itl_distribution.add(itl_ms);
786                if itl_ms > 0.0 {
787                    output_token_throughput_per_user.add(1000.0 / itl_ms);
788                }
789            }
790        }
791
792        let Some(first_ms) = times.first().copied() else {
793            self.token_timeline = TokenTimeline::default();
794            return;
795        };
796        let summary = FinalizedTokenTimeline {
797            first_ms,
798            second_ms: times.get(1).copied(),
799            last_ms: times.last().copied().unwrap_or(first_ms),
800            len: times.len(),
801        };
802        self.token_timeline = TokenTimeline::Finalized(summary);
803    }
804}
805
806impl TraceCollector {
807    /// Defer token-timeline folding until the entire replay has ended.
808    pub fn set_defer_token_timeline_finalization(&mut self, value: bool) {
809        self.defer_token_timeline_finalization = value;
810    }
811
812    /// Toggle whether `finish()` should build per-request records. Off by
813    /// default; the runtimes flip it on when the caller asks for JSONL output.
814    pub fn set_capture_per_request(&mut self, value: bool) {
815        self.capture_per_request = value;
816    }
817
818    /// Set the SLA thresholds used to classify goodput in `finish()`. With no
819    /// SLA set (the default), the report's `goodput` field stays `None`.
820    pub fn set_sla_thresholds(&mut self, sla: SlaThresholds) {
821        self.sla = sla;
822    }
823
824    /// Add provisioned worker-seconds for the interval just elapsed. The runtime
825    /// calls this each time it advances the sim clock, with
826    /// `provisioned_count × dt_ms / 1000` per role — the time-integral of the
827    /// *provisioned* worker count (active + starting-up + draining), so the
828    /// startup ramp and drain tail are included. Agg replay passes `prefill = 0`
829    /// and reports through `decode`.
830    pub(crate) fn add_worker_seconds(&mut self, prefill: f64, decode: f64) {
831        self.prefill_worker_seconds += prefill;
832        self.decode_worker_seconds += decode;
833    }
834
835    /// Declare a fixed `(prefill, decode)` provisioned worker count for an
836    /// externally clocked runtime that does not integrate worker counts on
837    /// each clock advance. `finish()` then reports `count × duration_s`
838    /// worker-seconds.
839    pub fn set_static_worker_count(&mut self, prefill: usize, decode: usize) {
840        self.static_worker_count = Some((prefill, decode));
841    }
842
843    pub(crate) fn clear_static_worker_count(&mut self) {
844        self.static_worker_count = None;
845    }
846
847    /// Set GPUs-per-worker per role (from the mocker engine parallelism). Used
848    /// in `finish()` to derive gpu_hours from the worker-seconds.
849    pub fn set_gpus_per_worker(&mut self, prefill: usize, decode: usize) {
850        self.prefill_gpus_per_worker = prefill;
851        self.decode_gpus_per_worker = decode;
852    }
853
854    pub(crate) fn set_runtime_evidence(&mut self, evidence: crate::replay::OfflineRuntimeEvidence) {
855        self.runtime_evidence = evidence;
856    }
857
858    pub fn on_arrival(
859        &mut self,
860        uuid: Uuid,
861        arrival_time_ms: f64,
862        input_length: usize,
863        requested_output_length: usize,
864    ) {
865        self.requests.insert(
866            uuid,
867            TraceRequestStats {
868                arrival_time_ms,
869                first_admit_ms: None,
870                terminal_time_ms: None,
871                terminal_status: None,
872                token_timeline: TokenTimeline::default(),
873                input_length,
874                requested_output_length,
875                reused_input_tokens: 0,
876                prefill_worker_idx: None,
877                decode_worker_idx: None,
878                session_id: None,
879                turn_index: None,
880                authored_id: None,
881                metadata: Value::Null,
882                first_admission_reused_input_tokens: 0,
883                detail: self
884                    .capture_per_request
885                    .then(|| Box::new(PerRequestDetail::default())),
886            },
887        );
888    }
889
890    /// Attach session/turn metadata to a request. Called by the disagg/agg
891    /// runtimes when the workload driver provides it (multi-turn traces).
892    /// Idempotent — set-once semantics, so calling on the same uuid more than
893    /// once is a no-op after the first.
894    pub fn on_session_metadata(&mut self, uuid: Uuid, session_id: String, turn_index: usize) {
895        if !self.capture_per_request {
896            return;
897        }
898        if let Some(stats) = self.requests.get_mut(&uuid)
899            && stats.session_id.is_none()
900        {
901            stats.session_id = Some(session_id);
902            stats.turn_index = Some(turn_index);
903        }
904    }
905
906    /// Retain the ReplaySpec correlation fields before the request crosses
907    /// placement and engine boundaries.
908    pub fn on_request_context(
909        &mut self,
910        uuid: Uuid,
911        context: &crate::replay::ReplayRequestContext,
912    ) {
913        if !self.capture_per_request {
914            return;
915        }
916        if let Some(stats) = self.requests.get_mut(&uuid) {
917            stats.authored_id = Some(context.authored_id.clone());
918            stats.session_id = context.session_id.clone().or(stats.session_id.take());
919            stats.turn_index = context.turn_index.or(stats.turn_index);
920            stats.metadata = context.metadata.clone();
921        }
922    }
923
924    /// Record that `uuid` was dispatched to `worker_idx` on the prefill pool
925    /// (offline disagg replay only). Idempotent — subsequent calls are no-ops
926    /// once a value is set, so the first dispatch wins. Aggregated replay does
927    /// not call this; for those requests `prefill_worker_idx` stays `None`.
928    pub(crate) fn on_prefill_assigned(&mut self, uuid: Uuid, worker_idx: usize) {
929        if let Some(stats) = self.requests.get_mut(&uuid)
930            && stats.prefill_worker_idx.is_none()
931        {
932            stats.prefill_worker_idx = Some(worker_idx);
933        }
934    }
935
936    /// Record that `uuid` was dispatched to `worker_idx` on the decode pool
937    /// (offline disagg replay), or to the only pool (aggregated replay).
938    /// Idempotent.
939    pub fn on_decode_assigned(&mut self, uuid: Uuid, worker_idx: usize) {
940        if let Some(stats) = self.requests.get_mut(&uuid)
941            && stats.decode_worker_idx.is_none()
942        {
943            stats.decode_worker_idx = Some(worker_idx);
944        }
945    }
946
947    pub fn on_admit(&mut self, uuid: Uuid, admit_time_ms: f64, reused_input_tokens: usize) {
948        if let Some(stats) = self.requests.get_mut(&uuid) {
949            if stats.first_admit_ms.is_none() {
950                stats.first_admission_reused_input_tokens = reused_input_tokens;
951                stats.first_admit_ms = Some(admit_time_ms);
952            }
953            stats.reused_input_tokens = stats.reused_input_tokens.max(reused_input_tokens);
954        }
955    }
956
957    pub(crate) fn on_prefill_admit(
958        &mut self,
959        uuid: Uuid,
960        admit_time_ms: f64,
961        reused_input_tokens: usize,
962    ) {
963        self.on_admit(uuid, admit_time_ms, reused_input_tokens);
964        self.on_pool_admission(
965            uuid,
966            ReplayRequestPool::Prefill,
967            admit_time_ms,
968            reused_input_tokens,
969        );
970        if let Some(detail) = self.detail_mut(uuid) {
971            detail.prefill_admit_ms.get_or_insert(admit_time_ms);
972            detail.prefill_reused_input_tokens = Some(
973                detail
974                    .prefill_reused_input_tokens
975                    .unwrap_or_default()
976                    .max(reused_input_tokens),
977            );
978        }
979    }
980
981    pub(crate) fn on_decode_admit(
982        &mut self,
983        uuid: Uuid,
984        admit_time_ms: f64,
985        reused_input_tokens: usize,
986    ) {
987        self.on_admit(uuid, admit_time_ms, reused_input_tokens);
988        self.on_pool_admission(
989            uuid,
990            ReplayRequestPool::Decode,
991            admit_time_ms,
992            reused_input_tokens,
993        );
994        if let Some(detail) = self.detail_mut(uuid) {
995            detail.decode_admit_ms.get_or_insert(admit_time_ms);
996            detail.decode_reused_input_tokens = Some(
997                detail
998                    .decode_reused_input_tokens
999                    .unwrap_or_default()
1000                    .max(reused_input_tokens),
1001            );
1002        }
1003    }
1004
1005    pub(crate) fn on_source_held(&mut self, uuid: Uuid, at_ms: f64) {
1006        if let Some(detail) = self.detail_mut(uuid) {
1007            detail.source_held_ms.get_or_insert(at_ms);
1008        }
1009    }
1010
1011    pub(crate) fn on_destination_reserved(&mut self, uuid: Uuid, at_ms: f64) {
1012        if let Some(detail) = self.detail_mut(uuid) {
1013            detail.destination_reserved_ms.get_or_insert(at_ms);
1014        }
1015    }
1016
1017    pub(crate) fn on_destination_activated(&mut self, uuid: Uuid, at_ms: f64) {
1018        if let Some(detail) = self.detail_mut(uuid) {
1019            detail.destination_activated_ms.get_or_insert(at_ms);
1020        }
1021    }
1022
1023    pub(crate) fn on_source_released(&mut self, uuid: Uuid, at_ms: f64) {
1024        if let Some(detail) = self.detail_mut(uuid) {
1025            detail.source_released_ms.get_or_insert(at_ms);
1026        }
1027    }
1028
1029    pub(crate) fn on_prefill_route_overlap(&mut self, uuid: Uuid, tokens: usize) {
1030        if let Some(detail) = self.detail_mut(uuid) {
1031            detail.prefill_route_overlap_tokens.get_or_insert(tokens);
1032        }
1033    }
1034
1035    pub(crate) fn on_decode_route_overlap(&mut self, uuid: Uuid, tokens: usize) {
1036        if let Some(detail) = self.detail_mut(uuid) {
1037            detail.decode_route_overlap_tokens.get_or_insert(tokens);
1038        }
1039    }
1040
1041    #[allow(clippy::too_many_arguments)]
1042    pub(crate) fn on_route_immediate(
1043        &mut self,
1044        uuid: Uuid,
1045        pool: ReplayRequestPool,
1046        logical_worker_id: usize,
1047        scheduler_id: usize,
1048        dp_rank: u32,
1049        reported_overlap_tokens: usize,
1050    ) {
1051        let Some(detail) = self.detail_mut(uuid) else {
1052            return;
1053        };
1054        detail.routing_history.push(PerRequestRoutingRecord {
1055            pool,
1056            outcome: ReplayRoutingOutcome::Immediate,
1057            queue_entered_at_ms: None,
1058            released_at_ms: None,
1059            queue_wait_ms: Some(0.0),
1060            logical_worker_id: Some(logical_worker_id),
1061            scheduler_id: Some(scheduler_id),
1062            dp_rank: Some(dp_rank),
1063            reported_overlap_tokens: Some(reported_overlap_tokens),
1064        });
1065    }
1066
1067    pub(crate) fn on_route_queued(&mut self, uuid: Uuid, pool: ReplayRequestPool, at_ms: f64) {
1068        let Some(detail) = self.detail_mut(uuid) else {
1069            return;
1070        };
1071        detail.routing_history.push(PerRequestRoutingRecord {
1072            pool,
1073            outcome: ReplayRoutingOutcome::Queued,
1074            queue_entered_at_ms: Some(at_ms),
1075            released_at_ms: None,
1076            queue_wait_ms: None,
1077            logical_worker_id: None,
1078            scheduler_id: None,
1079            dp_rank: None,
1080            reported_overlap_tokens: None,
1081        });
1082    }
1083
1084    #[allow(clippy::too_many_arguments)]
1085    pub(crate) fn on_route_released(
1086        &mut self,
1087        uuid: Uuid,
1088        pool: ReplayRequestPool,
1089        at_ms: f64,
1090        logical_worker_id: usize,
1091        scheduler_id: usize,
1092        dp_rank: u32,
1093        reported_overlap_tokens: usize,
1094    ) {
1095        let Some(detail) = self.detail_mut(uuid) else {
1096            return;
1097        };
1098        let Some(route) = detail.routing_history.iter_mut().rev().find(|route| {
1099            route.pool == pool
1100                && route.outcome == ReplayRoutingOutcome::Queued
1101                && route.released_at_ms.is_none()
1102        }) else {
1103            return;
1104        };
1105        let entered_at_ms = route.queue_entered_at_ms.unwrap_or(at_ms);
1106        route.released_at_ms = Some(at_ms);
1107        route.queue_wait_ms = Some((at_ms - entered_at_ms).max(0.0));
1108        route.logical_worker_id = Some(logical_worker_id);
1109        route.scheduler_id = Some(scheduler_id);
1110        route.dp_rank = Some(dp_rank);
1111        route.reported_overlap_tokens = Some(reported_overlap_tokens);
1112    }
1113
1114    pub(crate) fn on_pool_admission(
1115        &mut self,
1116        uuid: Uuid,
1117        pool: ReplayRequestPool,
1118        at_ms: f64,
1119        reused_input_tokens: usize,
1120    ) {
1121        let Some(detail) = self.detail_mut(uuid) else {
1122            return;
1123        };
1124        let admission_ordinal = detail.admission_history.len();
1125        let pool_count = &mut detail.pool_admission_counts[pool.index()];
1126        let pool_admission_ordinal = *pool_count;
1127        *pool_count += 1;
1128        detail.admission_history.push(PerRequestAdmissionRecord {
1129            admission_ordinal,
1130            pool_admission_ordinal,
1131            pool,
1132            at_ms,
1133            reused_input_tokens,
1134            is_readmission: pool_admission_ordinal > 0,
1135        });
1136    }
1137
1138    pub(crate) fn on_pressure_reference(&mut self, uuid: Uuid, pressure_ordinal: u64) {
1139        if let Some(detail) = self.detail_mut(uuid) {
1140            detail.pressure_record_ordinals.push(pressure_ordinal);
1141        }
1142    }
1143
1144    pub fn on_terminal(&mut self, uuid: Uuid, terminal_time_ms: f64, status: ReplayTerminalStatus) {
1145        let Self {
1146            requests,
1147            itl_distribution,
1148            output_token_throughput_per_user,
1149            defer_token_timeline_finalization,
1150            ..
1151        } = self;
1152        if let Some(stats) = requests.get_mut(&uuid)
1153            && stats.terminal_status.is_none()
1154        {
1155            stats.terminal_time_ms = Some(terminal_time_ms);
1156            stats.terminal_status = Some(status);
1157            if !*defer_token_timeline_finalization {
1158                stats.finalize_token_timeline(
1159                    status == ReplayTerminalStatus::Completed && stats.first_admit_ms.is_some(),
1160                    itl_distribution,
1161                    output_token_throughput_per_user,
1162                );
1163            }
1164        }
1165    }
1166
1167    fn detail_mut(&mut self, uuid: Uuid) -> Option<&mut PerRequestDetail> {
1168        if !self.capture_per_request {
1169            return None;
1170        }
1171        self.requests.get_mut(&uuid)?.detail.as_deref_mut()
1172    }
1173
1174    pub fn on_token(&mut self, uuid: Uuid, token_time_ms: f64) {
1175        if let Some(stats) = self.requests.get_mut(&uuid)
1176            && let TokenTimeline::Recording(times) = &mut stats.token_timeline
1177        {
1178            times.push(token_time_ms);
1179        }
1180    }
1181
1182    /// Return (ttft_ms, mean_itl_ms) for a completed request, if available.
1183    pub(crate) fn request_latencies(&self, uuid: Uuid) -> Option<(f64, f64)> {
1184        let stats = self.requests.get(&uuid)?;
1185        let first_token_ms = stats.first_token_ms()?;
1186        let ttft_ms = (first_token_ms - stats.arrival_time_ms).max(0.0);
1187        let mean_itl_ms = stats.mean_tpot_ms().unwrap_or(0.0);
1188        Some((ttft_ms, mean_itl_ms))
1189    }
1190
1191    pub(crate) fn actual_output_length(&self, uuid: Uuid) -> Option<usize> {
1192        self.requests
1193            .get(&uuid)
1194            .map(TraceRequestStats::actual_output_length)
1195    }
1196
1197    pub fn finish(mut self) -> ReplayReport {
1198        let Self {
1199            requests,
1200            itl_distribution,
1201            output_token_throughput_per_user,
1202            ..
1203        } = &mut self;
1204        for stats in requests.values_mut() {
1205            stats.finalize_token_timeline(
1206                stats.terminal_status == Some(ReplayTerminalStatus::Completed)
1207                    && stats.first_admit_ms.is_some(),
1208                itl_distribution,
1209                output_token_throughput_per_user,
1210            );
1211        }
1212
1213        // Build per-request records before we move `self.requests` into the
1214        // summary aggregation below. Gated on `capture_per_request` — the
1215        // ~100ms terminal pass + ~30MB allocation only runs when a caller asks
1216        // for per-request output. The summary report is unaffected either way
1217        // (custom Serialize impl skips `per_request`).
1218        let per_request = if self.capture_per_request {
1219            self.per_request_records()
1220        } else {
1221            Vec::new()
1222        };
1223        let sla = self.sla;
1224        let static_worker_count = self.static_worker_count;
1225        let accumulated_prefill_worker_seconds = self.prefill_worker_seconds;
1226        let accumulated_decode_worker_seconds = self.decode_worker_seconds;
1227        let prefill_gpus_per_worker = self.prefill_gpus_per_worker;
1228        let decode_gpus_per_worker = self.decode_gpus_per_worker;
1229        let runtime_evidence = self.runtime_evidence;
1230        let itl_distribution = self.itl_distribution.finish();
1231        let output_token_throughput_per_user = self.output_token_throughput_per_user.finish();
1232        let requests = self.requests;
1233        let request_count = requests.len();
1234        let mut ttfts = Vec::with_capacity(request_count);
1235        let mut ttsts = Vec::with_capacity(request_count);
1236        let mut tpots = Vec::with_capacity(request_count);
1237        let mut e2e_latencies = Vec::with_capacity(request_count);
1238        let mut duration_ms = 0.0_f64;
1239        let mut total_input_tokens = 0usize;
1240        let mut total_output_tokens = 0usize;
1241        let mut completed_requests = 0usize;
1242        let mut total_reused_tokens = 0usize;
1243        let mut total_first_admission_reused_tokens = 0usize;
1244        // Goodput: completed requests (and their output tokens) that satisfy the SLA.
1245        let mut goodput_requests = 0usize;
1246        let mut goodput_output_tokens = 0usize;
1247
1248        for stats in requests.values() {
1249            if stats.first_admit_ms.is_none() {
1250                continue;
1251            }
1252            if stats.terminal_status != Some(ReplayTerminalStatus::Completed) {
1253                continue;
1254            }
1255            let Some(terminal_time_ms) = stats.terminal_time_ms else {
1256                continue;
1257            };
1258
1259            completed_requests += 1;
1260            total_input_tokens += stats.input_length;
1261            let output_length = stats.actual_output_length();
1262            total_output_tokens += output_length;
1263            total_reused_tokens += stats.reused_input_tokens;
1264            total_first_admission_reused_tokens += stats.first_admission_reused_input_tokens;
1265            duration_ms = duration_ms.max(terminal_time_ms);
1266
1267            let (Some(first_token_ms), Some(last_token_ms)) =
1268                (stats.first_token_ms(), stats.last_token_ms())
1269            else {
1270                let e2e_ms = (terminal_time_ms - stats.arrival_time_ms).max(0.0);
1271                if sla.is_set() && sla.is_good_without_tokens(e2e_ms) {
1272                    goodput_requests += 1;
1273                }
1274                continue;
1275            };
1276
1277            let ttft_ms = (first_token_ms - stats.arrival_time_ms).max(0.0);
1278            let e2e_ms = (last_token_ms - stats.arrival_time_ms).max(0.0);
1279            ttfts.push(ttft_ms);
1280            e2e_latencies.push(e2e_ms);
1281
1282            // Goodput classification (aiperf avg-ITL; see SlaThresholds::is_good).
1283            if sla.is_set() && sla.is_good(ttft_ms, e2e_ms, output_length) {
1284                goodput_requests += 1;
1285                goodput_output_tokens += output_length;
1286            }
1287
1288            if let Some(ttst_ms) = stats.ttst_ms() {
1289                ttsts.push(ttst_ms);
1290            }
1291
1292            if let Some(tpot_ms) = stats.mean_tpot_ms() {
1293                tpots.push(tpot_ms);
1294            }
1295        }
1296
1297        let duration_s = (duration_ms / 1000.0).max(1e-9);
1298        // Provisioned worker-seconds: static count × duration for an externally
1299        // clocked runtime, else the runtime-integrated accumulator.
1300        let (prefill_worker_seconds, decode_worker_seconds) = match static_worker_count {
1301            Some((prefill, decode)) => (prefill as f64 * duration_s, decode as f64 * duration_s),
1302            None => (
1303                accumulated_prefill_worker_seconds,
1304                accumulated_decode_worker_seconds,
1305            ),
1306        };
1307        // GPU-hours straight from the mocker's own worker parallelism (no
1308        // external GPU-count config). 0 when gpus_per_worker was not set.
1309        let gpu_hours = (prefill_worker_seconds * prefill_gpus_per_worker as f64
1310            + decode_worker_seconds * decode_gpus_per_worker as f64)
1311            / 3600.0;
1312        // Goodput only when an SLA was supplied; otherwise it is undefined.
1313        let goodput = sla.is_set().then(|| TraceGoodputStats {
1314            completed_requests: goodput_requests,
1315            request_throughput_rps: goodput_requests as f64 / duration_s,
1316            output_throughput_tok_s: goodput_output_tokens as f64 / duration_s,
1317        });
1318        ReplayReport {
1319            request_counts: TraceRequestCounts {
1320                num_requests: request_count,
1321                completed_requests,
1322                total_input_tokens,
1323                total_output_tokens,
1324            },
1325            throughput: TraceThroughputStats {
1326                duration_ms,
1327                wall_time_ms: 0.0,
1328                request_throughput_rps: completed_requests as f64 / duration_s,
1329                input_throughput_tok_s: total_input_tokens as f64 / duration_s,
1330                output_throughput_tok_s: total_output_tokens as f64 / duration_s,
1331                total_throughput_tok_s: (total_input_tokens + total_output_tokens) as f64
1332                    / duration_s,
1333                prefill_worker_seconds,
1334                decode_worker_seconds,
1335                prefill_gpus_per_worker,
1336                decode_gpus_per_worker,
1337                gpu_hours,
1338            },
1339            prefix_cache_reused_ratio: if total_input_tokens == 0 {
1340                0.0
1341            } else {
1342                total_reused_tokens as f64 / total_input_tokens as f64
1343            },
1344            first_admission_prefix_cache_reused_ratio: if total_input_tokens == 0 {
1345                0.0
1346            } else {
1347                total_first_admission_reused_tokens as f64 / total_input_tokens as f64
1348            },
1349            latency: TraceLatencyStats {
1350                ttft: build_distribution_stats(ttfts),
1351                ttst: build_distribution_stats(ttsts),
1352                tpot: build_distribution_stats(tpots),
1353                itl: TraceInterTokenLatencyStats {
1354                    max_ms: itl_distribution.max_ms,
1355                    distribution: itl_distribution,
1356                },
1357                e2e: build_distribution_stats(e2e_latencies),
1358                output_token_throughput_per_user,
1359            },
1360            goodput,
1361            per_request,
1362            runtime_evidence,
1363        }
1364    }
1365
1366    /// Flatten each retained request into a serializable `PerRequestRecord`.
1367    /// Used to emit one JSON object per request to a JSONL file, mirroring
1368    /// AIPerf's per-request output shape.
1369    ///
1370    /// Only requests with a terminal outcome are emitted. Requests truncated
1371    /// by a simulation-time cap have no terminal outcome and remain omitted.
1372    pub fn per_request_records(&self) -> Vec<PerRequestRecord> {
1373        let mut records = Vec::with_capacity(self.requests.len());
1374        for (uuid, stats) in &self.requests {
1375            let Some(detail) = stats.detail.as_deref() else {
1376                continue;
1377            };
1378            let Some(terminal_status) = stats.terminal_status else {
1379                continue;
1380            };
1381            let Some(terminal_time_ms) = stats.terminal_time_ms else {
1382                continue;
1383            };
1384            let first_token_ms = stats.first_token_ms();
1385            let last_token_ms = stats.last_token_ms();
1386            records.push(PerRequestRecord {
1387                request_id: stats.authored_id.clone(),
1388                session_id: stats.session_id.clone(),
1389                turn_index: stats.turn_index,
1390                metadata: stats.metadata.clone(),
1391                uuid: uuid.to_string(),
1392                arrival_time_ms: stats.arrival_time_ms,
1393                first_admit_ms: stats.first_admit_ms,
1394                terminal_time_ms,
1395                first_token_ms,
1396                last_token_ms,
1397                ttft_ms: first_token_ms.map(|time| (time - stats.arrival_time_ms).max(0.0)),
1398                ttst_ms: stats.ttst_ms(),
1399                e2e_latency_ms: last_token_ms.map(|time| (time - stats.arrival_time_ms).max(0.0)),
1400                itl_ms: stats.mean_tpot_ms(),
1401                input_length: stats.input_length,
1402                requested_output_length: stats.requested_output_length,
1403                output_length: stats.actual_output_length(),
1404                reused_input_tokens: detail
1405                    .prefill_reused_input_tokens
1406                    .unwrap_or(stats.reused_input_tokens),
1407                prefill_worker_idx: stats.prefill_worker_idx,
1408                decode_worker_idx: stats.decode_worker_idx,
1409                prefill_admit_ms: detail.prefill_admit_ms,
1410                source_held_ms: detail.source_held_ms,
1411                destination_reserved_ms: detail.destination_reserved_ms,
1412                destination_activated_ms: detail.destination_activated_ms,
1413                decode_admit_ms: detail.decode_admit_ms,
1414                source_released_ms: detail.source_released_ms,
1415                decode_reused_input_tokens: detail.decode_reused_input_tokens,
1416                prefill_route_overlap_tokens: detail.prefill_route_overlap_tokens,
1417                decode_route_overlap_tokens: detail.decode_route_overlap_tokens,
1418                routing_history: detail.routing_history.clone(),
1419                admission_count: detail.admission_history.len(),
1420                readmission_count: detail
1421                    .pool_admission_counts
1422                    .iter()
1423                    .map(|count| count.saturating_sub(1))
1424                    .sum(),
1425                admission_history: detail.admission_history.clone(),
1426                pressure_record_ordinals: detail.pressure_record_ordinals.clone(),
1427                terminal_status,
1428            });
1429        }
1430        // Stable ordering: by arrival_time_ms (with uuid as tiebreaker) so the
1431        // JSONL file is reproducible across runs and matches the order
1432        // analysis tools usually expect.
1433        records.sort_by(|a, b| {
1434            a.arrival_time_ms
1435                .total_cmp(&b.arrival_time_ms)
1436                .then_with(|| a.uuid.cmp(&b.uuid))
1437        });
1438        records
1439    }
1440
1441    #[cfg(test)]
1442    pub(crate) fn snapshot(&self, uuid: Uuid) -> Option<TraceRequestStatsSnapshot> {
1443        self.requests
1444            .get(&uuid)
1445            .map(|stats| TraceRequestStatsSnapshot {
1446                arrival_time_ms: stats.arrival_time_ms,
1447                first_admit_ms: stats.first_admit_ms,
1448                first_token_ms: stats.first_token_ms(),
1449                last_token_ms: stats.last_token_ms(),
1450                input_length: stats.input_length,
1451                requested_output_length: stats.requested_output_length,
1452                output_length: stats.actual_output_length(),
1453                reused_input_tokens: stats.reused_input_tokens,
1454                first_admission_reused_input_tokens: stats.first_admission_reused_input_tokens,
1455            })
1456    }
1457
1458    #[cfg(test)]
1459    pub(crate) fn snapshots(&self) -> Vec<TraceRequestStatsSnapshot> {
1460        self.requests
1461            .values()
1462            .map(|stats| TraceRequestStatsSnapshot {
1463                arrival_time_ms: stats.arrival_time_ms,
1464                first_admit_ms: stats.first_admit_ms,
1465                first_token_ms: stats.first_token_ms(),
1466                last_token_ms: stats.last_token_ms(),
1467                input_length: stats.input_length,
1468                requested_output_length: stats.requested_output_length,
1469                output_length: stats.actual_output_length(),
1470                reused_input_tokens: stats.reused_input_tokens,
1471                first_admission_reused_input_tokens: stats.first_admission_reused_input_tokens,
1472            })
1473            .collect()
1474    }
1475
1476    #[cfg(test)]
1477    fn retained_token_timestamps(&self) -> usize {
1478        self.requests
1479            .values()
1480            .map(|stats| match &stats.token_timeline {
1481                TokenTimeline::Recording(times) => times.len(),
1482                TokenTimeline::Finalized(_) => 0,
1483            })
1484            .sum()
1485    }
1486}
1487
1488fn mean(values: &[f64]) -> f64 {
1489    if values.is_empty() {
1490        0.0
1491    } else {
1492        values.iter().sum::<f64>() / values.len() as f64
1493    }
1494}
1495
1496fn build_distribution_stats(mut values: Vec<f64>) -> TraceDistributionStats {
1497    if values.is_empty() {
1498        return empty_distribution_stats();
1499    }
1500
1501    let min_ms = values
1502        .iter()
1503        .copied()
1504        .min_by(|left, right| left.total_cmp(right))
1505        .expect("non-empty values must have a minimum");
1506    let max_ms = values
1507        .iter()
1508        .copied()
1509        .max_by(|left, right| left.total_cmp(right))
1510        .expect("non-empty values must have a maximum");
1511
1512    TraceDistributionStats {
1513        mean_ms: mean(&values),
1514        min_ms,
1515        max_ms,
1516        median_ms: percentile_in_place(&mut values, 50.0),
1517        p75_ms: percentile_in_place(&mut values, 75.0),
1518        p90_ms: percentile_in_place(&mut values, 90.0),
1519        p95_ms: percentile_in_place(&mut values, 95.0),
1520        p99_ms: percentile_in_place(&mut values, 99.0),
1521        std_ms: std_dev(&values),
1522    }
1523}
1524
1525fn empty_distribution_stats() -> TraceDistributionStats {
1526    TraceDistributionStats {
1527        mean_ms: 0.0,
1528        min_ms: 0.0,
1529        max_ms: 0.0,
1530        median_ms: 0.0,
1531        p75_ms: 0.0,
1532        p90_ms: 0.0,
1533        p95_ms: 0.0,
1534        p99_ms: 0.0,
1535        std_ms: 0.0,
1536    }
1537}
1538
1539fn percentile_in_place(values: &mut [f64], percentile: f64) -> f64 {
1540    let rank = percentile_rank(values.len(), percentile);
1541    let (_, selected, _) = values.select_nth_unstable_by(rank, |left, right| left.total_cmp(right));
1542    *selected
1543}
1544
1545fn percentile_rank(len: usize, percentile: f64) -> usize {
1546    let rank = ((len - 1) as f64 * percentile / 100.0).round() as usize;
1547    rank.min(len - 1)
1548}
1549
1550fn std_dev(values: &[f64]) -> f64 {
1551    if values.is_empty() {
1552        return 0.0;
1553    }
1554
1555    let mean = mean(values);
1556    let variance = values
1557        .iter()
1558        .map(|value| {
1559            let centered = value - mean;
1560            centered * centered
1561        })
1562        .sum::<f64>()
1563        / values.len() as f64;
1564    variance.sqrt()
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569    use super::*;
1570
1571    fn build_distribution_stats_sorted(values: &[f64]) -> TraceDistributionStats {
1572        if values.is_empty() {
1573            return TraceDistributionStats {
1574                mean_ms: 0.0,
1575                min_ms: 0.0,
1576                max_ms: 0.0,
1577                median_ms: 0.0,
1578                p75_ms: 0.0,
1579                p90_ms: 0.0,
1580                p95_ms: 0.0,
1581                p99_ms: 0.0,
1582                std_ms: 0.0,
1583            };
1584        }
1585
1586        let mut sorted = values.to_vec();
1587        sorted.sort_by(|left, right| left.total_cmp(right));
1588        TraceDistributionStats {
1589            mean_ms: mean(values),
1590            min_ms: sorted[0],
1591            max_ms: *sorted.last().expect("sorted values must be non-empty"),
1592            median_ms: sorted[percentile_rank(sorted.len(), 50.0)],
1593            p75_ms: sorted[percentile_rank(sorted.len(), 75.0)],
1594            p90_ms: sorted[percentile_rank(sorted.len(), 90.0)],
1595            p95_ms: sorted[percentile_rank(sorted.len(), 95.0)],
1596            p99_ms: sorted[percentile_rank(sorted.len(), 99.0)],
1597            std_ms: std_dev(values),
1598        }
1599    }
1600
1601    #[test]
1602    fn build_distribution_stats_matches_sorted_baseline() {
1603        let values = vec![
1604            0.0, 1.0, 1.0, 2.5, 4.0, 4.0, 7.25, 9.5, 15.0, 22.0, 22.0, 100.0,
1605        ];
1606
1607        let expected = build_distribution_stats_sorted(&values);
1608        let actual = build_distribution_stats(values);
1609
1610        assert_eq!(actual.mean_ms, expected.mean_ms);
1611        assert_eq!(actual.min_ms, expected.min_ms);
1612        assert_eq!(actual.max_ms, expected.max_ms);
1613        assert_eq!(actual.median_ms, expected.median_ms);
1614        assert_eq!(actual.p75_ms, expected.p75_ms);
1615        assert_eq!(actual.p90_ms, expected.p90_ms);
1616        assert_eq!(actual.p95_ms, expected.p95_ms);
1617        assert_eq!(actual.p99_ms, expected.p99_ms);
1618        assert_eq!(actual.std_ms, expected.std_ms);
1619    }
1620
1621    #[test]
1622    fn built_in_ddsketch_configuration_and_quantiles_are_valid() {
1623        let mut distribution = StreamingDistribution::default();
1624        assert!((distribution.sketch.alpha() - DDSKETCH_RELATIVE_ACCURACY).abs() < f64::EPSILON);
1625        for percentile in [50.0, 75.0, 90.0, 95.0, 99.0] {
1626            assert_eq!(distribution.percentile(percentile), 0.0);
1627        }
1628
1629        // This is wider than any plausible replay latency or token-rate
1630        // range, and stays below the configured store's ~10^28 span.
1631        for value in [1e-9, 1e18] {
1632            distribution.add(value);
1633        }
1634        for (quantile, expected) in [(0.0, 1e-9), (1.0, 1e18)] {
1635            let actual = distribution.sketch.quantile(quantile).unwrap();
1636            assert!((actual - expected).abs() <= expected * DDSKETCH_RELATIVE_ACCURACY);
1637        }
1638    }
1639
1640    #[test]
1641    fn streaming_distribution_preserves_all_zero_samples() {
1642        let mut distribution = StreamingDistribution::default();
1643        for _ in 0..128 {
1644            distribution.add(0.0);
1645        }
1646
1647        assert_eq!(distribution.sketch.get_zero_count(), 128);
1648        let stats = distribution.finish();
1649        for value in [
1650            stats.mean_ms,
1651            stats.min_ms,
1652            stats.max_ms,
1653            stats.median_ms,
1654            stats.p75_ms,
1655            stats.p90_ms,
1656            stats.p95_ms,
1657            stats.p99_ms,
1658            stats.std_ms,
1659        ] {
1660            assert_eq!(value, 0.0);
1661        }
1662    }
1663
1664    #[test]
1665    fn streaming_percentiles_select_the_historical_rounded_rank() {
1666        let percentiles = [
1667            0.0, 1.0, 10.0, 25.0, 49.0, 50.0, 51.0, 75.0, 90.0, 95.0, 99.0, 100.0,
1668        ];
1669        for len in [2, 3, 4, 5, 10, 11, 100, 101, 256, 257] {
1670            let values = (0..len)
1671                .map(|index| 1_000.0 + index as f64 * 10.0)
1672                .collect::<Vec<_>>();
1673            let mut distribution = StreamingDistribution::default();
1674            for &value in &values {
1675                distribution.add(value);
1676            }
1677
1678            for percentile in percentiles {
1679                let expected = values[percentile_rank(values.len(), percentile)];
1680                let actual = distribution.percentile(percentile);
1681                assert!(
1682                    (actual - expected).abs() <= expected * DDSKETCH_RELATIVE_ACCURACY,
1683                    "len={len} percentile={percentile}: expected rank value {expected}, got {actual}"
1684                );
1685            }
1686        }
1687    }
1688
1689    #[test]
1690    fn completed_zero_output_request_counts_without_latency_samples() {
1691        let mut collector = TraceCollector::default();
1692        collector.set_static_worker_count(0, 1);
1693        collector.set_gpus_per_worker(0, 4);
1694        let uuid = Uuid::from_u128(99);
1695        collector.on_arrival(uuid, 0.0, 32, 0);
1696        collector.on_admit(uuid, 5.0, 8);
1697        collector.on_terminal(uuid, 25.0, ReplayTerminalStatus::Completed);
1698
1699        let report = collector.finish();
1700
1701        assert_eq!(report.request_counts.completed_requests, 1);
1702        assert_eq!(report.request_counts.total_input_tokens, 32);
1703        assert_eq!(report.request_counts.total_output_tokens, 0);
1704        assert_eq!(report.throughput.duration_ms, 25.0);
1705        assert_eq!(report.throughput.decode_worker_seconds, 0.025);
1706        assert!((report.throughput.gpu_hours - 0.1 / 3600.0).abs() < 1e-12);
1707        assert_eq!(report.latency.ttft.mean_ms, 0.0);
1708        assert_eq!(report.latency.e2e.mean_ms, 0.0);
1709    }
1710
1711    #[test]
1712    fn token_before_simulation_cap_does_not_count_as_completion() {
1713        let mut collector = TraceCollector::default();
1714        let uuid = Uuid::from_u128(100);
1715        collector.on_arrival(uuid, 0.0, 32, 4);
1716        collector.on_admit(uuid, 5.0, 0);
1717        collector.on_token(uuid, 25.0);
1718
1719        let report = collector.finish();
1720
1721        assert_eq!(report.request_counts.completed_requests, 0);
1722        assert_eq!(report.request_counts.total_input_tokens, 0);
1723        assert_eq!(report.request_counts.total_output_tokens, 0);
1724        assert_eq!(report.throughput.duration_ms, 0.0);
1725    }
1726
1727    #[test]
1728    fn zero_output_goodput_requires_e2e_only_sla() {
1729        let collect = |sla| {
1730            let mut collector = TraceCollector::default();
1731            collector.set_sla_thresholds(sla);
1732            let uuid = Uuid::from_u128(101);
1733            collector.on_arrival(uuid, 0.0, 32, 0);
1734            collector.on_admit(uuid, 5.0, 0);
1735            collector.on_terminal(uuid, 100.0, ReplayTerminalStatus::Completed);
1736            collector.finish().goodput.unwrap().completed_requests
1737        };
1738
1739        assert_eq!(
1740            collect(SlaThresholds {
1741                e2e_ms: Some(100.0),
1742                ..Default::default()
1743            }),
1744            1
1745        );
1746        assert_eq!(
1747            collect(SlaThresholds {
1748                ttft_ms: Some(1_000.0),
1749                ..Default::default()
1750            }),
1751            0
1752        );
1753    }
1754
1755    /// With per-request capture on, a standard disagg-style request lifecycle
1756    /// (arrival → admit → prefill_assigned → decode_assigned → tokens) yields
1757    /// exactly one record with all fields populated correctly.
1758    #[test]
1759    fn per_request_disagg_record_populates_all_fields() {
1760        let mut collector = TraceCollector::default();
1761        collector.set_capture_per_request(true);
1762        let uuid = Uuid::from_u128(1);
1763        collector.on_arrival(uuid, 0.0, 100, 4);
1764        collector.on_prefill_route_overlap(uuid, 64);
1765        collector.on_prefill_admit(uuid, 5.0, 30);
1766        collector.on_source_held(uuid, 10.0);
1767        collector.on_destination_reserved(uuid, 12.0);
1768        collector.on_destination_activated(uuid, 20.0);
1769        collector.on_source_released(uuid, 21.0);
1770        collector.on_decode_route_overlap(uuid, 32);
1771        collector.on_decode_admit(uuid, 25.0, 40);
1772        collector.on_prefill_assigned(uuid, 2);
1773        collector.on_decode_assigned(uuid, 7);
1774        collector.on_token(uuid, 50.0);
1775        collector.on_token(uuid, 60.0);
1776        collector.on_token(uuid, 75.0);
1777        collector.on_token(uuid, 95.0);
1778        collector.on_terminal(uuid, 95.0, ReplayTerminalStatus::Completed);
1779
1780        let report = collector.finish();
1781        assert_eq!(report.per_request.len(), 1);
1782        let rec = &report.per_request[0];
1783        assert_eq!(rec.uuid, uuid.to_string());
1784        assert_eq!(rec.arrival_time_ms, 0.0);
1785        assert_eq!(rec.first_admit_ms, Some(5.0));
1786        assert_eq!(rec.terminal_time_ms, 95.0);
1787        assert_eq!(rec.first_token_ms, Some(50.0));
1788        assert_eq!(rec.last_token_ms, Some(95.0));
1789        assert_eq!(rec.ttft_ms, Some(50.0));
1790        assert_eq!(rec.ttst_ms, Some(10.0));
1791        assert_eq!(rec.e2e_latency_ms, Some(95.0));
1792        // Mean per-token gap across 4 tokens: (10 + 15 + 20) / 3 = 15.0
1793        assert_eq!(rec.itl_ms, Some(15.0));
1794        assert_eq!(rec.input_length, 100);
1795        assert_eq!(rec.output_length, 4);
1796        assert_eq!(rec.reused_input_tokens, 30);
1797        assert_eq!(rec.prefill_worker_idx, Some(2));
1798        assert_eq!(rec.decode_worker_idx, Some(7));
1799        assert_eq!(rec.prefill_admit_ms, Some(5.0));
1800        assert_eq!(rec.source_held_ms, Some(10.0));
1801        assert_eq!(rec.destination_reserved_ms, Some(12.0));
1802        assert_eq!(rec.destination_activated_ms, Some(20.0));
1803        assert_eq!(rec.source_released_ms, Some(21.0));
1804        assert_eq!(rec.decode_admit_ms, Some(25.0));
1805        assert_eq!(rec.decode_reused_input_tokens, Some(40));
1806        assert_eq!(rec.prefill_route_overlap_tokens, Some(64));
1807        assert_eq!(rec.decode_route_overlap_tokens, Some(32));
1808        assert_eq!(rec.terminal_status, ReplayTerminalStatus::Completed);
1809    }
1810
1811    /// A conditional-prefill bypass is reflected by `prefill_worker_idx ==
1812    /// None` while `decode_worker_idx` is set. This is how downstream tooling
1813    /// distinguishes bypassed requests from standard disagg flow.
1814    #[test]
1815    fn per_request_bypass_leaves_prefill_worker_idx_none() {
1816        let mut collector = TraceCollector::default();
1817        collector.set_capture_per_request(true);
1818        let uuid = Uuid::from_u128(42);
1819        collector.on_arrival(uuid, 0.0, 100, 2);
1820        collector.on_admit(uuid, 5.0, 0);
1821        // No on_prefill_assigned call — request bypassed remote prefill.
1822        collector.on_decode_assigned(uuid, 1);
1823        collector.on_token(uuid, 30.0);
1824        collector.on_token(uuid, 45.0);
1825        collector.on_terminal(uuid, 45.0, ReplayTerminalStatus::Completed);
1826
1827        let report = collector.finish();
1828        assert_eq!(report.per_request.len(), 1);
1829        let rec = &report.per_request[0];
1830        assert!(
1831            rec.prefill_worker_idx.is_none(),
1832            "bypassed request must have prefill_worker_idx = None"
1833        );
1834        assert_eq!(rec.decode_worker_idx, Some(1));
1835    }
1836
1837    /// Default: capture is off, so `per_request` is empty and the ~100ms
1838    /// terminal pass is skipped. The summary report is otherwise identical.
1839    #[test]
1840    fn per_request_default_off() {
1841        let mut collector = TraceCollector::default();
1842        // Note: NOT calling set_capture_per_request — capture stays false.
1843        let uuid = Uuid::from_u128(1);
1844        collector.on_arrival(uuid, 0.0, 100, 2);
1845        collector.on_admit(uuid, 5.0, 0);
1846        collector.on_decode_assigned(uuid, 0);
1847        collector.on_token(uuid, 50.0);
1848        collector.on_token(uuid, 60.0);
1849        collector.on_terminal(uuid, 60.0, ReplayTerminalStatus::Completed);
1850
1851        assert!(collector.requests[&uuid].detail.is_none());
1852
1853        let report = collector.finish();
1854        assert!(report.per_request.is_empty());
1855        // Summary stats still work.
1856        assert_eq!(report.request_counts.completed_requests, 1);
1857    }
1858
1859    /// Register a completed request: arrival, output length (osl), and the
1860    /// explicit per-output-token timestamps (first → ttft, last → e2e).
1861    fn add_completed(
1862        collector: &mut TraceCollector,
1863        uuid_n: u128,
1864        arrival_ms: f64,
1865        output_length: usize,
1866        token_times_ms: &[f64],
1867    ) {
1868        let uuid = Uuid::from_u128(uuid_n);
1869        collector.on_arrival(uuid, arrival_ms, 100, output_length);
1870        collector.on_admit(uuid, arrival_ms, 0);
1871        collector.on_decode_assigned(uuid, 0);
1872        for &t in token_times_ms {
1873            collector.on_token(uuid, t);
1874        }
1875        let terminal_time_ms = token_times_ms.last().copied().unwrap_or(arrival_ms);
1876        collector.on_terminal(uuid, terminal_time_ms, ReplayTerminalStatus::Completed);
1877    }
1878
1879    /// Goodput classifies a request "good" using aiperf's average ITL,
1880    /// `avg_itl = (e2e − ttft) / (osl − 1)`, and skips the ITL check when
1881    /// `osl ≤ 1`.
1882    #[test]
1883    fn goodput_classifies_by_aiperf_avg_itl() {
1884        let mut collector = TraceCollector::default();
1885        collector.set_sla_thresholds(SlaThresholds {
1886            ttft_ms: Some(150.0),
1887            itl_ms: Some(30.0),
1888            e2e_ms: None,
1889        });
1890        // A: ttft=100, e2e=200, osl=3 → avg_itl=(200−100)/2=50 > 30 → BAD.
1891        add_completed(&mut collector, 1, 0.0, 3, &[100.0, 150.0, 200.0]);
1892        // B: ttft=100, e2e=140, osl=3 → avg_itl=20 ≤ 30, ttft ok → GOOD.
1893        add_completed(&mut collector, 2, 0.0, 3, &[100.0, 120.0, 140.0]);
1894        // C: osl=1 → ITL check skipped; ttft=100 ≤ 150 → GOOD.
1895        add_completed(&mut collector, 3, 0.0, 1, &[100.0]);
1896
1897        let goodput = collector
1898            .finish()
1899            .goodput
1900            .expect("SLA set → goodput present");
1901        assert_eq!(goodput.completed_requests, 2); // B and C
1902        // duration = max last token = 200ms → 0.2s; good output tokens = 3 (B) + 1 (C) = 4.
1903        assert!((goodput.output_throughput_tok_s - 4.0 / 0.2).abs() < 1e-6);
1904        assert!((goodput.request_throughput_rps - 2.0 / 0.2).abs() < 1e-6);
1905    }
1906
1907    /// A request straddling the ITL bound flips good↔bad at the boundary.
1908    #[test]
1909    fn goodput_itl_boundary_is_inclusive() {
1910        let sla = SlaThresholds {
1911            ttft_ms: None,
1912            itl_ms: Some(50.0),
1913            e2e_ms: None,
1914        };
1915        // avg_itl = (200−100)/(3−1) = 50.0, exactly the bound → good (≤).
1916        let mut at_bound = TraceCollector::default();
1917        at_bound.set_sla_thresholds(sla);
1918        add_completed(&mut at_bound, 1, 0.0, 3, &[100.0, 150.0, 200.0]);
1919        assert_eq!(at_bound.finish().goodput.unwrap().completed_requests, 1);
1920        // avg_itl = (201−100)/2 = 50.5 > 50 → bad.
1921        let mut over = TraceCollector::default();
1922        over.set_sla_thresholds(sla);
1923        add_completed(&mut over, 1, 0.0, 3, &[100.0, 150.0, 201.0]);
1924        assert_eq!(over.finish().goodput.unwrap().completed_requests, 0);
1925    }
1926
1927    /// An e2e-only SLA gates on end-to-end latency alone.
1928    #[test]
1929    fn goodput_e2e_only_sla() {
1930        let mut collector = TraceCollector::default();
1931        collector.set_sla_thresholds(SlaThresholds {
1932            ttft_ms: None,
1933            itl_ms: None,
1934            e2e_ms: Some(150.0),
1935        });
1936        add_completed(&mut collector, 1, 0.0, 2, &[100.0, 200.0]); // e2e=200 > 150 → BAD
1937        add_completed(&mut collector, 2, 0.0, 2, &[60.0, 120.0]); // e2e=120 ≤ 150 → GOOD
1938        assert_eq!(collector.finish().goodput.unwrap().completed_requests, 1);
1939    }
1940
1941    /// No SLA → goodput is omitted entirely.
1942    #[test]
1943    fn goodput_absent_without_sla() {
1944        let mut collector = TraceCollector::default();
1945        add_completed(&mut collector, 1, 0.0, 2, &[10.0, 20.0]);
1946        assert!(collector.finish().goodput.is_none());
1947    }
1948
1949    /// Worker-seconds: the accumulator (agg/disagg) sums runtime contributions;
1950    /// an externally clocked static runtime reports `count × duration_s`.
1951    #[test]
1952    fn worker_seconds_accumulated_and_static() {
1953        let mut accumulated = TraceCollector::default();
1954        add_completed(&mut accumulated, 1, 0.0, 2, &[10.0, 20.0]);
1955        accumulated.add_worker_seconds(1.5, 4.0);
1956        accumulated.add_worker_seconds(0.5, 1.0);
1957        let report = accumulated.finish();
1958        assert!((report.throughput.prefill_worker_seconds - 2.0).abs() < 1e-9);
1959        assert!((report.throughput.decode_worker_seconds - 5.0).abs() < 1e-9);
1960
1961        let mut static_runtime = TraceCollector::default();
1962        static_runtime.set_static_worker_count(0, 1);
1963        add_completed(&mut static_runtime, 1, 0.0, 2, &[100.0, 200.0]); // duration = 0.2s
1964        let report = static_runtime.finish();
1965        assert!(report.throughput.prefill_worker_seconds.abs() < 1e-9);
1966        assert!((report.throughput.decode_worker_seconds - 0.2).abs() < 1e-9);
1967    }
1968
1969    #[test]
1970    fn compact_summary_excludes_execution_evidence() {
1971        let mut collector = TraceCollector::default();
1972        add_completed(&mut collector, 1, 0.0, 2, &[10.0, 20.0]);
1973        let mut report = collector.finish();
1974        report.runtime_evidence.pressure = Some(crate::replay::PressureEvidence::default());
1975
1976        let summary = serde_json::to_value(&report).unwrap();
1977        assert!(summary.get("runtime_evidence").is_none());
1978    }
1979
1980    /// gpu_hours derives from worker-seconds x the per-role GPUs/worker that the
1981    /// runtime records from the mocker's own parallelism.
1982    #[test]
1983    fn gpu_hours_from_worker_seconds_and_gpus_per_worker() {
1984        let mut collector = TraceCollector::default();
1985        collector.set_gpus_per_worker(2, 4); // prefill 2 GPUs/worker, decode 4
1986        add_completed(&mut collector, 1, 0.0, 2, &[100.0, 200.0]);
1987        collector.add_worker_seconds(10.0, 5.0); // prefill_ws=10, decode_ws=5
1988        let report = collector.finish();
1989        assert_eq!(report.throughput.prefill_gpus_per_worker, 2);
1990        assert_eq!(report.throughput.decode_gpus_per_worker, 4);
1991        // gpu_hours = (10*2 + 5*4) / 3600 = 40 / 3600
1992        assert!((report.throughput.gpu_hours - 40.0 / 3600.0).abs() < 1e-9);
1993    }
1994
1995    /// Records emerge in arrival-time order, so the JSONL file produced from
1996    /// them is deterministic across runs (important for diff-friendly CI).
1997    #[test]
1998    fn per_request_records_are_sorted_by_arrival_time() {
1999        let mut collector = TraceCollector::default();
2000        collector.set_capture_per_request(true);
2001        // Insert out of order on purpose.
2002        for (uuid_n, arrival) in [(3u128, 30.0), (1, 0.0), (2, 10.0)] {
2003            let uuid = Uuid::from_u128(uuid_n);
2004            collector.on_arrival(uuid, arrival, 100, 1);
2005            collector.on_admit(uuid, arrival + 1.0, 0);
2006            collector.on_decode_assigned(uuid, 0);
2007            collector.on_token(uuid, arrival + 5.0);
2008            collector.on_terminal(uuid, arrival + 5.0, ReplayTerminalStatus::Completed);
2009        }
2010        let report = collector.finish();
2011        let arrivals: Vec<f64> = report
2012            .per_request
2013            .iter()
2014            .map(|r| r.arrival_time_ms)
2015            .collect();
2016        assert_eq!(arrivals, vec![0.0, 10.0, 30.0]);
2017    }
2018
2019    /// Each record must round-trip cleanly to JSON. Guards against accidental
2020    /// serde regressions (e.g., adding a non-serializable field to
2021    /// `PerRequestRecord`).
2022    #[test]
2023    fn per_request_record_serializes_to_json_object() {
2024        let mut collector = TraceCollector::default();
2025        collector.set_capture_per_request(true);
2026        let uuid = Uuid::from_u128(123);
2027        collector.on_arrival(uuid, 0.0, 50, 2);
2028        collector.on_admit(uuid, 1.0, 10);
2029        collector.on_prefill_assigned(uuid, 0);
2030        collector.on_decode_assigned(uuid, 1);
2031        collector.on_token(uuid, 20.0);
2032        collector.on_token(uuid, 25.0);
2033        collector.on_terminal(uuid, 25.0, ReplayTerminalStatus::Completed);
2034
2035        let report = collector.finish();
2036        let line = serde_json::to_string(&report.per_request[0])
2037            .expect("PerRequestRecord must serialize cleanly");
2038        // Parse it back and spot-check a few keys to confirm shape.
2039        let parsed: serde_json::Value =
2040            serde_json::from_str(&line).expect("emitted JSON must parse");
2041        assert!(parsed.is_object());
2042        assert_eq!(parsed["uuid"], uuid.to_string());
2043        assert_eq!(parsed["input_length"], 50);
2044        assert_eq!(parsed["output_length"], 2);
2045        assert_eq!(parsed["prefill_worker_idx"], 0);
2046        assert_eq!(parsed["decode_worker_idx"], 1);
2047        assert!(parsed["itl_ms"].is_number());
2048        assert_eq!(parsed["terminal_status"], "completed");
2049    }
2050
2051    #[test]
2052    fn terminal_failures_emit_nullable_latencies_and_unfinished_requests_are_omitted() {
2053        let mut collector = TraceCollector::default();
2054        collector.set_capture_per_request(true);
2055        for (uuid_n, status) in [
2056            (1, ReplayTerminalStatus::Rejected),
2057            (2, ReplayTerminalStatus::Canceled),
2058            (3, ReplayTerminalStatus::Failed),
2059        ] {
2060            let uuid = Uuid::from_u128(uuid_n);
2061            collector.on_arrival(uuid, uuid_n as f64, 64, 2);
2062            collector.on_terminal(uuid, uuid_n as f64 + 1.0, status);
2063        }
2064        collector.on_arrival(Uuid::from_u128(4), 4.0, 64, 2);
2065
2066        let report = collector.finish();
2067
2068        assert_eq!(report.per_request.len(), 3);
2069        assert_eq!(
2070            report
2071                .per_request
2072                .iter()
2073                .map(|record| record.terminal_status)
2074                .collect::<Vec<_>>(),
2075            vec![
2076                ReplayTerminalStatus::Rejected,
2077                ReplayTerminalStatus::Canceled,
2078                ReplayTerminalStatus::Failed,
2079            ]
2080        );
2081        assert!(report.per_request.iter().all(|record| {
2082            record.first_admit_ms.is_none()
2083                && record.first_token_ms.is_none()
2084                && record.last_token_ms.is_none()
2085                && record.ttft_ms.is_none()
2086                && record.e2e_latency_ms.is_none()
2087        }));
2088    }
2089
2090    #[test]
2091    fn first_admission_reuse_ignores_later_readmission_self_reuse() {
2092        let uuid = Uuid::from_u128(1);
2093        let mut collector = TraceCollector::default();
2094        collector.on_arrival(uuid, 0.0, 100, 1);
2095        collector.on_admit(uuid, 1.0, 0);
2096        collector.on_admit(uuid, 2.0, 80);
2097        collector.on_token(uuid, 3.0);
2098        collector.on_terminal(uuid, 3.0, ReplayTerminalStatus::Completed);
2099
2100        let report = collector.finish();
2101
2102        assert_eq!(report.prefix_cache_reused_ratio, 0.8);
2103        assert_eq!(report.first_admission_prefix_cache_reused_ratio, 0.0);
2104    }
2105
2106    #[test]
2107    fn terminal_request_releases_per_token_timestamps() {
2108        let uuid = Uuid::from_u128(7);
2109        let mut collector = TraceCollector::default();
2110        collector.on_arrival(uuid, 0.0, 128, 100_000);
2111        collector.on_admit(uuid, 1.0, 0);
2112        for token_index in 0..100_000 {
2113            collector.on_token(uuid, token_index as f64 + 10.0);
2114        }
2115        assert_eq!(collector.retained_token_timestamps(), 100_000);
2116
2117        collector.on_terminal(uuid, 100_009.0, ReplayTerminalStatus::Completed);
2118
2119        assert_eq!(collector.retained_token_timestamps(), 0);
2120        let snapshot = collector
2121            .snapshot(uuid)
2122            .expect("request must remain summarized");
2123        assert_eq!(snapshot.output_length, 100_000);
2124        assert_eq!(snapshot.first_token_ms, Some(10.0));
2125        assert_eq!(snapshot.last_token_ms, Some(100_009.0));
2126        let report = collector.finish();
2127        assert_eq!(report.latency.itl.distribution.mean_ms, 1.0);
2128        assert_eq!(report.latency.itl.distribution.min_ms, 1.0);
2129        assert_eq!(report.latency.itl.distribution.max_ms, 1.0);
2130    }
2131
2132    #[test]
2133    fn deferred_token_timeline_finalization_folds_at_finish() {
2134        let uuid = Uuid::from_u128(8);
2135        let mut collector = TraceCollector::default();
2136        collector.set_defer_token_timeline_finalization(true);
2137        collector.on_arrival(uuid, 0.0, 128, 3);
2138        collector.on_admit(uuid, 1.0, 0);
2139        collector.on_token(uuid, 10.0);
2140        collector.on_token(uuid, 12.0);
2141        collector.on_token(uuid, 15.0);
2142        collector.on_terminal(uuid, 15.0, ReplayTerminalStatus::Completed);
2143
2144        assert_eq!(collector.retained_token_timestamps(), 3);
2145
2146        let report = collector.finish();
2147        assert_eq!(report.latency.itl.distribution.mean_ms, 2.5);
2148        assert_eq!(report.latency.itl.distribution.min_ms, 2.0);
2149        assert_eq!(report.latency.itl.distribution.max_ms, 3.0);
2150    }
2151}