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