1use anyhow::{Context, Result, bail};
18use dynamo_kv_hashing::{Request, compute_hash_v2, compute_next_sequence_hash, compute_salt_hash};
19use rustc_hash::FxHashMap;
20use serde::{Deserialize, Serialize};
21use std::fs::File;
22use std::io::{BufWriter, Write};
23use std::path::Path;
24
25#[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
66pub const AGENTIC_MOONCAKE_SCHEMA: &str = "dynamo.agentic_mooncake";
67pub const AGENTIC_MOONCAKE_VERSION: u32 = 2;
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70pub struct AgenticSourceProvenance {
71 pub format: String,
72 pub digest: String,
73}
74
75#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum AgenticHashIdScope {
78 Local,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct AgenticMooncakeHeader {
83 pub schema: String,
84 pub version: u32,
85 pub block_size: usize,
86 pub hash_id_scope: AgenticHashIdScope,
87 pub source: AgenticSourceProvenance,
88}
89
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
91#[serde(rename_all = "snake_case")]
92pub enum AgenticDependencyTrigger {
93 Dispatch,
94 Completion,
95}
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
98#[serde(rename_all = "snake_case")]
99pub enum AgenticDependencyRelation {
100 Sequence,
101 Spawn,
102 Join,
103 ReplayBarrier,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct AgenticDependency {
108 pub request_id: String,
109 pub trigger: AgenticDependencyTrigger,
110 pub delay_ms: f64,
111 pub relation: AgenticDependencyRelation,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct AgenticMooncakeRow {
117 pub request_id: String,
118 pub play_id: String,
119 pub session_id: String,
120 pub model: String,
121 #[serde(default, alias = "input_tokens")]
122 pub input_length: Option<usize>,
123 #[serde(default, alias = "output_tokens")]
124 pub output_length: Option<usize>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub output_token_ids: Option<Vec<u32>>,
127 #[serde(default)]
128 pub hash_ids: Option<Vec<u64>>,
129 pub not_before_ms: f64,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub priority: Option<i32>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub strict_priority: Option<u32>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub policy_class: Option<String>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub dependencies: Vec<AgenticDependency>,
138}
139
140pub struct RollingHashIdMapper {
151 block_size: usize,
152 hash_to_id: FxHashMap<u64, u64>,
153 next_id: u64,
154}
155
156impl RollingHashIdMapper {
157 pub fn new(block_size: usize) -> Self {
159 Self {
160 block_size,
161 hash_to_id: FxHashMap::default(),
162 next_id: 0,
163 }
164 }
165
166 pub fn block_size(&self) -> usize {
168 self.block_size
169 }
170
171 pub fn hash_token_blocks(&mut self, tokens: &[u32]) -> Vec<u64> {
179 hash_token_blocks(self, tokens)
180 }
181
182 pub fn try_hash_token_blocks(&mut self, tokens: &[u32]) -> Result<Vec<u64>> {
184 try_hash_token_blocks(self, tokens)
185 }
186
187 pub fn ids_for_sequence_hashes(&mut self, sequence_hashes: &[u64]) -> Vec<u64> {
192 ids_for_sequence_hashes(self, sequence_hashes)
193 }
194}
195
196pub fn hash_token_blocks(mapper: &mut RollingHashIdMapper, tokens: &[u32]) -> Vec<u64> {
206 try_hash_token_blocks(mapper, tokens).expect("Mooncake token-block hashing failed")
207}
208
209pub fn try_hash_token_blocks(mapper: &mut RollingHashIdMapper, tokens: &[u32]) -> Result<Vec<u64>> {
212 let sequence_hashes = sequence_hashes_for_tokens(tokens, mapper.block_size)?;
213 Ok(ids_for_sequence_hashes(mapper, &sequence_hashes))
214}
215
216pub fn sequence_hashes_for_tokens(tokens: &[u32], block_size: usize) -> Result<Vec<u64>> {
218 require_positive("block size", block_size)?;
219 let block_size_u32: u32 = block_size
220 .try_into()
221 .context("block_size does not fit u32")?;
222 let request = Request::builder().tokens(tokens.to_vec()).build()?;
223 let salt_hash = request.salt_hash()?;
224 let mut sequence_hashes = request.into_sequence_hashes(block_size_u32)?;
225 if let Some(partial_hash) =
226 trailing_partial_sequence_hash(salt_hash, block_size, tokens, &sequence_hashes)
227 {
228 sequence_hashes.push(partial_hash);
229 }
230 Ok(sequence_hashes)
231}
232
233pub fn extend_sequence_hashes(
239 prefix_hashes: &[u64],
240 tokens: &[u32],
241 block_size: usize,
242) -> Result<Vec<u64>> {
243 require_positive("block size", block_size)?;
244 let full_blocks = tokens.len() / block_size;
245 if prefix_hashes.len() > full_blocks {
246 bail!(
247 "{} reusable sequence hashes exceed {full_blocks} full blocks",
248 prefix_hashes.len()
249 );
250 }
251 let salt_hash = compute_salt_hash(None, None)?;
252 let mut sequence_hashes = Vec::with_capacity(full_blocks + 1);
253 sequence_hashes.extend_from_slice(prefix_hashes);
254 let mut block_bytes = Vec::with_capacity(block_size * std::mem::size_of::<u32>());
255 for block in
256 tokens[prefix_hashes.len() * block_size..full_blocks * block_size].chunks_exact(block_size)
257 {
258 block_bytes.clear();
259 for token in block {
260 block_bytes.extend_from_slice(&token.to_ne_bytes());
261 }
262 let block_hash = compute_hash_v2(&block_bytes, salt_hash);
263 let sequence_hash = match sequence_hashes.last() {
264 Some(parent) => compute_next_sequence_hash(*parent, block_hash),
265 None => block_hash,
266 };
267 sequence_hashes.push(sequence_hash);
268 }
269 if let Some(partial_hash) =
270 trailing_partial_sequence_hash(salt_hash, block_size, tokens, &sequence_hashes)
271 {
272 sequence_hashes.push(partial_hash);
273 }
274 Ok(sequence_hashes)
275}
276
277fn trailing_partial_sequence_hash(
278 salt_hash: u64,
279 block_size: usize,
280 tokens: &[u32],
281 complete_sequence_hashes: &[u64],
282) -> Option<u64> {
283 let tail_len = tokens.len() % block_size;
284 if tail_len == 0 {
285 return None;
286 }
287
288 let tail = &tokens[tokens.len() - tail_len..];
289 let mut tail_bytes = Vec::with_capacity(std::mem::size_of_val(tail));
290 for token in tail {
291 tail_bytes.extend_from_slice(&token.to_ne_bytes());
292 }
293 let tail_block_hash = compute_hash_v2(&tail_bytes, salt_hash);
294 Some(match complete_sequence_hashes.last().copied() {
295 Some(parent) => compute_next_sequence_hash(parent, tail_block_hash),
296 None => tail_block_hash,
297 })
298}
299
300pub fn ids_for_sequence_hashes(
302 mapper: &mut RollingHashIdMapper,
303 sequence_hashes: &[u64],
304) -> Vec<u64> {
305 sequence_hashes
306 .iter()
307 .map(|sequence_hash| {
308 *mapper.hash_to_id.entry(*sequence_hash).or_insert_with(|| {
309 let next_id = mapper.next_id;
310 mapper.next_id += 1;
311 next_id
312 })
313 })
314 .collect()
315}
316
317#[derive(Debug, Clone, Copy, Default)]
319pub struct WriterStats {
320 pub row_count: usize,
321 pub sidecar_count: usize,
322}
323
324pub struct MooncakeJsonlWriter {
332 output: BufWriter<File>,
333 sidecar: Option<BufWriter<File>>,
334 stats: WriterStats,
335 agentic_header_written: bool,
336}
337
338impl MooncakeJsonlWriter {
339 pub fn create(output_path: &Path, sidecar_path: Option<&Path>) -> Result<Self> {
342 if let Some(parent) = output_path.parent() {
343 std::fs::create_dir_all(parent)?;
344 }
345 let output = BufWriter::new(
346 File::create(output_path)
347 .with_context(|| format!("failed to create {}", output_path.display()))?,
348 );
349 let sidecar = if let Some(path) = sidecar_path {
350 if let Some(parent) = path.parent() {
351 std::fs::create_dir_all(parent)?;
352 }
353 Some(BufWriter::new(File::create(path).with_context(|| {
354 format!("failed to create {}", path.display())
355 })?))
356 } else {
357 None
358 };
359 Ok(Self {
360 output,
361 sidecar,
362 stats: WriterStats::default(),
363 agentic_header_written: false,
364 })
365 }
366
367 pub fn write_row(&mut self, row: &MooncakeRow) -> Result<()> {
369 if self.agentic_header_written {
370 bail!("standard Mooncake rows cannot follow an agentic Mooncake v2 header");
371 }
372 serde_json::to_writer(&mut self.output, row)?;
373 self.output.write_all(b"\n")?;
374 self.stats.row_count += 1;
375 Ok(())
376 }
377
378 pub fn write_agentic_header(&mut self, header: &AgenticMooncakeHeader) -> Result<()> {
379 if self.agentic_header_written || self.stats.row_count != 0 {
380 bail!("agentic Mooncake header must be the first JSONL record");
381 }
382 serde_json::to_writer(&mut self.output, header)?;
383 self.output.write_all(b"\n")?;
384 self.agentic_header_written = true;
385 Ok(())
386 }
387
388 pub fn write_agentic_row(&mut self, row: &AgenticMooncakeRow) -> Result<()> {
390 if !self.agentic_header_written {
391 bail!("agentic Mooncake row requires a preceding v2 header");
392 }
393 serde_json::to_writer(&mut self.output, row)?;
394 self.output.write_all(b"\n")?;
395 self.stats.row_count += 1;
396 Ok(())
397 }
398
399 pub fn write_sidecar<S: Serialize>(&mut self, sidecar: &S) -> Result<()> {
401 let writer = self
402 .sidecar
403 .as_mut()
404 .ok_or_else(|| anyhow::anyhow!("sidecar was not configured for this writer"))?;
405 serde_json::to_writer(writer, sidecar)?;
406 let writer = self.sidecar.as_mut().unwrap();
407 writer.write_all(b"\n")?;
408 self.stats.sidecar_count += 1;
409 Ok(())
410 }
411
412 pub fn has_sidecar(&self) -> bool {
414 self.sidecar.is_some()
415 }
416
417 pub fn stats(&self) -> WriterStats {
419 self.stats
420 }
421
422 pub fn finish(mut self) -> Result<WriterStats> {
424 self.output.flush()?;
425 if let Some(sidecar) = self.sidecar.as_mut() {
426 sidecar.flush()?;
427 }
428 Ok(self.stats)
429 }
430}
431
432pub fn write_empty_files(output_path: &Path, sidecar_path: Option<&Path>) -> Result<()> {
436 if let Some(parent) = output_path.parent() {
437 std::fs::create_dir_all(parent)?;
438 }
439 File::create(output_path)
440 .with_context(|| format!("failed to create {}", output_path.display()))?;
441 if let Some(path) = sidecar_path {
442 if let Some(parent) = path.parent() {
443 std::fs::create_dir_all(parent)?;
444 }
445 File::create(path).with_context(|| format!("failed to create {}", path.display()))?;
446 }
447 Ok(())
448}
449
450pub fn require_positive(name: &str, value: usize) -> Result<()> {
454 if value == 0 {
455 bail!("{name} must be greater than 0");
456 }
457 Ok(())
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use serde_json::{Value, json};
464 use tempfile::TempDir;
465
466 #[test]
467 fn extended_sequence_hashes_match_full_hashing() {
468 let mut state = 0x9e37_79b9_u32;
469 let tokens = (0..300)
470 .map(|_| {
471 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
472 state
473 })
474 .collect::<Vec<_>>();
475 for block_size in [1, 3, 64] {
476 for length in [0, 1, 2, 63, 64, 65, 128, 191, 300] {
477 let tokens = &tokens[..length];
478 let expected = sequence_hashes_for_tokens(tokens, block_size).unwrap();
479 for reused in 0..=length / block_size {
480 let extended =
481 extend_sequence_hashes(&expected[..reused], tokens, block_size).unwrap();
482 assert_eq!(
483 extended, expected,
484 "block={block_size} len={length} reused={reused}"
485 );
486 }
487 }
488 }
489 assert!(extend_sequence_hashes(&[1, 2], &tokens[..3], 2).is_err());
490 }
491
492 #[test]
493 fn shared_prefix_yields_shared_leading_hash_ids() {
494 let mut mapper = RollingHashIdMapper::new(2);
495 let prefix = vec![1u32, 2, 3, 4];
496 let extended = vec![1u32, 2, 3, 4, 5, 6];
497
498 let prefix_ids = mapper.hash_token_blocks(&prefix);
499 let extended_ids = mapper.hash_token_blocks(&extended);
500
501 assert_eq!(prefix_ids.len(), 2);
502 assert_eq!(extended_ids.len(), 3);
503 assert_eq!(extended_ids[..2], prefix_ids[..]);
504 }
505
506 #[test]
507 fn mapper_state_is_reused_across_requests() {
508 let mut mapper = RollingHashIdMapper::new(4);
509 let request_a = vec![10u32, 20, 30, 40, 50, 60, 70, 80];
510 let request_b = vec![10u32, 20, 30, 40, 50, 60, 70, 80];
511 let request_c = vec![10u32, 20, 30, 40, 99, 99, 99, 99];
512
513 let ids_a = mapper.hash_token_blocks(&request_a);
514 let ids_b = mapper.hash_token_blocks(&request_b);
515 let ids_c = mapper.hash_token_blocks(&request_c);
516
517 assert_eq!(ids_a, ids_b);
518 assert_eq!(ids_c[0], ids_a[0], "shared first block should keep its id");
519 assert_ne!(
520 ids_c[1], ids_a[1],
521 "diverging tail block must get a fresh id"
522 );
523 }
524
525 #[test]
526 fn free_function_and_method_agree() {
527 let mut mapper_a = RollingHashIdMapper::new(2);
528 let mut mapper_b = RollingHashIdMapper::new(2);
529 let tokens = vec![7u32, 8, 9, 10, 11];
530
531 let via_method = mapper_a.hash_token_blocks(&tokens);
532 let via_function = hash_token_blocks(&mut mapper_b, &tokens);
533
534 assert_eq!(via_method, via_function);
535 }
536
537 #[test]
538 fn exact_token_blocks_match_shared_kv_hashing_contract() {
539 let tokens = vec![7u32, 8, 9, 10, 11, 12, 13, 14];
540 let request = Request::builder().tokens(tokens.clone()).build().unwrap();
541 let expected = request.into_sequence_hashes(4).unwrap();
542
543 assert_eq!(sequence_hashes_for_tokens(&tokens, 4).unwrap(), expected);
544 }
545
546 #[test]
547 fn empty_token_input_yields_empty_hash_ids() {
548 let mut mapper = RollingHashIdMapper::new(4);
549 assert!(mapper.hash_token_blocks(&[]).is_empty());
550 }
551
552 #[test]
553 fn trailing_partial_block_preserves_replay_capacity() {
554 let mut mapper = RollingHashIdMapper::new(4);
555
556 assert_eq!(mapper.hash_token_blocks(&[1, 2, 3]), vec![0]);
557 assert_eq!(mapper.hash_token_blocks(&[1, 2, 3, 4, 5, 6]), vec![1, 2]);
558 }
559
560 #[test]
561 fn trailing_partial_block_uses_shared_chain_contract() {
562 let tokens = vec![1u32, 2, 3, 4, 5, 6];
563 let request = Request::builder().tokens(tokens.clone()).build().unwrap();
564 let salt_hash = request.salt_hash().unwrap();
565 let mut expected = request.into_sequence_hashes(4).unwrap();
566 let mut tail_bytes = Vec::new();
567 for token in &tokens[4..] {
568 tail_bytes.extend_from_slice(&token.to_ne_bytes());
569 }
570 let tail_block_hash = compute_hash_v2(&tail_bytes, salt_hash);
571 expected.push(compute_next_sequence_hash(expected[0], tail_block_hash));
572
573 assert_eq!(sequence_hashes_for_tokens(&tokens, 4).unwrap(), expected);
574 }
575
576 #[test]
577 fn exact_block_boundary_does_not_add_partial_hash_id() {
578 let mut mapper = RollingHashIdMapper::new(4);
579
580 assert_eq!(mapper.hash_token_blocks(&[1, 2, 3, 4]), vec![0]);
581 assert_eq!(
582 mapper.hash_token_blocks(&[1, 2, 3, 4, 5, 6, 7, 8]),
583 vec![0, 1]
584 );
585 }
586
587 #[test]
588 fn try_hash_token_blocks_rejects_zero_block_size() {
589 let mut mapper = RollingHashIdMapper::new(0);
590 let err = mapper.try_hash_token_blocks(&[1, 2, 3]).unwrap_err();
591
592 assert!(err.to_string().contains("block size"));
593 }
594
595 #[test]
596 fn precomputed_sequence_hashes_map_to_stable_ids() {
597 let mut mapper = RollingHashIdMapper::new(64);
598
599 let first = mapper.ids_for_sequence_hashes(&[101, 202, 303]);
600 let second = mapper.ids_for_sequence_hashes(&[101, 202, 404]);
601
602 assert_eq!(first[..2], second[..2]);
603 assert_ne!(first[2], second[2]);
604 }
605
606 #[test]
607 fn row_omits_timestamp_and_delay_when_absent() {
608 let row = MooncakeRow {
609 session_id: Some("s".to_string()),
610 input_length: Some(4),
611 output_length: Some(1),
612 hash_ids: Some(vec![0, 1]),
613 timestamp: None,
614 delay: None,
615 ..Default::default()
616 };
617 let rendered: Value = serde_json::to_value(&row).unwrap();
618 assert!(rendered.get("timestamp").is_none());
619 assert!(rendered.get("delay").is_none());
620 assert_eq!(rendered["hash_ids"], json!([0, 1]));
621 }
622
623 #[test]
624 fn row_serializes_optional_fields_when_set() {
625 let with_timestamp = MooncakeRow {
626 session_id: Some("s".to_string()),
627 input_length: Some(4),
628 output_length: Some(1),
629 hash_ids: Some(vec![]),
630 timestamp: Some(0.0),
631 delay: None,
632 ..Default::default()
633 };
634 let with_delay = MooncakeRow {
635 session_id: Some("s".to_string()),
636 input_length: Some(4),
637 output_length: Some(1),
638 hash_ids: Some(vec![]),
639 timestamp: None,
640 delay: Some(123.0),
641 ..Default::default()
642 };
643 let v_ts: Value = serde_json::to_value(&with_timestamp).unwrap();
644 let v_dl: Value = serde_json::to_value(&with_delay).unwrap();
645 assert_eq!(v_ts["timestamp"], json!(0.0));
646 assert!(v_ts.get("delay").is_none());
647 assert_eq!(v_dl["delay"], json!(123.0));
648 assert!(v_dl.get("timestamp").is_none());
649 }
650
651 #[test]
652 fn row_deserializes_canonical_field_names() {
653 let raw = r#"{"session_id":"s","input_length":4,"output_length":1,"hash_ids":[0,1],"timestamp":12.5,"delay":3.0}"#;
654 let row: MooncakeRow = serde_json::from_str(raw).unwrap();
655 assert_eq!(row.session_id.as_deref(), Some("s"));
656 assert_eq!(row.input_length, Some(4));
657 assert_eq!(row.output_length, Some(1));
658 assert_eq!(row.hash_ids, Some(vec![0, 1]));
659 assert_eq!(row.timestamp, Some(12.5));
660 assert_eq!(row.delay, Some(3.0));
661 }
662
663 #[test]
664 fn row_deserializes_upstream_mooncake_aliases() {
665 let raw = r#"{"input_tokens":4,"output_tokens":1,"hash_ids":[0,1],"created_time":12.5,"delay_ms":3.0}"#;
666 let row: MooncakeRow = serde_json::from_str(raw).unwrap();
667 assert_eq!(row.input_length, Some(4));
668 assert_eq!(row.output_length, Some(1));
669 assert_eq!(row.timestamp, Some(12.5));
670 assert_eq!(row.delay, Some(3.0));
671 }
672
673 #[test]
674 fn row_alias_input_round_trips_to_canonical_fields() {
675 let raw = r#"{"input_tokens":8,"output_tokens":2,"created_time":12.5,"delay_ms":3.0}"#;
676 let mut row: MooncakeRow = serde_json::from_str(raw).unwrap();
677
678 let tokens: Vec<u32> = (0..row.input_length.unwrap() as u32).collect();
679 let mut mapper = RollingHashIdMapper::new(4);
680 row.hash_ids = Some(mapper.hash_token_blocks(&tokens));
681
682 let rendered: Value = serde_json::to_value(&row).unwrap();
683 assert_eq!(rendered["input_length"], json!(8));
684 assert_eq!(rendered["output_length"], json!(2));
685 assert_eq!(rendered["timestamp"], json!(12.5));
686 assert_eq!(rendered["delay"], json!(3.0));
687 assert_eq!(rendered["hash_ids"], json!([0, 1]));
688 assert!(rendered.get("input_tokens").is_none());
689 assert!(rendered.get("output_tokens").is_none());
690 assert!(rendered.get("created_time").is_none());
691 assert!(rendered.get("delay_ms").is_none());
692 }
693
694 #[test]
695 fn row_replay_fields_round_trip_canonical_and_alias_inputs() {
696 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}"#;
697 let row: MooncakeRow = serde_json::from_str(canonical).unwrap();
698 assert_eq!(row.request_id.as_deref(), Some("r1"));
699 assert_eq!(row.output_length, Some(3));
700 assert_eq!(row.output_token_ids, Some(vec![101, 102, 103]));
701
702 let rendered: Value = serde_json::to_value(&row).unwrap();
703 assert_eq!(rendered["request_id"], json!("r1"));
704 assert_eq!(rendered["output_token_ids"], json!([101, 102, 103]));
705 let decoded: MooncakeRow = serde_json::from_value(rendered).unwrap();
706 assert_eq!(decoded.request_id.as_deref(), Some("r1"));
707 assert_eq!(decoded.output_token_ids, Some(vec![101, 102, 103]));
708
709 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}"#;
710 let row: MooncakeRow = serde_json::from_str(aliased).unwrap();
711 assert_eq!(row.request_id.as_deref(), Some("r2"));
712 assert_eq!(row.input_length, Some(4));
713 assert_eq!(row.output_length, Some(2));
714 assert_eq!(row.output_token_ids, Some(vec![201, 202]));
715
716 let rendered: Value = serde_json::to_value(&row).unwrap();
717 assert_eq!(rendered["input_length"], json!(4));
718 assert_eq!(rendered["output_length"], json!(2));
719 assert_eq!(rendered["output_token_ids"], json!([201, 202]));
720 assert!(rendered.get("input_tokens").is_none());
721 assert!(rendered.get("output_tokens").is_none());
722 }
723
724 #[test]
725 fn row_canonical_input_round_trips_without_renaming() {
726 let raw = r#"{"input_length":8,"output_length":2,"timestamp":12.5,"delay":3.0}"#;
727 let mut row: MooncakeRow = serde_json::from_str(raw).unwrap();
728
729 let tokens: Vec<u32> = (0..row.input_length.unwrap() as u32).collect();
730 let mut mapper = RollingHashIdMapper::new(4);
731 row.hash_ids = Some(mapper.hash_token_blocks(&tokens));
732
733 let rendered: Value = serde_json::to_value(&row).unwrap();
734 assert_eq!(rendered["input_length"], json!(8));
735 assert_eq!(rendered["output_length"], json!(2));
736 assert_eq!(rendered["timestamp"], json!(12.5));
737 assert_eq!(rendered["delay"], json!(3.0));
738 assert_eq!(rendered["hash_ids"], json!([0, 1]));
739 assert!(rendered.get("input_tokens").is_none());
740 assert!(rendered.get("output_tokens").is_none());
741 assert!(rendered.get("created_time").is_none());
742 assert!(rendered.get("delay_ms").is_none());
743 }
744
745 #[test]
746 fn row_deserializes_with_missing_optional_fields() {
747 let raw = r#"{"output_length":2}"#;
748 let row: MooncakeRow = serde_json::from_str(raw).unwrap();
749 assert_eq!(row.session_id, None);
750 assert_eq!(row.input_length, None);
751 assert_eq!(row.output_length, Some(2));
752 assert_eq!(row.hash_ids, None);
753 assert_eq!(row.timestamp, None);
754 assert_eq!(row.delay, None);
755 assert_eq!(row.priority, None);
756 assert_eq!(row.strict_priority, None);
757 assert_eq!(row.policy_class, None);
758 let rendered: Value = serde_json::to_value(&row).unwrap();
759 assert!(rendered.get("priority").is_none());
760 assert!(rendered.get("strict_priority").is_none());
761 assert!(rendered.get("policy_class").is_none());
762 assert!(rendered.get("request_id").is_none());
763 assert!(rendered.get("output_token_ids").is_none());
764 }
765
766 #[test]
767 fn row_round_trips_priorities() {
768 for priority in [Some(7), Some(0), Some(-3)] {
769 let raw = json!({
770 "output_length": 2,
771 "priority": priority,
772 "strict_priority": 9,
773 "policy_class": "latency"
774 });
775 let row: MooncakeRow = serde_json::from_value(raw).unwrap();
776 assert_eq!(row.priority, priority);
777 assert_eq!(row.strict_priority, Some(9));
778 assert_eq!(row.policy_class.as_deref(), Some("latency"));
779
780 let rendered: Value = serde_json::to_value(&row).unwrap();
781 assert_eq!(rendered["priority"], json!(priority.unwrap()));
782 assert_eq!(rendered["strict_priority"], json!(9));
783 assert_eq!(rendered["policy_class"], json!("latency"));
784 }
785 }
786
787 #[test]
788 fn agentic_v2_row_round_trips_typed_dependencies() {
789 let raw = r#"{"request_id":"r2","play_id":"play","session_id":"child","model":"model","input_length":4,"output_length":1,"hash_ids":[7],"not_before_ms":10.0,"dependencies":[{"request_id":"r1","trigger":"dispatch","delay_ms":3.0,"relation":"spawn"}]}"#;
790 let row: AgenticMooncakeRow = serde_json::from_str(raw).unwrap();
791
792 assert_eq!(row.play_id, "play");
793 assert_eq!(row.model, "model");
794 assert_eq!(row.dependencies.len(), 1);
795 assert_eq!(
796 row.dependencies[0].trigger,
797 AgenticDependencyTrigger::Dispatch
798 );
799 assert_eq!(
800 row.dependencies[0].relation,
801 AgenticDependencyRelation::Spawn
802 );
803 assert_eq!(row.dependencies[0].delay_ms, 3.0);
804 let rendered: Value = serde_json::to_value(&row).unwrap();
805 assert_eq!(rendered["not_before_ms"], json!(10.0));
806 assert_eq!(rendered["dependencies"][0]["trigger"], json!("dispatch"));
807 }
808
809 #[test]
810 fn agentic_row_rejects_header_fields_by_shape() {
811 let raw = r#"{"request_id":"r1","input_length":4,"output_length":1,"hash_ids":[0],"timestamp":10.0}"#;
812 assert!(serde_json::from_str::<AgenticMooncakeRow>(raw).is_err());
813 }
814
815 #[test]
816 fn writer_writes_rows_and_sidecar_jsonl() {
817 let temp = TempDir::new().unwrap();
818 let output = temp.path().join("trace.jsonl");
819 let sidecar = temp.path().join("trace.sidecar.jsonl");
820
821 let mut writer = MooncakeJsonlWriter::create(&output, Some(&sidecar)).unwrap();
822 writer
823 .write_row(&MooncakeRow {
824 session_id: Some("s".to_string()),
825 input_length: Some(2),
826 output_length: Some(1),
827 hash_ids: Some(vec![0]),
828 timestamp: Some(0.0),
829 delay: None,
830 ..Default::default()
831 })
832 .unwrap();
833 writer.write_sidecar(&json!({"k": "v"})).unwrap();
834 let stats = writer.finish().unwrap();
835
836 assert_eq!(stats.row_count, 1);
837 assert_eq!(stats.sidecar_count, 1);
838
839 let row_lines: Vec<Value> = std::fs::read_to_string(&output)
840 .unwrap()
841 .lines()
842 .map(|line| serde_json::from_str(line).unwrap())
843 .collect();
844 let sidecar_lines: Vec<Value> = std::fs::read_to_string(&sidecar)
845 .unwrap()
846 .lines()
847 .map(|line| serde_json::from_str(line).unwrap())
848 .collect();
849 assert_eq!(row_lines.len(), 1);
850 assert_eq!(sidecar_lines, vec![json!({"k": "v"})]);
851 assert_eq!(row_lines[0]["session_id"], json!("s"));
852 assert!(row_lines[0].get("delay").is_none());
853 }
854
855 #[test]
856 fn writer_writes_agentic_rows() {
857 let temp = TempDir::new().unwrap();
858 let output = temp.path().join("agentic.jsonl");
859 let mut writer = MooncakeJsonlWriter::create(&output, None).unwrap();
860 writer
861 .write_agentic_header(&AgenticMooncakeHeader {
862 schema: AGENTIC_MOONCAKE_SCHEMA.to_string(),
863 version: AGENTIC_MOONCAKE_VERSION,
864 block_size: 2,
865 hash_id_scope: AgenticHashIdScope::Local,
866 source: AgenticSourceProvenance {
867 format: "test".to_string(),
868 digest: "digest".to_string(),
869 },
870 })
871 .unwrap();
872 writer
873 .write_agentic_row(&AgenticMooncakeRow {
874 request_id: "r1".to_string(),
875 play_id: "play".to_string(),
876 session_id: "session".to_string(),
877 model: "model".to_string(),
878 input_length: Some(2),
879 output_length: Some(1),
880 hash_ids: Some(vec![0]),
881 not_before_ms: 0.0,
882 ..Default::default()
883 })
884 .unwrap();
885 let stats = writer.finish().unwrap();
886
887 assert_eq!(stats.row_count, 1);
888 let row_lines: Vec<Value> = std::fs::read_to_string(&output)
889 .unwrap()
890 .lines()
891 .map(|line| serde_json::from_str(line).unwrap())
892 .collect();
893 assert_eq!(row_lines.len(), 2);
894 assert_eq!(row_lines[0]["version"], json!(2));
895 assert_eq!(row_lines[1]["request_id"], json!("r1"));
896 }
897
898 #[test]
899 fn writer_without_sidecar_rejects_sidecar_writes() {
900 let temp = TempDir::new().unwrap();
901 let output = temp.path().join("trace.jsonl");
902 let mut writer = MooncakeJsonlWriter::create(&output, None).unwrap();
903 assert!(!writer.has_sidecar());
904 let err = writer.write_sidecar(&json!({})).unwrap_err();
905 assert!(err.to_string().contains("sidecar was not configured"));
906 }
907}