Skip to main content

dynamo_bench/coding/claude/
export.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Claude-specific request-trace export orchestration.
5//!
6//! Handles session scheduling, parallel tokenization with text-overlap reuse,
7//! and global ordering across sessions.
8
9use crate::coding::claude::parser::{
10    SessionTurnBuilder, SourceFidelityOracle, TraceIndex, TraceRecord, TurnDraft,
11    request_start_bound_ms,
12};
13use crate::coding::replay::{
14    AgentContext, HARNESS_EVENT_SOURCE, PrefixPool, REQUEST_TRACE_SCHEMA, ReplayBase, ReplayFields,
15    RequestEndEvent, RequestFields, ToolEvent, ToolFields, TraceLine, synthetic_stream_seed,
16    synthetic_token, usage_shaped_tokens,
17};
18use crate::coding::tokenizer::{
19    LazyTokenizer, TokenizerFactory, TokenizerWorker, last_word_overlap_start,
20};
21use anyhow::{Result, anyhow, bail};
22use crossbeam_channel::{Receiver, Sender, bounded, unbounded};
23use rustc_hash::FxHashMap;
24use serde::Serialize;
25use serde_json::json;
26use std::cmp::Reverse;
27use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
28use std::fs::File;
29use std::io::{BufWriter, Write};
30use std::path::Path;
31use std::thread::{self, JoinHandle};
32
33#[derive(Debug, Clone, Copy)]
34pub struct ExportConfig {
35    pub block_size: usize,
36    pub delta_overlap_words: usize,
37    pub tokenizer_workers: usize,
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct ExportStats {
42    pub row_count: usize,
43    pub tool_row_count: usize,
44    pub sidecar_count: usize,
45    pub max_heap_len: usize,
46    pub fidelity: FidelityReport,
47}
48
49#[derive(Debug, Clone, Default)]
50pub struct FidelityReport {
51    pub requests_verified: usize,
52    pub compactions_verified: usize,
53    pub usage_requests_verified: usize,
54    pub tools_verified: usize,
55    pub child_links_verified: usize,
56    pub background_tools: usize,
57    pub background_agents: usize,
58    pub background_completions_missing: usize,
59    pub background_titles_unreplayable: usize,
60    pub cache_prefix_blocks_verified: usize,
61    pub compaction_prefix_blocks_verified: usize,
62    pub post_compaction_prefix_blocks_verified: usize,
63    pub pooled_prefix_blocks: usize,
64    pub unmatched_tool_calls: usize,
65    pub unmatched_tool_results: usize,
66    pub unresolved_child_sessions: usize,
67}
68
69/// Claude-only evidence used to reconstruct tool scheduling after export.
70///
71/// Live tool events cannot know their future consumer request. Claude's saved
72/// session can, so the exporter stores that post-hoc evidence under
73/// `tool.claude` without extending the live request-trace tool API.
74#[derive(Serialize)]
75struct ClaudeToolReplayMetadata {
76    source_request_id: String,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    consumer_request_id: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    child_session_id: Option<String>,
81    execution_mode: String,
82}
83
84impl FidelityReport {
85    pub fn render(&self) -> String {
86        let ordinary_requests = self
87            .requests_verified
88            .saturating_sub(self.compactions_verified);
89        format!(
90            "Fidelity: requests={0}/{0} compactions={1}/{1} usage={2}/{15} tools={3}/{3} child_links={4}/{4} cache_prefix_blocks={5} compaction_prefix_blocks={6} post_compaction_prefix_blocks={7} pooled_prefix_blocks={16}\nBackground: tools={8} agents={9} missing_completions={10} title_requests_unreplayable={11}\nLimitations: synthetic_kv_hashes={0} unmatched_tool_calls={12} unmatched_tool_results={13} unresolved_child_sessions={14}",
91            self.requests_verified,
92            self.compactions_verified,
93            self.usage_requests_verified,
94            self.tools_verified,
95            self.child_links_verified,
96            self.cache_prefix_blocks_verified,
97            self.compaction_prefix_blocks_verified,
98            self.post_compaction_prefix_blocks_verified,
99            self.background_tools,
100            self.background_agents,
101            self.background_completions_missing,
102            self.background_titles_unreplayable,
103            self.unmatched_tool_calls,
104            self.unmatched_tool_results,
105            self.unresolved_child_sessions,
106            ordinary_requests,
107            self.pooled_prefix_blocks,
108        )
109    }
110}
111
112struct FidelityVerifier {
113    oracle: SourceFidelityOracle,
114    seen_requests: BTreeSet<(String, String)>,
115    seen_compactions: BTreeSet<(String, String)>,
116    tools_by_class: BTreeMap<String, usize>,
117    tool_count: usize,
118    tool_errors: usize,
119    child_links: usize,
120    background_tools: usize,
121    background_agents: usize,
122    usage_requests: usize,
123    cache_prefix_blocks_verified: usize,
124    compaction_prefix_blocks_verified: usize,
125    post_compaction_prefix_blocks_verified: usize,
126    next_turn_by_session: FxHashMap<String, usize>,
127    previous_hashes_by_session: FxHashMap<String, Vec<u64>>,
128    previous_input_length_by_session: FxHashMap<String, usize>,
129    previous_was_compaction_by_session: FxHashMap<String, bool>,
130    expected_next_cache_read_by_session: FxHashMap<String, usize>,
131    export_sessions: BTreeSet<String>,
132    causal_references: Vec<(String, usize, String)>,
133    child_session_references: Vec<String>,
134}
135
136#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
137struct HeapEntry {
138    request_start_ms: i64,
139    turn_index: usize,
140    export_session_id: String,
141    session_id: String,
142}
143
144#[derive(Debug)]
145struct OverlapBase {
146    previous_text: String,
147    previous_tokens: Vec<u32>,
148}
149
150#[derive(Debug)]
151struct ReadyTurn {
152    current_text: String,
153    tokens: Vec<u32>,
154}
155
156#[derive(Debug)]
157struct HeadTurn {
158    turn: TurnDraft,
159    sidecar_line: String,
160    turn_key: u64,
161    scheduled: bool,
162    ready: Option<ReadyTurn>,
163}
164
165/// A turn waiting for the merge, with its sidecar row already serialized.
166#[derive(Debug)]
167struct QueuedTurn {
168    turn: TurnDraft,
169    sidecar_line: String,
170}
171
172impl QueuedTurn {
173    fn new(mut turn: TurnDraft) -> Result<Self> {
174        let sidecar_line = serde_json::to_string(&turn.sidecar)?;
175        turn.sidecar = serde_json::Value::Null;
176        // Usage-shaped turns replay synthetic hashes, so their transcript is never tokenized.
177        if turn.observed_input_length.is_some() {
178            turn.input_text = String::new();
179        }
180        Ok(Self { turn, sidecar_line })
181    }
182}
183
184/// Where a session's remaining turns come from.
185#[derive(Debug)]
186enum SessionTurns {
187    /// Built when the session opened, so its rows are already freed.
188    Built(VecDeque<QueuedTurn>),
189    /// Built one at a time, because each transcript-shaped turn carries the transcript so far.
190    Lazy(Box<SessionTurnBuilder>),
191}
192
193impl SessionTurns {
194    /// Builds every turn now unless some turn needs its transcript tokenized.
195    fn open(mut builder: SessionTurnBuilder, tokenizer: &mut impl TokenizerWorker) -> Result<Self> {
196        if !builder.requests_are_usage_shaped() {
197            return Ok(Self::Lazy(Box::new(builder)));
198        }
199        let mut turns = VecDeque::new();
200        while let Some(turn) = builder.next_turn(tokenizer)? {
201            turns.push_back(QueuedTurn::new(turn)?);
202        }
203        Ok(Self::Built(turns))
204    }
205
206    fn next(&mut self, tokenizer: &mut impl TokenizerWorker) -> Result<Option<QueuedTurn>> {
207        match self {
208            Self::Built(turns) => Ok(turns.pop_front()),
209            Self::Lazy(builder) => builder
210                .next_turn(tokenizer)?
211                .map(QueuedTurn::new)
212                .transpose(),
213        }
214    }
215}
216
217#[derive(Debug)]
218struct SessionState {
219    turns: SessionTurns,
220    head: Option<HeadTurn>,
221    overlap_base: Option<OverlapBase>,
222    replay_base: Option<ReplayBase>,
223    next_turn_key: u64,
224}
225
226#[derive(Debug)]
227struct TokenizeJob {
228    session_id: String,
229    turn_key: u64,
230    current_text: String,
231    overlap_start: Option<usize>,
232    previous_overlap_text: Option<String>,
233    previous_tokens: Option<Vec<u32>>,
234    overlap_words: usize,
235}
236
237#[derive(Debug)]
238struct TokenizeResponse {
239    session_id: String,
240    turn_key: u64,
241    outcome: Result<ReadyTurn, String>,
242}
243
244impl FidelityVerifier {
245    fn new(oracle: SourceFidelityOracle) -> Self {
246        Self {
247            oracle,
248            seen_requests: BTreeSet::new(),
249            seen_compactions: BTreeSet::new(),
250            tools_by_class: BTreeMap::new(),
251            tool_count: 0,
252            tool_errors: 0,
253            child_links: 0,
254            background_tools: 0,
255            background_agents: 0,
256            usage_requests: 0,
257            cache_prefix_blocks_verified: 0,
258            compaction_prefix_blocks_verified: 0,
259            post_compaction_prefix_blocks_verified: 0,
260            next_turn_by_session: FxHashMap::default(),
261            previous_hashes_by_session: FxHashMap::default(),
262            previous_input_length_by_session: FxHashMap::default(),
263            previous_was_compaction_by_session: FxHashMap::default(),
264            expected_next_cache_read_by_session: FxHashMap::default(),
265            export_sessions: BTreeSet::new(),
266            causal_references: Vec::new(),
267            child_session_references: Vec::new(),
268        }
269    }
270
271    fn observe(
272        &mut self,
273        turn: &TurnDraft,
274        replay_tokens: &[u32],
275        input_sequence_hashes: &[u64],
276        block_size: usize,
277    ) -> Result<()> {
278        let key = (turn.session_id.clone(), turn.source_request_id.clone());
279        if let Some(compaction) = &turn.compaction {
280            let expected = self.oracle.compactions.get(&key).ok_or_else(|| {
281                anyhow!(
282                    "fidelity verification found unexpected compaction {} in session {}",
283                    turn.source_request_id,
284                    turn.session_id
285                )
286            })?;
287            if compaction != expected || !self.seen_compactions.insert(key) {
288                bail!(
289                    "fidelity verification compaction mismatch for session {} sequence {}",
290                    turn.session_id,
291                    compaction.sequence
292                );
293            }
294            let expected_turn = self
295                .next_turn_by_session
296                .get(&turn.session_id)
297                .copied()
298                .unwrap_or_default();
299            let expected_start = compaction
300                .ended_at_ms
301                .saturating_sub(compaction.duration_ms);
302            if turn.turn_index != expected_turn
303                || turn.request_start_ms != expected_start
304                || turn.assistant_end_ms != compaction.ended_at_ms
305                || turn.observed_input_length != Some(compaction.pre_tokens)
306                || turn.cache_read_input_tokens.is_some()
307                || replay_tokens.len() != compaction.pre_tokens
308            {
309                bail!(
310                    "fidelity verification compaction timing/cache mismatch for session {} sequence {}",
311                    turn.session_id,
312                    compaction.sequence
313                );
314            }
315        } else {
316            let expected = self.oracle.requests.get(&key).ok_or_else(|| {
317                anyhow!(
318                    "fidelity verification found unexpected request {} in session {}",
319                    turn.source_request_id,
320                    turn.session_id
321                )
322            })?;
323            if !self.seen_requests.insert(key) {
324                bail!(
325                    "fidelity verification found duplicate request {} in session {}",
326                    turn.source_request_id,
327                    turn.session_id
328                );
329            }
330            let expected_turn = self
331                .next_turn_by_session
332                .entry(turn.session_id.clone())
333                .or_default();
334            if turn.turn_index != *expected_turn {
335                bail!(
336                    "fidelity verification expected turn {} for session {}, got {}",
337                    *expected_turn,
338                    turn.session_id,
339                    turn.turn_index
340                );
341            }
342            *expected_turn += 1;
343            if turn.request_start_ms != expected.request_start_ms
344                || turn.assistant_end_ms != expected.assistant_end_ms
345            {
346                bail!(
347                    "fidelity verification timing mismatch for session {} turn {}: expected {}..{}, got {}..{}",
348                    turn.session_id,
349                    turn.turn_index,
350                    expected.request_start_ms,
351                    expected.assistant_end_ms,
352                    turn.request_start_ms,
353                    turn.assistant_end_ms
354                );
355            }
356            if let Some(output_length) = expected.output_length
357                && turn.output_length != output_length
358            {
359                bail!(
360                    "fidelity verification output mismatch for session {} turn {}: expected {}, got {}",
361                    turn.session_id,
362                    turn.turn_index,
363                    output_length,
364                    turn.output_length
365                );
366            }
367            if let Some(input_length) = expected.input_length {
368                self.usage_requests += 1;
369                if replay_tokens.len() != input_length
370                    || turn.cache_read_input_tokens != expected.cache_read_input_tokens
371                    || turn.cache_creation_input_tokens != expected.cache_creation_input_tokens
372                {
373                    bail!(
374                        "fidelity verification input/cache mismatch for session {} turn {}",
375                        turn.session_id,
376                        turn.turn_index
377                    );
378                }
379            }
380        }
381        self.export_sessions.insert(turn.export_session_id.clone());
382        if turn.request_start_ms > turn.assistant_start_ms
383            || turn.assistant_start_ms > turn.assistant_end_ms
384        {
385            bail!(
386                "invalid request timing for session {} turn {}",
387                turn.session_id,
388                turn.turn_index
389            );
390        }
391        let expected_hashes = replay_tokens.len().div_ceil(block_size);
392        if input_sequence_hashes.len() != expected_hashes {
393            bail!(
394                "fidelity verification expected {} hashes for session {} turn {}, got {}",
395                expected_hashes,
396                turn.session_id,
397                turn.turn_index,
398                input_sequence_hashes.len()
399            );
400        }
401        let previous_was_compaction = self
402            .previous_was_compaction_by_session
403            .get(&turn.session_id)
404            .copied()
405            .unwrap_or(false);
406        let previous_input_length = self
407            .previous_input_length_by_session
408            .get(&turn.session_id)
409            .copied();
410        let previous_hashes = self.previous_hashes_by_session.get(&turn.session_id);
411        if turn.compaction.is_some() && previous_hashes.is_none() {
412            bail!(
413                "fidelity verification cannot recover compaction prefix for session {}",
414                turn.session_id
415            );
416        }
417        if let (Some(previous_hashes), Some(previous_input_length)) =
418            (previous_hashes, previous_input_length)
419        {
420            let verifiable_blocks = if let Some(compaction) = &turn.compaction {
421                previous_input_length.min(compaction.pre_tokens.saturating_sub(1)) / block_size
422            } else {
423                let cached_blocks = turn.cache_read_input_tokens.unwrap_or(0) / block_size;
424                cached_blocks
425                    .min(previous_input_length / block_size)
426                    .min(previous_hashes.len())
427                    .min(input_sequence_hashes.len())
428            };
429            if previous_hashes[..verifiable_blocks] != input_sequence_hashes[..verifiable_blocks] {
430                bail!(
431                    "fidelity verification cached prefix mismatch for session {} turn {}",
432                    turn.session_id,
433                    turn.turn_index
434                );
435            }
436            self.cache_prefix_blocks_verified += verifiable_blocks;
437            if turn.compaction.is_some() {
438                if verifiable_blocks == 0 {
439                    bail!(
440                        "fidelity verification found no recoverable compaction prefix blocks for session {}",
441                        turn.session_id
442                    );
443                }
444                self.compaction_prefix_blocks_verified += verifiable_blocks;
445            } else if previous_was_compaction {
446                let cached_tokens = turn.cache_read_input_tokens.unwrap_or(0);
447                let cached_blocks = cached_tokens / block_size;
448                let cache_creation_tokens = turn.cache_creation_input_tokens.unwrap_or(0);
449                if cached_blocks == 0
450                    || cache_creation_tokens == 0
451                    || cached_tokens > previous_input_length
452                    || verifiable_blocks != cached_blocks
453                {
454                    bail!(
455                        "fidelity verification found post-compaction cache miss for session {}",
456                        turn.session_id
457                    );
458                }
459                self.post_compaction_prefix_blocks_verified += verifiable_blocks;
460                self.expected_next_cache_read_by_session.insert(
461                    turn.session_id.clone(),
462                    cached_tokens.saturating_add(cache_creation_tokens),
463                );
464            }
465        }
466        if turn.compaction.is_none()
467            && !previous_was_compaction
468            && let Some(expected_cache_read) = self
469                .expected_next_cache_read_by_session
470                .remove(&turn.session_id)
471            && turn.cache_read_input_tokens != Some(expected_cache_read)
472        {
473            bail!(
474                "fidelity verification expected {} post-compaction cache-read tokens for session {}, got {:?}",
475                expected_cache_read,
476                turn.session_id,
477                turn.cache_read_input_tokens
478            );
479        }
480        self.previous_hashes_by_session
481            .insert(turn.session_id.clone(), input_sequence_hashes.to_vec());
482        self.previous_input_length_by_session
483            .insert(turn.session_id.clone(), replay_tokens.len());
484        self.previous_was_compaction_by_session
485            .insert(turn.session_id.clone(), turn.compaction.is_some());
486
487        for tool in &turn.tools {
488            if tool.started_at_ms > tool.ended_at_ms {
489                bail!(
490                    "invalid tool timing for {} in session {}",
491                    tool.tool_call_id,
492                    turn.session_id
493                );
494            }
495            self.tool_count += 1;
496            *self
497                .tools_by_class
498                .entry(tool.tool_class.clone())
499                .or_insert(0) += 1;
500            self.tool_errors += usize::from(tool.is_error);
501            self.child_links += usize::from(tool.child_session_id.is_some());
502            if let Some(child_session_id) = &tool.child_session_id {
503                self.child_session_references.push(child_session_id.clone());
504            }
505            self.background_tools += usize::from(tool.execution_mode == "background");
506            self.background_agents +=
507                usize::from(tool.execution_mode == "background" && tool.child_session_id.is_some());
508            if !matches!(tool.execution_mode.as_str(), "blocking" | "background") {
509                bail!(
510                    "fidelity verification found invalid execution mode {} for {}",
511                    tool.execution_mode,
512                    tool.tool_call_id
513                );
514            }
515            if tool.child_session_id.as_deref() == Some(turn.export_session_id.as_str()) {
516                bail!(
517                    "fidelity verification found self-referential child session for {}",
518                    tool.tool_call_id
519                );
520            }
521            if let Some(consumer_turn_index) = tool.consumer_turn_index {
522                if consumer_turn_index <= turn.turn_index {
523                    bail!(
524                        "fidelity verification found non-forward consumer for {}",
525                        tool.tool_call_id
526                    );
527                }
528                self.causal_references.push((
529                    turn.session_id.clone(),
530                    consumer_turn_index,
531                    tool.tool_call_id.clone(),
532                ));
533            }
534        }
535        Ok(())
536    }
537
538    fn finish(
539        self,
540        request_rows: usize,
541        tool_rows: usize,
542        sidecar_rows: usize,
543    ) -> Result<FidelityReport> {
544        for (session_id, consumer_turn_index, tool_call_id) in &self.causal_references {
545            let turn_count = self
546                .next_turn_by_session
547                .get(session_id)
548                .copied()
549                .unwrap_or(0);
550            if *consumer_turn_index >= turn_count {
551                bail!(
552                    "fidelity verification found missing consumer turn {} for {}",
553                    consumer_turn_index,
554                    tool_call_id
555                );
556            }
557        }
558        let unresolved_child_sessions = self
559            .child_session_references
560            .iter()
561            .filter(|session_id| !self.export_sessions.contains(*session_id))
562            .count();
563        let source_request_rows = self.oracle.requests.len() + self.oracle.compactions.len();
564        let seen_request_rows = self.seen_requests.len() + self.seen_compactions.len();
565        if request_rows != source_request_rows
566            || request_rows != seen_request_rows
567            || self.seen_compactions.len() != self.oracle.compactions.len()
568            || sidecar_rows != request_rows
569        {
570            bail!(
571                "fidelity verification request mismatch: source={} ({} compactions), emitted={}, sidecar={}",
572                source_request_rows,
573                self.oracle.compactions.len(),
574                request_rows,
575                sidecar_rows
576            );
577        }
578        if tool_rows != self.oracle.paired_tools
579            || self.tool_count != self.oracle.paired_tools
580            || self.tool_errors != self.oracle.tool_errors
581            || self.tools_by_class != self.oracle.tools_by_class
582        {
583            bail!(
584                "fidelity verification tool mismatch: count={}/{}, errors={}/{}, classes_equal={}",
585                self.oracle.paired_tools,
586                tool_rows,
587                self.oracle.tool_errors,
588                self.tool_errors,
589                self.tools_by_class == self.oracle.tools_by_class
590            );
591        }
592        if self.child_links != self.oracle.child_links
593            || self.background_tools != self.oracle.background_tools
594            || self.background_agents != self.oracle.background_agents
595        {
596            bail!(
597                "fidelity verification agent mismatch: child_links={}/{}, background_tools={}/{}, background_agents={}/{}",
598                self.oracle.child_links,
599                self.child_links,
600                self.oracle.background_tools,
601                self.background_tools,
602                self.oracle.background_agents,
603                self.background_agents
604            );
605        }
606        Ok(FidelityReport {
607            requests_verified: request_rows,
608            compactions_verified: self.seen_compactions.len(),
609            usage_requests_verified: self.usage_requests,
610            tools_verified: tool_rows,
611            child_links_verified: self.child_links,
612            background_tools: self.background_tools,
613            background_agents: self.background_agents,
614            background_completions_missing: self.oracle.background_completions_missing,
615            background_titles_unreplayable: self.oracle.background_titles.len(),
616            cache_prefix_blocks_verified: self.cache_prefix_blocks_verified,
617            compaction_prefix_blocks_verified: self.compaction_prefix_blocks_verified,
618            post_compaction_prefix_blocks_verified: self.post_compaction_prefix_blocks_verified,
619            pooled_prefix_blocks: 0,
620            unmatched_tool_calls: self.oracle.unmatched_tool_calls,
621            unmatched_tool_results: self.oracle.unmatched_tool_results,
622            unresolved_child_sessions,
623        })
624    }
625}
626
627/// Claude sessions the export opens one at a time.
628pub trait SessionSource {
629    /// Every session, with a lower bound on when its requests can start when one is known.
630    fn session_bounds(&self) -> Vec<(String, Option<i64>)>;
631
632    /// The session's rows in source order.
633    fn take_session(&mut self, trace_id: &str) -> Result<Vec<TraceRecord>>;
634}
635
636impl SessionSource for TraceIndex {
637    fn session_bounds(&self) -> Vec<(String, Option<i64>)> {
638        self.sessions()
639            .map(|(trace_id, bound)| (trace_id.to_string(), bound))
640            .collect()
641    }
642
643    fn take_session(&mut self, trace_id: &str) -> Result<Vec<TraceRecord>> {
644        self.load_session(trace_id)
645    }
646}
647
648impl SessionSource for FxHashMap<String, Vec<TraceRecord>> {
649    fn session_bounds(&self) -> Vec<(String, Option<i64>)> {
650        self.iter()
651            .map(|(trace_id, records)| {
652                let bound = records.iter().filter_map(request_start_bound_ms).min();
653                (trace_id.clone(), bound)
654            })
655            .collect()
656    }
657
658    fn take_session(&mut self, trace_id: &str) -> Result<Vec<TraceRecord>> {
659        self.remove(trace_id)
660            .ok_or_else(|| anyhow!("unknown Claude session {trace_id}"))
661    }
662}
663
664/// Writes request-trace and sidecar rows in global request-start order.
665///
666/// A session is parsed only once the merge could reach its earliest request and is dropped after
667/// its last turn, so memory follows the sessions active at one time rather than the corpus.
668pub fn write_streamed_request_trace_rows<F, S>(
669    output_path: &Path,
670    sidecar_path: &Path,
671    mut sessions: S,
672    preserve_session_ids: bool,
673    tokenizer_factory: F,
674    config: ExportConfig,
675) -> Result<ExportStats>
676where
677    F: TokenizerFactory,
678    S: SessionSource,
679{
680    if config.block_size == 0 {
681        bail!("block_size must be greater than 0");
682    }
683    if config.tokenizer_workers == 0 {
684        bail!("tokenizer_workers must be greater than 0");
685    }
686
687    let mut verifier = FidelityVerifier::new(SourceFidelityOracle::default());
688    let mut parser_tokenizer = LazyTokenizer::new(tokenizer_factory.clone());
689    let mut states = FxHashMap::default();
690    let mut heap = BinaryHeap::new();
691    let mut unscheduled_sessions = VecDeque::new();
692    let mut stats = ExportStats::default();
693    let mut unopened = sessions.session_bounds();
694    unopened.sort_by(|left, right| {
695        (left.1.unwrap_or(i64::MIN), &left.0).cmp(&(right.1.unwrap_or(i64::MIN), &right.0))
696    });
697    let mut unopened = unopened.into_iter().peekable();
698
699    let mut output = create_writer(output_path)?;
700    let mut sidecar = create_writer(sidecar_path)?;
701
702    let (job_tx, job_rx) = bounded::<TokenizeJob>(config.tokenizer_workers);
703    let (result_tx, result_rx) = unbounded::<TokenizeResponse>();
704    let workers = spawn_tokenizer_workers(
705        tokenizer_factory,
706        config.tokenizer_workers,
707        job_rx,
708        result_tx,
709    );
710
711    let mut trace_start_ms = None;
712    let mut prefix_pool = PrefixPool::default();
713    let mut pooled_prefix_blocks = 0_usize;
714    let mut inflight_jobs = 0_usize;
715    loop {
716        // Open every session whose requests could precede the earliest open head. Sessions open
717        // in bound order, so each remaining session starts after that head.
718        while let Some((_, bound)) = unopened.peek() {
719            let earliest_head_ms = heap
720                .peek()
721                .map(|Reverse(entry): &Reverse<HeapEntry>| entry.request_start_ms);
722            if let (Some(bound), Some(earliest_head_ms)) = (bound, earliest_head_ms)
723                && *bound > earliest_head_ms
724            {
725                break;
726            }
727            let (session_id, _) = unopened.next().expect("peeked session");
728            let records = sessions.take_session(&session_id)?;
729            verifier.oracle.add_session(&session_id, &records)?;
730            let builder =
731                SessionTurnBuilder::new(session_id.clone(), records, preserve_session_ids);
732            let mut turns = SessionTurns::open(builder, &mut parser_tokenizer)?;
733            let Some(first_turn) = turns.next(&mut parser_tokenizer)? else {
734                continue;
735            };
736            let state = SessionState {
737                turns,
738                head: Some(head_turn(first_turn, 0)),
739                overlap_base: None,
740                replay_base: None,
741                next_turn_key: 1,
742            };
743            push_heap_entry(&mut heap, &session_id, &state);
744            states.insert(session_id.clone(), state);
745            unscheduled_sessions.push_back(session_id);
746            stats.max_heap_len = stats.max_heap_len.max(heap.len());
747        }
748        if heap.is_empty() {
749            break;
750        }
751        schedule_pending_jobs(
752            &mut states,
753            &mut unscheduled_sessions,
754            &job_tx,
755            &mut inflight_jobs,
756            config.delta_overlap_words,
757            config.tokenizer_workers,
758        )?;
759
760        let Some(Reverse(entry)) = heap.peek() else {
761            break;
762        };
763        let head_ready = states
764            .get(&entry.session_id)
765            .and_then(|state| state.head.as_ref())
766            .and_then(|head| head.ready.as_ref())
767            .is_some();
768        if !head_ready {
769            let response = result_rx
770                .recv()
771                .map_err(|_| anyhow!("tokenizer worker channel closed unexpectedly"))?;
772            inflight_jobs = inflight_jobs.saturating_sub(1);
773            apply_tokenize_response(&mut states, response)?;
774            continue;
775        }
776
777        let Reverse(entry) = heap.pop().unwrap();
778        let session_id = entry.session_id.clone();
779        let (turn, sidecar_line, ready_turn) = {
780            let state = states
781                .get_mut(&session_id)
782                .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
783            let mut head = state
784                .head
785                .take()
786                .ok_or_else(|| anyhow!("missing head for session {}", session_id))?;
787            let ready_turn = head
788                .ready
789                .take()
790                .ok_or_else(|| anyhow!("missing tokenized result for session {}", session_id))?;
791            (head.turn, head.sidecar_line, ready_turn)
792        };
793
794        let trace_start_ms = *trace_start_ms.get_or_insert(turn.request_start_ms);
795        let next_turn = {
796            let state = states
797                .get_mut(&session_id)
798                .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
799            state.turns.next(&mut parser_tokenizer)?
800        };
801        let replay = {
802            let state = states
803                .get(&session_id)
804                .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
805            let cached_tokens = turn.cache_read_input_tokens.unwrap_or(0);
806            let source = match state.replay_base.as_ref() {
807                Some(base) => Some(base),
808                None if turn.compaction.is_none()
809                    && turn.observed_input_length.is_some()
810                    && cached_tokens > 0 =>
811                {
812                    Some(prefix_pool.prefix(
813                        &turn.prefix_pool_key,
814                        cached_tokens,
815                        config.block_size,
816                    )?)
817                }
818                None => None,
819            };
820            let (tokens, shared_tokens) = materialize_replay_tokens(
821                &turn,
822                &ready_turn.tokens,
823                source.map(|base| base.tokens.as_slice()),
824            );
825            if state.replay_base.is_none() {
826                pooled_prefix_blocks += shared_tokens / config.block_size;
827            }
828            ReplayBase::derive(source, shared_tokens, tokens, config.block_size)?
829        };
830        verifier.observe(&turn, &replay.tokens, &replay.hashes, config.block_size)?;
831        let request_id = turn.compaction.as_ref().map_or_else(
832            || canonical_request_id(&turn.export_session_id, turn.turn_index),
833            |compaction| {
834                canonical_compaction_request_id(&turn.export_session_id, compaction.sequence)
835            },
836        );
837        let agent_context = AgentContext {
838            session_id: &turn.export_session_id,
839            parent_session_id: turn.export_parent_session_id.as_deref(),
840        };
841        let claude = turn.compaction.as_ref().map(|compaction| {
842            json!({
843                "compaction": {
844                    "trigger": compaction.trigger,
845                    "pre_tokens": compaction.pre_tokens,
846                    "post_tokens": compaction.post_tokens,
847                    "duration_ms": compaction.duration_ms,
848                    "cache_fidelity": "recoverable_cache_safe_prefix",
849                    "output_fidelity": "tokenized_compact_summary",
850                }
851            })
852        });
853        let row = TraceLine {
854            timestamp: nonnegative_ms(turn.assistant_end_ms - trace_start_ms),
855            event: RequestEndEvent {
856                schema: REQUEST_TRACE_SCHEMA,
857                event_type: "request_end",
858                event_time_unix_ms: nonnegative_ms(turn.assistant_end_ms),
859                event_source: HARNESS_EVENT_SOURCE,
860                agent_context: &agent_context,
861                request: RequestFields {
862                    request_id: &request_id,
863                    model: &turn.model,
864                    input_tokens: replay.tokens.len(),
865                    output_tokens: turn.output_length,
866                    request_received_ms: nonnegative_ms(turn.request_start_ms),
867                    total_time_ms: (turn.assistant_end_ms - turn.request_start_ms).max(0) as f64,
868                    replay: ReplayFields {
869                        trace_block_size: config.block_size,
870                        input_length: replay.tokens.len(),
871                        input_sequence_hashes: &replay.hashes,
872                        dependencies: &[],
873                    },
874                    cached_tokens: turn.cache_read_input_tokens,
875                    claude,
876                },
877            },
878        };
879        write_json_line(&mut output, &row)?;
880        for tool in &turn.tools {
881            let claude = ClaudeToolReplayMetadata {
882                source_request_id: request_id.clone(),
883                consumer_request_id: tool
884                    .consumer_turn_index
885                    .map(|turn_index| canonical_request_id(&turn.export_session_id, turn_index)),
886                child_session_id: tool.child_session_id.clone(),
887                execution_mode: tool.execution_mode.clone(),
888            };
889            let tool_row = TraceLine {
890                timestamp: nonnegative_ms(tool.ended_at_ms - trace_start_ms),
891                event: ToolEvent {
892                    schema: REQUEST_TRACE_SCHEMA,
893                    event_type: if tool.is_error {
894                        "tool_error"
895                    } else {
896                        "tool_end"
897                    },
898                    event_time_unix_ms: nonnegative_ms(tool.ended_at_ms),
899                    event_source: HARNESS_EVENT_SOURCE,
900                    agent_context: &agent_context,
901                    tool: ToolFields {
902                        tool_call_id: &tool.tool_call_id,
903                        tool_class: &tool.tool_class,
904                        claude: Some(claude),
905                        started_at_unix_ms: nonnegative_ms(tool.started_at_ms),
906                        ended_at_unix_ms: nonnegative_ms(tool.ended_at_ms),
907                        duration_ms: (tool.ended_at_ms - tool.started_at_ms).max(0) as f64,
908                        status: if tool.is_error { "error" } else { "succeeded" },
909                        output_bytes: Some(tool.output_bytes),
910                        error_type: tool.is_error.then_some("claude_tool_error"),
911                    },
912                },
913            };
914            write_json_line(&mut output, &tool_row)?;
915            stats.tool_row_count += 1;
916        }
917        sidecar.write_all(sidecar_line.as_bytes())?;
918        sidecar.write_all(b"\n")?;
919        stats.row_count += 1;
920        stats.sidecar_count += 1;
921
922        let state = states
923            .get_mut(&session_id)
924            .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
925        state.overlap_base = turn.observed_input_length.is_none().then_some(OverlapBase {
926            previous_text: ready_turn.current_text,
927            previous_tokens: ready_turn.tokens,
928        });
929        state.replay_base = Some(replay);
930
931        if let Some(next_turn) = next_turn {
932            let turn_key = state.next_turn_key;
933            state.next_turn_key += 1;
934            state.head = Some(head_turn(next_turn, turn_key));
935            push_heap_entry(&mut heap, &session_id, state);
936            unscheduled_sessions.push_back(session_id);
937            stats.max_heap_len = stats.max_heap_len.max(heap.len());
938            continue;
939        }
940
941        states.remove(&session_id);
942    }
943
944    drop(job_tx);
945    for worker in workers {
946        worker
947            .join()
948            .map_err(|_| anyhow!("tokenizer worker panicked"))?;
949    }
950    stats.fidelity = verifier.finish(stats.row_count, stats.tool_row_count, stats.sidecar_count)?;
951    stats.fidelity.pooled_prefix_blocks = pooled_prefix_blocks;
952    output.flush()?;
953    sidecar.flush()?;
954    Ok(stats)
955}
956
957fn create_writer(path: &Path) -> Result<BufWriter<File>> {
958    if let Some(parent) = path.parent() {
959        std::fs::create_dir_all(parent)?;
960    }
961    Ok(BufWriter::new(File::create(path)?))
962}
963
964fn write_json_line(writer: &mut impl Write, value: &impl Serialize) -> Result<()> {
965    serde_json::to_writer(&mut *writer, value)?;
966    writer.write_all(b"\n")?;
967    Ok(())
968}
969
970fn nonnegative_ms(value: i64) -> u64 {
971    value.max(0) as u64
972}
973
974fn canonical_request_id(session_id: &str, turn_index: usize) -> String {
975    format!("claude:{session_id}:{turn_index}")
976}
977
978fn canonical_compaction_request_id(session_id: &str, sequence: usize) -> String {
979    format!("claude:{session_id}:compact:{sequence}")
980}
981
982/// Returns replay tokens and how many of them were copied from `prefix_source`.
983///
984/// Usage-shaped turns copy their cached prefix from the session's previous request, or for a
985/// session's first request from the shared prefix pool, and fill the rest with synthetic tokens.
986fn materialize_replay_tokens(
987    turn: &TurnDraft,
988    rendered_tokens: &[u32],
989    prefix_source: Option<&[u32]>,
990) -> (Vec<u32>, usize) {
991    let Some(input_length) = turn.observed_input_length else {
992        return (rendered_tokens.to_vec(), 0);
993    };
994    let seed = synthetic_stream_seed(&turn.export_session_id);
995
996    if turn.compaction.is_some() {
997        let shared_length = prefix_source
998            .map(<[u32]>::len)
999            .unwrap_or_default()
1000            .min(input_length.saturating_sub(1));
1001        let mut tokens = Vec::with_capacity(input_length);
1002        if let Some(prefix_source) = prefix_source {
1003            tokens.extend_from_slice(&prefix_source[..shared_length]);
1004        }
1005        let start = tokens.len();
1006        tokens.extend(
1007            (start..input_length).map(|position| synthetic_token(seed, turn.turn_index, position)),
1008        );
1009        return (tokens, shared_length);
1010    }
1011
1012    usage_shaped_tokens(
1013        seed,
1014        turn.turn_index,
1015        input_length,
1016        turn.cache_read_input_tokens.unwrap_or(0),
1017        prefix_source,
1018    )
1019}
1020
1021/// Usage-shaped turns replay synthetic hashes, so only transcript-shaped turns are tokenized.
1022fn head_turn(queued: QueuedTurn, turn_key: u64) -> HeadTurn {
1023    let ready = queued
1024        .turn
1025        .observed_input_length
1026        .is_some()
1027        .then(|| ReadyTurn {
1028            current_text: String::new(),
1029            tokens: Vec::new(),
1030        });
1031    HeadTurn {
1032        turn: queued.turn,
1033        sidecar_line: queued.sidecar_line,
1034        turn_key,
1035        scheduled: false,
1036        ready,
1037    }
1038}
1039
1040fn push_heap_entry(
1041    heap: &mut BinaryHeap<Reverse<HeapEntry>>,
1042    session_id: &str,
1043    state: &SessionState,
1044) {
1045    if let Some(head) = state.head.as_ref() {
1046        heap.push(Reverse(HeapEntry {
1047            request_start_ms: head.turn.request_start_ms,
1048            turn_index: head.turn.turn_index,
1049            export_session_id: head.turn.export_session_id.clone(),
1050            session_id: session_id.to_string(),
1051        }));
1052    }
1053}
1054
1055fn schedule_pending_jobs(
1056    states: &mut FxHashMap<String, SessionState>,
1057    unscheduled_sessions: &mut VecDeque<String>,
1058    job_tx: &Sender<TokenizeJob>,
1059    inflight_jobs: &mut usize,
1060    overlap_words: usize,
1061    worker_limit: usize,
1062) -> Result<()> {
1063    while *inflight_jobs < worker_limit {
1064        let Some(session_id) = unscheduled_sessions.pop_front() else {
1065            return Ok(());
1066        };
1067        let Some(state) = states.get_mut(&session_id) else {
1068            continue;
1069        };
1070        let Some(head) = state.head.as_mut() else {
1071            continue;
1072        };
1073        if head.scheduled || head.ready.is_some() {
1074            continue;
1075        }
1076
1077        let overlap_base = state.overlap_base.take();
1078        let current_text = std::mem::take(&mut head.turn.input_text);
1079        let (overlap_start, previous_overlap_text, previous_tokens) =
1080            prepare_overlap_inputs(overlap_base, &current_text, overlap_words);
1081        let job = TokenizeJob {
1082            session_id: session_id.clone(),
1083            turn_key: head.turn_key,
1084            current_text,
1085            overlap_start,
1086            previous_overlap_text,
1087            previous_tokens,
1088            overlap_words,
1089        };
1090        job_tx
1091            .send(job)
1092            .map_err(|_| anyhow!("failed to schedule tokenization job"))?;
1093        head.scheduled = true;
1094        *inflight_jobs += 1;
1095    }
1096    Ok(())
1097}
1098
1099fn apply_tokenize_response(
1100    states: &mut FxHashMap<String, SessionState>,
1101    response: TokenizeResponse,
1102) -> Result<()> {
1103    let Some(state) = states.get_mut(&response.session_id) else {
1104        return Ok(());
1105    };
1106    let Some(head) = state.head.as_mut() else {
1107        return Ok(());
1108    };
1109    if head.turn_key != response.turn_key {
1110        return Ok(());
1111    }
1112    head.scheduled = false;
1113    match response.outcome {
1114        Ok(ready) => {
1115            head.ready = Some(ready);
1116            Ok(())
1117        }
1118        Err(message) => bail!("{message}"),
1119    }
1120}
1121
1122fn prepare_overlap_inputs(
1123    overlap_base: Option<OverlapBase>,
1124    current_text: &str,
1125    overlap_words: usize,
1126) -> (Option<usize>, Option<String>, Option<Vec<u32>>) {
1127    if overlap_words == 0 {
1128        return (None, None, None);
1129    }
1130    let Some(overlap_base) = overlap_base else {
1131        return (None, None, None);
1132    };
1133    if !current_text.starts_with(&overlap_base.previous_text) {
1134        return (None, None, None);
1135    }
1136
1137    let overlap_start = last_word_overlap_start(&overlap_base.previous_text, overlap_words);
1138    (
1139        Some(overlap_start),
1140        Some(overlap_base.previous_text[overlap_start..].to_string()),
1141        Some(overlap_base.previous_tokens),
1142    )
1143}
1144
1145fn spawn_tokenizer_workers<F>(
1146    factory: F,
1147    worker_count: usize,
1148    job_rx: Receiver<TokenizeJob>,
1149    result_tx: Sender<TokenizeResponse>,
1150) -> Vec<JoinHandle<()>>
1151where
1152    F: TokenizerFactory,
1153{
1154    (0..worker_count)
1155        .map(|_| {
1156            let job_rx = job_rx.clone();
1157            let result_tx = result_tx.clone();
1158            let factory = factory.clone();
1159            thread::spawn(move || {
1160                let mut tokenizer = LazyTokenizer::new(factory);
1161                while let Ok(job) = job_rx.recv() {
1162                    let outcome = tokenize_job(&mut tokenizer, &job)
1163                        .map(|tokens| ReadyTurn {
1164                            current_text: job.current_text,
1165                            tokens,
1166                        })
1167                        .map_err(|error| {
1168                            format!("failed to tokenize session {}: {error:#}", job.session_id)
1169                        });
1170                    let _ = result_tx.send(TokenizeResponse {
1171                        session_id: job.session_id,
1172                        turn_key: job.turn_key,
1173                        outcome,
1174                    });
1175                }
1176            })
1177        })
1178        .collect()
1179}
1180
1181fn tokenize_job(tokenizer: &mut impl TokenizerWorker, job: &TokenizeJob) -> Result<Vec<u32>> {
1182    let Some(overlap_start) = job.overlap_start else {
1183        return tokenizer.encode(&job.current_text);
1184    };
1185    let Some(previous_overlap_text) = job.previous_overlap_text.as_deref() else {
1186        return tokenizer.encode(&job.current_text);
1187    };
1188    let Some(previous_tokens) = job.previous_tokens.as_deref() else {
1189        return tokenizer.encode(&job.current_text);
1190    };
1191    if job.overlap_words == 0 || !job.current_text.is_char_boundary(overlap_start) {
1192        return tokenizer.encode(&job.current_text);
1193    }
1194
1195    let previous_overlap_tokens = tokenizer.encode(previous_overlap_text)?;
1196    let prefix_token_count = previous_tokens
1197        .len()
1198        .saturating_sub(previous_overlap_tokens.len());
1199    let suffix_tokens = tokenizer.encode(&job.current_text[overlap_start..])?;
1200    let mut merged = Vec::with_capacity(prefix_token_count + suffix_tokens.len());
1201    merged.extend_from_slice(&previous_tokens[..prefix_token_count]);
1202    merged.extend(suffix_tokens);
1203    Ok(merged)
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::{
1209        ExportConfig, HeadTurn, ReadyTurn, SessionState, SessionTurns, TurnDraft,
1210        apply_tokenize_response, write_streamed_request_trace_rows,
1211    };
1212    use crate::coding::claude::parser::TraceRecord;
1213    use crate::coding::tokenizer::{TokenizerFactory, TokenizerWorker};
1214    use anyhow::Result;
1215    use rustc_hash::FxHashMap;
1216    use serde_json::{Value, json};
1217    use std::sync::{Arc, Mutex};
1218    use std::thread;
1219    use std::time::Duration;
1220    use tempfile::TempDir;
1221
1222    #[derive(Clone, Default)]
1223    struct StubFactory {
1224        calls: Arc<Mutex<Vec<String>>>,
1225    }
1226
1227    struct StubWorker {
1228        calls: Arc<Mutex<Vec<String>>>,
1229    }
1230
1231    impl TokenizerFactory for StubFactory {
1232        type Worker = StubWorker;
1233
1234        fn create_worker(&self) -> Result<Self::Worker> {
1235            Ok(StubWorker {
1236                calls: self.calls.clone(),
1237            })
1238        }
1239    }
1240
1241    impl TokenizerWorker for StubWorker {
1242        fn encode(&mut self, text: &str) -> Result<Vec<u32>> {
1243            if text.contains("slow") {
1244                thread::sleep(Duration::from_millis(20));
1245            }
1246            self.calls.lock().unwrap().push(text.to_string());
1247            Ok(text
1248                .split_whitespace()
1249                .map(|word| word.len() as u32)
1250                .collect())
1251        }
1252    }
1253
1254    fn make_record(
1255        session_id: &str,
1256        row_type: &str,
1257        timestamp_ms: i64,
1258        source_order: u64,
1259        raw: Value,
1260    ) -> TraceRecord {
1261        TraceRecord {
1262            session_id: session_id.to_string(),
1263            parent_session_id: None,
1264            row_type: row_type.to_string(),
1265            timestamp_ms,
1266            source_order,
1267            raw,
1268        }
1269    }
1270
1271    #[test]
1272    fn stale_result_is_dropped_by_turn_key() {
1273        let mut states = FxHashMap::default();
1274        states.insert(
1275            "session-a".to_string(),
1276            SessionState {
1277                turns: SessionTurns::Built(std::collections::VecDeque::new()),
1278                head: Some(HeadTurn {
1279                    sidecar_line: String::new(),
1280                    turn: TurnDraft {
1281                        session_id: "session-a".to_string(),
1282                        source_request_id: "req-1".to_string(),
1283                        export_session_id: "session-a".to_string(),
1284                        export_parent_session_id: None,
1285                        turn_index: 1,
1286                        model: "test-model".to_string(),
1287                        input_text: String::new(),
1288                        prefix_pool_key: String::new(),
1289                        output_length: 1,
1290                        observed_input_length: None,
1291                        cache_read_input_tokens: None,
1292                        cache_creation_input_tokens: None,
1293                        request_start_ms: 1,
1294                        assistant_start_ms: 1,
1295                        assistant_end_ms: 2,
1296                        delay_ms: None,
1297                        tools: Vec::new(),
1298                        sidecar: json!({}),
1299                        compaction: None,
1300                    },
1301                    turn_key: 9,
1302                    scheduled: true,
1303                    ready: None,
1304                }),
1305                overlap_base: None,
1306                replay_base: None,
1307                next_turn_key: 10,
1308            },
1309        );
1310
1311        apply_tokenize_response(
1312            &mut states,
1313            super::TokenizeResponse {
1314                session_id: "session-a".to_string(),
1315                turn_key: 7,
1316                outcome: Ok(ReadyTurn {
1317                    current_text: "stale".to_string(),
1318                    tokens: vec![1],
1319                }),
1320            },
1321        )
1322        .unwrap();
1323
1324        assert!(
1325            states
1326                .get("session-a")
1327                .unwrap()
1328                .head
1329                .as_ref()
1330                .unwrap()
1331                .ready
1332                .is_none()
1333        );
1334    }
1335
1336    #[test]
1337    fn streamed_writer_preserves_global_order_with_parallel_tokenization() {
1338        let temp = TempDir::new().unwrap();
1339        let output_path = temp.path().join("trace.jsonl");
1340        let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1341        let mut sessions = FxHashMap::default();
1342        sessions.insert(
1343            "session-a".to_string(),
1344            vec![
1345                make_record(
1346                    "session-a",
1347                    "user",
1348                    1_000,
1349                    0,
1350                    json!({"type":"user","message":{"role":"user","content":"slow first a"}}),
1351                ),
1352                make_record(
1353                    "session-a",
1354                    "assistant",
1355                    2_000,
1356                    1,
1357                    json!({"type":"assistant","message":{"id":"a-1","content":[{"type":"text","text":"done a"}],"usage":{"input_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":3}}}),
1358                ),
1359                make_record(
1360                    "session-a",
1361                    "user",
1362                    2_100,
1363                    2,
1364                    json!({"type":"user","message":{"role":"user","content":"follow a"}}),
1365                ),
1366                make_record(
1367                    "session-a",
1368                    "assistant",
1369                    2_200,
1370                    3,
1371                    json!({"type":"assistant","message":{"id":"a-2","content":[{"type":"text","text":"done a 2"}],"usage":{"input_tokens":2,"cache_read_input_tokens":4,"cache_creation_input_tokens":0,"output_tokens":4}}}),
1372                ),
1373            ],
1374        );
1375        sessions.insert(
1376            "session-b".to_string(),
1377            vec![
1378                make_record(
1379                    "session-b",
1380                    "user",
1381                    900,
1382                    4,
1383                    json!({"type":"user","message":{"role":"user","content":"first b"}}),
1384                ),
1385                make_record(
1386                    "session-b",
1387                    "assistant",
1388                    1_100,
1389                    5,
1390                    json!({"type":"assistant","message":{"id":"b-1","content":[{"type":"text","text":"done b"}],"usage":{"output_tokens":2}}}),
1391                ),
1392            ],
1393        );
1394
1395        let stats = write_streamed_request_trace_rows(
1396            &output_path,
1397            &sidecar_path,
1398            sessions,
1399            true,
1400            StubFactory::default(),
1401            ExportConfig {
1402                block_size: 2,
1403                delta_overlap_words: 50,
1404                tokenizer_workers: 2,
1405            },
1406        )
1407        .unwrap();
1408
1409        let rows = std::fs::read_to_string(&output_path)
1410            .unwrap()
1411            .lines()
1412            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1413            .collect::<Vec<_>>();
1414        let sidecar_rows = std::fs::read_to_string(&sidecar_path)
1415            .unwrap()
1416            .lines()
1417            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1418            .collect::<Vec<_>>();
1419
1420        assert_eq!(stats.row_count, 3);
1421        assert_eq!(stats.sidecar_count, 3);
1422        assert!(stats.max_heap_len <= 2);
1423        assert_eq!(rows.len(), 3);
1424        assert_eq!(sidecar_rows.len(), 3);
1425        assert_eq!(rows[0]["event"]["agent_context"]["session_id"], "session-b");
1426        assert_eq!(rows[1]["event"]["agent_context"]["session_id"], "session-a");
1427        assert!(
1428            rows[1]["event"]["agent_context"]
1429                .get("session_final")
1430                .is_none()
1431        );
1432        assert_eq!(rows[2]["event"]["request"]["request_received_ms"], 2_100);
1433        assert_eq!(rows[1]["event"]["request"]["replay"]["input_length"], 4);
1434        assert_eq!(rows[2]["event"]["request"]["replay"]["input_length"], 6);
1435        let first_hashes = rows[1]["event"]["request"]["replay"]["input_sequence_hashes"]
1436            .as_array()
1437            .unwrap();
1438        let second_hashes = rows[2]["event"]["request"]["replay"]["input_sequence_hashes"]
1439            .as_array()
1440            .unwrap();
1441        assert_eq!(first_hashes.as_slice(), &second_hashes[..2]);
1442    }
1443
1444    #[test]
1445    fn transcript_shaped_sessions_tokenize_during_the_merge() {
1446        let temp = TempDir::new().unwrap();
1447        let output_path = temp.path().join("trace.jsonl");
1448        let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1449        let mut sessions = FxHashMap::default();
1450        sessions.insert(
1451            "session-u".to_string(),
1452            vec![
1453                make_record(
1454                    "session-u",
1455                    "user",
1456                    1_000,
1457                    0,
1458                    json!({"type":"user","message":{"role":"user","content":"usage prompt"}}),
1459                ),
1460                make_record(
1461                    "session-u",
1462                    "assistant",
1463                    1_100,
1464                    1,
1465                    json!({"type":"assistant","message":{"id":"u-1","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":4,"output_tokens":1}}}),
1466                ),
1467            ],
1468        );
1469        sessions.insert(
1470            "session-t".to_string(),
1471            vec![
1472                make_record(
1473                    "session-t",
1474                    "user",
1475                    1_050,
1476                    2,
1477                    json!({"type":"user","message":{"role":"user","content":"first t prompt"}}),
1478                ),
1479                make_record(
1480                    "session-t",
1481                    "assistant",
1482                    1_200,
1483                    3,
1484                    json!({"type":"assistant","message":{"id":"t-1","content":[{"type":"text","text":"answer"}]}}),
1485                ),
1486                make_record(
1487                    "session-t",
1488                    "user",
1489                    1_300,
1490                    4,
1491                    json!({"type":"user","message":{"role":"user","content":"second t"}}),
1492                ),
1493                make_record(
1494                    "session-t",
1495                    "assistant",
1496                    1_400,
1497                    5,
1498                    json!({"type":"assistant","message":{"id":"t-2","content":[{"type":"text","text":"again"}]}}),
1499                ),
1500            ],
1501        );
1502        let factory = StubFactory::default();
1503        let calls = factory.calls.clone();
1504
1505        let stats = write_streamed_request_trace_rows(
1506            &output_path,
1507            &sidecar_path,
1508            sessions,
1509            true,
1510            factory,
1511            ExportConfig {
1512                block_size: 2,
1513                delta_overlap_words: 50,
1514                tokenizer_workers: 2,
1515            },
1516        )
1517        .unwrap();
1518
1519        let rows = std::fs::read_to_string(&output_path)
1520            .unwrap()
1521            .lines()
1522            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1523            .collect::<Vec<_>>();
1524        assert_eq!(stats.row_count, 3);
1525        let order = rows
1526            .iter()
1527            .map(|row| row["event"]["request"]["request_id"].as_str().unwrap())
1528            .collect::<Vec<_>>();
1529        assert_eq!(
1530            order,
1531            [
1532                "claude:session-u:0",
1533                "claude:session-t:0",
1534                "claude:session-t:1"
1535            ]
1536        );
1537        // The transcript-shaped turn replays its tokenized transcript: "[user] first t prompt".
1538        assert_eq!(rows[1]["event"]["request"]["replay"]["input_length"], 4);
1539        assert!(
1540            rows[2]["event"]["request"]["replay"]["input_length"]
1541                .as_u64()
1542                .unwrap()
1543                > 4
1544        );
1545        // The usage-shaped session never reaches the tokenizer.
1546        assert!(
1547            calls
1548                .lock()
1549                .unwrap()
1550                .iter()
1551                .all(|text| !text.contains("usage prompt"))
1552        );
1553    }
1554
1555    #[test]
1556    fn streamed_writer_replays_cache_safe_compaction() {
1557        use dynamo_data_gen::request_trace::{
1558            agentic::lower_agentic_mooncake_rows, load::load_request_trace_records,
1559        };
1560
1561        let temp = TempDir::new().unwrap();
1562        let output_path = temp.path().join("trace.jsonl");
1563        let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1564        let mut sessions = FxHashMap::default();
1565        sessions.insert(
1566            "session-a".to_string(),
1567            vec![
1568                make_record(
1569                    "session-a",
1570                    "user",
1571                    1_000,
1572                    0,
1573                    json!({"type":"user","message":{"role":"user","content":"first prompt"}}),
1574                ),
1575                make_record(
1576                    "session-a",
1577                    "assistant",
1578                    1_100,
1579                    1,
1580                    json!({"type":"assistant","requestId":"req-0","message":{"id":"a-0","model":"test-model","content":[{"type":"text","text":"first answer"}],"usage":{"input_tokens":8,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":2}}}),
1581                ),
1582                make_record(
1583                    "session-a",
1584                    "system",
1585                    2_000,
1586                    2,
1587                    json!({"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":10,"postTokens":3,"durationMs":500}}),
1588                ),
1589                make_record(
1590                    "session-a",
1591                    "user",
1592                    2_000,
1593                    3,
1594                    json!({"type":"user","isCompactSummary":true,"message":{"role":"user","content":"compact summary"}}),
1595                ),
1596                make_record(
1597                    "session-a",
1598                    "assistant",
1599                    2_100,
1600                    4,
1601                    json!({"type":"assistant","requestId":"req-1","message":{"id":"a-1","model":"test-model","content":[{"type":"text","text":"after compact"}],"usage":{"input_tokens":2,"cache_read_input_tokens":4,"cache_creation_input_tokens":6,"output_tokens":2}}}),
1602                ),
1603                make_record(
1604                    "session-a",
1605                    "user",
1606                    2_200,
1607                    5,
1608                    json!({"type":"user","message":{"role":"user","content":"next prompt"}}),
1609                ),
1610                make_record(
1611                    "session-a",
1612                    "assistant",
1613                    2_300,
1614                    6,
1615                    json!({"type":"assistant","requestId":"req-2","message":{"id":"a-2","model":"test-model","content":[{"type":"text","text":"next answer"}],"usage":{"input_tokens":2,"cache_read_input_tokens":10,"cache_creation_input_tokens":2,"output_tokens":2}}}),
1616                ),
1617            ],
1618        );
1619
1620        let no_prefix_error = write_streamed_request_trace_rows(
1621            &temp.path().join("no-prefix.jsonl"),
1622            &temp.path().join("no-prefix.sidecar.jsonl"),
1623            sessions.clone(),
1624            true,
1625            StubFactory::default(),
1626            ExportConfig {
1627                block_size: 16,
1628                delta_overlap_words: 50,
1629                tokenizer_workers: 1,
1630            },
1631        )
1632        .unwrap_err();
1633        assert!(
1634            no_prefix_error
1635                .to_string()
1636                .contains("no recoverable compaction prefix")
1637        );
1638
1639        let mut no_summary_write = sessions.clone();
1640        let first_post = no_summary_write
1641            .get_mut("session-a")
1642            .unwrap()
1643            .iter_mut()
1644            .find(|record| record.raw["requestId"] == "req-1")
1645            .unwrap();
1646        first_post.raw["message"]["usage"]["cache_creation_input_tokens"] = json!(0);
1647        let no_summary_write_error = write_streamed_request_trace_rows(
1648            &temp.path().join("no-summary-write.jsonl"),
1649            &temp.path().join("no-summary-write.sidecar.jsonl"),
1650            no_summary_write,
1651            true,
1652            StubFactory::default(),
1653            ExportConfig {
1654                block_size: 2,
1655                delta_overlap_words: 50,
1656                tokenizer_workers: 1,
1657            },
1658        )
1659        .unwrap_err();
1660        assert!(
1661            no_summary_write_error
1662                .to_string()
1663                .contains("post-compaction cache miss")
1664        );
1665
1666        let stats = write_streamed_request_trace_rows(
1667            &output_path,
1668            &sidecar_path,
1669            sessions,
1670            true,
1671            StubFactory::default(),
1672            ExportConfig {
1673                block_size: 2,
1674                delta_overlap_words: 50,
1675                tokenizer_workers: 1,
1676            },
1677        )
1678        .unwrap();
1679
1680        let rows = std::fs::read_to_string(&output_path)
1681            .unwrap()
1682            .lines()
1683            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1684            .collect::<Vec<_>>();
1685        let sidecars = std::fs::read_to_string(&sidecar_path)
1686            .unwrap()
1687            .lines()
1688            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1689            .collect::<Vec<_>>();
1690
1691        assert_eq!(stats.row_count, 4);
1692        assert_eq!(stats.sidecar_count, 4);
1693        assert_eq!(stats.fidelity.compactions_verified, 1);
1694        assert_eq!(stats.fidelity.compaction_prefix_blocks_verified, 4);
1695        assert_eq!(stats.fidelity.post_compaction_prefix_blocks_verified, 2);
1696        assert_eq!(rows.len(), 4);
1697        assert_eq!(sidecars.len(), 4);
1698        assert_eq!(
1699            rows[1]["event"]["request"]["request_id"],
1700            "claude:session-a:compact:0"
1701        );
1702        assert_eq!(rows[1]["event"]["request"]["request_received_ms"], 1_500);
1703        assert_eq!(rows[1]["event"]["event_time_unix_ms"], 2_000);
1704        assert_eq!(rows[1]["event"]["request"]["total_time_ms"], 500.0);
1705        assert!(rows[1]["event"]["request"].get("cached_tokens").is_none());
1706        assert_eq!(rows[1]["event"]["request"]["replay"]["input_length"], 10);
1707        assert_eq!(
1708            rows[1]["event"]["request"]["claude"]["compaction"]["pre_tokens"],
1709            10
1710        );
1711        assert_eq!(
1712            rows[1]["event"]["request"]["claude"]["compaction"]["post_tokens"],
1713            3
1714        );
1715
1716        let hashes = rows
1717            .iter()
1718            .map(|row| {
1719                row["event"]["request"]["replay"]["input_sequence_hashes"]
1720                    .as_array()
1721                    .unwrap()
1722            })
1723            .collect::<Vec<_>>();
1724        assert_eq!(hashes[0], &hashes[1][..4]);
1725        assert_eq!(&hashes[1][..2], &hashes[2][..2]);
1726        assert_ne!(hashes[1][2], hashes[2][2]);
1727        assert_eq!(&hashes[2][..5], &hashes[3][..5]);
1728        assert_ne!(hashes[2][5], hashes[3][5]);
1729
1730        let loaded = load_request_trace_records(&[output_path]).unwrap();
1731        assert_eq!(loaded.requests.len(), 4);
1732        let mut agentic_rows = Vec::new();
1733        lower_agentic_mooncake_rows(loaded, |_, row| {
1734            agentic_rows.push(row);
1735            Ok(())
1736        })
1737        .unwrap();
1738        assert_eq!(agentic_rows.len(), 4);
1739        assert_eq!(agentic_rows[1].request_id, "claude:session-a:compact:0");
1740    }
1741
1742    #[test]
1743    fn streamed_writer_emits_canonical_tool_terminal_events() {
1744        use dynamo_data_gen::request_trace::load::load_request_trace_records;
1745
1746        let temp = TempDir::new().unwrap();
1747        let output_path = temp.path().join("trace.jsonl");
1748        let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1749        let mut sessions = FxHashMap::default();
1750        sessions.insert(
1751            "session-a".to_string(),
1752            vec![
1753                make_record(
1754                    "session-a",
1755                    "user",
1756                    1_000,
1757                    0,
1758                    json!({"type":"user","message":{"role":"user","content":"run"}}),
1759                ),
1760                make_record(
1761                    "session-a",
1762                    "assistant",
1763                    1_100,
1764                    1,
1765                    json!({"type":"assistant","requestId":"req-1","message":{"id":"a-1","content":[{"type":"tool_use","id":"raw-1","name":"Bash","input":{}}],"usage":{"input_tokens":2,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":3}}}),
1766                ),
1767                make_record(
1768                    "session-a",
1769                    "user",
1770                    1_200,
1771                    2,
1772                    json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"raw-1","content":"bad","is_error":true}]}}),
1773                ),
1774                make_record(
1775                    "session-a",
1776                    "ai-title",
1777                    0,
1778                    3,
1779                    json!({"type":"ai-title","aiTitle":"Background title"}),
1780                ),
1781            ],
1782        );
1783
1784        let stats = write_streamed_request_trace_rows(
1785            &output_path,
1786            &sidecar_path,
1787            sessions,
1788            true,
1789            StubFactory::default(),
1790            ExportConfig {
1791                block_size: 2,
1792                delta_overlap_words: 50,
1793                tokenizer_workers: 1,
1794            },
1795        )
1796        .unwrap();
1797
1798        assert_eq!(stats.row_count, 1);
1799        assert_eq!(stats.tool_row_count, 1);
1800        assert_eq!(stats.fidelity.requests_verified, 1);
1801        assert_eq!(stats.fidelity.tools_verified, 1);
1802        assert_eq!(stats.fidelity.background_titles_unreplayable, 1);
1803        let rows = std::fs::read_to_string(&output_path).unwrap();
1804        assert!(rows.lines().any(|line| {
1805            let row: Value = serde_json::from_str(line).unwrap();
1806            row["event"]["event_type"] == "tool_error"
1807                && row["event"]["tool"]["tool_class"] == "Bash"
1808        }));
1809        let loaded = load_request_trace_records(&[output_path]).unwrap();
1810        assert_eq!(loaded.tools.len(), 1);
1811    }
1812
1813    #[test]
1814    fn request_trace_preserves_child_identity_and_anonymized_causality() {
1815        use dynamo_data_gen::request_trace::{
1816            agentic::lower_agentic_mooncake_rows, load::load_request_trace_records,
1817        };
1818
1819        let temp = TempDir::new().unwrap();
1820        let output_path = temp.path().join("trace.jsonl");
1821        let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1822        let mut sessions = FxHashMap::default();
1823        sessions.insert(
1824            "root-session".to_string(),
1825            vec![
1826                make_record(
1827                    "root-session",
1828                    "user",
1829                    1_000,
1830                    0,
1831                    json!({"type":"user","message":{"role":"user","content":"spawn child"}}),
1832                ),
1833                make_record(
1834                    "root-session",
1835                    "assistant",
1836                    1_100,
1837                    1,
1838                    json!({"type":"assistant","requestId":"root-1","message":{"id":"root-1","content":[{"type":"tool_use","id":"agent-call","name":"Agent","input":{"run_in_background":true}}],"usage":{"output_tokens":2}}}),
1839                ),
1840                make_record(
1841                    "root-session",
1842                    "user",
1843                    1_150,
1844                    2,
1845                    json!({"type":"user","toolUseResult":{"isAsync":true,"agentId":"child-agent","status":"async_launched"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"agent-call","content":"launched"}]}}),
1846                ),
1847                make_record(
1848                    "root-session",
1849                    "user",
1850                    1_300,
1851                    4,
1852                    json!({"type":"user","message":{"role":"user","content":"continue parent work"}}),
1853                ),
1854                make_record(
1855                    "root-session",
1856                    "assistant",
1857                    1_400,
1858                    5,
1859                    json!({"type":"assistant","requestId":"root-2","message":{"id":"root-2","content":[{"type":"text","text":"working"}],"usage":{"output_tokens":1}}}),
1860                ),
1861                make_record(
1862                    "root-session",
1863                    "queue-operation",
1864                    1_800,
1865                    6,
1866                    json!({"type":"queue-operation","operation":"enqueue","content":"<tool-use-id>agent-call</tool-use-id><status>completed</status>done"}),
1867                ),
1868                make_record(
1869                    "root-session",
1870                    "user",
1871                    1_850,
1872                    7,
1873                    json!({"type":"user","message":{"role":"user","content":"child done"}}),
1874                ),
1875                make_record(
1876                    "root-session",
1877                    "assistant",
1878                    1_950,
1879                    8,
1880                    json!({"type":"assistant","requestId":"root-3","message":{"id":"root-3","content":[{"type":"text","text":"finished"}],"usage":{"output_tokens":1}}}),
1881                ),
1882            ],
1883        );
1884        sessions.insert(
1885            "child-agent".to_string(),
1886            vec![
1887                make_record(
1888                    "root-session",
1889                    "user",
1890                    1_200,
1891                    3,
1892                    json!({"type":"user","isSidechain":true,"agentId":"child-agent","message":{"role":"user","content":"investigate"}}),
1893                ),
1894                make_record(
1895                    "root-session",
1896                    "assistant",
1897                    1_700,
1898                    9,
1899                    json!({"type":"assistant","isSidechain":true,"agentId":"child-agent","message":{"id":"child-1","content":[{"type":"text","text":"result"}],"usage":{"output_tokens":1}}}),
1900                ),
1901            ],
1902        );
1903
1904        let config = ExportConfig {
1905            block_size: 2,
1906            delta_overlap_words: 50,
1907            tokenizer_workers: 2,
1908        };
1909        let stats = write_streamed_request_trace_rows(
1910            &output_path,
1911            &sidecar_path,
1912            sessions.clone(),
1913            true,
1914            StubFactory::default(),
1915            config,
1916        )
1917        .unwrap();
1918
1919        let anonymous_stats = write_streamed_request_trace_rows(
1920            &temp.path().join("anonymous.jsonl"),
1921            &temp.path().join("anonymous.sidecar.jsonl"),
1922            sessions,
1923            false,
1924            StubFactory::default(),
1925            config,
1926        )
1927        .unwrap();
1928        assert_eq!(anonymous_stats.fidelity.requests_verified, 4);
1929
1930        assert_eq!(stats.fidelity.requests_verified, 4);
1931        assert_eq!(stats.fidelity.tools_verified, 1);
1932        assert_eq!(stats.fidelity.child_links_verified, 1);
1933        assert_eq!(stats.fidelity.background_tools, 1);
1934        assert_eq!(stats.fidelity.background_agents, 1);
1935
1936        let rows = std::fs::read_to_string(&output_path)
1937            .unwrap()
1938            .lines()
1939            .map(|line| serde_json::from_str::<Value>(line).unwrap())
1940            .collect::<Vec<_>>();
1941        let child = rows
1942            .iter()
1943            .find(|row| row["event"]["agent_context"]["session_id"] == "child-agent")
1944            .unwrap();
1945        assert_eq!(
1946            child["event"]["agent_context"]["parent_session_id"],
1947            "root-session"
1948        );
1949        assert!(
1950            child["event"]["agent_context"]
1951                .get("session_final")
1952                .is_none()
1953        );
1954
1955        let loaded = load_request_trace_records(&[output_path]).unwrap();
1956        let mut agentic_rows = Vec::new();
1957        lower_agentic_mooncake_rows(loaded, |_, row| {
1958            agentic_rows.push(row);
1959            Ok(())
1960        })
1961        .unwrap();
1962        assert_eq!(agentic_rows.len(), 4);
1963        let by_id = agentic_rows
1964            .iter()
1965            .map(|row| (row.request_id.as_str(), row))
1966            .collect::<std::collections::HashMap<_, _>>();
1967        assert!(
1968            by_id["claude:child-agent:0"]
1969                .dependencies
1970                .iter()
1971                .any(|edge| {
1972                    edge.request_id == "claude:root-session:0"
1973                        && edge.relation == dynamo_data_gen::AgenticDependencyRelation::Spawn
1974                })
1975        );
1976        assert!(
1977            by_id["claude:root-session:1"]
1978                .dependencies
1979                .iter()
1980                .any(|edge| {
1981                    edge.request_id == "claude:root-session:0"
1982                        && edge.relation == dynamo_data_gen::AgenticDependencyRelation::Sequence
1983                })
1984        );
1985        assert!(
1986            !by_id["claude:root-session:2"]
1987                .dependencies
1988                .iter()
1989                .any(|edge| {
1990                    edge.request_id == "claude:child-agent:0"
1991                        && edge.relation == dynamo_data_gen::AgenticDependencyRelation::Join
1992                })
1993        );
1994    }
1995}