Skip to main content

dynamo_data_gen/
mooncake.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Mooncake JSONL primitives.
5//!
6//! This module is producer- and consumer-agnostic: it defines the row schema,
7//! the block-hash-to-id mapping, the token-block hashing helper, and the JSONL
8//! writer. Workload-specific orchestration such as scheduling, tokenization,
9//! and parsing lives elsewhere.
10//!
11//! The [`MooncakeRow`] schema deliberately matches the externally-authored
12//! Mooncake trace format: `timestamp` and `delay` are `f64` milliseconds, and
13//! `input_length`/`output_length`/`timestamp`/`delay` accept the upstream
14//! aliases (`input_tokens`, `output_tokens`, `created_time`, `delay_ms`) on
15//! deserialization. Serialization emits the canonical names.
16
17use anyhow::{Context, Result, bail};
18use dynamo_kv_hashing::{Request, compute_hash_v2, compute_next_sequence_hash};
19use rustc_hash::FxHashMap;
20use serde::{Deserialize, Serialize};
21use std::fs::File;
22use std::io::{BufWriter, Write};
23use std::path::Path;
24
25/// One row of a Mooncake replay trace.
26///
27/// `timestamp` is an absolute request arrival offset in milliseconds. Rows
28/// without a `session_id` are independent request arrivals. Rows that share a
29/// `session_id` are interpreted as closed-loop turns; later turns use `delay`
30/// or timestamp deltas relative to the previous row in that session.
31///
32/// The row type is `Serialize + Deserialize` so the same definition serves
33/// producers and consumers. Field-level aliases on deserialization accept the
34/// upstream Mooncake field names (`input_tokens`, `output_tokens`,
35/// `created_time`, `delay_ms`) without requiring producers to emit them.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct MooncakeRow {
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub request_id: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub session_id: Option<String>,
42    #[serde(default, alias = "input_tokens")]
43    pub input_length: Option<usize>,
44    #[serde(default, alias = "output_tokens")]
45    pub output_length: Option<usize>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub output_token_ids: Option<Vec<u32>>,
48    #[serde(default)]
49    pub hash_ids: Option<Vec<u64>>,
50    #[serde(
51        default,
52        skip_serializing_if = "Option::is_none",
53        alias = "created_time"
54    )]
55    pub timestamp: Option<f64>,
56    #[serde(default, skip_serializing_if = "Option::is_none", alias = "delay_ms")]
57    pub delay: Option<f64>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub priority: Option<i32>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub strict_priority: Option<u32>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub policy_class: Option<String>,
64}
65
66/// One row of an agentic Mooncake replay trace.
67///
68/// This format keeps the request/cache fields from [`MooncakeRow`] and adds a
69/// tiny workflow layer above them. `request_id` names the row. `wait_for` names
70/// request ids whose simulated completions must arrive before this row becomes
71/// eligible. Once all dependencies are satisfied, replay waits `delay` plus
72/// `tool_wait_ms` before dispatching the request. Rows with no dependencies
73/// use `timestamp` as their open-loop start time.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AgenticMooncakeRow {
76    pub request_id: String,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub session_id: Option<String>,
79    #[serde(default, alias = "input_tokens")]
80    pub input_length: Option<usize>,
81    #[serde(default, alias = "output_tokens")]
82    pub output_length: Option<usize>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub output_token_ids: Option<Vec<u32>>,
85    #[serde(default)]
86    pub hash_ids: Option<Vec<u64>>,
87    #[serde(
88        default,
89        skip_serializing_if = "Option::is_none",
90        alias = "created_time"
91    )]
92    pub timestamp: Option<f64>,
93    #[serde(default, skip_serializing_if = "Option::is_none", alias = "delay_ms")]
94    pub delay: Option<f64>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub priority: Option<i32>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub strict_priority: Option<u32>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub policy_class: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub request_kind: Option<String>,
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub wait_for: Vec<String>,
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub branches: Vec<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub prefix_reset: Option<bool>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub tool_wait_ms: Option<f64>,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub tool_events: Vec<AgenticToolEvent>,
113}
114
115impl AgenticMooncakeRow {
116    /// Return the total wait after all dependencies complete.
117    pub fn dependency_delay_ms(&self) -> f64 {
118        self.delay.unwrap_or(0.0) + self.tool_wait_ms.unwrap_or(0.0)
119    }
120}
121
122/// Harness tool span attributed to the LLM request that consumed it.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct AgenticToolEvent {
125    pub tool_call_id: String,
126    pub tool_class: String,
127    pub started_at_unix_ms: u64,
128    pub ended_at_unix_ms: u64,
129    pub duration_ms: f64,
130    pub status: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub output_bytes: Option<u64>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub output_tokens: Option<u64>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub error_type: Option<String>,
137}
138
139/// Maps sequence-aware block hashes to compact, stable `u64` ids.
140///
141/// The mapper is intentionally stateful and reusable across requests/turns: a
142/// block of tokens that appears at the same prefix position in two different
143/// requests will be assigned the same id. Equality of leading `hash_ids`
144/// between rows therefore signals shared prompt prefixes for replay purposes.
145///
146/// `hash_ids` here are workload identity labels, not literal Dynamo runtime
147/// KV-cache hashes. Producers should not try to reconcile them with a
148/// production cache.
149pub struct RollingHashIdMapper {
150    block_size: usize,
151    hash_to_id: FxHashMap<u64, u64>,
152    next_id: u64,
153}
154
155impl RollingHashIdMapper {
156    /// Create a new mapper for the given block size.
157    pub fn new(block_size: usize) -> Self {
158        Self {
159            block_size,
160            hash_to_id: FxHashMap::default(),
161            next_id: 0,
162        }
163    }
164
165    /// Block size that this mapper was constructed with.
166    pub fn block_size(&self) -> usize {
167        self.block_size
168    }
169
170    /// Hash a sequence of tokens into Mooncake `hash_ids`.
171    ///
172    /// Tokens are chunked by `block_size`; each complete block contributes one
173    /// compact id derived from Dynamo's shared KV-hashing contract. A trailing
174    /// partial block also contributes one compact id so replay capacity still
175    /// covers the full prompt length. Identical prefixes across requests
176    /// resolve to identical leading `hash_ids` once the mapper has seen them.
177    pub fn hash_token_blocks(&mut self, tokens: &[u32]) -> Vec<u64> {
178        hash_token_blocks(self, tokens)
179    }
180
181    /// Fallible variant of [`Self::hash_token_blocks`].
182    pub fn try_hash_token_blocks(&mut self, tokens: &[u32]) -> Result<Vec<u64>> {
183        try_hash_token_blocks(self, tokens)
184    }
185
186    /// Map precomputed sequence-aware block hashes into compact Mooncake IDs.
187    ///
188    /// This is useful for producers that record stable block hashes in the
189    /// serving path and only compact them during offline trace conversion.
190    pub fn ids_for_sequence_hashes(&mut self, sequence_hashes: &[u64]) -> Vec<u64> {
191        ids_for_sequence_hashes(self, sequence_hashes)
192    }
193}
194
195/// Token-block hashing helper for the Mooncake replay schema.
196///
197/// Splits `tokens` into chunks of `mapper.block_size()`, derives sequence-aware
198/// hashes for complete blocks through `dynamo-kv-hashing`, appends a sequence
199/// hash for a trailing partial block when present, and returns the
200/// compact ids assigned by `mapper`. Mirrors
201/// [`RollingHashIdMapper::hash_token_blocks`] as a free function so callers
202/// that already hold a mutable mapper reference can invoke it without
203/// re-borrowing.
204pub fn hash_token_blocks(mapper: &mut RollingHashIdMapper, tokens: &[u32]) -> Vec<u64> {
205    try_hash_token_blocks(mapper, tokens).expect("Mooncake token-block hashing failed")
206}
207
208/// Fallible token-block hashing helper for callers that want to surface
209/// invalid block-size or request-shape errors.
210pub fn try_hash_token_blocks(mapper: &mut RollingHashIdMapper, tokens: &[u32]) -> Result<Vec<u64>> {
211    let sequence_hashes = sequence_hashes_for_tokens(tokens, mapper.block_size)?;
212    Ok(ids_for_sequence_hashes(mapper, &sequence_hashes))
213}
214
215/// Derive the sequence-aware block hashes recorded by Dynamo request traces.
216pub fn sequence_hashes_for_tokens(tokens: &[u32], block_size: usize) -> Result<Vec<u64>> {
217    require_positive("block size", block_size)?;
218    let block_size_u32: u32 = block_size
219        .try_into()
220        .context("block_size does not fit u32")?;
221    let request = Request::builder().tokens(tokens.to_vec()).build()?;
222    let salt_hash = request.salt_hash()?;
223    let mut sequence_hashes = request.into_sequence_hashes(block_size_u32)?;
224    if let Some(partial_hash) =
225        trailing_partial_sequence_hash(salt_hash, block_size, tokens, &sequence_hashes)
226    {
227        sequence_hashes.push(partial_hash);
228    }
229    Ok(sequence_hashes)
230}
231
232fn trailing_partial_sequence_hash(
233    salt_hash: u64,
234    block_size: usize,
235    tokens: &[u32],
236    complete_sequence_hashes: &[u64],
237) -> Option<u64> {
238    let tail_len = tokens.len() % block_size;
239    if tail_len == 0 {
240        return None;
241    }
242
243    let tail = &tokens[tokens.len() - tail_len..];
244    let mut tail_bytes = Vec::with_capacity(std::mem::size_of_val(tail));
245    for token in tail {
246        tail_bytes.extend_from_slice(&token.to_ne_bytes());
247    }
248    let tail_block_hash = compute_hash_v2(&tail_bytes, salt_hash);
249    Some(match complete_sequence_hashes.last().copied() {
250        Some(parent) => compute_next_sequence_hash(parent, tail_block_hash),
251        None => tail_block_hash,
252    })
253}
254
255/// Map stable sequence hashes to compact Mooncake IDs with a shared mapper.
256pub fn ids_for_sequence_hashes(
257    mapper: &mut RollingHashIdMapper,
258    sequence_hashes: &[u64],
259) -> Vec<u64> {
260    sequence_hashes
261        .iter()
262        .map(|sequence_hash| {
263            *mapper.hash_to_id.entry(*sequence_hash).or_insert_with(|| {
264                let next_id = mapper.next_id;
265                mapper.next_id += 1;
266                next_id
267            })
268        })
269        .collect()
270}
271
272/// Counters for what a [`MooncakeJsonlWriter`] has emitted.
273#[derive(Debug, Clone, Copy, Default)]
274pub struct WriterStats {
275    pub row_count: usize,
276    pub sidecar_count: usize,
277}
278
279/// JSONL writer for Mooncake rows plus an optional sidecar stream.
280///
281/// The sidecar stream is configured at construction time. Producers that do
282/// not emit sidecar metadata pass `None` for `sidecar_path` and never call
283/// [`Self::write_sidecar`]. When a sidecar path is configured, callers are
284/// responsible for choosing the path -- this writer does not enforce a naming
285/// convention.
286pub struct MooncakeJsonlWriter {
287    output: BufWriter<File>,
288    sidecar: Option<BufWriter<File>>,
289    stats: WriterStats,
290}
291
292impl MooncakeJsonlWriter {
293    /// Create a writer at `output_path`, optionally with a paired sidecar
294    /// JSONL file at `sidecar_path`. Parent directories are created as needed.
295    pub fn create(output_path: &Path, sidecar_path: Option<&Path>) -> Result<Self> {
296        if let Some(parent) = output_path.parent() {
297            std::fs::create_dir_all(parent)?;
298        }
299        let output = BufWriter::new(
300            File::create(output_path)
301                .with_context(|| format!("failed to create {}", output_path.display()))?,
302        );
303        let sidecar = if let Some(path) = sidecar_path {
304            if let Some(parent) = path.parent() {
305                std::fs::create_dir_all(parent)?;
306            }
307            Some(BufWriter::new(File::create(path).with_context(|| {
308                format!("failed to create {}", path.display())
309            })?))
310        } else {
311            None
312        };
313        Ok(Self {
314            output,
315            sidecar,
316            stats: WriterStats::default(),
317        })
318    }
319
320    /// Append one Mooncake row.
321    pub fn write_row(&mut self, row: &MooncakeRow) -> Result<()> {
322        serde_json::to_writer(&mut self.output, row)?;
323        self.output.write_all(b"\n")?;
324        self.stats.row_count += 1;
325        Ok(())
326    }
327
328    /// Append one agentic Mooncake row.
329    pub fn write_agentic_row(&mut self, row: &AgenticMooncakeRow) -> Result<()> {
330        serde_json::to_writer(&mut self.output, row)?;
331        self.output.write_all(b"\n")?;
332        self.stats.row_count += 1;
333        Ok(())
334    }
335
336    /// Append one sidecar entry. Errors if no sidecar was configured.
337    pub fn write_sidecar<S: Serialize>(&mut self, sidecar: &S) -> Result<()> {
338        let writer = self
339            .sidecar
340            .as_mut()
341            .ok_or_else(|| anyhow::anyhow!("sidecar was not configured for this writer"))?;
342        serde_json::to_writer(writer, sidecar)?;
343        let writer = self.sidecar.as_mut().unwrap();
344        writer.write_all(b"\n")?;
345        self.stats.sidecar_count += 1;
346        Ok(())
347    }
348
349    /// True if a sidecar stream is configured.
350    pub fn has_sidecar(&self) -> bool {
351        self.sidecar.is_some()
352    }
353
354    /// Snapshot of how many rows and sidecar entries have been written so far.
355    pub fn stats(&self) -> WriterStats {
356        self.stats
357    }
358
359    /// Flush both streams and return the final stats.
360    pub fn finish(mut self) -> Result<WriterStats> {
361        self.output.flush()?;
362        if let Some(sidecar) = self.sidecar.as_mut() {
363            sidecar.flush()?;
364        }
365        Ok(self.stats)
366    }
367}
368
369/// Create both files empty (touch-equivalent), preserving directory creation
370/// semantics for callers that want a "no rows produced" outcome to still emit
371/// well-formed (empty) JSONL files.
372pub fn write_empty_files(output_path: &Path, sidecar_path: Option<&Path>) -> Result<()> {
373    if let Some(parent) = output_path.parent() {
374        std::fs::create_dir_all(parent)?;
375    }
376    File::create(output_path)
377        .with_context(|| format!("failed to create {}", output_path.display()))?;
378    if let Some(path) = sidecar_path {
379        if let Some(parent) = path.parent() {
380            std::fs::create_dir_all(parent)?;
381        }
382        File::create(path).with_context(|| format!("failed to create {}", path.display()))?;
383    }
384    Ok(())
385}
386
387/// Sentinel used by callers that want to bail when neither block_size nor
388/// worker count is allowed to be zero. Producers may also enforce this on
389/// their own configuration types.
390pub fn require_positive(name: &str, value: usize) -> Result<()> {
391    if value == 0 {
392        bail!("{name} must be greater than 0");
393    }
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use serde_json::{Value, json};
401    use tempfile::TempDir;
402
403    #[test]
404    fn shared_prefix_yields_shared_leading_hash_ids() {
405        let mut mapper = RollingHashIdMapper::new(2);
406        let prefix = vec![1u32, 2, 3, 4];
407        let extended = vec![1u32, 2, 3, 4, 5, 6];
408
409        let prefix_ids = mapper.hash_token_blocks(&prefix);
410        let extended_ids = mapper.hash_token_blocks(&extended);
411
412        assert_eq!(prefix_ids.len(), 2);
413        assert_eq!(extended_ids.len(), 3);
414        assert_eq!(extended_ids[..2], prefix_ids[..]);
415    }
416
417    #[test]
418    fn mapper_state_is_reused_across_requests() {
419        let mut mapper = RollingHashIdMapper::new(4);
420        let request_a = vec![10u32, 20, 30, 40, 50, 60, 70, 80];
421        let request_b = vec![10u32, 20, 30, 40, 50, 60, 70, 80];
422        let request_c = vec![10u32, 20, 30, 40, 99, 99, 99, 99];
423
424        let ids_a = mapper.hash_token_blocks(&request_a);
425        let ids_b = mapper.hash_token_blocks(&request_b);
426        let ids_c = mapper.hash_token_blocks(&request_c);
427
428        assert_eq!(ids_a, ids_b);
429        assert_eq!(ids_c[0], ids_a[0], "shared first block should keep its id");
430        assert_ne!(
431            ids_c[1], ids_a[1],
432            "diverging tail block must get a fresh id"
433        );
434    }
435
436    #[test]
437    fn free_function_and_method_agree() {
438        let mut mapper_a = RollingHashIdMapper::new(2);
439        let mut mapper_b = RollingHashIdMapper::new(2);
440        let tokens = vec![7u32, 8, 9, 10, 11];
441
442        let via_method = mapper_a.hash_token_blocks(&tokens);
443        let via_function = hash_token_blocks(&mut mapper_b, &tokens);
444
445        assert_eq!(via_method, via_function);
446    }
447
448    #[test]
449    fn exact_token_blocks_match_shared_kv_hashing_contract() {
450        let tokens = vec![7u32, 8, 9, 10, 11, 12, 13, 14];
451        let request = Request::builder().tokens(tokens.clone()).build().unwrap();
452        let expected = request.into_sequence_hashes(4).unwrap();
453
454        assert_eq!(sequence_hashes_for_tokens(&tokens, 4).unwrap(), expected);
455    }
456
457    #[test]
458    fn empty_token_input_yields_empty_hash_ids() {
459        let mut mapper = RollingHashIdMapper::new(4);
460        assert!(mapper.hash_token_blocks(&[]).is_empty());
461    }
462
463    #[test]
464    fn trailing_partial_block_preserves_replay_capacity() {
465        let mut mapper = RollingHashIdMapper::new(4);
466
467        assert_eq!(mapper.hash_token_blocks(&[1, 2, 3]), vec![0]);
468        assert_eq!(mapper.hash_token_blocks(&[1, 2, 3, 4, 5, 6]), vec![1, 2]);
469    }
470
471    #[test]
472    fn trailing_partial_block_uses_shared_chain_contract() {
473        let tokens = vec![1u32, 2, 3, 4, 5, 6];
474        let request = Request::builder().tokens(tokens.clone()).build().unwrap();
475        let salt_hash = request.salt_hash().unwrap();
476        let mut expected = request.into_sequence_hashes(4).unwrap();
477        let mut tail_bytes = Vec::new();
478        for token in &tokens[4..] {
479            tail_bytes.extend_from_slice(&token.to_ne_bytes());
480        }
481        let tail_block_hash = compute_hash_v2(&tail_bytes, salt_hash);
482        expected.push(compute_next_sequence_hash(expected[0], tail_block_hash));
483
484        assert_eq!(sequence_hashes_for_tokens(&tokens, 4).unwrap(), expected);
485    }
486
487    #[test]
488    fn exact_block_boundary_does_not_add_partial_hash_id() {
489        let mut mapper = RollingHashIdMapper::new(4);
490
491        assert_eq!(mapper.hash_token_blocks(&[1, 2, 3, 4]), vec![0]);
492        assert_eq!(
493            mapper.hash_token_blocks(&[1, 2, 3, 4, 5, 6, 7, 8]),
494            vec![0, 1]
495        );
496    }
497
498    #[test]
499    fn try_hash_token_blocks_rejects_zero_block_size() {
500        let mut mapper = RollingHashIdMapper::new(0);
501        let err = mapper.try_hash_token_blocks(&[1, 2, 3]).unwrap_err();
502
503        assert!(err.to_string().contains("block size"));
504    }
505
506    #[test]
507    fn precomputed_sequence_hashes_map_to_stable_ids() {
508        let mut mapper = RollingHashIdMapper::new(64);
509
510        let first = mapper.ids_for_sequence_hashes(&[101, 202, 303]);
511        let second = mapper.ids_for_sequence_hashes(&[101, 202, 404]);
512
513        assert_eq!(first[..2], second[..2]);
514        assert_ne!(first[2], second[2]);
515    }
516
517    #[test]
518    fn row_omits_timestamp_and_delay_when_absent() {
519        let row = MooncakeRow {
520            session_id: Some("s".to_string()),
521            input_length: Some(4),
522            output_length: Some(1),
523            hash_ids: Some(vec![0, 1]),
524            timestamp: None,
525            delay: None,
526            ..Default::default()
527        };
528        let rendered: Value = serde_json::to_value(&row).unwrap();
529        assert!(rendered.get("timestamp").is_none());
530        assert!(rendered.get("delay").is_none());
531        assert_eq!(rendered["hash_ids"], json!([0, 1]));
532    }
533
534    #[test]
535    fn row_serializes_optional_fields_when_set() {
536        let with_timestamp = MooncakeRow {
537            session_id: Some("s".to_string()),
538            input_length: Some(4),
539            output_length: Some(1),
540            hash_ids: Some(vec![]),
541            timestamp: Some(0.0),
542            delay: None,
543            ..Default::default()
544        };
545        let with_delay = MooncakeRow {
546            session_id: Some("s".to_string()),
547            input_length: Some(4),
548            output_length: Some(1),
549            hash_ids: Some(vec![]),
550            timestamp: None,
551            delay: Some(123.0),
552            ..Default::default()
553        };
554        let v_ts: Value = serde_json::to_value(&with_timestamp).unwrap();
555        let v_dl: Value = serde_json::to_value(&with_delay).unwrap();
556        assert_eq!(v_ts["timestamp"], json!(0.0));
557        assert!(v_ts.get("delay").is_none());
558        assert_eq!(v_dl["delay"], json!(123.0));
559        assert!(v_dl.get("timestamp").is_none());
560    }
561
562    #[test]
563    fn row_deserializes_canonical_field_names() {
564        let raw = r#"{"session_id":"s","input_length":4,"output_length":1,"hash_ids":[0,1],"timestamp":12.5,"delay":3.0}"#;
565        let row: MooncakeRow = serde_json::from_str(raw).unwrap();
566        assert_eq!(row.session_id.as_deref(), Some("s"));
567        assert_eq!(row.input_length, Some(4));
568        assert_eq!(row.output_length, Some(1));
569        assert_eq!(row.hash_ids, Some(vec![0, 1]));
570        assert_eq!(row.timestamp, Some(12.5));
571        assert_eq!(row.delay, Some(3.0));
572    }
573
574    #[test]
575    fn row_deserializes_upstream_mooncake_aliases() {
576        let raw = r#"{"input_tokens":4,"output_tokens":1,"hash_ids":[0,1],"created_time":12.5,"delay_ms":3.0}"#;
577        let row: MooncakeRow = serde_json::from_str(raw).unwrap();
578        assert_eq!(row.input_length, Some(4));
579        assert_eq!(row.output_length, Some(1));
580        assert_eq!(row.timestamp, Some(12.5));
581        assert_eq!(row.delay, Some(3.0));
582    }
583
584    #[test]
585    fn row_alias_input_round_trips_to_canonical_fields() {
586        let raw = r#"{"input_tokens":8,"output_tokens":2,"created_time":12.5,"delay_ms":3.0}"#;
587        let mut row: MooncakeRow = serde_json::from_str(raw).unwrap();
588
589        let tokens: Vec<u32> = (0..row.input_length.unwrap() as u32).collect();
590        let mut mapper = RollingHashIdMapper::new(4);
591        row.hash_ids = Some(mapper.hash_token_blocks(&tokens));
592
593        let rendered: Value = serde_json::to_value(&row).unwrap();
594        assert_eq!(rendered["input_length"], json!(8));
595        assert_eq!(rendered["output_length"], json!(2));
596        assert_eq!(rendered["timestamp"], json!(12.5));
597        assert_eq!(rendered["delay"], json!(3.0));
598        assert_eq!(rendered["hash_ids"], json!([0, 1]));
599        assert!(rendered.get("input_tokens").is_none());
600        assert!(rendered.get("output_tokens").is_none());
601        assert!(rendered.get("created_time").is_none());
602        assert!(rendered.get("delay_ms").is_none());
603    }
604
605    #[test]
606    fn row_replay_fields_round_trip_canonical_and_alias_inputs() {
607        let canonical = r#"{"request_id":"r1","session_id":"s","input_length":8,"output_length":3,"output_token_ids":[101,102,103],"hash_ids":[0,1],"timestamp":12.5,"delay":3.0}"#;
608        let row: MooncakeRow = serde_json::from_str(canonical).unwrap();
609        assert_eq!(row.request_id.as_deref(), Some("r1"));
610        assert_eq!(row.output_length, Some(3));
611        assert_eq!(row.output_token_ids, Some(vec![101, 102, 103]));
612
613        let rendered: Value = serde_json::to_value(&row).unwrap();
614        assert_eq!(rendered["request_id"], json!("r1"));
615        assert_eq!(rendered["output_token_ids"], json!([101, 102, 103]));
616        let decoded: MooncakeRow = serde_json::from_value(rendered).unwrap();
617        assert_eq!(decoded.request_id.as_deref(), Some("r1"));
618        assert_eq!(decoded.output_token_ids, Some(vec![101, 102, 103]));
619
620        let aliased = r#"{"request_id":"r2","input_tokens":4,"output_tokens":2,"output_token_ids":[201,202],"hash_ids":[7],"created_time":1.5,"delay_ms":0.5}"#;
621        let row: MooncakeRow = serde_json::from_str(aliased).unwrap();
622        assert_eq!(row.request_id.as_deref(), Some("r2"));
623        assert_eq!(row.input_length, Some(4));
624        assert_eq!(row.output_length, Some(2));
625        assert_eq!(row.output_token_ids, Some(vec![201, 202]));
626
627        let rendered: Value = serde_json::to_value(&row).unwrap();
628        assert_eq!(rendered["input_length"], json!(4));
629        assert_eq!(rendered["output_length"], json!(2));
630        assert_eq!(rendered["output_token_ids"], json!([201, 202]));
631        assert!(rendered.get("input_tokens").is_none());
632        assert!(rendered.get("output_tokens").is_none());
633    }
634
635    #[test]
636    fn row_canonical_input_round_trips_without_renaming() {
637        let raw = r#"{"input_length":8,"output_length":2,"timestamp":12.5,"delay":3.0}"#;
638        let mut row: MooncakeRow = serde_json::from_str(raw).unwrap();
639
640        let tokens: Vec<u32> = (0..row.input_length.unwrap() as u32).collect();
641        let mut mapper = RollingHashIdMapper::new(4);
642        row.hash_ids = Some(mapper.hash_token_blocks(&tokens));
643
644        let rendered: Value = serde_json::to_value(&row).unwrap();
645        assert_eq!(rendered["input_length"], json!(8));
646        assert_eq!(rendered["output_length"], json!(2));
647        assert_eq!(rendered["timestamp"], json!(12.5));
648        assert_eq!(rendered["delay"], json!(3.0));
649        assert_eq!(rendered["hash_ids"], json!([0, 1]));
650        assert!(rendered.get("input_tokens").is_none());
651        assert!(rendered.get("output_tokens").is_none());
652        assert!(rendered.get("created_time").is_none());
653        assert!(rendered.get("delay_ms").is_none());
654    }
655
656    #[test]
657    fn row_deserializes_with_missing_optional_fields() {
658        let raw = r#"{"output_length":2}"#;
659        let row: MooncakeRow = serde_json::from_str(raw).unwrap();
660        assert_eq!(row.session_id, None);
661        assert_eq!(row.input_length, None);
662        assert_eq!(row.output_length, Some(2));
663        assert_eq!(row.hash_ids, None);
664        assert_eq!(row.timestamp, None);
665        assert_eq!(row.delay, None);
666        assert_eq!(row.priority, None);
667        assert_eq!(row.strict_priority, None);
668        assert_eq!(row.policy_class, None);
669        let rendered: Value = serde_json::to_value(&row).unwrap();
670        assert!(rendered.get("priority").is_none());
671        assert!(rendered.get("strict_priority").is_none());
672        assert!(rendered.get("policy_class").is_none());
673        assert!(rendered.get("request_id").is_none());
674        assert!(rendered.get("output_token_ids").is_none());
675    }
676
677    #[test]
678    fn row_round_trips_priorities() {
679        for priority in [Some(7), Some(0), Some(-3)] {
680            let raw = json!({
681                "output_length": 2,
682                "priority": priority,
683                "strict_priority": 9,
684                "policy_class": "latency"
685            });
686            let row: MooncakeRow = serde_json::from_value(raw).unwrap();
687            assert_eq!(row.priority, priority);
688            assert_eq!(row.strict_priority, Some(9));
689            assert_eq!(row.policy_class.as_deref(), Some("latency"));
690
691            let rendered: Value = serde_json::to_value(&row).unwrap();
692            assert_eq!(rendered["priority"], json!(priority.unwrap()));
693            assert_eq!(rendered["strict_priority"], json!(9));
694            assert_eq!(rendered["policy_class"], json!("latency"));
695        }
696    }
697
698    #[test]
699    fn agentic_row_defaults_workflow_fields() {
700        let raw = r#"{"request_id":"r1","input_length":4,"output_length":1,"hash_ids":[0,1],"timestamp":10.0}"#;
701        let row: AgenticMooncakeRow = serde_json::from_str(raw).unwrap();
702
703        assert_eq!(row.request_id, "r1");
704        assert!(row.wait_for.is_empty());
705        assert!(row.branches.is_empty());
706        assert_eq!(row.prefix_reset, None);
707        assert_eq!(row.dependency_delay_ms(), 0.0);
708        assert_eq!(row.priority, None);
709        assert_eq!(row.strict_priority, None);
710        assert_eq!(row.policy_class, None);
711        let rendered: Value = serde_json::to_value(&row).unwrap();
712        assert!(rendered.get("priority").is_none());
713        assert!(rendered.get("strict_priority").is_none());
714        assert!(rendered.get("policy_class").is_none());
715        assert!(rendered.get("output_token_ids").is_none());
716    }
717
718    #[test]
719    fn agentic_row_replay_fields_round_trip_canonical_and_alias_inputs() {
720        let canonical = r#"{"request_id":"r1","session_id":"s","input_length":8,"output_length":3,"output_token_ids":[101,102,103],"hash_ids":[0,1],"timestamp":12.5,"delay":3.0}"#;
721        let row: AgenticMooncakeRow = serde_json::from_str(canonical).unwrap();
722        assert_eq!(row.request_id, "r1");
723        assert_eq!(row.output_length, Some(3));
724        assert_eq!(row.output_token_ids, Some(vec![101, 102, 103]));
725
726        let rendered: Value = serde_json::to_value(&row).unwrap();
727        assert_eq!(rendered["request_id"], json!("r1"));
728        assert_eq!(rendered["output_token_ids"], json!([101, 102, 103]));
729        let decoded: AgenticMooncakeRow = serde_json::from_value(rendered).unwrap();
730        assert_eq!(decoded.request_id, "r1");
731        assert_eq!(decoded.output_token_ids, Some(vec![101, 102, 103]));
732
733        let aliased = r#"{"request_id":"r2","input_tokens":4,"output_tokens":2,"output_token_ids":[201,202],"hash_ids":[7],"created_time":1.5,"delay_ms":0.5}"#;
734        let row: AgenticMooncakeRow = serde_json::from_str(aliased).unwrap();
735        assert_eq!(row.request_id, "r2");
736        assert_eq!(row.input_length, Some(4));
737        assert_eq!(row.output_length, Some(2));
738        assert_eq!(row.output_token_ids, Some(vec![201, 202]));
739
740        let rendered: Value = serde_json::to_value(&row).unwrap();
741        assert_eq!(rendered["input_length"], json!(4));
742        assert_eq!(rendered["output_length"], json!(2));
743        assert_eq!(rendered["output_token_ids"], json!([201, 202]));
744        assert!(rendered.get("input_tokens").is_none());
745        assert!(rendered.get("output_tokens").is_none());
746    }
747
748    #[test]
749    fn agentic_row_round_trips_priorities() {
750        let raw = r#"{"request_id":"r1","priority":-2,"strict_priority":4,"policy_class":"batch"}"#;
751        let row: AgenticMooncakeRow = serde_json::from_str(raw).unwrap();
752        assert_eq!(row.priority, Some(-2));
753        assert_eq!(row.strict_priority, Some(4));
754        assert_eq!(row.policy_class.as_deref(), Some("batch"));
755
756        let rendered: Value = serde_json::to_value(&row).unwrap();
757        assert_eq!(rendered["priority"], json!(-2));
758        assert_eq!(rendered["strict_priority"], json!(4));
759        assert_eq!(rendered["policy_class"], json!("batch"));
760    }
761
762    #[test]
763    fn agentic_row_delay_includes_tool_wait() {
764        let row = AgenticMooncakeRow {
765            request_id: "r2".to_string(),
766            session_id: Some("trajectory-a".to_string()),
767            input_length: Some(4),
768            output_length: Some(1),
769            hash_ids: Some(vec![0, 1]),
770            timestamp: Some(20.0),
771            delay: Some(3.0),
772            wait_for: vec!["r1".to_string()],
773            branches: vec!["r3".to_string()],
774            prefix_reset: Some(false),
775            tool_wait_ms: Some(7.0),
776            ..Default::default()
777        };
778
779        assert_eq!(row.dependency_delay_ms(), 10.0);
780        let rendered: Value = serde_json::to_value(&row).unwrap();
781        assert_eq!(rendered["request_id"], json!("r2"));
782        assert_eq!(rendered["wait_for"], json!(["r1"]));
783        assert_eq!(rendered["branches"], json!(["r3"]));
784        assert_eq!(rendered["tool_wait_ms"], json!(7.0));
785        assert!(rendered.get("tool_events").is_none());
786    }
787
788    #[test]
789    fn agentic_row_round_trips_tool_events() {
790        let row = AgenticMooncakeRow {
791            request_id: "r1".to_string(),
792            session_id: Some("trajectory-a".to_string()),
793            input_length: Some(4),
794            output_length: Some(1),
795            hash_ids: Some(vec![0, 1]),
796            timestamp: Some(0.0),
797            delay: Some(0.0),
798            priority: Some(5),
799            strict_priority: Some(6),
800            prefix_reset: Some(true),
801            tool_wait_ms: Some(8.0),
802            tool_events: vec![AgenticToolEvent {
803                tool_call_id: "call-1".to_string(),
804                tool_class: "web_search".to_string(),
805                started_at_unix_ms: 1_000,
806                ended_at_unix_ms: 1_008,
807                duration_ms: 8.0,
808                status: "succeeded".to_string(),
809                output_bytes: Some(512),
810                output_tokens: None,
811                error_type: None,
812            }],
813            ..Default::default()
814        };
815
816        let rendered = serde_json::to_string(&row).unwrap();
817        let decoded: AgenticMooncakeRow = serde_json::from_str(&rendered).unwrap();
818        assert_eq!(decoded.tool_events.len(), 1);
819        assert_eq!(decoded.tool_events[0].tool_class, "web_search");
820        assert_eq!(decoded.tool_events[0].output_bytes, Some(512));
821        assert_eq!(decoded.priority, Some(5));
822        assert_eq!(decoded.strict_priority, Some(6));
823    }
824
825    #[test]
826    fn writer_writes_rows_and_sidecar_jsonl() {
827        let temp = TempDir::new().unwrap();
828        let output = temp.path().join("trace.jsonl");
829        let sidecar = temp.path().join("trace.sidecar.jsonl");
830
831        let mut writer = MooncakeJsonlWriter::create(&output, Some(&sidecar)).unwrap();
832        writer
833            .write_row(&MooncakeRow {
834                session_id: Some("s".to_string()),
835                input_length: Some(2),
836                output_length: Some(1),
837                hash_ids: Some(vec![0]),
838                timestamp: Some(0.0),
839                delay: None,
840                ..Default::default()
841            })
842            .unwrap();
843        writer.write_sidecar(&json!({"k": "v"})).unwrap();
844        let stats = writer.finish().unwrap();
845
846        assert_eq!(stats.row_count, 1);
847        assert_eq!(stats.sidecar_count, 1);
848
849        let row_lines: Vec<Value> = std::fs::read_to_string(&output)
850            .unwrap()
851            .lines()
852            .map(|line| serde_json::from_str(line).unwrap())
853            .collect();
854        let sidecar_lines: Vec<Value> = std::fs::read_to_string(&sidecar)
855            .unwrap()
856            .lines()
857            .map(|line| serde_json::from_str(line).unwrap())
858            .collect();
859        assert_eq!(row_lines.len(), 1);
860        assert_eq!(sidecar_lines, vec![json!({"k": "v"})]);
861        assert_eq!(row_lines[0]["session_id"], json!("s"));
862        assert!(row_lines[0].get("delay").is_none());
863    }
864
865    #[test]
866    fn writer_writes_agentic_rows() {
867        let temp = TempDir::new().unwrap();
868        let output = temp.path().join("agentic.jsonl");
869        let mut writer = MooncakeJsonlWriter::create(&output, None).unwrap();
870        writer
871            .write_agentic_row(&AgenticMooncakeRow {
872                request_id: "r1".to_string(),
873                session_id: None,
874                input_length: Some(2),
875                output_length: Some(1),
876                hash_ids: Some(vec![0]),
877                timestamp: Some(0.0),
878                delay: None,
879                prefix_reset: Some(true),
880                ..Default::default()
881            })
882            .unwrap();
883        let stats = writer.finish().unwrap();
884
885        assert_eq!(stats.row_count, 1);
886        let row_lines: Vec<Value> = std::fs::read_to_string(&output)
887            .unwrap()
888            .lines()
889            .map(|line| serde_json::from_str(line).unwrap())
890            .collect();
891        assert_eq!(row_lines[0]["request_id"], json!("r1"));
892        assert_eq!(row_lines[0]["prefix_reset"], json!(true));
893    }
894
895    #[test]
896    fn writer_without_sidecar_rejects_sidecar_writes() {
897        let temp = TempDir::new().unwrap();
898        let output = temp.path().join("trace.jsonl");
899        let mut writer = MooncakeJsonlWriter::create(&output, None).unwrap();
900        assert!(!writer.has_sidecar());
901        let err = writer.write_sidecar(&json!({})).unwrap_err();
902        assert!(err.to_string().contains("sidecar was not configured"));
903    }
904}