Skip to main content

aisimulate_core/replay/loadgen/
trace.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::fs::File;
6use std::io::{BufRead, BufReader};
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result, anyhow, bail, ensure};
10use rand::rngs::StdRng;
11use rand::{Rng, SeedableRng};
12use rustc_hash::FxHashMap;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use super::driver::WorkloadDriver;
17use super::types::{
18    AGENTIC_MOONCAKE_SCHEMA, AGENTIC_MOONCAKE_VERSION, AgenticDependency,
19    AgenticDependencyRelation, AgenticDependencyTrigger, AgenticHashIdScope, AgenticMooncakeHeader,
20    AgenticMooncakeRow, AgenticNode, AgenticPlay, AgenticSourceProvenance, AgenticTrace, DelaySpec,
21    LengthSpec, MooncakeRow, ReplayRequestHashes, SessionPartitionSpec, SessionTrace,
22    SyntheticTraceSpec, Trace, TraceFileFormat, TurnTrace, effective_replay_key,
23};
24use super::{SYNTHETIC_OUTPUT_SEED, planned_output_token_ids};
25use crate::replay::protocol::DirectRequest;
26
27#[derive(Debug, Deserialize)]
28struct RawAppliedComputeAgenticRecord {
29    num_turns: usize,
30    input_prompt_length: usize,
31    assistant_response_length: Vec<usize>,
32    tool_call_output_length: Vec<usize>,
33    tool_call_latency: Vec<f64>,
34    final_assistant_response_length: usize,
35}
36
37#[derive(Debug, Deserialize)]
38struct LegacyAgenticMooncakeRow {
39    request_id: String,
40    #[serde(default)]
41    session_id: Option<String>,
42    #[serde(default, alias = "input_tokens")]
43    input_length: Option<usize>,
44    #[serde(alias = "output_tokens")]
45    output_length: usize,
46    #[serde(default)]
47    output_token_ids: Option<Vec<u32>>,
48    hash_ids: Vec<u64>,
49    #[serde(default, alias = "created_time")]
50    timestamp: Option<f64>,
51    #[serde(default)]
52    delay: Option<f64>,
53    #[serde(default)]
54    delay_ms: Option<f64>,
55    #[serde(default)]
56    tool_wait_ms: f64,
57    #[serde(default)]
58    wait_for: Vec<String>,
59    #[serde(default)]
60    priority: Option<i32>,
61    #[serde(default)]
62    strict_priority: Option<u32>,
63    #[serde(default)]
64    policy_class: Option<String>,
65}
66
67/// Load either the versioned agentic-Mooncake graph or the legacy flat rows.
68///
69/// The versioned header owns its block size. ``legacy_trace_block_size`` is
70/// consulted only when lowering headerless legacy rows.
71pub fn load_agentic_mooncake(path: &Path, legacy_trace_block_size: usize) -> Result<AgenticTrace> {
72    let file = File::open(path)
73        .with_context(|| format!("failed to open trace file {}", path.display()))?;
74    let mut lines = BufReader::new(file).lines();
75    let first = loop {
76        let line = lines
77            .next()
78            .transpose()
79            .with_context(|| format!("failed to read trace file {}", path.display()))?
80            .context("agentic trace file is empty")?;
81        if !line.trim().is_empty() {
82            break line;
83        }
84    };
85    let first_json: serde_json::Value =
86        serde_json::from_str(&first).context("failed to parse first agentic trace row")?;
87    if first_json.get("schema").and_then(serde_json::Value::as_str) == Some(AGENTIC_MOONCAKE_SCHEMA)
88    {
89        return AgenticTrace::from_agentic_mooncake(path);
90    }
91
92    let mut raw_rows = vec![
93        serde_json::from_value::<LegacyAgenticMooncakeRow>(first_json)
94            .context("failed to parse first legacy agentic Mooncake row")?,
95    ];
96    for (line_index, line) in lines.enumerate() {
97        let line = line.with_context(|| {
98            format!(
99                "failed to read legacy agentic trace line {}",
100                line_index + 2
101            )
102        })?;
103        if line.trim().is_empty() {
104            continue;
105        }
106        raw_rows.push(serde_json::from_str(&line).with_context(|| {
107            format!(
108                "failed to parse legacy agentic trace line {}",
109                line_index + 2
110            )
111        })?);
112    }
113    let mut rows = raw_rows
114        .into_iter()
115        .map(|raw| -> Result<AgenticMooncakeRow> {
116            if raw.request_id.trim().is_empty() {
117                bail!("request_id must be nonempty");
118            }
119            if raw.hash_ids.is_empty() {
120                bail!("hash_ids must be nonempty");
121            }
122            if !raw.tool_wait_ms.is_finite() || raw.tool_wait_ms < 0.0 {
123                bail!("tool_wait_ms must be finite and nonnegative");
124            }
125            if raw.delay.is_some() && raw.delay_ms.is_some() {
126                bail!("delay and delay_ms cannot both be set");
127            }
128            let delay = raw.delay.or(raw.delay_ms).unwrap_or(0.0) + raw.tool_wait_ms;
129            if !delay.is_finite() || delay < 0.0 {
130                bail!("dependency delay must be finite and nonnegative");
131            }
132            let relation = if raw.wait_for.len() > 1 {
133                AgenticDependencyRelation::Join
134            } else {
135                AgenticDependencyRelation::Sequence
136            };
137            let dependencies = raw
138                .wait_for
139                .into_iter()
140                .map(|request_id| AgenticDependency {
141                    request_id,
142                    trigger: AgenticDependencyTrigger::Completion,
143                    delay_ms: delay,
144                    relation,
145                })
146                .collect::<Vec<_>>();
147            Ok(AgenticMooncakeRow {
148                request_id: raw.request_id,
149                play_id: "agentic-play".to_string(),
150                session_id: raw
151                    .session_id
152                    .unwrap_or_else(|| "agentic-session".to_string()),
153                model: "unknown".to_string(),
154                input_length: raw.input_length,
155                output_length: Some(raw.output_length),
156                output_token_ids: raw.output_token_ids,
157                hash_ids: Some(raw.hash_ids),
158                not_before_ms: if dependencies.is_empty() {
159                    raw.timestamp.unwrap_or(0.0)
160                } else {
161                    0.0
162                },
163                priority: raw.priority,
164                strict_priority: raw.strict_priority,
165                policy_class: raw.policy_class,
166                dependencies,
167            })
168        })
169        .collect::<Result<Vec<_>>>()?;
170    assign_dependency_component_play_ids(&mut rows, "legacy-play");
171    AgenticTrace::from_agentic_mooncake_rows(
172        AgenticMooncakeHeader {
173            schema: AGENTIC_MOONCAKE_SCHEMA.to_string(),
174            version: AGENTIC_MOONCAKE_VERSION,
175            block_size: legacy_trace_block_size,
176            hash_id_scope: AgenticHashIdScope::Local,
177            source: AgenticSourceProvenance {
178                format: "legacy_agentic_mooncake".to_string(),
179                digest: format!("{}:{}", path.display(), rows.len()),
180            },
181        },
182        rows,
183    )
184}
185
186pub(super) fn assign_dependency_component_play_ids(rows: &mut [AgenticMooncakeRow], prefix: &str) {
187    fn find(parent: &mut [usize], value: usize) -> usize {
188        if parent[value] != value {
189            parent[value] = find(parent, parent[value]);
190        }
191        parent[value]
192    }
193
194    let by_id = rows
195        .iter()
196        .enumerate()
197        .map(|(index, row)| (row.request_id.clone(), index))
198        .collect::<HashMap<_, _>>();
199    let mut parent = (0..rows.len()).collect::<Vec<_>>();
200    for (index, row) in rows.iter().enumerate() {
201        for dependency in &row.dependencies {
202            let Some(&source) = by_id.get(&dependency.request_id) else {
203                continue;
204            };
205            let left = find(&mut parent, index);
206            let right = find(&mut parent, source);
207            if left != right {
208                parent[left] = right;
209            }
210        }
211    }
212    let mut roots_by_component: HashMap<usize, Vec<usize>> = HashMap::new();
213    for (index, row) in rows.iter().enumerate() {
214        if row.dependencies.is_empty() {
215            let component = find(&mut parent, index);
216            roots_by_component.entry(component).or_default().push(index);
217        }
218    }
219    let mut labels = HashMap::new();
220    for (component, roots) in roots_by_component {
221        let canonical = roots
222            .iter()
223            .copied()
224            .min_by(|left, right| {
225                rows[*left]
226                    .not_before_ms
227                    .total_cmp(&rows[*right].not_before_ms)
228                    .then_with(|| rows[*left].request_id.cmp(&rows[*right].request_id))
229            })
230            .expect("a root component is nonempty");
231        labels.insert(component, rows[canonical].request_id.clone());
232    }
233    for (index, row) in rows.iter_mut().enumerate() {
234        let component = find(&mut parent, index);
235        let label = labels
236            .entry(component)
237            .or_insert_with(|| row.request_id.clone());
238        row.play_id = format!("{prefix}:{label}");
239    }
240}
241
242#[derive(Debug, Default)]
243struct HashIdInterner {
244    canonical_ids: FxHashMap<u64, u32>,
245}
246
247impl HashIdInterner {
248    fn intern_all(&mut self, hash_ids: Vec<u64>) -> Result<Vec<u32>> {
249        hash_ids
250            .into_iter()
251            .map(|hash_id| self.intern(hash_id))
252            .collect()
253    }
254
255    fn intern(&mut self, hash_id: u64) -> Result<u32> {
256        let next_id = self.canonical_ids.len();
257        match self.canonical_ids.entry(hash_id) {
258            std::collections::hash_map::Entry::Occupied(entry) => Ok(*entry.get()),
259            std::collections::hash_map::Entry::Vacant(entry) => {
260                let canonical_id = u32::try_from(next_id)
261                    .context("trace contains more unique hash IDs than u32 can represent")?;
262                entry.insert(canonical_id);
263                Ok(canonical_id)
264            }
265        }
266    }
267}
268
269pub fn validate_trace_files(format: TraceFileFormat, paths: &[PathBuf]) -> Result<()> {
270    if paths.is_empty() {
271        bail!("trace replay requires at least one trace file");
272    }
273    if format != TraceFileFormat::Dynamo && paths.len() != 1 {
274        bail!(
275            "trace_format='{}' requires exactly one trace file, got {}",
276            format.as_str(),
277            paths.len()
278        );
279    }
280    Ok(())
281}
282
283fn single_turn_request_uuid(_request_ordinal: usize) -> Uuid {
284    Uuid::new_v4()
285}
286
287pub(super) fn validate_synthesizable_prompt(
288    input_length: usize,
289    hash_ids: &[u32],
290    trace_block_size: usize,
291) -> Result<()> {
292    if trace_block_size == 0 {
293        bail!("trace_block_size must be greater than 0");
294    }
295    let synthesizable_capacity = hash_ids
296        .len()
297        .checked_mul(trace_block_size)
298        .context("synthesized prompt capacity overflow")?;
299    let required_hash_ids = input_length.div_ceil(trace_block_size);
300    if hash_ids.len() < required_hash_ids {
301        bail!(
302            "input_length {} exceeds synthesized capacity {}",
303            input_length,
304            synthesizable_capacity
305        );
306    }
307
308    Ok(())
309}
310
311pub(super) fn synthesize_trace_tokens(
312    input_length: usize,
313    hash_ids: &[u32],
314    trace_block_size: usize,
315) -> Result<Vec<u32>> {
316    validate_synthesizable_prompt(input_length, hash_ids, trace_block_size)?;
317
318    Ok(synthesize_validated_trace_tokens(
319        input_length,
320        hash_ids,
321        trace_block_size,
322    ))
323}
324
325pub(super) fn synthesize_validated_trace_tokens(
326    input_length: usize,
327    hash_ids: &[u32],
328    trace_block_size: usize,
329) -> Vec<u32> {
330    let mut tokens = Vec::with_capacity(input_length);
331    for &hash_id in hash_ids {
332        let remaining = input_length - tokens.len();
333        tokens.extend(std::iter::repeat_n(
334            hash_id,
335            remaining.min(trace_block_size),
336        ));
337        if tokens.len() == input_length {
338            break;
339        }
340    }
341
342    debug_assert_eq!(tokens.len(), input_length);
343    tokens
344}
345
346fn trace_to_replay_hashes(
347    input_length: usize,
348    hash_ids: &[u32],
349    trace_block_size: usize,
350    engine_block_size: usize,
351) -> Result<ReplayRequestHashes> {
352    if engine_block_size == 0 {
353        bail!("engine_block_size must be greater than 0");
354    }
355
356    let tokens = synthesize_trace_tokens(input_length, hash_ids, trace_block_size)?;
357    let engine_block_size =
358        u32::try_from(engine_block_size).context("engine_block_size does not fit in u32")?;
359    Ok(ReplayRequestHashes::from_tokens(&tokens, engine_block_size))
360}
361
362impl TurnTrace {
363    pub fn synthesize_tokens(&self, trace_block_size: usize) -> Result<Vec<u32>> {
364        synthesize_trace_tokens(self.input_length, &self.hash_ids, trace_block_size)
365    }
366
367    pub fn to_direct_request(
368        &self,
369        trace_block_size: usize,
370        request_uuid: Uuid,
371        arrival_timestamp_ms: Option<f64>,
372    ) -> Result<DirectRequest> {
373        let tokens = self.synthesize_tokens(trace_block_size)?;
374        Ok(DirectRequest {
375            tokens,
376            max_output_tokens: self.max_output_tokens,
377            output_token_ids: self.output_token_ids.clone(),
378            uuid: Some(request_uuid),
379            dp_rank: 0,
380            preferred_dp_rank: None,
381            arrival_timestamp_ms,
382            priority: self.priority,
383            strict_priority: self.strict_priority,
384            policy_class: self.policy_class.clone(),
385            replay_context: None,
386        })
387    }
388
389    pub fn to_replay_hashes(
390        &self,
391        trace_block_size: usize,
392        engine_block_size: usize,
393    ) -> Result<ReplayRequestHashes> {
394        trace_to_replay_hashes(
395            self.input_length,
396            &self.hash_ids,
397            trace_block_size,
398            engine_block_size,
399        )
400    }
401}
402
403struct MooncakeTraceBuilder {
404    trace_block_size: usize,
405    hash_id_interner: HashIdInterner,
406    sessions: Vec<SessionTrace>,
407    session_indices: HashMap<String, usize>,
408    last_timestamps: Vec<Option<f64>>,
409}
410
411impl MooncakeTraceBuilder {
412    fn new(trace_block_size: usize) -> Self {
413        Self {
414            trace_block_size,
415            hash_id_interner: HashIdInterner::default(),
416            sessions: Vec::new(),
417            session_indices: HashMap::new(),
418            last_timestamps: Vec::new(),
419        }
420    }
421
422    fn is_empty(&self) -> bool {
423        self.sessions.is_empty()
424    }
425
426    fn push(&mut self, line_idx: usize, raw: MooncakeRow) -> Result<()> {
427        let request_id = raw.request_id;
428        let raw_session_id = raw.session_id;
429        let session_id = raw_session_id
430            .clone()
431            .unwrap_or_else(|| format!("request_{}", line_idx + 1));
432        let hash_ids = raw
433            .hash_ids
434            .ok_or_else(|| anyhow!("trace line {} is missing hash_ids", line_idx + 1))?;
435        let synthesizable_capacity = hash_ids
436            .len()
437            .checked_mul(self.trace_block_size)
438            .ok_or_else(|| anyhow!("trace line {} synthesized capacity overflow", line_idx + 1))?;
439        let input_length = raw.input_length.unwrap_or(synthesizable_capacity);
440        ensure!(
441            input_length <= synthesizable_capacity,
442            "trace line {} input_length {} exceeds hash_ids capacity {}",
443            line_idx + 1,
444            input_length,
445            synthesizable_capacity
446        );
447        let output_length = raw
448            .output_length
449            .ok_or_else(|| anyhow!("trace line {} is missing output_length", line_idx + 1))?;
450        let output_token_ids = raw.output_token_ids;
451        if let Some(output_token_ids) = output_token_ids.as_ref()
452            && output_token_ids.len() != output_length
453        {
454            bail!(
455                "trace line {} output_length {} does not match output_token_ids length {}",
456                line_idx + 1,
457                output_length,
458                output_token_ids.len()
459            );
460        }
461        let timestamp_ms = raw.timestamp;
462        let explicit_delay_ms = raw.delay;
463        let priority = raw.priority.unwrap_or(0);
464        let strict_priority = raw.strict_priority.unwrap_or(0);
465        let policy_class = raw.policy_class.clone();
466
467        let session_index = *self
468            .session_indices
469            .entry(session_id.clone())
470            .or_insert_with(|| {
471                let idx = self.sessions.len();
472                self.sessions.push(SessionTrace {
473                    session_id: session_id.clone(),
474                    first_arrival_timestamp_ms: timestamp_ms,
475                    turns: Vec::new(),
476                });
477                self.last_timestamps.push(timestamp_ms);
478                idx
479            });
480
481        let session = self
482            .sessions
483            .get_mut(session_index)
484            .expect("newly inserted session must exist");
485        let turn_idx = session.turns.len();
486        let replay_key = output_token_ids.as_ref().map(|_| {
487            effective_replay_key(
488                request_id.as_deref(),
489                raw_session_id.as_deref(),
490                turn_idx,
491                line_idx,
492            )
493        });
494        let delay_after_previous_ms = if turn_idx == 0 {
495            let delay = explicit_delay_ms.unwrap_or(0.0);
496            if delay != 0.0 {
497                bail!(
498                    "trace line {} sets delay on the first turn of session {}",
499                    line_idx + 1,
500                    session.session_id
501                );
502            }
503            0.0
504        } else if let Some(delay_ms) = explicit_delay_ms {
505            delay_ms
506        } else if let Some(timestamp_ms) = timestamp_ms {
507            let previous_timestamp_ms = self.last_timestamps[session_index].ok_or_else(|| {
508                anyhow!(
509                    "trace line {} for session {} cannot infer delay without a previous timestamp",
510                    line_idx + 1,
511                    session.session_id
512                )
513            })?;
514            timestamp_ms - previous_timestamp_ms
515        } else {
516            0.0
517        };
518
519        if !delay_after_previous_ms.is_finite() || delay_after_previous_ms < 0.0 {
520            bail!(
521                "trace line {} has invalid delay {}",
522                line_idx + 1,
523                delay_after_previous_ms
524            );
525        }
526
527        if hash_ids.len() * self.trace_block_size < input_length {
528            bail!(
529                "trace line {} input_length {} exceeds synthesized capacity {}",
530                line_idx + 1,
531                input_length,
532                hash_ids.len() * self.trace_block_size
533            );
534        }
535
536        let hash_ids = self.hash_id_interner.intern_all(hash_ids)?;
537
538        session.turns.push(TurnTrace {
539            input_length,
540            max_output_tokens: output_length,
541            output_token_ids,
542            replay_key,
543            hash_ids,
544            delay_after_previous_ms,
545            priority,
546            strict_priority,
547            policy_class,
548        });
549        if let Some(timestamp_ms) = timestamp_ms {
550            self.last_timestamps[session_index] = Some(timestamp_ms);
551        }
552        Ok(())
553    }
554
555    fn finish(self) -> Trace {
556        Trace {
557            block_size: self.trace_block_size,
558            sessions: self.sessions,
559        }
560    }
561}
562
563impl Trace {
564    pub fn from_mooncake(path: &Path, trace_block_size: usize) -> Result<Self> {
565        if trace_block_size == 0 {
566            bail!("trace_block_size must be greater than 0");
567        }
568
569        let file = File::open(path)
570            .with_context(|| format!("failed to open trace file {}", path.display()))?;
571        let reader = BufReader::new(file);
572        let mut builder = MooncakeTraceBuilder::new(trace_block_size);
573
574        for (line_idx, line) in reader.lines().enumerate() {
575            let line = line.with_context(|| {
576                format!(
577                    "failed to read line {} from {}",
578                    line_idx + 1,
579                    path.display()
580                )
581            })?;
582            if line.trim().is_empty() {
583                continue;
584            }
585
586            let row = serde_json::from_str(&line).with_context(|| {
587                format!(
588                    "failed to parse line {} from {} as JSON",
589                    line_idx + 1,
590                    path.display()
591                )
592            })?;
593            builder.push(line_idx, row)?;
594        }
595
596        if builder.is_empty() {
597            bail!("trace file {} did not contain any requests", path.display());
598        }
599
600        Ok(builder.finish())
601    }
602
603    pub fn from_mooncake_rows(rows: Vec<MooncakeRow>, trace_block_size: usize) -> Result<Self> {
604        if trace_block_size == 0 {
605            bail!("trace_block_size must be greater than 0");
606        }
607        let mut builder = MooncakeTraceBuilder::new(trace_block_size);
608        for (line_idx, row) in rows.into_iter().enumerate() {
609            builder.push(line_idx, row)?;
610        }
611        if builder.is_empty() {
612            bail!("Mooncake rows did not contain any requests");
613        }
614        Ok(builder.finish())
615    }
616
617    pub fn from_applied_compute_agentic(
618        path: &Path,
619        trace_block_size: usize,
620        shared_prefix_ratio: f64,
621        num_prefix_groups: usize,
622    ) -> Result<Self> {
623        if trace_block_size == 0 {
624            bail!("trace_block_size must be greater than 0");
625        }
626        if !(0.0..=1.0).contains(&shared_prefix_ratio) {
627            bail!(
628                "shared_prefix_ratio must be between 0.0 and 1.0, got {}",
629                shared_prefix_ratio
630            );
631        }
632
633        let file = File::open(path)
634            .with_context(|| format!("failed to open trace file {}", path.display()))?;
635        let reader = BufReader::new(file);
636        let mut sessions = Vec::new();
637        let mut hash_id_interner = HashIdInterner::default();
638        let mut next_unique_hash = 1_u64;
639
640        for (line_idx, line) in reader.lines().enumerate() {
641            let line = line.with_context(|| {
642                format!(
643                    "failed to read line {} from {}",
644                    line_idx + 1,
645                    path.display()
646                )
647            })?;
648            if line.trim().is_empty() {
649                continue;
650            }
651
652            let raw: RawAppliedComputeAgenticRecord =
653                serde_json::from_str(&line).with_context(|| {
654                    format!(
655                        "failed to parse line {} from {} as JSON",
656                        line_idx + 1,
657                        path.display()
658                    )
659                })?;
660
661            for (name, values) in [
662                (
663                    "assistant_response_length",
664                    raw.assistant_response_length.len(),
665                ),
666                ("tool_call_output_length", raw.tool_call_output_length.len()),
667                ("tool_call_latency", raw.tool_call_latency.len()),
668            ] {
669                if values != raw.num_turns {
670                    bail!(
671                        "trace line {} field {} length {} does not match num_turns {}",
672                        line_idx + 1,
673                        name,
674                        values,
675                        raw.num_turns
676                    );
677                }
678            }
679
680            if raw.input_prompt_length == 0 {
681                bail!(
682                    "trace line {} input_prompt_length must be positive",
683                    line_idx + 1
684                );
685            }
686
687            let group_id = if shared_prefix_ratio > 0.0 && num_prefix_groups > 0 {
688                Some(line_idx % num_prefix_groups)
689            } else {
690                None
691            };
692            let mut current_input_length = raw.input_prompt_length;
693            let mut hash_ids = Vec::new();
694            let shared_initial_blocks = ((current_input_length.div_ceil(trace_block_size) as f64)
695                * shared_prefix_ratio)
696                .round() as usize;
697            extend_applied_compute_agentic_hash_ids(
698                &mut hash_ids,
699                current_input_length,
700                trace_block_size,
701                shared_initial_blocks,
702                group_id,
703                &mut next_unique_hash,
704            )?;
705
706            let mut turns = Vec::with_capacity(raw.num_turns + 1);
707            let mut next_turn_delay_ms = 0.0;
708            for turn_idx in 0..raw.num_turns {
709                let tool_call_latency = raw.tool_call_latency[turn_idx];
710                if !tool_call_latency.is_finite() || tool_call_latency < 0.0 {
711                    bail!(
712                        "trace line {} tool_call_latency[{}] must be a finite non-negative number",
713                        line_idx + 1,
714                        turn_idx
715                    );
716                }
717
718                turns.push(TurnTrace {
719                    input_length: current_input_length,
720                    max_output_tokens: raw.assistant_response_length[turn_idx],
721                    hash_ids: hash_id_interner.intern_all(hash_ids.clone())?,
722                    delay_after_previous_ms: next_turn_delay_ms,
723                    ..Default::default()
724                });
725
726                current_input_length = current_input_length
727                    .checked_add(raw.assistant_response_length[turn_idx])
728                    .and_then(|value| value.checked_add(raw.tool_call_output_length[turn_idx]))
729                    .ok_or_else(|| {
730                        anyhow!(
731                            "trace line {} cumulative input length overflow",
732                            line_idx + 1
733                        )
734                    })?;
735                extend_applied_compute_agentic_hash_ids(
736                    &mut hash_ids,
737                    current_input_length,
738                    trace_block_size,
739                    shared_initial_blocks,
740                    group_id,
741                    &mut next_unique_hash,
742                )?;
743                next_turn_delay_ms = tool_call_latency * 1000.0;
744            }
745
746            turns.push(TurnTrace {
747                input_length: current_input_length,
748                max_output_tokens: raw.final_assistant_response_length,
749                hash_ids: hash_id_interner.intern_all(hash_ids)?,
750                delay_after_previous_ms: next_turn_delay_ms,
751                ..Default::default()
752            });
753
754            sessions.push(SessionTrace {
755                session_id: format!("applied_compute_agentic_session_{}", line_idx + 1),
756                first_arrival_timestamp_ms: None,
757                turns,
758            });
759        }
760
761        if sessions.is_empty() {
762            bail!("trace file {} did not contain any requests", path.display());
763        }
764
765        Ok(Self {
766            block_size: trace_block_size,
767            sessions,
768        })
769    }
770
771    pub fn synthetic(spec: SyntheticTraceSpec) -> Result<Self> {
772        if spec.block_size == 0 {
773            bail!("block_size must be greater than 0");
774        }
775        if spec.num_sessions == 0 {
776            bail!("num_sessions must be greater than 0");
777        }
778        if spec.turns_per_session == 0 {
779            bail!("turns_per_session must be greater than 0");
780        }
781        if !(0.0..=1.0).contains(&spec.shared_prefix_ratio) {
782            bail!(
783                "shared_prefix_ratio must be between 0.0 and 1.0, got {}",
784                spec.shared_prefix_ratio
785            );
786        }
787
788        let mut rng = StdRng::seed_from_u64(spec.seed);
789        let mut sessions = Vec::with_capacity(spec.num_sessions);
790        let first_arrivals = spec
791            .first_turn_arrivals
792            .timestamps(spec.num_sessions, spec.arrival_seed)?;
793
794        let mut next_unique_hash = 1_u64;
795        let mut hash_id_interner = HashIdInterner::default();
796        for (session_idx, first_arrival_timestamp_ms) in first_arrivals.into_iter().enumerate() {
797            let group_id = if spec.num_prefix_groups > 0 && spec.shared_prefix_ratio > 0.0 {
798                Some(rng.random_range(0..spec.num_prefix_groups) as u64)
799            } else {
800                None
801            };
802            let mut turns = Vec::with_capacity(spec.turns_per_session);
803            for turn_idx in 0..spec.turns_per_session {
804                let input_length = sample_length(&spec.input_tokens, 1, &mut rng);
805                let max_output_tokens = sample_length(&spec.output_tokens, 1, &mut rng);
806                let num_blocks = input_length.div_ceil(spec.block_size);
807                let prefix_blocks =
808                    ((num_blocks as f64) * spec.shared_prefix_ratio).round() as usize;
809                let prefix_blocks = prefix_blocks.min(num_blocks);
810                let mut hash_ids = Vec::with_capacity(num_blocks);
811
812                for block_idx in 0..prefix_blocks {
813                    if let Some(group_id) = group_id {
814                        hash_ids.push(0xD00D_0000_0000_0000 | (group_id << 32) | block_idx as u64);
815                    }
816                }
817
818                while hash_ids.len() < num_blocks {
819                    hash_ids.push(next_unique_hash);
820                    next_unique_hash = next_unique_hash
821                        .checked_add(1)
822                        .expect("synthetic hash id overflow");
823                }
824
825                turns.push(TurnTrace {
826                    input_length,
827                    max_output_tokens,
828                    hash_ids: hash_id_interner.intern_all(hash_ids)?,
829                    delay_after_previous_ms: if turn_idx == 0 {
830                        0.0
831                    } else {
832                        sample_delay_ms(&spec.inter_turn_delays, &mut rng)?
833                    },
834                    ..Default::default()
835                });
836            }
837
838            sessions.push(SessionTrace {
839                session_id: format!("session_{session_idx}"),
840                first_arrival_timestamp_ms: Some(first_arrival_timestamp_ms),
841                turns,
842            });
843        }
844
845        Ok(Self {
846            block_size: spec.block_size,
847            sessions,
848        })
849    }
850
851    pub fn validate_for_trace_mode(&self) -> Result<()> {
852        self.validate(false)
853    }
854
855    pub fn validate_for_concurrency_mode(&self) -> Result<()> {
856        self.validate(true)
857    }
858
859    pub fn normalize_session_starts(mut self) -> Result<Self> {
860        let Some(min_timestamp_ms) = self
861            .sessions
862            .iter()
863            .filter_map(|session| session.first_arrival_timestamp_ms)
864            .min_by(|left, right| left.total_cmp(right))
865        else {
866            return Ok(self);
867        };
868
869        for session in &mut self.sessions {
870            if let Some(timestamp_ms) = session.first_arrival_timestamp_ms.as_mut() {
871                *timestamp_ms -= min_timestamp_ms;
872            }
873        }
874        Ok(self)
875    }
876
877    pub fn speed_up_timing(mut self, ratio: f64) -> Result<Self> {
878        if !ratio.is_finite() || ratio <= 0.0 {
879            bail!("ratio must be a finite positive number, got {ratio}");
880        }
881
882        for session in &mut self.sessions {
883            if let Some(timestamp_ms) = session.first_arrival_timestamp_ms.as_mut() {
884                *timestamp_ms /= ratio;
885            }
886            for turn in &mut session.turns {
887                turn.delay_after_previous_ms /= ratio;
888            }
889        }
890        Ok(self)
891    }
892
893    pub fn rescale_session_start_span(mut self, duration_ms: u64) -> Result<Self> {
894        let Some(min_timestamp_ms) = self
895            .sessions
896            .iter()
897            .filter_map(|session| session.first_arrival_timestamp_ms)
898            .min_by(|left, right| left.total_cmp(right))
899        else {
900            return Ok(self);
901        };
902        let Some(max_timestamp_ms) = self
903            .sessions
904            .iter()
905            .filter_map(|session| session.first_arrival_timestamp_ms)
906            .max_by(|left, right| left.total_cmp(right))
907        else {
908            return Ok(self);
909        };
910
911        let target_span_ms = duration_ms as f64;
912        let source_span_ms = max_timestamp_ms - min_timestamp_ms;
913        for session in &mut self.sessions {
914            if let Some(timestamp_ms) = session.first_arrival_timestamp_ms.as_mut() {
915                *timestamp_ms = if source_span_ms == 0.0 {
916                    0.0
917                } else {
918                    (*timestamp_ms - min_timestamp_ms) * target_span_ms / source_span_ms
919                };
920            }
921        }
922        Ok(self)
923    }
924
925    pub fn rescale_ready_span(mut self, duration_ms: u64) -> Result<Self> {
926        let Some(min_start_ms) = self
927            .sessions
928            .iter()
929            .map(|session| session.first_arrival_timestamp_ms.unwrap_or(0.0))
930            .min_by(|left, right| left.total_cmp(right))
931        else {
932            return Ok(self);
933        };
934
935        let Some(max_ready_ms) = self
936            .sessions
937            .iter()
938            .map(|session| {
939                session.first_arrival_timestamp_ms.unwrap_or(0.0)
940                    + session
941                        .turns
942                        .iter()
943                        .enumerate()
944                        .filter(|(turn_idx, _)| *turn_idx > 0)
945                        .map(|(_, turn)| turn.delay_after_previous_ms)
946                        .sum::<f64>()
947            })
948            .max_by(|left, right| left.total_cmp(right))
949        else {
950            return Ok(self);
951        };
952
953        let ratio = duration_ms as f64 / (max_ready_ms - min_start_ms).max(1.0);
954        for session in &mut self.sessions {
955            if let Some(start_ms) = session.first_arrival_timestamp_ms.as_mut() {
956                *start_ms = (*start_ms - min_start_ms) * ratio;
957            }
958            for (turn_idx, turn) in session.turns.iter_mut().enumerate() {
959                if turn_idx > 0 {
960                    turn.delay_after_previous_ms *= ratio;
961                }
962            }
963        }
964        Ok(self)
965    }
966
967    pub fn expand_hash_prefix_depth(mut self, factor: usize) -> Self {
968        if factor <= 1 {
969            return self;
970        }
971        let factor = u32::try_from(factor).expect("hash prefix expansion factor exceeds u32");
972        for session in &mut self.sessions {
973            for turn in &mut session.turns {
974                turn.input_length = turn
975                    .input_length
976                    .checked_mul(factor as usize)
977                    .expect("input_length expansion overflow");
978                turn.hash_ids = turn
979                    .hash_ids
980                    .iter()
981                    .flat_map(|&hash_id| {
982                        let base = hash_id
983                            .checked_mul(factor)
984                            .expect("hash prefix expansion overflow");
985                        (0..factor).map(move |offset| {
986                            base.checked_add(offset)
987                                .expect("hash prefix expansion overflow")
988                        })
989                    })
990                    .collect();
991            }
992        }
993        self
994    }
995
996    pub fn duplicate_hash_space(mut self, copies: usize) -> Self {
997        if copies <= 1 {
998            return self;
999        }
1000
1001        let max_hash_id = self
1002            .sessions
1003            .iter()
1004            .flat_map(|session| session.turns.iter())
1005            .flat_map(|turn| turn.hash_ids.iter().copied())
1006            .max()
1007            .unwrap_or(0);
1008        let offset_base = max_hash_id
1009            .checked_add(1)
1010            .expect("hash duplication offset overflow");
1011        let original_sessions = self.sessions.clone();
1012        self.sessions.clear();
1013
1014        for copy_idx in 0..copies {
1015            let copy_idx = u32::try_from(copy_idx).expect("hash copy index exceeds u32");
1016            let offset = offset_base
1017                .checked_mul(copy_idx)
1018                .expect("hash duplication offset overflow");
1019            for session in &original_sessions {
1020                let mut duplicated = session.clone();
1021                duplicated.session_id = format!("{}:copy_{copy_idx}", session.session_id);
1022                for turn in &mut duplicated.turns {
1023                    turn.hash_ids = turn
1024                        .hash_ids
1025                        .iter()
1026                        .map(|&hash_id| {
1027                            hash_id
1028                                .checked_add(offset)
1029                                .expect("hash duplication overflow")
1030                        })
1031                        .collect();
1032                }
1033                self.sessions.push(duplicated);
1034            }
1035        }
1036        self
1037    }
1038
1039    pub fn partition_by_session(&self, spec: SessionPartitionSpec) -> Vec<Self> {
1040        let num_partitions = match spec {
1041            SessionPartitionSpec::Random { num_partitions, .. } => num_partitions,
1042            SessionPartitionSpec::RoundRobin { num_partitions } => num_partitions,
1043        }
1044        .max(1);
1045        let mut partitions = vec![
1046            Self {
1047                block_size: self.block_size,
1048                sessions: Vec::new(),
1049            };
1050            num_partitions
1051        ];
1052
1053        let mut rng = match spec {
1054            SessionPartitionSpec::Random { seed, .. } => Some(StdRng::seed_from_u64(seed)),
1055            SessionPartitionSpec::RoundRobin { .. } => None,
1056        };
1057
1058        for (session_idx, session) in self.sessions.iter().cloned().enumerate() {
1059            let partition_idx = match spec {
1060                SessionPartitionSpec::Random { .. } => rng
1061                    .as_mut()
1062                    .expect("random partitioner must exist")
1063                    .random_range(0..num_partitions),
1064                SessionPartitionSpec::RoundRobin { .. } => session_idx % num_partitions,
1065            };
1066            partitions[partition_idx].sessions.push(session);
1067        }
1068
1069        partitions
1070    }
1071
1072    pub fn to_single_turn_requests(&self) -> Result<Vec<DirectRequest>> {
1073        let mut requests = Vec::with_capacity(self.sessions.len());
1074        let mut output_rng = StdRng::seed_from_u64(SYNTHETIC_OUTPUT_SEED);
1075        for (request_ordinal, session) in self.sessions.iter().enumerate() {
1076            if session.turns.len() != 1 {
1077                bail!(
1078                    "to_single_turn_requests requires exactly one turn per session, but session {} has {} turns",
1079                    session.session_id,
1080                    session.turns.len()
1081                );
1082            }
1083            let request_uuid = single_turn_request_uuid(request_ordinal);
1084            let mut request = session.turns[0].to_direct_request(
1085                self.block_size,
1086                request_uuid,
1087                session.first_arrival_timestamp_ms,
1088            )?;
1089            request.output_token_ids = Some(planned_output_token_ids(
1090                request.output_token_ids,
1091                request.max_output_tokens,
1092                &mut output_rng,
1093            ));
1094            requests.push(request);
1095        }
1096        Ok(requests)
1097    }
1098
1099    pub fn is_single_turn(&self) -> bool {
1100        self.sessions.iter().all(|session| session.turns.len() == 1)
1101    }
1102
1103    pub fn into_trace_driver(self) -> Result<WorkloadDriver> {
1104        self.validate_for_trace_mode()?;
1105        let engine_block_size = self.block_size;
1106        WorkloadDriver::new_trace(self, engine_block_size)
1107    }
1108
1109    pub fn into_concurrency_driver(self, max_in_flight: usize) -> Result<WorkloadDriver> {
1110        self.validate_for_concurrency_mode()?;
1111        let engine_block_size = self.block_size;
1112        WorkloadDriver::new_concurrency(self, engine_block_size, max_in_flight)
1113    }
1114
1115    pub fn into_trace_driver_with_block_size(
1116        self,
1117        engine_block_size: usize,
1118    ) -> Result<WorkloadDriver> {
1119        self.validate_for_trace_mode()?;
1120        WorkloadDriver::new_trace(self, engine_block_size)
1121    }
1122
1123    pub fn into_delta_accumulating_trace_driver_with_block_size(
1124        self,
1125        engine_block_size: usize,
1126    ) -> Result<WorkloadDriver> {
1127        self.validate_for_trace_mode()?;
1128        WorkloadDriver::new_trace_accumulating_deltas(self, engine_block_size)
1129    }
1130
1131    pub fn into_concurrency_driver_with_block_size(
1132        self,
1133        engine_block_size: usize,
1134        max_in_flight: usize,
1135    ) -> Result<WorkloadDriver> {
1136        self.validate_for_concurrency_mode()?;
1137        WorkloadDriver::new_concurrency(self, engine_block_size, max_in_flight)
1138    }
1139
1140    pub fn into_delta_accumulating_concurrency_driver_with_block_size(
1141        self,
1142        engine_block_size: usize,
1143        max_in_flight: usize,
1144    ) -> Result<WorkloadDriver> {
1145        self.validate_for_concurrency_mode()?;
1146        WorkloadDriver::new_concurrency_accumulating_deltas(self, engine_block_size, max_in_flight)
1147    }
1148
1149    fn validate(&self, allow_missing_first_timestamp: bool) -> Result<()> {
1150        if self.block_size == 0 {
1151            bail!("block_size must be greater than 0");
1152        }
1153        if self.sessions.is_empty() {
1154            bail!("trace must contain at least one session");
1155        }
1156
1157        for session in &self.sessions {
1158            if session.turns.is_empty() {
1159                bail!(
1160                    "session {} must contain at least one turn",
1161                    session.session_id
1162                );
1163            }
1164            if !allow_missing_first_timestamp {
1165                let timestamp_ms = session.first_arrival_timestamp_ms.ok_or_else(|| {
1166                    anyhow!(
1167                        "trace mode requires first_arrival_timestamp_ms for session {}",
1168                        session.session_id
1169                    )
1170                })?;
1171                if !timestamp_ms.is_finite() || timestamp_ms < 0.0 {
1172                    bail!(
1173                        "session {} has invalid first_arrival_timestamp_ms {}",
1174                        session.session_id,
1175                        timestamp_ms
1176                    );
1177                }
1178            } else if let Some(timestamp_ms) = session.first_arrival_timestamp_ms
1179                && (!timestamp_ms.is_finite() || timestamp_ms < 0.0)
1180            {
1181                bail!(
1182                    "session {} has invalid first_arrival_timestamp_ms {}",
1183                    session.session_id,
1184                    timestamp_ms
1185                );
1186            }
1187
1188            for (turn_idx, turn) in session.turns.iter().enumerate() {
1189                if let Some(output_token_ids) = turn.output_token_ids.as_ref()
1190                    && output_token_ids.len() != turn.max_output_tokens
1191                {
1192                    bail!(
1193                        "session {} turn {} max_output_tokens {} does not match output_token_ids length {}",
1194                        session.session_id,
1195                        turn_idx,
1196                        turn.max_output_tokens,
1197                        output_token_ids.len()
1198                    );
1199                }
1200                if turn.input_length == 0 {
1201                    bail!(
1202                        "session {} turn {} must have a positive input_length",
1203                        session.session_id,
1204                        turn_idx
1205                    );
1206                }
1207                if turn.hash_ids.is_empty() {
1208                    bail!(
1209                        "session {} turn {} must contain at least one hash id",
1210                        session.session_id,
1211                        turn_idx
1212                    );
1213                }
1214                validate_synthesizable_prompt(turn.input_length, &turn.hash_ids, self.block_size)
1215                    .with_context(|| {
1216                    format!(
1217                        "session {} turn {} has invalid prompt",
1218                        session.session_id, turn_idx
1219                    )
1220                })?;
1221                if !turn.delay_after_previous_ms.is_finite() || turn.delay_after_previous_ms < 0.0 {
1222                    bail!(
1223                        "session {} turn {} has invalid delay {}",
1224                        session.session_id,
1225                        turn_idx,
1226                        turn.delay_after_previous_ms
1227                    );
1228                }
1229                if turn_idx == 0 && turn.delay_after_previous_ms != 0.0 {
1230                    bail!(
1231                        "session {} first turn must have delay_after_previous_ms == 0.0",
1232                        session.session_id
1233                    );
1234                }
1235            }
1236        }
1237
1238        Ok(())
1239    }
1240}
1241
1242struct AgenticTraceBuilder {
1243    header: AgenticMooncakeHeader,
1244    nodes: Vec<AgenticNode>,
1245    request_ids: std::collections::HashSet<String>,
1246}
1247
1248/// Incremental builder for an already-versioned agentic request stream.
1249///
1250/// Producers can validate and compile rows without retaining a second full
1251/// row collection in memory. The returned graph owns the row payloads.
1252pub struct AgenticGraphBuilder {
1253    inner: AgenticTraceBuilder,
1254    row_count: usize,
1255}
1256
1257impl AgenticGraphBuilder {
1258    pub fn new(header: AgenticMooncakeHeader) -> Result<Self> {
1259        Ok(Self {
1260            inner: AgenticTraceBuilder::new(header)?,
1261            row_count: 0,
1262        })
1263    }
1264
1265    pub fn push(&mut self, row: AgenticMooncakeRow) -> Result<()> {
1266        self.inner.push(self.row_count + 1, row)?;
1267        self.row_count += 1;
1268        Ok(())
1269    }
1270
1271    pub fn finish(self) -> Result<AgenticTrace> {
1272        if self.inner.is_empty() {
1273            bail!("agentic Mooncake rows did not contain any requests");
1274        }
1275        self.inner.finish()
1276    }
1277}
1278
1279impl AgenticTraceBuilder {
1280    fn new(header: AgenticMooncakeHeader) -> Result<Self> {
1281        if header.schema != AGENTIC_MOONCAKE_SCHEMA {
1282            bail!(
1283                "unsupported agentic Mooncake schema {:?}; expected {:?}",
1284                header.schema,
1285                AGENTIC_MOONCAKE_SCHEMA
1286            );
1287        }
1288        if header.version != AGENTIC_MOONCAKE_VERSION {
1289            bail!(
1290                "unsupported agentic Mooncake version {}; expected {}",
1291                header.version,
1292                AGENTIC_MOONCAKE_VERSION
1293            );
1294        }
1295        if header.block_size == 0 {
1296            bail!("agentic Mooncake block_size must be greater than 0");
1297        }
1298        if header.source.format.trim().is_empty() || header.source.digest.trim().is_empty() {
1299            bail!("agentic Mooncake source provenance requires nonempty format and digest");
1300        }
1301
1302        Ok(Self {
1303            header,
1304            nodes: Vec::new(),
1305            request_ids: std::collections::HashSet::new(),
1306        })
1307    }
1308
1309    fn is_empty(&self) -> bool {
1310        self.nodes.is_empty()
1311    }
1312
1313    fn push(&mut self, line_idx: usize, raw: AgenticMooncakeRow) -> Result<()> {
1314        if raw.request_id.trim().is_empty() {
1315            bail!("trace line {} has empty request_id", line_idx + 1);
1316        }
1317        if raw.play_id.trim().is_empty() {
1318            bail!("trace line {} has empty play_id", line_idx + 1);
1319        }
1320        if raw.session_id.trim().is_empty() {
1321            bail!("trace line {} has empty session_id", line_idx + 1);
1322        }
1323        if !self.request_ids.insert(raw.request_id.clone()) {
1324            bail!(
1325                "trace line {} duplicates request_id {}",
1326                line_idx + 1,
1327                raw.request_id
1328            );
1329        }
1330
1331        let hash_ids = raw
1332            .hash_ids
1333            .ok_or_else(|| anyhow!("trace line {} is missing hash_ids", line_idx + 1))?;
1334        if hash_ids.is_empty() {
1335            bail!("trace line {} has empty hash_ids", line_idx + 1);
1336        }
1337        let input_length = raw
1338            .input_length
1339            .ok_or_else(|| anyhow!("trace line {} is missing input_length", line_idx + 1))?;
1340        if input_length == 0 {
1341            bail!("trace line {} has zero input_length", line_idx + 1);
1342        }
1343        let expected_hashes = input_length.div_ceil(self.header.block_size);
1344        if hash_ids.len() != expected_hashes {
1345            bail!(
1346                "trace line {} has input_length {} at block_size {} and requires exactly {} hash_ids, got {}",
1347                line_idx + 1,
1348                input_length,
1349                self.header.block_size,
1350                expected_hashes,
1351                hash_ids.len()
1352            );
1353        }
1354        if raw.model.trim().is_empty() {
1355            bail!("trace line {} has an empty model", line_idx + 1);
1356        }
1357        let output_length = raw
1358            .output_length
1359            .ok_or_else(|| anyhow!("trace line {} is missing output_length", line_idx + 1))?;
1360        let output_token_ids = raw.output_token_ids;
1361        if let Some(output_token_ids) = output_token_ids.as_ref()
1362            && output_token_ids.len() != output_length
1363        {
1364            bail!(
1365                "trace line {} output_length {} does not match output_token_ids length {}",
1366                line_idx + 1,
1367                output_length,
1368                output_token_ids.len()
1369            );
1370        }
1371        if !raw.not_before_ms.is_finite() || raw.not_before_ms < 0.0 {
1372            bail!(
1373                "trace line {} has invalid not_before_ms {}",
1374                line_idx + 1,
1375                raw.not_before_ms
1376            );
1377        }
1378        for dependency in &raw.dependencies {
1379            if dependency.request_id.trim().is_empty() {
1380                bail!(
1381                    "trace line {} has a dependency with an empty request_id",
1382                    line_idx + 1
1383                );
1384            }
1385            if !dependency.delay_ms.is_finite() || dependency.delay_ms < 0.0 {
1386                bail!(
1387                    "trace line {} has invalid dependency delay {}",
1388                    line_idx + 1,
1389                    dependency.delay_ms
1390                );
1391            }
1392            match (dependency.relation, dependency.trigger) {
1393                (AgenticDependencyRelation::Sequence, AgenticDependencyTrigger::Completion)
1394                | (AgenticDependencyRelation::Spawn, _)
1395                | (AgenticDependencyRelation::Join, AgenticDependencyTrigger::Completion)
1396                | (
1397                    AgenticDependencyRelation::ReplayBarrier,
1398                    AgenticDependencyTrigger::Completion,
1399                ) => {}
1400                (relation, trigger) => bail!(
1401                    "trace line {} has invalid {:?} dependency with {:?} trigger",
1402                    line_idx + 1,
1403                    relation,
1404                    trigger
1405                ),
1406            }
1407        }
1408
1409        let replay_key = output_token_ids.as_ref().map(|_| {
1410            effective_replay_key(
1411                Some(raw.request_id.as_str()),
1412                Some(raw.session_id.as_str()),
1413                0,
1414                line_idx,
1415            )
1416        });
1417        self.nodes.push(AgenticNode {
1418            replay_key,
1419            request_id: raw.request_id,
1420            play_id: raw.play_id,
1421            session_id: raw.session_id,
1422            model: raw.model,
1423            input_length,
1424            max_output_tokens: output_length,
1425            output_token_ids,
1426            hash_ids,
1427            not_before_ms: raw.not_before_ms,
1428            priority: raw.priority.unwrap_or(0),
1429            strict_priority: raw.strict_priority.unwrap_or(0),
1430            policy_class: raw.policy_class,
1431            dependencies: raw.dependencies,
1432        });
1433        Ok(())
1434    }
1435
1436    fn finish(mut self) -> Result<AgenticTrace> {
1437        self.nodes
1438            .sort_by(|left, right| left.request_id.cmp(&right.request_id));
1439        let index_by_id: HashMap<&str, usize> = self
1440            .nodes
1441            .iter()
1442            .enumerate()
1443            .map(|(index, node)| (node.request_id.as_str(), index))
1444            .collect();
1445        let mut nodes_by_play: HashMap<String, Vec<usize>> = HashMap::new();
1446
1447        for (node_index, node) in self.nodes.iter().enumerate() {
1448            nodes_by_play
1449                .entry(node.play_id.clone())
1450                .or_default()
1451                .push(node_index);
1452            for dependency in &node.dependencies {
1453                let Some(&dependency_index) = index_by_id.get(dependency.request_id.as_str())
1454                else {
1455                    bail!(
1456                        "request {} references unknown request_id {}",
1457                        node.request_id,
1458                        dependency.request_id
1459                    );
1460                };
1461                let dependency_node = &self.nodes[dependency_index];
1462                if dependency_index == node_index {
1463                    bail!("request {} cannot depend on itself", node.request_id);
1464                }
1465                if dependency_node.play_id != node.play_id {
1466                    bail!(
1467                        "request {} in play {} depends on request {} in play {}",
1468                        node.request_id,
1469                        node.play_id,
1470                        dependency.request_id,
1471                        dependency_node.play_id
1472                    );
1473                }
1474            }
1475        }
1476        validate_agentic_trace_is_acyclic(&self.nodes, &index_by_id)?;
1477
1478        let mut plays = Vec::with_capacity(nodes_by_play.len());
1479        for (play_id, mut node_indices) in nodes_by_play {
1480            node_indices.sort_unstable();
1481            let roots: Vec<_> = node_indices
1482                .iter()
1483                .copied()
1484                .filter(|node_index| self.nodes[*node_index].dependencies.is_empty())
1485                .collect();
1486            if roots.is_empty() {
1487                bail!(
1488                    "play {} must have at least one root request, found {}",
1489                    play_id,
1490                    roots.len()
1491                );
1492            }
1493            plays.push(AgenticPlay {
1494                play_id,
1495                root_nodes: roots,
1496                nodes: node_indices,
1497            });
1498        }
1499        plays.sort_by(|left, right| left.play_id.cmp(&right.play_id));
1500        let graph_digest = canonical_agentic_graph_digest(self.header.block_size, &mut self.nodes)?;
1501
1502        Ok(AgenticTrace {
1503            block_size: self.header.block_size,
1504            source: self.header.source,
1505            graph_digest,
1506            nodes: self.nodes,
1507            plays,
1508        })
1509    }
1510}
1511
1512impl AgenticTrace {
1513    pub fn from_agentic_mooncake(path: &Path) -> Result<Self> {
1514        let file = File::open(path)
1515            .with_context(|| format!("failed to open trace file {}", path.display()))?;
1516        let reader = BufReader::new(file);
1517        let mut builder = None;
1518
1519        for (line_idx, line) in reader.lines().enumerate() {
1520            let line = line.with_context(|| {
1521                format!(
1522                    "failed to read line {} from {}",
1523                    line_idx + 1,
1524                    path.display()
1525                )
1526            })?;
1527            if line.trim().is_empty() {
1528                continue;
1529            }
1530
1531            if builder.is_none() {
1532                let header = serde_json::from_str(&line).with_context(|| {
1533                    format!(
1534                        "failed to parse line {} from {} as the agentic Mooncake v2 header",
1535                        line_idx + 1,
1536                        path.display()
1537                    )
1538                })?;
1539                builder = Some(AgenticTraceBuilder::new(header)?);
1540                continue;
1541            }
1542            let row = serde_json::from_str(&line).with_context(|| {
1543                format!(
1544                    "failed to parse line {} from {} as an agentic Mooncake v2 request",
1545                    line_idx + 1,
1546                    path.display()
1547                )
1548            })?;
1549            builder
1550                .as_mut()
1551                .expect("builder was initialized from the v2 header")
1552                .push(line_idx, row)?;
1553        }
1554
1555        let Some(builder) = builder else {
1556            bail!(
1557                "agentic trace file {} is missing its v2 header",
1558                path.display()
1559            );
1560        };
1561        if builder.is_empty() {
1562            bail!(
1563                "agentic trace file {} did not contain any requests",
1564                path.display()
1565            );
1566        }
1567
1568        builder.finish()
1569    }
1570
1571    pub fn from_agentic_mooncake_rows(
1572        header: AgenticMooncakeHeader,
1573        rows: Vec<AgenticMooncakeRow>,
1574    ) -> Result<Self> {
1575        let mut builder = AgenticTraceBuilder::new(header)?;
1576        for (line_idx, row) in rows.into_iter().enumerate() {
1577            builder.push(line_idx + 1, row)?;
1578        }
1579        if builder.is_empty() {
1580            bail!("agentic Mooncake rows did not contain any requests");
1581        }
1582        builder.finish()
1583    }
1584
1585    pub fn normalize_starts(mut self) -> Self {
1586        let min_timestamp_ms = self
1587            .nodes
1588            .iter()
1589            .map(|node| node.not_before_ms)
1590            .min_by(|left, right| left.total_cmp(right))
1591            .unwrap_or(0.0);
1592
1593        for node in &mut self.nodes {
1594            node.not_before_ms -= min_timestamp_ms;
1595        }
1596        self.graph_digest = canonical_agentic_graph_digest(self.block_size, &mut self.nodes)
1597            .expect("validated agentic graph remains serializable after normalization");
1598        self
1599    }
1600
1601    pub fn speed_up_timing(mut self, ratio: f64) -> Result<Self> {
1602        if !ratio.is_finite() || ratio <= 0.0 {
1603            bail!("ratio must be a finite positive number, got {ratio}");
1604        }
1605
1606        for node in &mut self.nodes {
1607            node.not_before_ms /= ratio;
1608            for dependency in &mut node.dependencies {
1609                dependency.delay_ms /= ratio;
1610            }
1611        }
1612        self.graph_digest = canonical_agentic_graph_digest(self.block_size, &mut self.nodes)?;
1613        Ok(self)
1614    }
1615
1616    pub fn into_trace_driver_with_block_size(
1617        self,
1618        engine_block_size: usize,
1619    ) -> Result<WorkloadDriver> {
1620        WorkloadDriver::new_agentic_trace(self, engine_block_size)
1621    }
1622
1623    pub fn into_trace_driver_with_options(
1624        self,
1625        engine_block_size: usize,
1626        include_replay_hashes: bool,
1627        agentic_lanes: Option<usize>,
1628    ) -> Result<WorkloadDriver> {
1629        WorkloadDriver::new_agentic_trace_with_options(
1630            self,
1631            engine_block_size,
1632            include_replay_hashes,
1633            agentic_lanes,
1634        )
1635    }
1636}
1637
1638fn validate_agentic_trace_is_acyclic(
1639    nodes: &[AgenticNode],
1640    index_by_id: &HashMap<&str, usize>,
1641) -> Result<()> {
1642    let mut indegree = nodes
1643        .iter()
1644        .map(|node| node.dependencies.len())
1645        .collect::<Vec<_>>();
1646    let mut dependents = vec![Vec::new(); nodes.len()];
1647    for (node_index, node) in nodes.iter().enumerate() {
1648        for dependency in &node.dependencies {
1649            let dependency_index = *index_by_id
1650                .get(dependency.request_id.as_str())
1651                .expect("dependencies were prevalidated");
1652            dependents[dependency_index].push(node_index);
1653        }
1654    }
1655    let mut ready = std::collections::VecDeque::from_iter(
1656        indegree
1657            .iter()
1658            .enumerate()
1659            .filter_map(|(index, degree)| (*degree == 0).then_some(index)),
1660    );
1661    let mut visited = 0;
1662    while let Some(node_index) = ready.pop_front() {
1663        visited += 1;
1664        for dependent in &dependents[node_index] {
1665            indegree[*dependent] -= 1;
1666            if indegree[*dependent] == 0 {
1667                ready.push_back(*dependent);
1668            }
1669        }
1670    }
1671    if visited == nodes.len() {
1672        return Ok(());
1673    }
1674    let blocked = indegree
1675        .iter()
1676        .enumerate()
1677        .filter_map(|(index, degree)| (*degree > 0).then_some(nodes[index].request_id.as_str()))
1678        .take(8)
1679        .collect::<Vec<_>>();
1680    bail!(
1681        "cycle detected among {} agentic requests; blocked request IDs: {}",
1682        nodes.len() - visited,
1683        blocked.join(", ")
1684    )
1685}
1686
1687fn canonical_agentic_graph_digest(block_size: usize, nodes: &mut [AgenticNode]) -> Result<String> {
1688    struct Blake3Writer<'a>(&'a mut blake3::Hasher);
1689
1690    impl std::io::Write for Blake3Writer<'_> {
1691        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1692            self.0.update(bytes);
1693            Ok(bytes.len())
1694        }
1695
1696        fn flush(&mut self) -> std::io::Result<()> {
1697            Ok(())
1698        }
1699    }
1700
1701    #[derive(Serialize)]
1702    struct CanonicalGraph<'a> {
1703        block_size: usize,
1704        nodes: &'a [AgenticNode],
1705    }
1706
1707    for node in nodes.iter_mut() {
1708        node.dependencies.sort_by(|left, right| {
1709            left.request_id
1710                .cmp(&right.request_id)
1711                .then_with(|| trigger_rank(left.trigger).cmp(&trigger_rank(right.trigger)))
1712                .then_with(|| relation_rank(left.relation).cmp(&relation_rank(right.relation)))
1713                .then_with(|| left.delay_ms.total_cmp(&right.delay_ms))
1714        });
1715    }
1716    let mut hasher = blake3::Hasher::new();
1717    serde_json::to_writer(
1718        Blake3Writer(&mut hasher),
1719        &CanonicalGraph { block_size, nodes },
1720    )?;
1721    Ok(hasher.finalize().to_hex().to_string())
1722}
1723
1724fn trigger_rank(trigger: AgenticDependencyTrigger) -> u8 {
1725    match trigger {
1726        AgenticDependencyTrigger::Dispatch => 0,
1727        AgenticDependencyTrigger::Completion => 1,
1728    }
1729}
1730
1731fn relation_rank(relation: AgenticDependencyRelation) -> u8 {
1732    match relation {
1733        AgenticDependencyRelation::Sequence => 0,
1734        AgenticDependencyRelation::Spawn => 1,
1735        AgenticDependencyRelation::Join => 2,
1736        AgenticDependencyRelation::ReplayBarrier => 3,
1737    }
1738}
1739
1740fn extend_applied_compute_agentic_hash_ids(
1741    hash_ids: &mut Vec<u64>,
1742    input_length: usize,
1743    trace_block_size: usize,
1744    shared_initial_blocks: usize,
1745    group_id: Option<usize>,
1746    next_unique_hash: &mut u64,
1747) -> Result<()> {
1748    let target_blocks = input_length.div_ceil(trace_block_size);
1749    while hash_ids.len() < target_blocks {
1750        let block_idx = hash_ids.len();
1751        if block_idx < shared_initial_blocks
1752            && let Some(group_id) = group_id
1753        {
1754            hash_ids.push(0xA63E_0000_0000_0000 | ((group_id as u64) << 32) | block_idx as u64);
1755            continue;
1756        }
1757        hash_ids.push(*next_unique_hash);
1758        *next_unique_hash = next_unique_hash
1759            .checked_add(1)
1760            .ok_or_else(|| anyhow!("synthetic hash id overflow"))?;
1761    }
1762    Ok(())
1763}
1764
1765fn sample_delay_ms(spec: &DelaySpec, rng: &mut StdRng) -> Result<f64> {
1766    match spec {
1767        DelaySpec::None => Ok(0.0),
1768        DelaySpec::ConstantMs(delay_ms) => {
1769            if !delay_ms.is_finite() || *delay_ms < 0.0 {
1770                bail!("delay must be a finite non-negative number, got {delay_ms}");
1771            }
1772            Ok(*delay_ms)
1773        }
1774        DelaySpec::ExponentialMs { mean_ms } => {
1775            if !mean_ms.is_finite() || *mean_ms < 0.0 {
1776                bail!("mean_ms must be a finite non-negative number, got {mean_ms}");
1777            }
1778            Ok(sample_exponential_delay_ms(*mean_ms, rng))
1779        }
1780    }
1781}
1782
1783fn sample_length(spec: &LengthSpec, min_value: usize, rng: &mut StdRng) -> usize {
1784    if spec.stddev == 0.0 {
1785        return spec.mean.max(min_value);
1786    }
1787
1788    let stddev = spec.stddev.abs();
1789    let u1 = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
1790    let u2 = rng.random::<f64>();
1791    let z0 = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
1792    let sample = spec.mean as f64 + z0 * stddev;
1793    sample.round().max(min_value as f64) as usize
1794}
1795
1796fn sample_exponential_delay_ms(mean_ms: f64, rng: &mut StdRng) -> f64 {
1797    if mean_ms == 0.0 {
1798        return 0.0;
1799    }
1800    let u = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
1801    -mean_ms * u.ln()
1802}