Skip to main content

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