jtool_notebook/
timing.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4
5/// Execution timing metadata from notebook cells
6///
7/// JupyterLab 1.2+ can record timing information in cell metadata under the "execution" key.
8/// This captures timestamps at various stages of cell execution.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct ExecutionTiming {
11    /// When kernel became busy (started processing request)
12    #[serde(rename = "iopub.status.busy", skip_serializing_if = "Option::is_none")]
13    pub status_busy: Option<DateTime<Utc>>,
14
15    /// When execute_input message was sent
16    #[serde(
17        rename = "iopub.execute_input",
18        skip_serializing_if = "Option::is_none"
19    )]
20    pub execute_input: Option<DateTime<Utc>>,
21
22    /// When execution started (from shell.execute_reply metadata)
23    #[serde(
24        rename = "shell.execute_reply.started",
25        skip_serializing_if = "Option::is_none"
26    )]
27    pub reply_started: Option<DateTime<Utc>>,
28
29    /// When execution finished (shell.execute_reply received)
30    #[serde(
31        rename = "shell.execute_reply",
32        skip_serializing_if = "Option::is_none"
33    )]
34    pub reply: Option<DateTime<Utc>>,
35
36    /// When kernel became idle (all outputs published)
37    #[serde(rename = "iopub.status.idle", skip_serializing_if = "Option::is_none")]
38    pub status_idle: Option<DateTime<Utc>>,
39}
40
41impl ExecutionTiming {
42    /// Calculate total duration (busy → idle)
43    ///
44    /// This is the wall-clock time from when the kernel started processing
45    /// the request until all outputs were published.
46    pub fn total_duration(&self) -> Option<Duration> {
47        match (self.status_busy, self.status_idle) {
48            (Some(busy), Some(idle)) => {
49                let duration = idle.signed_duration_since(busy);
50                duration.to_std().ok()
51            }
52            _ => None,
53        }
54    }
55
56    /// Calculate pure execution duration (reply_started → reply)
57    ///
58    /// This is the actual execution time, excluding output publishing overhead.
59    pub fn execution_duration(&self) -> Option<Duration> {
60        match (self.reply_started, self.reply) {
61            (Some(started), Some(finished)) => {
62                let duration = finished.signed_duration_since(started);
63                duration.to_std().ok()
64            }
65            _ => None,
66        }
67    }
68
69    /// Calculate output overhead (reply → idle)
70    ///
71    /// This is the time spent publishing outputs after execution finished.
72    pub fn output_overhead(&self) -> Option<Duration> {
73        match (self.reply, self.status_idle) {
74            (Some(reply), Some(idle)) => {
75                let duration = idle.signed_duration_since(reply);
76                duration.to_std().ok()
77            }
78            _ => None,
79        }
80    }
81
82    /// Create timing from start and end timestamps
83    ///
84    /// This is a simplified version for when we only have start/end times
85    /// (e.g., when capturing timing during execution without all intermediate events)
86    pub fn from_duration(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
87        Self {
88            status_busy: Some(start),
89            execute_input: Some(start),
90            reply_started: Some(start),
91            reply: Some(end),
92            status_idle: Some(end),
93        }
94    }
95
96    /// Check if this timing has valid data
97    pub fn has_timing(&self) -> bool {
98        self.status_busy.is_some() && self.status_idle.is_some()
99    }
100}
101
102/// Cell timing information with index and execution count
103#[derive(Debug, Clone, Serialize)]
104pub struct CellTiming {
105    /// Cell index in the notebook
106    pub cell_index: usize,
107    /// Execution count (if the cell has been executed)
108    pub execution_count: Option<i32>,
109    /// Timing metadata
110    pub timing: ExecutionTiming,
111    /// Calculated duration (total_duration from timing)
112    pub duration: Option<Duration>,
113}
114
115impl CellTiming {
116    /// Create a new CellTiming
117    pub fn new(cell_index: usize, execution_count: Option<i32>, timing: ExecutionTiming) -> Self {
118        let duration = timing.total_duration();
119        Self {
120            cell_index,
121            execution_count,
122            timing,
123            duration,
124        }
125    }
126
127    /// Get duration as seconds (f64)
128    pub fn duration_seconds(&self) -> Option<f64> {
129        self.duration.map(|d| d.as_secs_f64())
130    }
131}
132
133/// Summary statistics for notebook timing
134#[derive(Debug, Clone, Serialize)]
135pub struct TimingSummary {
136    /// Total execution time across all cells
137    pub total_duration: Duration,
138    /// Number of cells that have been executed
139    pub cells_executed: usize,
140    /// Average execution time per cell
141    pub average_duration: Duration,
142    /// Maximum execution time
143    pub max_duration: Duration,
144    /// Cell index with maximum execution time
145    pub max_duration_cell: usize,
146    /// Minimum execution time
147    pub min_duration: Duration,
148    /// Cell index with minimum execution time
149    pub min_duration_cell: usize,
150}
151
152impl TimingSummary {
153    /// Calculate summary from a list of cell timings
154    pub fn from_cells(cells: &[CellTiming]) -> Option<Self> {
155        if cells.is_empty() {
156            return None;
157        }
158
159        let cells_with_duration: Vec<_> = cells.iter().filter(|c| c.duration.is_some()).collect();
160
161        if cells_with_duration.is_empty() {
162            return None;
163        }
164
165        let total_duration: Duration = cells_with_duration.iter().filter_map(|c| c.duration).sum();
166
167        let average_duration = total_duration
168            .checked_div(cells_with_duration.len() as u32)
169            .unwrap_or(Duration::ZERO);
170
171        // Find max and min durations
172        // Safe: cells_with_duration is non-empty (checked above) and all have Some(duration)
173        let first_cell = cells_with_duration.first()?;
174
175        let (max_cell, min_cell) =
176            cells_with_duration
177                .iter()
178                .fold((first_cell, first_cell), |(max, min), cell| {
179                    let new_max = if cell.duration > max.duration {
180                        cell
181                    } else {
182                        max
183                    };
184                    let new_min = if cell.duration < min.duration {
185                        cell
186                    } else {
187                        min
188                    };
189                    (new_max, new_min)
190                });
191
192        // Safe: we know these cells have duration because of the filter above
193        let max_duration = max_cell.duration?;
194        let max_duration_cell = max_cell.cell_index;
195
196        let min_duration = min_cell.duration?;
197        let min_duration_cell = min_cell.cell_index;
198
199        Some(Self {
200            total_duration,
201            cells_executed: cells_with_duration.len(),
202            average_duration,
203            max_duration,
204            max_duration_cell,
205            min_duration,
206            min_duration_cell,
207        })
208    }
209}
210
211/// Format for displaying durations
212#[derive(Debug, Clone)]
213pub enum TimingFormat {
214    /// Display as seconds with decimals (e.g., "1.234s")
215    Seconds,
216    /// Display as milliseconds (e.g., "1234ms")
217    Milliseconds,
218    /// Display in human-readable format (e.g., "2m 5s" or "1s 234ms")
219    Human,
220}
221
222/// Format a duration for display
223pub fn format_duration(duration: Duration, format: &TimingFormat) -> String {
224    match format {
225        TimingFormat::Seconds => {
226            format!("{:.2}s", duration.as_secs_f64())
227        }
228        TimingFormat::Milliseconds => {
229            format!("{}ms", duration.as_millis())
230        }
231        TimingFormat::Human => {
232            let secs = duration.as_secs();
233            let millis = duration.subsec_millis();
234
235            if secs >= 60 {
236                let mins = secs / 60;
237                let remaining_secs = secs % 60;
238                if millis > 0 {
239                    format!("{mins}m {remaining_secs}s {millis}ms")
240                } else {
241                    format!("{mins}m {remaining_secs}s")
242                }
243            } else if secs > 0 {
244                if millis > 0 {
245                    format!("{secs}s {millis}ms")
246                } else {
247                    format!("{secs}s")
248                }
249            } else {
250                format!("{millis}ms")
251            }
252        }
253    }
254}
255
256/// Parse a duration string (e.g., "5s", "500ms", "2m")
257pub fn parse_duration(s: &str) -> Result<Duration, String> {
258    let s = s.trim();
259
260    if let Some(stripped) = s.strip_suffix("ms") {
261        let num = stripped
262            .trim()
263            .parse::<u64>()
264            .map_err(|_| format!("Invalid duration: {s}"))?;
265        Ok(Duration::from_millis(num))
266    } else if let Some(stripped) = s.strip_suffix('s') {
267        let num = stripped
268            .trim()
269            .parse::<f64>()
270            .map_err(|_| format!("Invalid duration: {s}"))?;
271        Ok(Duration::from_secs_f64(num))
272    } else if let Some(stripped) = s.strip_suffix('m') {
273        let num = stripped
274            .trim()
275            .parse::<u64>()
276            .map_err(|_| format!("Invalid duration: {s}"))?;
277        Ok(Duration::from_secs(num * 60))
278    } else {
279        Err(format!(
280            "Invalid duration format: {s}. Use '5s', '500ms', or '2m'"
281        ))
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_timing_total_duration() {
291        let timing = ExecutionTiming {
292            status_busy: "2020-01-01T00:00:00Z".parse().ok(),
293            status_idle: "2020-01-01T00:00:05Z".parse().ok(),
294            ..Default::default()
295        };
296
297        assert_eq!(timing.total_duration().map(|d| d.as_secs()), Some(5));
298    }
299
300    #[test]
301    fn test_timing_execution_duration() {
302        let timing = ExecutionTiming {
303            reply_started: "2020-01-01T00:00:00Z".parse().ok(),
304            reply: "2020-01-01T00:00:03Z".parse().ok(),
305            ..Default::default()
306        };
307
308        assert_eq!(timing.execution_duration().map(|d| d.as_secs()), Some(3));
309    }
310
311    #[test]
312    fn test_format_duration_seconds() {
313        let duration = Duration::from_millis(1234);
314        assert_eq!(format_duration(duration, &TimingFormat::Seconds), "1.23s");
315    }
316
317    #[test]
318    fn test_format_duration_milliseconds() {
319        let duration = Duration::from_millis(1234);
320        assert_eq!(
321            format_duration(duration, &TimingFormat::Milliseconds),
322            "1234ms"
323        );
324    }
325
326    #[test]
327    fn test_format_duration_human() {
328        let duration = Duration::from_secs(125);
329        assert_eq!(format_duration(duration, &TimingFormat::Human), "2m 5s");
330
331        let duration = Duration::from_millis(1500);
332        assert_eq!(format_duration(duration, &TimingFormat::Human), "1s 500ms");
333
334        let duration = Duration::from_millis(500);
335        assert_eq!(format_duration(duration, &TimingFormat::Human), "500ms");
336    }
337
338    #[test]
339    fn test_parse_duration() {
340        assert_eq!(parse_duration("5s"), Ok(Duration::from_secs(5)));
341        assert_eq!(parse_duration("500ms"), Ok(Duration::from_millis(500)));
342        assert_eq!(parse_duration("2m"), Ok(Duration::from_secs(120)));
343        assert_eq!(parse_duration("1.5s"), Ok(Duration::from_millis(1500)));
344
345        assert!(parse_duration("invalid").is_err());
346        assert!(parse_duration("5x").is_err());
347    }
348
349    #[test]
350    fn test_timing_summary() {
351        let cells = vec![
352            CellTiming::new(
353                0,
354                Some(1),
355                ExecutionTiming {
356                    status_busy: "2020-01-01T00:00:00Z".parse().ok(),
357                    status_idle: "2020-01-01T00:00:01Z".parse().ok(),
358                    ..Default::default()
359                },
360            ),
361            CellTiming::new(
362                1,
363                Some(2),
364                ExecutionTiming {
365                    status_busy: "2020-01-01T00:00:00Z".parse().ok(),
366                    status_idle: "2020-01-01T00:00:05Z".parse().ok(),
367                    ..Default::default()
368                },
369            ),
370        ];
371
372        let summary = TimingSummary::from_cells(&cells);
373        assert!(summary.is_some(), "Failed to create summary: {summary:?}");
374        if let Some(summary) = summary {
375            assert_eq!(summary.total_duration.as_secs(), 6);
376            assert_eq!(summary.cells_executed, 2);
377            assert_eq!(summary.average_duration.as_secs(), 3);
378            assert_eq!(summary.max_duration.as_secs(), 5);
379            assert_eq!(summary.max_duration_cell, 1);
380        }
381    }
382}