Skip to main content

dynamo_data_gen/
satf.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SATF workload artifacts and Dynamo request-trace conversion.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::io::Write;
8use std::path::PathBuf;
9
10use anyhow::{Context, Result, bail};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use crate::mooncake::RollingHashIdMapper;
15use crate::request_trace::load::{
16    LoadedAgentTrace, RequestEntry, RequestTraceFinishReasonMetadata, ToolEntry,
17    load_request_trace_records,
18};
19
20pub const SATF_FORMAT: &str = "satf";
21pub const SATF_VERSION: &str = "2.0.0";
22pub const SATF_TIME_BASE: &str = "us";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct SatfTrace {
26    pub format: String,
27    pub format_version: String,
28    pub time_base: String,
29    pub block_size_tokens: usize,
30    pub sessions: Vec<SatfSession>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub producer: Option<SatfProducer>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub extensions: Option<Value>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct SatfProducer {
39    pub name: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub version: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub commit: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct SatfSession {
48    pub session_id: String,
49    pub nodes: Vec<SatfNode>,
50    pub edges: Vec<SatfEdge>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub extensions: Option<Value>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56#[serde(tag = "kind", rename_all = "snake_case")]
57pub enum SatfNode {
58    LlmInfer {
59        id: String,
60        #[serde(skip_serializing_if = "Option::is_none")]
61        duration_us: Option<u64>,
62        #[serde(skip_serializing_if = "Option::is_none")]
63        min_start_delay_us: Option<u64>,
64        #[serde(skip_serializing_if = "Option::is_none")]
65        performance_metrics: Option<SatfPerformanceMetrics>,
66        payload: SatfLlmInferPayload,
67        #[serde(skip_serializing_if = "Option::is_none")]
68        extensions: Option<Value>,
69    },
70    ToolCall {
71        id: String,
72        #[serde(skip_serializing_if = "Option::is_none")]
73        min_start_delay_us: Option<u64>,
74        payload: SatfToolCallPayload,
75        #[serde(skip_serializing_if = "Option::is_none")]
76        extensions: Option<Value>,
77    },
78    ToolResult {
79        id: String,
80        #[serde(skip_serializing_if = "Option::is_none")]
81        duration_us: Option<u64>,
82        payload: SatfToolResultPayload,
83        #[serde(skip_serializing_if = "Option::is_none")]
84        extensions: Option<Value>,
85    },
86}
87
88impl SatfNode {
89    pub fn id(&self) -> &str {
90        match self {
91            Self::LlmInfer { id, .. } | Self::ToolCall { id, .. } | Self::ToolResult { id, .. } => {
92                id
93            }
94        }
95    }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct SatfEdge {
100    pub from: String,
101    pub to: String,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub delay_after_predecessor_us: Option<u64>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct SatfLlmInferPayload {
108    pub input_tokens_total: u64,
109    pub output_tokens: u64,
110    pub kv_block_ids: Vec<u64>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub request_parameters: Option<SatfRequestParameters>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct SatfRequestParameters {
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub model: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122pub struct SatfToolCallPayload {
123    pub tool_name: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub tool_call_id: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub struct SatfToolResultPayload {
130    pub tool_call_id: String,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub is_error: Option<bool>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct SatfPerformanceMetrics {
137    pub source: String,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub time_to_first_token_us: Option<f64>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub inter_token_latency_us: Option<f64>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub queue_delay_us: Option<f64>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub kv_cache_hit_rate: Option<f64>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub kv_transfer_latency_us: Option<f64>,
148}
149
150pub fn convert_request_trace_to_satf(paths: &[PathBuf]) -> Result<SatfTrace> {
151    convert_loaded_trace(load_request_trace_records(paths)?)
152}
153
154pub fn write_satf<W: Write>(writer: W, trace: &SatfTrace, pretty: bool) -> Result<()> {
155    if pretty {
156        serde_json::to_writer_pretty(writer, trace).context("serializing pretty SATF JSON")?;
157    } else {
158        serde_json::to_writer(writer, trace).context("serializing SATF JSON")?;
159    }
160    Ok(())
161}
162
163fn convert_loaded_trace(mut loaded: LoadedAgentTrace) -> Result<SatfTrace> {
164    loaded.requests.sort_by(|left, right| {
165        (left.start_ms, left.end_ms, &left.request.request_id).cmp(&(
166            right.start_ms,
167            right.end_ms,
168            &right.request.request_id,
169        ))
170    });
171
172    let block_size_tokens = loaded.requests[0].replay.trace_block_size;
173    if loaded
174        .requests
175        .iter()
176        .any(|request| request.replay.trace_block_size != block_size_tokens)
177    {
178        bail!("SATF conversion requires one trace block size per artifact");
179    }
180
181    let artifact_start_unix_us = loaded
182        .requests
183        .iter()
184        .map(request_start_us)
185        .min()
186        .expect("request-trace loading requires at least one request");
187
188    let mut mapper = RollingHashIdMapper::new(block_size_tokens);
189    for request in &mut loaded.requests {
190        request.replay.input_sequence_hashes =
191            mapper.ids_for_sequence_hashes(&request.replay.input_sequence_hashes);
192    }
193
194    let mut requests_by_session: BTreeMap<String, Vec<RequestEntry>> = BTreeMap::new();
195    for request in loaded.requests {
196        requests_by_session
197            .entry(session_id_for(&request))
198            .or_default()
199            .push(request);
200    }
201
202    let mut tools_by_session: HashMap<String, Vec<ToolEntry>> = HashMap::new();
203    for tool in loaded.tools {
204        tools_by_session
205            .entry(tool.session_id.clone())
206            .or_default()
207            .push(tool);
208    }
209
210    let mut sessions = requests_by_session
211        .into_iter()
212        .map(|(session_id, requests)| {
213            let tools = tools_by_session.remove(&session_id).unwrap_or_default();
214            convert_session(session_id, requests, tools, artifact_start_unix_us)
215        })
216        .collect::<Result<Vec<_>>>()?;
217    sessions.sort_by(|left, right| {
218        session_first_start_us(left)
219            .cmp(&session_first_start_us(right))
220            .then_with(|| left.session_id.cmp(&right.session_id))
221    });
222
223    Ok(SatfTrace {
224        format: SATF_FORMAT.to_string(),
225        format_version: SATF_VERSION.to_string(),
226        time_base: SATF_TIME_BASE.to_string(),
227        block_size_tokens,
228        sessions,
229        producer: Some(SatfProducer {
230            name: "dynamo-request-trace-converter".to_string(),
231            version: Some(env!("CARGO_PKG_VERSION").to_string()),
232            commit: None,
233        }),
234        extensions: Some(vendor_extension(Value::Object(Map::from_iter([(
235            "artifact_start_unix_us".to_string(),
236            artifact_start_unix_us.into(),
237        )])))),
238    })
239}
240
241fn convert_session(
242    session_id: String,
243    mut requests: Vec<RequestEntry>,
244    mut tools: Vec<ToolEntry>,
245    artifact_start_unix_us: u64,
246) -> Result<SatfSession> {
247    requests.sort_by(|left, right| {
248        (
249            request_start_us(left),
250            request_end_us(left),
251            &left.request.request_id,
252        )
253            .cmp(&(
254                request_start_us(right),
255                request_end_us(right),
256                &right.request.request_id,
257            ))
258    });
259    tools.sort_by_key(|tool| (tool_start_us(tool), tool_end_us(tool)));
260
261    let extensions = session_extensions(&requests[0]);
262    let mut nodes = requests
263        .iter()
264        .enumerate()
265        .map(|(index, request)| llm_node(request, (index == 0).then_some(artifact_start_unix_us)))
266        .collect::<Vec<_>>();
267    let mut edges = Vec::new();
268    let request_index = requests
269        .iter()
270        .enumerate()
271        .map(|(index, request)| (request.request.request_id.as_str(), index))
272        .collect::<HashMap<_, _>>();
273    let mut tool_paths = HashSet::new();
274
275    for (ordinal, tool) in tools.iter().enumerate() {
276        let source_index = tool
277            .claude
278            .as_ref()
279            .and_then(|metadata| {
280                request_index
281                    .get(metadata.source_request_id.as_str())
282                    .copied()
283            })
284            .or_else(|| find_tool_source(&requests, tool));
285        let consumer_index = tool
286            .claude
287            .as_ref()
288            .and_then(|metadata| metadata.consumer_request_id.as_deref())
289            .and_then(|request_id| request_index.get(request_id).copied())
290            .or_else(|| find_tool_consumer(&requests, tool, source_index));
291
292        let call_node_id = format!("tool_call:{ordinal}");
293        let result_node_id = format!("tool_result:{ordinal}");
294        let source_request = source_index.map(|index| &requests[index]);
295        let tool_name = source_request
296            .and_then(|request| tool_name_from_finish_metadata(request, &tool.tool_call_id))
297            .unwrap_or_else(|| tool.tool_class.clone());
298
299        nodes.push(SatfNode::ToolCall {
300            id: call_node_id.clone(),
301            min_start_delay_us: source_index
302                .is_none()
303                .then(|| tool_start_us(tool).saturating_sub(artifact_start_unix_us)),
304            payload: SatfToolCallPayload {
305                tool_name,
306                tool_call_id: Some(tool.tool_call_id.clone()),
307            },
308            extensions: None,
309        });
310        nodes.push(SatfNode::ToolResult {
311            id: result_node_id.clone(),
312            duration_us: Some(tool_duration_us(tool)),
313            payload: SatfToolResultPayload {
314                tool_call_id: tool.tool_call_id.clone(),
315                is_error: Some(tool_is_error(tool)),
316            },
317            extensions: Some(tool_extensions(tool)),
318        });
319
320        if let Some(source_index) = source_index {
321            edges.push(edge(
322                llm_node_id(&requests[source_index]),
323                call_node_id.clone(),
324                tool_start_us(tool).saturating_sub(request_end_us(&requests[source_index])),
325            ));
326        }
327        edges.push(edge(call_node_id, result_node_id.clone(), 0));
328        if let Some(consumer_index) = consumer_index {
329            edges.push(edge(
330                result_node_id,
331                llm_node_id(&requests[consumer_index]),
332                request_start_us(&requests[consumer_index]).saturating_sub(tool_end_us(tool)),
333            ));
334            if let Some(source_index) = source_index {
335                tool_paths.insert((source_index, consumer_index));
336            }
337        }
338    }
339
340    for current_index in 1..requests.len() {
341        let previous_index = current_index - 1;
342        if tool_paths.contains(&(previous_index, current_index)) {
343            continue;
344        }
345        edges.push(edge(
346            llm_node_id(&requests[previous_index]),
347            llm_node_id(&requests[current_index]),
348            request_start_us(&requests[current_index])
349                .saturating_sub(request_end_us(&requests[previous_index])),
350        ));
351    }
352
353    Ok(SatfSession {
354        session_id,
355        nodes,
356        edges,
357        extensions,
358    })
359}
360
361fn llm_node(request: &RequestEntry, artifact_start_unix_us: Option<u64>) -> SatfNode {
362    let metrics = SatfPerformanceMetrics {
363        source: "dynamo".to_string(),
364        time_to_first_token_us: request.request.ttft_ms.map(|value| value * 1_000.0),
365        inter_token_latency_us: request.request.avg_itl_ms.map(|value| value * 1_000.0),
366        queue_delay_us: request
367            .request
368            .prefill_wait_time_ms
369            .map(|value| value * 1_000.0),
370        kv_cache_hit_rate: request.request.kv_hit_rate,
371        kv_transfer_latency_us: request
372            .request
373            .kv_transfer_estimated_latency_ms
374            .map(|value| value * 1_000.0),
375    };
376    let metrics_present = metrics.time_to_first_token_us.is_some()
377        || metrics.inter_token_latency_us.is_some()
378        || metrics.queue_delay_us.is_some()
379        || metrics.kv_cache_hit_rate.is_some()
380        || metrics.kv_transfer_latency_us.is_some();
381
382    SatfNode::LlmInfer {
383        id: llm_node_id(request),
384        duration_us: Some(request_duration_us(request)),
385        min_start_delay_us: artifact_start_unix_us
386            .map(|start| request_start_us(request).saturating_sub(start)),
387        performance_metrics: metrics_present.then_some(metrics),
388        payload: SatfLlmInferPayload {
389            input_tokens_total: request.replay.input_length as u64,
390            output_tokens: request.request.output_tokens.unwrap_or_default(),
391            kv_block_ids: request.replay.input_sequence_hashes.clone(),
392            request_parameters: request
393                .request
394                .model
395                .as_ref()
396                .map(|model| SatfRequestParameters {
397                    model: Some(model.clone()),
398                }),
399        },
400        extensions: Some(request_extensions(request)),
401    }
402}
403
404fn request_extensions(request: &RequestEntry) -> Value {
405    let mut fields = Map::from_iter([(
406        "request_id".to_string(),
407        request.request.request_id.clone().into(),
408    )]);
409    insert_optional(
410        &mut fields,
411        "x_request_id",
412        request.request.x_request_id.clone(),
413    );
414    insert_optional(&mut fields, "input_tokens", request.request.input_tokens);
415    insert_optional(&mut fields, "cached_tokens", request.request.cached_tokens);
416    insert_optional(
417        &mut fields,
418        "prefill_time_ms",
419        request.request.prefill_time_ms,
420    );
421    insert_optional(&mut fields, "queue_depth", request.request.queue_depth);
422    insert_optional(&mut fields, "worker", request.request.worker.clone());
423    insert_optional(
424        &mut fields,
425        "finish_reason_metadata",
426        request.request.finish_reason_metadata.clone(),
427    );
428    vendor_extension(Value::Object(fields))
429}
430
431fn tool_extensions(tool: &ToolEntry) -> Value {
432    let mut fields = Map::from_iter([
433        ("status".to_string(), tool.status.clone().into()),
434        ("tool_class".to_string(), tool.tool_class.clone().into()),
435    ]);
436    insert_optional(&mut fields, "output_bytes", tool.output_bytes);
437    insert_optional(&mut fields, "output_tokens", tool.output_tokens);
438    insert_optional(&mut fields, "error_type", tool.error_type.clone());
439    vendor_extension(Value::Object(fields))
440}
441
442fn session_extensions(request: &RequestEntry) -> Option<Value> {
443    let context = request.agent_context.as_ref()?;
444    let mut fields = Map::new();
445    insert_optional(
446        &mut fields,
447        "parent_session_id",
448        context.parent_session_id.clone(),
449    );
450    (!fields.is_empty()).then(|| vendor_extension(Value::Object(fields)))
451}
452
453fn vendor_extension(value: Value) -> Value {
454    Value::Object(Map::from_iter([("nvidia.dynamo".to_string(), value)]))
455}
456
457fn insert_optional<T: Serialize>(fields: &mut Map<String, Value>, name: &str, value: Option<T>) {
458    if let Some(value) = value {
459        fields.insert(
460            name.to_string(),
461            serde_json::to_value(value).expect("serializing trace metadata cannot fail"),
462        );
463    }
464}
465
466fn session_id_for(request: &RequestEntry) -> String {
467    request
468        .agent_context
469        .as_ref()
470        .map(|context| context.session_id.clone())
471        .unwrap_or_else(|| request.request.request_id.clone())
472}
473
474fn llm_node_id(request: &RequestEntry) -> String {
475    format!("llm:{}", request.request.request_id)
476}
477
478fn edge(from: String, to: String, delay_us: u64) -> SatfEdge {
479    SatfEdge {
480        from,
481        to,
482        delay_after_predecessor_us: (delay_us > 0).then_some(delay_us),
483    }
484}
485
486fn request_start_us(request: &RequestEntry) -> u64 {
487    request
488        .request
489        .request_received_ms
490        .map(|value| value.saturating_mul(1_000))
491        .unwrap_or_else(|| nonnegative_us(request.start_ms))
492}
493
494fn request_duration_us(request: &RequestEntry) -> u64 {
495    request
496        .request
497        .total_time_ms
498        .map(ms_to_us)
499        .unwrap_or_else(|| nonnegative_us(request.end_ms.saturating_sub(request.start_ms)))
500}
501
502fn request_end_us(request: &RequestEntry) -> u64 {
503    request_start_us(request).saturating_add(request_duration_us(request))
504}
505
506fn tool_duration_us(tool: &ToolEntry) -> u64 {
507    ms_to_us(tool.duration_ms)
508}
509
510fn tool_end_us(tool: &ToolEntry) -> u64 {
511    nonnegative_us(tool.end_ms)
512}
513
514fn tool_start_us(tool: &ToolEntry) -> u64 {
515    nonnegative_us(tool.start_ms)
516}
517
518fn nonnegative_us(value_ms: i64) -> u64 {
519    u64::try_from(value_ms)
520        .unwrap_or_default()
521        .saturating_mul(1_000)
522}
523
524fn ms_to_us(value: f64) -> u64 {
525    let value = (value.max(0.0) * 1_000.0).round();
526    if value >= u64::MAX as f64 {
527        u64::MAX
528    } else {
529        value as u64
530    }
531}
532
533fn find_tool_source(requests: &[RequestEntry], tool: &ToolEntry) -> Option<usize> {
534    let tool_start_us = tool_start_us(tool);
535    requests
536        .iter()
537        .enumerate()
538        .rev()
539        .find(|(_, request)| request_end_us(request) <= tool_start_us)
540        .or_else(|| {
541            requests
542                .iter()
543                .enumerate()
544                .rev()
545                .find(|(_, request)| request_start_us(request) <= tool_start_us)
546        })
547        .map(|(index, _)| index)
548}
549
550fn find_tool_consumer(
551    requests: &[RequestEntry],
552    tool: &ToolEntry,
553    source_index: Option<usize>,
554) -> Option<usize> {
555    if tool
556        .claude
557        .as_ref()
558        .is_some_and(|metadata| metadata.execution_mode == "background")
559    {
560        return None;
561    }
562    requests
563        .iter()
564        .enumerate()
565        .skip(source_index.map_or(0, |index| index + 1))
566        .find(|(_, request)| request_start_us(request) >= tool_end_us(tool))
567        .map(|(index, _)| index)
568}
569
570fn tool_name_from_finish_metadata(request: &RequestEntry, tool_call_id: &str) -> Option<String> {
571    request.request.finish_reason_metadata.as_ref().and_then(
572        |metadata: &RequestTraceFinishReasonMetadata| {
573            metadata
574                .tool_calls
575                .iter()
576                .find(|call| call.id.as_deref() == Some(tool_call_id))
577                .and_then(|call| call.name.clone())
578        },
579    )
580}
581
582fn tool_is_error(tool: &ToolEntry) -> bool {
583    tool.error_type.is_some()
584        || matches!(
585            tool.status.as_str(),
586            "error" | "failed" | "cancelled" | "canceled"
587        )
588}
589
590fn session_first_start_us(session: &SatfSession) -> u64 {
591    session
592        .nodes
593        .iter()
594        .find_map(|node| match node {
595            SatfNode::LlmInfer {
596                min_start_delay_us, ..
597            } => *min_start_delay_us,
598            _ => None,
599        })
600        .unwrap_or_default()
601}
602
603#[cfg(test)]
604mod tests {
605    use std::io::Write as _;
606
607    use tempfile::NamedTempFile;
608
609    use super::*;
610
611    #[test]
612    fn request_trace_sessions_share_kv_ids() {
613        let mut file = NamedTempFile::new().unwrap();
614        for (request_id, session_id, timestamp) in
615            [("req-a", "task-a", 1_000), ("req-b", "task-b", 1_100)]
616        {
617            writeln!(
618                file,
619                "{}",
620                serde_json::json!({
621                    "schema": "dynamo.request.trace.v1",
622                    "event_type": "request_end",
623                    "event_time_unix_ms": timestamp + 100,
624                    "agent_context": {"session_id": session_id},
625                    "request": {
626                        "request_id": request_id,
627                        "request_received_ms": timestamp,
628                        "output_tokens": 4,
629                        "replay": {
630                            "trace_block_size": 2,
631                            "input_length": 3,
632                            "input_sequence_hashes": [11, 22],
633                        }
634                    }
635                })
636            )
637            .unwrap();
638        }
639
640        let trace = convert_request_trace_to_satf(&[file.path().to_path_buf()]).unwrap();
641
642        assert_eq!(trace.sessions.len(), 2);
643        assert_eq!(trace.sessions[0].session_id, "task-a");
644        assert_eq!(trace.sessions[1].session_id, "task-b");
645        let first_ids = llm_payload(&trace.sessions[0].nodes[0])
646            .kv_block_ids
647            .clone();
648        let second_ids = llm_payload(&trace.sessions[1].nodes[0])
649            .kv_block_ids
650            .clone();
651        assert_eq!(first_ids, second_ids);
652        assert_eq!(first_ids, vec![0, 1]);
653    }
654
655    #[test]
656    fn request_trace_tool_span_becomes_call_and_result_path() {
657        let mut file = NamedTempFile::new().unwrap();
658        writeln!(
659            file,
660            r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"agent_context":{{"session_id":"root"}},"request":{{"request_id":"req-1","model":"test","request_received_ms":1000,"total_time_ms":100.0,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":3,"input_sequence_hashes":[11,22]}},"finish_reason_metadata":{{"finish_reason":"tool_calls","tool_calls":[{{"choice_index":0,"tool_call_index":0,"id":"call-1","name":"search"}}]}}}}}}"#
661        )
662        .unwrap();
663        writeln!(
664            file,
665            r#"{{"schema":"dynamo.request.trace.v1","event_type":"tool_end","event_time_unix_ms":1200,"agent_context":{{"session_id":"root"}},"tool":{{"tool_call_id":"call-1","tool_class":"fallback","started_at_unix_ms":1110,"ended_at_unix_ms":1200,"status":"succeeded","duration_ms":50.0}}}}"#
666        )
667        .unwrap();
668        writeln!(
669            file,
670            r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1400,"agent_context":{{"session_id":"root"}},"request":{{"request_id":"req-2","request_received_ms":1300,"total_time_ms":100.0,"output_tokens":2,"replay":{{"trace_block_size":2,"input_length":4,"input_sequence_hashes":[11,33]}}}}}}"#
671        )
672        .unwrap();
673
674        let trace = convert_request_trace_to_satf(&[file.path().to_path_buf()]).unwrap();
675        let session = &trace.sessions[0];
676
677        assert_eq!(session.nodes.len(), 4);
678        assert_eq!(session.edges.len(), 3);
679        assert!(session.edges.iter().any(|edge| {
680            edge.from == "llm:req-1"
681                && edge.to == "tool_call:0"
682                && edge.delay_after_predecessor_us == Some(10_000)
683        }));
684        assert!(session.edges.iter().any(|edge| {
685            edge.from == "tool_result:0"
686                && edge.to == "llm:req-2"
687                && edge.delay_after_predecessor_us == Some(100_000)
688        }));
689        let call = session
690            .nodes
691            .iter()
692            .find(|node| node.id() == "tool_call:0")
693            .unwrap();
694        assert!(matches!(
695            call,
696            SatfNode::ToolCall {
697                payload: SatfToolCallPayload { tool_name, .. },
698                ..
699            } if tool_name == "search"
700        ));
701
702        let encoded = serde_json::to_string(&trace).unwrap();
703        let decoded: SatfTrace = serde_json::from_str(&encoded).unwrap();
704        assert_eq!(decoded, trace);
705    }
706
707    fn llm_payload(node: &SatfNode) -> &SatfLlmInferPayload {
708        match node {
709            SatfNode::LlmInfer { payload, .. } => payload,
710            other => panic!("expected llm_infer node, got {other:?}"),
711        }
712    }
713}