Skip to main content

cu29_export/
logstats.rs

1use crate::copperlists_reader;
2use cu29::clock::{CuDuration, OptionCuTime};
3use cu29::config::{CuConfig, CuGraph, DEFAULT_MISSION_ID, Flavor};
4use cu29::curuntime::{CuExecutionUnit, CuStepPhase};
5use cu29::monitoring::CuDurationStatistics;
6use cu29::planner::{PlanEntityKind, assemble_runtime_plan, assemble_runtime_plan_from_step_keys};
7use cu29::prelude::{CopperListTuple, CuMsgMetadataTrait, CuPayloadRawBytes};
8use cu29::{CuError, CuResult};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, HashMap};
11use std::fs::File;
12use std::io::Read;
13use std::path::Path;
14
15const LOGSTATS_SCHEMA_VERSION: u32 = 2;
16const MAX_LATENCY_NS: u64 = 10_000_000_000;
17const MAX_QUANTILE_SAMPLES: usize = 2_048;
18const MAX_REPRESENTATIVE_TRACES: usize = 256;
19const MIN_CURRENT_CLUSTER_GAP_NS: u64 = 100_000;
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct LogStats {
23    pub schema_version: u32,
24    pub config_signature: String,
25    pub mission: Option<String>,
26    pub edges: Vec<EdgeLogStats>,
27    pub perf: PerfStats,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub schedule: Option<ScheduleLogStats>,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33pub struct ScheduleLogStats {
34    pub stages: Vec<StageLogStats>,
35    pub traces: Vec<ExecutionTrace>,
36    pub residual_before: DurationStats,
37    pub resource_overlaps: Vec<ResourceOverlapStats>,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct StageLogStats {
42    pub origin: String,
43    pub samples: u64,
44    pub durations: DurationStats,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct ExecutionTrace {
49    pub kind: String,
50    pub culist_id: u64,
51    /// Sum of recorded process intervals in this CopperList.
52    pub duration_ns: u64,
53    /// Wall-clock span from the first through last retained interval.
54    pub wall_span_ns: u64,
55    /// Message slots carrying timestamps outside the current execution cluster.
56    pub excluded_intervals: u32,
57    pub residual_before_ns: Option<u64>,
58    pub intervals: Vec<ExecutionInterval>,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct ExecutionInterval {
63    pub origin: String,
64    pub start_ns: u64,
65    pub end_ns: u64,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69pub struct ResourceOverlapStats {
70    pub resource: String,
71    pub left: String,
72    pub right: String,
73    pub occurrences: u64,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct EdgeLogStats {
78    pub src: String,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub src_channel: Option<String>,
81    pub dst: String,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub dst_channel: Option<String>,
84    pub msg: String,
85    pub samples: u64,
86    pub none_samples: u64,
87    pub valid_time_samples: u64,
88    pub total_raw_bytes: u64,
89    pub avg_raw_bytes: Option<f64>,
90    pub rate_hz: Option<f64>,
91    pub throughput_bytes_per_sec: Option<f64>,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct PerfStats {
96    pub samples: u64,
97    pub valid_time_samples: u64,
98    pub end_to_end: DurationStats,
99    pub jitter: DurationStats,
100}
101
102#[derive(Debug, Default, Serialize, Deserialize)]
103pub struct DurationStats {
104    pub min_ns: Option<u64>,
105    pub max_ns: Option<u64>,
106    pub mean_ns: Option<f64>,
107    pub stddev_ns: Option<f64>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub p50_ns: Option<u64>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub p95_ns: Option<u64>,
112}
113
114#[derive(Debug, Default)]
115struct TimingAccumulator {
116    samples: u64,
117    min: Option<u64>,
118    max: Option<u64>,
119    sum: f64,
120    sum_squares: f64,
121    quantiles: WeightedQuantiles,
122}
123
124impl TimingAccumulator {
125    fn record(&mut self, value: u64) {
126        self.samples = self.samples.saturating_add(1);
127        self.min = Some(self.min.map_or(value, |current| current.min(value)));
128        self.max = Some(self.max.map_or(value, |current| current.max(value)));
129        let value_f64 = value as f64;
130        self.sum += value_f64;
131        self.sum_squares += value_f64 * value_f64;
132        self.quantiles.record(value);
133    }
134
135    fn stats(&self) -> DurationStats {
136        if self.samples == 0 {
137            return DurationStats::default();
138        }
139        let mean = self.sum / self.samples as f64;
140        let variance = (self.sum_squares / self.samples as f64 - mean * mean).max(0.0);
141        DurationStats {
142            min_ns: self.min,
143            max_ns: self.max,
144            mean_ns: Some(mean),
145            stddev_ns: Some(variance.sqrt()),
146            p50_ns: self.quantiles.quantile(0.50),
147            p95_ns: self.quantiles.quantile(0.95),
148        }
149    }
150}
151
152#[derive(Debug, Default)]
153struct WeightedQuantiles {
154    samples: Vec<u64>,
155    seen: u64,
156}
157
158impl WeightedQuantiles {
159    fn record(&mut self, value: u64) {
160        self.seen = self.seen.saturating_add(1);
161        if self.samples.len() < MAX_QUANTILE_SAMPLES {
162            self.samples.push(value);
163            return;
164        }
165        let candidate = splitmix64(self.seen) % self.seen;
166        if candidate < MAX_QUANTILE_SAMPLES as u64 {
167            self.samples[candidate as usize] = value;
168        }
169    }
170
171    fn quantile(&self, quantile: f64) -> Option<u64> {
172        if self.samples.is_empty() {
173            return None;
174        }
175        let mut values = self.samples.clone();
176        values.sort_unstable();
177        let index = ((values.len().saturating_sub(1)) as f64 * quantile).round() as usize;
178        values.get(index).copied()
179    }
180}
181
182#[derive(Clone, Debug, Eq, Hash, PartialEq)]
183struct EdgeKey {
184    src: String,
185    src_channel: Option<String>,
186    dst: String,
187    dst_channel: Option<String>,
188    msg: String,
189}
190
191#[derive(Clone, Debug)]
192struct OutputSlot {
193    edges: Vec<EdgeKey>,
194}
195
196#[derive(Debug, Default, Clone)]
197struct EdgeAccumulator {
198    samples: u64,
199    none_samples: u64,
200    valid_time_samples: u64,
201    total_raw_bytes: u64,
202    min_end_ns: Option<u64>,
203    max_end_ns: Option<u64>,
204}
205
206impl EdgeAccumulator {
207    fn record_sample(&mut self, payload_bytes: Option<u64>, end_time_ns: Option<u64>) {
208        self.samples = self.samples.saturating_add(1);
209        if let Some(bytes) = payload_bytes {
210            self.total_raw_bytes = self.total_raw_bytes.saturating_add(bytes);
211        } else {
212            self.none_samples = self.none_samples.saturating_add(1);
213        }
214
215        if let Some(end_ns) = end_time_ns {
216            self.valid_time_samples = self.valid_time_samples.saturating_add(1);
217            self.min_end_ns = Some(self.min_end_ns.map_or(end_ns, |min| min.min(end_ns)));
218            self.max_end_ns = Some(self.max_end_ns.map_or(end_ns, |max| max.max(end_ns)));
219        }
220    }
221
222    fn finalize(self, key: EdgeKey) -> EdgeLogStats {
223        let payload_samples = self.samples.saturating_sub(self.none_samples);
224        let avg_raw_bytes = if payload_samples > 0 {
225            Some(self.total_raw_bytes as f64 / payload_samples as f64)
226        } else {
227            None
228        };
229
230        let (rate_hz, throughput_bytes_per_sec) = if self.valid_time_samples >= 2 {
231            match (self.min_end_ns, self.max_end_ns) {
232                (Some(min_ns), Some(max_ns)) if max_ns > min_ns => {
233                    let duration_ns = max_ns - min_ns;
234                    let duration_secs = duration_ns as f64 / 1_000_000_000.0;
235                    let intervals = (self.valid_time_samples - 1) as f64;
236                    (
237                        Some(intervals / duration_secs),
238                        Some(self.total_raw_bytes as f64 / duration_secs),
239                    )
240                }
241                _ => (None, None),
242            }
243        } else {
244            (None, None)
245        };
246
247        EdgeLogStats {
248            src: key.src,
249            src_channel: key.src_channel,
250            dst: key.dst,
251            dst_channel: key.dst_channel,
252            msg: key.msg,
253            samples: self.samples,
254            none_samples: self.none_samples,
255            valid_time_samples: self.valid_time_samples,
256            total_raw_bytes: self.total_raw_bytes,
257            avg_raw_bytes,
258            rate_hz,
259            throughput_bytes_per_sec,
260        }
261    }
262}
263
264#[derive(Debug)]
265struct PerfAccumulator {
266    stats: CuDurationStatistics,
267    samples: u64,
268    valid_time_samples: u64,
269}
270
271impl PerfAccumulator {
272    fn new() -> Self {
273        Self {
274            stats: CuDurationStatistics::new(CuDuration(MAX_LATENCY_NS)),
275            samples: 0,
276            valid_time_samples: 0,
277        }
278    }
279
280    fn record_sample(&mut self, latency: Option<CuDuration>) {
281        self.samples = self.samples.saturating_add(1);
282        if let Some(latency) = latency {
283            self.stats.record(latency);
284            self.valid_time_samples = self.valid_time_samples.saturating_add(1);
285        }
286    }
287
288    fn finalize(&self) -> PerfStats {
289        let end_to_end = duration_stats_from(&self.stats);
290        let jitter = jitter_stats_from(&self.stats);
291
292        PerfStats {
293            samples: self.samples,
294            valid_time_samples: self.valid_time_samples,
295            end_to_end,
296            jitter,
297        }
298    }
299}
300
301pub fn compute_logstats<P>(
302    mut reader: impl Read,
303    config: &CuConfig,
304    mission: Option<&str>,
305) -> CuResult<LogStats>
306where
307    P: CopperListTuple + CuPayloadRawBytes,
308{
309    let graph = config.get_graph(mission)?;
310    let signature = build_graph_signature(graph, mission);
311    let output_slots = build_output_slots::<P>(config, graph, mission).map_err(|e| {
312        CuError::from(format!(
313            "mission '{}': {e}",
314            mission.unwrap_or(DEFAULT_MISSION_ID)
315        ))
316    })?;
317    let mut edge_accumulators = build_edge_accumulators(graph);
318    let mut perf = PerfAccumulator::new();
319    let resource_bindings = build_resource_bindings::<P>(config, graph);
320    let mut stage_accumulators = BTreeMap::<String, TimingAccumulator>::new();
321    let mut trace_duration_accumulator = TimingAccumulator::default();
322    let mut residual_accumulator = TimingAccumulator::default();
323    let mut representative_traces = Vec::<ExecutionTrace>::new();
324    let mut worst_trace: Option<ExecutionTrace> = None;
325    let mut active_intervals = Vec::<ExecutionInterval>::new();
326    let mut overlap_counts = BTreeMap::<(String, String, String), u64>::new();
327    let mut previous_end_ns = None;
328    let mut trace_count = 0u64;
329    let mut warned_lengths = false;
330
331    for culist in copperlists_reader::<P>(&mut reader) {
332        let payload_sizes = culist.msgs.payload_raw_bytes();
333        let cumsgs = culist.msgs.cumsgs();
334
335        let payload_len = payload_sizes.len();
336        let msg_len = cumsgs.len();
337        let slot_len = output_slots.len();
338        if !warned_lengths && (payload_len != msg_len || payload_len != slot_len) {
339            eprintln!(
340                "Warning: output mapping length mismatch (sizes={}, msgs={}, slots={})",
341                payload_len, msg_len, slot_len
342            );
343            warned_lengths = true;
344        }
345
346        let count = payload_len.min(msg_len).min(slot_len);
347
348        for idx in 0..count {
349            let slot = &output_slots[idx];
350            if slot.edges.is_empty() {
351                continue;
352            }
353            let payload_bytes = payload_sizes[idx];
354            let end_time_ns = extract_end_time_ns(cumsgs[idx].metadata());
355            for edge in &slot.edges {
356                if let Some(acc) = edge_accumulators.get_mut(edge) {
357                    acc.record_sample(payload_bytes, end_time_ns);
358                }
359            }
360        }
361
362        perf.record_sample(compute_end_to_end_latency(&cumsgs));
363
364        if let Some(trace) =
365            build_execution_trace(culist.id, &cumsgs, P::get_all_task_ids(), previous_end_ns)
366        {
367            for interval in &trace.intervals {
368                stage_accumulators
369                    .entry(interval.origin.clone())
370                    .or_default()
371                    .record(interval.end_ns - interval.start_ns);
372            }
373            trace_duration_accumulator.record(trace.duration_ns);
374            if let Some(residual) = trace.residual_before_ns {
375                residual_accumulator.record(residual);
376            }
377            record_resource_overlaps(
378                &trace,
379                &resource_bindings,
380                &mut active_intervals,
381                &mut overlap_counts,
382            );
383            previous_end_ns = trace.intervals.iter().map(|interval| interval.end_ns).max();
384            trace_count = trace_count.saturating_add(1);
385            sample_trace(&mut representative_traces, trace.clone(), trace_count);
386            if worst_trace
387                .as_ref()
388                .is_none_or(|worst| trace.duration_ns > worst.duration_ns)
389            {
390                worst_trace = Some(trace);
391            }
392        }
393    }
394
395    let edges = edge_accumulators
396        .into_iter()
397        .map(|(key, acc)| acc.finalize(key))
398        .collect();
399
400    let duration_p50 = trace_duration_accumulator
401        .stats()
402        .p50_ns
403        .unwrap_or_default();
404    let typical_trace = representative_traces
405        .into_iter()
406        .min_by_key(|trace| trace.duration_ns.abs_diff(duration_p50));
407    let mut traces = Vec::new();
408    if let Some(mut typical) = typical_trace {
409        typical.kind = "typical".to_string();
410        traces.push(typical);
411    }
412    if let Some(mut worst) = worst_trace
413        && traces
414            .first()
415            .is_none_or(|typical| typical.culist_id != worst.culist_id)
416    {
417        worst.kind = "slowest".to_string();
418        traces.push(worst);
419    }
420
421    let stages = stage_accumulators
422        .into_iter()
423        .map(|(origin, accumulator)| StageLogStats {
424            origin,
425            samples: accumulator.samples,
426            durations: accumulator.stats(),
427        })
428        .collect();
429    let resource_overlaps = overlap_counts
430        .into_iter()
431        .map(
432            |((resource, left, right), occurrences)| ResourceOverlapStats {
433                resource,
434                left,
435                right,
436                occurrences,
437            },
438        )
439        .collect();
440
441    Ok(LogStats {
442        schema_version: LOGSTATS_SCHEMA_VERSION,
443        config_signature: signature,
444        mission: mission.map(|value| value.to_string()),
445        edges,
446        perf: perf.finalize(),
447        schedule: Some(ScheduleLogStats {
448            stages,
449            traces,
450            residual_before: residual_accumulator.stats(),
451            resource_overlaps,
452        }),
453    })
454}
455
456fn build_execution_trace(
457    culist_id: u64,
458    msgs: &[&dyn cu29::prelude::ErasedCuStampedData],
459    origins: &[&str],
460    previous_end_ns: Option<u64>,
461) -> Option<ExecutionTrace> {
462    let mut grouped = BTreeMap::<String, (u64, u64)>::new();
463    for (msg, origin) in msgs.iter().zip(origins.iter()) {
464        let (Some(start), Some(end)) = (
465            extract_start_time_ns(msg.metadata()),
466            extract_end_time_ns(msg.metadata()),
467        ) else {
468            continue;
469        };
470        if end < start {
471            continue;
472        }
473        grouped
474            .entry((*origin).to_string())
475            .and_modify(|range| {
476                range.0 = range.0.min(start);
477                range.1 = range.1.max(end);
478            })
479            .or_insert((start, end));
480    }
481    if grouped.is_empty() {
482        return None;
483    }
484    let mut intervals = grouped
485        .into_iter()
486        .map(|(origin, (start_ns, end_ns))| ExecutionInterval {
487            origin,
488            start_ns,
489            end_ns,
490        })
491        .collect::<Vec<_>>();
492    intervals.sort_by_key(|interval| (interval.start_ns, interval.end_ns));
493    let original_count = intervals.len();
494    let intervals = select_current_execution_cluster(intervals);
495    let start_ns = intervals.first()?.start_ns;
496    let end_ns = intervals.iter().map(|interval| interval.end_ns).max()?;
497    let duration_ns = intervals
498        .iter()
499        .map(|interval| interval.end_ns.saturating_sub(interval.start_ns))
500        .sum();
501    Some(ExecutionTrace {
502        kind: String::new(),
503        culist_id,
504        duration_ns,
505        wall_span_ns: end_ns.saturating_sub(start_ns),
506        excluded_intervals: original_count.saturating_sub(intervals.len()) as u32,
507        residual_before_ns: previous_end_ns.and_then(|end| start_ns.checked_sub(end)),
508        intervals,
509    })
510}
511
512fn select_current_execution_cluster(intervals: Vec<ExecutionInterval>) -> Vec<ExecutionInterval> {
513    if intervals.len() < 2 {
514        return intervals;
515    }
516    let total_process_ns: u64 = intervals
517        .iter()
518        .map(|interval| interval.end_ns.saturating_sub(interval.start_ns))
519        .sum();
520    let split_gap_ns = total_process_ns
521        .saturating_mul(4)
522        .max(MIN_CURRENT_CLUSTER_GAP_NS);
523    let mut ranges = Vec::new();
524    let mut cluster_start = 0;
525    for index in 1..intervals.len() {
526        let gap = intervals[index]
527            .start_ns
528            .saturating_sub(intervals[index - 1].end_ns);
529        if gap > split_gap_ns {
530            ranges.push(cluster_start..index);
531            cluster_start = index;
532        }
533    }
534    ranges.push(cluster_start..intervals.len());
535    let selected = ranges
536        .into_iter()
537        .max_by_key(|range| (range.len(), intervals[range.end - 1].end_ns))
538        .unwrap_or(0..intervals.len());
539    intervals[selected].to_vec()
540}
541
542fn sample_trace(samples: &mut Vec<ExecutionTrace>, trace: ExecutionTrace, seen: u64) {
543    if samples.len() < MAX_REPRESENTATIVE_TRACES {
544        samples.push(trace);
545        return;
546    }
547    let hash = splitmix64(trace.culist_id);
548    let candidate = hash % seen;
549    if candidate < MAX_REPRESENTATIVE_TRACES as u64 {
550        samples[candidate as usize] = trace;
551    }
552}
553
554fn splitmix64(mut value: u64) -> u64 {
555    value = value.wrapping_add(0x9e3779b97f4a7c15);
556    value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
557    value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
558    value ^ (value >> 31)
559}
560
561fn build_resource_bindings<P: CopperListTuple>(
562    config: &CuConfig,
563    graph: &CuGraph,
564) -> HashMap<String, Vec<String>> {
565    let mut bindings = HashMap::new();
566    for origin in P::get_all_task_ids() {
567        if bindings.contains_key(*origin) {
568            continue;
569        }
570        let mut targets: Vec<String> = if let Some(rest) = origin.strip_prefix("bridge::") {
571            let bridge_id = rest.split("::").next().unwrap_or_default();
572            config
573                .bridges
574                .iter()
575                .find(|bridge| bridge.id == bridge_id)
576                .and_then(|bridge| bridge.resources.as_ref())
577                .map(|resources| resources.values().cloned().collect())
578                .unwrap_or_default()
579        } else {
580            graph
581                .get_node_id_by_name(origin)
582                .and_then(|node_id| graph.get_node(node_id))
583                .and_then(|node| node.get_resources())
584                .map(|resources| resources.values().cloned().collect())
585                .unwrap_or_default()
586        };
587        targets.sort();
588        targets.dedup();
589        bindings.insert((*origin).to_string(), targets);
590    }
591    bindings
592}
593
594fn record_resource_overlaps(
595    trace: &ExecutionTrace,
596    bindings: &HashMap<String, Vec<String>>,
597    active: &mut Vec<ExecutionInterval>,
598    counts: &mut BTreeMap<(String, String, String), u64>,
599) {
600    let Some(trace_start) = trace
601        .intervals
602        .iter()
603        .map(|interval| interval.start_ns)
604        .min()
605    else {
606        return;
607    };
608    active.retain(|interval| interval.end_ns > trace_start);
609
610    for (index, interval) in trace.intervals.iter().enumerate() {
611        for other in active.iter().chain(trace.intervals[..index].iter()) {
612            if interval.origin == other.origin
613                || interval.start_ns >= other.end_ns
614                || other.start_ns >= interval.end_ns
615            {
616                continue;
617            }
618            let Some(left_resources) = bindings.get(&interval.origin) else {
619                continue;
620            };
621            let Some(right_resources) = bindings.get(&other.origin) else {
622                continue;
623            };
624            for resource in left_resources {
625                if right_resources.contains(resource) {
626                    let (left, right) = if interval.origin <= other.origin {
627                        (interval.origin.clone(), other.origin.clone())
628                    } else {
629                        (other.origin.clone(), interval.origin.clone())
630                    };
631                    *counts.entry((resource.clone(), left, right)).or_default() += 1;
632                }
633            }
634        }
635    }
636    active.extend(trace.intervals.iter().cloned());
637}
638
639pub fn write_logstats(stats: &LogStats, path: &Path) -> CuResult<()> {
640    let file = File::create(path)
641        .map_err(|e| CuError::new_with_cause("Failed to create logstats output", e))?;
642    serde_json::to_writer_pretty(file, stats)
643        .map_err(|e| CuError::new_with_cause("Failed to serialize logstats", e))?;
644    Ok(())
645}
646
647fn build_output_slots<P: CopperListTuple>(
648    config: &CuConfig,
649    graph: &CuGraph,
650    mission: Option<&str>,
651) -> CuResult<Vec<OutputSlot>> {
652    let specs = P::get_output_specs();
653    if specs.is_empty() {
654        let resolved = config.planner_resolved_order(mission.unwrap_or(DEFAULT_MISSION_ID));
655        return build_output_slots_from_plan(config, graph, resolved);
656    }
657    Ok(specs
658        .iter()
659        .map(|spec| OutputSlot {
660            edges: graph
661                .edges()
662                .filter(|edge| edge.msg == spec.msg_type && edge_matches_origin(edge, spec.task_id))
663                .map(edge_key_from_connection)
664                .collect(),
665        })
666        .collect())
667}
668
669fn edge_matches_origin(edge: &cu29::config::Cnx, origin: &str) -> bool {
670    if edge.src == origin {
671        return true;
672    }
673    let Some(rest) = origin.strip_prefix("bridge::") else {
674        return false;
675    };
676    let mut parts = rest.split("::");
677    let (Some(bridge), Some(direction), Some(channel), None) =
678        (parts.next(), parts.next(), parts.next(), parts.next())
679    else {
680        return false;
681    };
682    direction == "rx" && edge.src == bridge && edge.src_channel.as_deref() == Some(channel)
683}
684
685fn edge_key_from_connection(cnx: &cu29::config::Cnx) -> EdgeKey {
686    EdgeKey {
687        src: cnx.src.clone(),
688        src_channel: cnx.src_channel.clone(),
689        dst: cnx.dst.clone(),
690        dst_channel: cnx.dst_channel.clone(),
691        msg: cnx.msg.clone(),
692    }
693}
694
695/// Recover the culist slot layout from the config alone, for logs whose payload
696/// type carries no output specs. Each slot lists the graph edges feeding it.
697fn build_output_slots_from_plan(
698    config: &CuConfig,
699    graph: &CuGraph,
700    resolved: Option<&[String]>,
701) -> CuResult<Vec<OutputSlot>> {
702    // Share the generated-runtime construction path (bridge stages, slot
703    // indices) so this cannot drift from the compiled plan.
704    let plan = match resolved {
705        Some(step_keys) => assemble_runtime_plan_from_step_keys(config, graph, step_keys)?,
706        None => assemble_runtime_plan(config, graph)?,
707    };
708
709    let mut packs: Vec<(u32, String, Vec<String>)> = Vec::new();
710    for unit in &plan.execution.steps {
711        let CuExecutionUnit::Step(step) = unit else {
712            continue;
713        };
714        // Anytime refine steps reuse their base step's culist slot; counting
715        // them would emit duplicate slots and misalign every later slot.
716        if step.phase == CuStepPhase::AnytimeRefine {
717            continue;
718        }
719        let Some(output_pack) = &step.output_msg_pack else {
720            continue;
721        };
722        let entity = &plan.entities[step.node_id as usize];
723        let origin = match entity.kind {
724            PlanEntityKind::Task { .. } => entity.label.clone(),
725            PlanEntityKind::BridgeRx { .. } | PlanEntityKind::BridgeTx { .. } => {
726                format!("bridge::{}", entity.label)
727            }
728        };
729        packs.push((
730            output_pack.culist_index,
731            origin,
732            output_pack.msg_types.clone(),
733        ));
734    }
735
736    packs.sort_by_key(|(culist_index, _, _)| *culist_index);
737    Ok(packs
738        .into_iter()
739        .flat_map(|(_, origin, msg_types)| {
740            msg_types.into_iter().map(move |msg| OutputSlot {
741                edges: graph
742                    .edges()
743                    .filter(|edge| edge.msg == msg && edge_matches_origin(edge, &origin))
744                    .map(edge_key_from_connection)
745                    .collect(),
746            })
747        })
748        .collect())
749}
750
751fn build_edge_accumulators(graph: &CuGraph) -> HashMap<EdgeKey, EdgeAccumulator> {
752    let mut acc = HashMap::new();
753    for cnx in graph.edges() {
754        let key = EdgeKey {
755            src: cnx.src.clone(),
756            src_channel: cnx.src_channel.clone(),
757            dst: cnx.dst.clone(),
758            dst_channel: cnx.dst_channel.clone(),
759            msg: cnx.msg.clone(),
760        };
761        acc.entry(key).or_default();
762    }
763    acc
764}
765
766fn compute_end_to_end_latency(
767    msgs: &[&dyn cu29::prelude::ErasedCuStampedData],
768) -> Option<CuDuration> {
769    let start = msgs
770        .first()
771        .and_then(|msg| extract_start_time_ns(msg.metadata()))?;
772    let end = msgs
773        .last()
774        .and_then(|msg| extract_end_time_ns(msg.metadata()))?;
775    end.checked_sub(start).map(CuDuration::from_nanos)
776}
777
778fn extract_start_time_ns(meta: &dyn CuMsgMetadataTrait) -> Option<u64> {
779    option_time_ns(meta.process_time().start)
780}
781
782fn extract_end_time_ns(meta: &dyn CuMsgMetadataTrait) -> Option<u64> {
783    option_time_ns(meta.process_time().end)
784}
785
786fn option_time_ns(value: OptionCuTime) -> Option<u64> {
787    Option::<cu29::clock::CuTime>::from(value).map(|t| t.as_nanos())
788}
789
790fn duration_stats_from(stats: &CuDurationStatistics) -> DurationStats {
791    if stats.is_empty() {
792        return DurationStats::default();
793    }
794    DurationStats {
795        min_ns: Some(stats.min().as_nanos()),
796        max_ns: Some(stats.max().as_nanos()),
797        mean_ns: Some(stats.mean().as_nanos() as f64),
798        stddev_ns: Some(stats.stddev().as_nanos() as f64),
799        p50_ns: None,
800        p95_ns: None,
801    }
802}
803
804fn jitter_stats_from(stats: &CuDurationStatistics) -> DurationStats {
805    if stats.len() < 2 {
806        return DurationStats::default();
807    }
808    DurationStats {
809        min_ns: Some(stats.jitter_min().as_nanos()),
810        max_ns: Some(stats.jitter_max().as_nanos()),
811        mean_ns: Some(stats.jitter_mean().as_nanos() as f64),
812        stddev_ns: Some(stats.jitter_stddev().as_nanos() as f64),
813        p50_ns: None,
814        p95_ns: None,
815    }
816}
817
818fn build_graph_signature(graph: &CuGraph, mission: Option<&str>) -> String {
819    let mut parts = Vec::new();
820    parts.push(format!("mission={}", mission.unwrap_or("default")));
821
822    let mut nodes: Vec<_> = graph.get_all_nodes();
823    nodes.sort_by_key(|a| a.1.get_id());
824    for (_, node) in nodes {
825        parts.push(format!(
826            "node|{}|{}|{}",
827            node.get_id(),
828            node.get_type(),
829            flavor_label(node.get_flavor())
830        ));
831    }
832
833    let mut edges: Vec<String> = graph
834        .edges()
835        .map(|cnx| {
836            format!(
837                "edge|{}|{}|{}",
838                format_endpoint(cnx.src.as_str(), cnx.src_channel.as_deref()),
839                format_endpoint(cnx.dst.as_str(), cnx.dst_channel.as_deref()),
840                cnx.msg
841            )
842        })
843        .collect();
844    edges.sort();
845    parts.extend(edges);
846
847    let joined = parts.join("\n");
848    format!("fnv1a64:{:016x}", fnv1a64(joined.as_bytes()))
849}
850
851fn flavor_label(flavor: Flavor) -> &'static str {
852    match flavor {
853        Flavor::Task => "task",
854        Flavor::Bridge => "bridge",
855    }
856}
857
858fn format_endpoint(node: &str, channel: Option<&str>) -> String {
859    match channel {
860        Some(ch) => format!("{node}/{ch}"),
861        None => node.to_string(),
862    }
863}
864
865fn fnv1a64(data: &[u8]) -> u64 {
866    const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
867    const PRIME: u64 = 0x100000001b3;
868    let mut hash = OFFSET_BASIS;
869    for byte in data {
870        hash ^= u64::from(*byte);
871        hash = hash.wrapping_mul(PRIME);
872    }
873    hash
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn plan_output_slots_skip_anytime_refine_duplicates() {
882        // A foreground anytime task (max_refines: 2) expands into one base step
883        // plus refine steps that reuse the base's culist slot.
884        let config = CuConfig::deserialize_ron(
885            r#"(
886                tasks: [
887                    (id: "src", type: "demo::Src"),
888                    (id: "any", type: "demo::Any", anytime: (max_refines: 2)),
889                    (id: "sink", type: "demo::Sink"),
890                ],
891                cnx: [
892                    (src: "src", dst: "any", msg: "u32"),
893                    (src: "any", dst: "sink", msg: "u32"),
894                ],
895            )"#,
896        )
897        .expect("valid anytime config");
898        let graph = config.get_graph(None).unwrap();
899
900        let plan = assemble_runtime_plan(&config, graph).unwrap();
901        // The regression can only trigger if refine steps are actually present.
902        assert!(
903            plan.execution.steps.iter().any(|unit| matches!(
904                unit,
905                CuExecutionUnit::Step(step) if step.phase == CuStepPhase::AnytimeRefine
906            )),
907            "config must exercise anytime refine steps"
908        );
909        // One slot per emitted culist slot: sum of msg types over non-refine
910        // steps, and each culist index appears exactly once.
911        let mut expected_indices = Vec::new();
912        let mut expected_slots = 0usize;
913        for unit in &plan.execution.steps {
914            if let CuExecutionUnit::Step(step) = unit
915                && step.phase != CuStepPhase::AnytimeRefine
916                && let Some(pack) = &step.output_msg_pack
917            {
918                expected_indices.push(pack.culist_index);
919                expected_slots += pack.msg_types.len();
920            }
921        }
922        let mut deduped = expected_indices.clone();
923        deduped.sort_unstable();
924        deduped.dedup();
925        assert_eq!(deduped.len(), expected_indices.len(), "culist indices dup");
926
927        let slots = build_output_slots_from_plan(&config, graph, None).unwrap();
928        assert_eq!(slots.len(), expected_slots);
929    }
930
931    fn edge_key() -> EdgeKey {
932        EdgeKey {
933            src: "src".to_string(),
934            src_channel: None,
935            dst: "dst".to_string(),
936            dst_channel: None,
937            msg: "Msg".to_string(),
938        }
939    }
940
941    #[test]
942    fn edge_stats_average_and_rate() {
943        let mut acc = EdgeAccumulator::default();
944        acc.record_sample(Some(100), Some(1_000_000_000));
945        acc.record_sample(Some(300), Some(2_000_000_000));
946        let stats = acc.finalize(edge_key());
947
948        assert_eq!(stats.samples, 2);
949        assert_eq!(stats.none_samples, 0);
950        assert_eq!(stats.total_raw_bytes, 400);
951        assert!((stats.avg_raw_bytes.unwrap() - 200.0).abs() < 1e-6);
952        assert!((stats.rate_hz.unwrap() - 1.0).abs() < 1e-6);
953        assert!((stats.throughput_bytes_per_sec.unwrap() - 400.0).abs() < 1e-6);
954    }
955
956    #[test]
957    fn edge_stats_handles_missing_times() {
958        let mut acc = EdgeAccumulator::default();
959        acc.record_sample(Some(64), None);
960        let stats = acc.finalize(edge_key());
961        assert_eq!(stats.samples, 1);
962        assert_eq!(stats.valid_time_samples, 0);
963        assert!(stats.rate_hz.is_none());
964        assert!(stats.throughput_bytes_per_sec.is_none());
965    }
966
967    #[test]
968    fn perf_stats_skip_missing_latency() {
969        let mut perf = PerfAccumulator::new();
970        perf.record_sample(Some(CuDuration::from_nanos(1_000)));
971        perf.record_sample(None);
972        let stats = perf.finalize();
973
974        assert_eq!(stats.samples, 2);
975        assert_eq!(stats.valid_time_samples, 1);
976        assert_eq!(stats.end_to_end.min_ns, Some(1_000));
977        assert_eq!(stats.end_to_end.max_ns, Some(1_000));
978        assert_eq!(stats.jitter.min_ns, None);
979    }
980
981    #[test]
982    fn timing_accumulator_reports_bounded_quantiles() {
983        let mut timings = TimingAccumulator::default();
984        for value in 1..=10_000 {
985            timings.record(value);
986        }
987        let stats = timings.stats();
988        assert_eq!(stats.min_ns, Some(1));
989        assert_eq!(stats.max_ns, Some(10_000));
990        assert!(stats.p50_ns.unwrap().abs_diff(5_000) < 250);
991        assert!(stats.p95_ns.unwrap().abs_diff(9_500) < 250);
992        assert!(timings.quantiles.samples.len() <= MAX_QUANTILE_SAMPLES);
993    }
994
995    #[test]
996    fn resource_overlap_requires_time_and_declared_target_overlap() {
997        let trace = ExecutionTrace {
998            kind: String::new(),
999            culist_id: 1,
1000            duration_ns: 30,
1001            wall_span_ns: 30,
1002            excluded_intervals: 0,
1003            residual_before_ns: None,
1004            intervals: vec![
1005                ExecutionInterval {
1006                    origin: "left".to_string(),
1007                    start_ns: 10,
1008                    end_ns: 30,
1009                },
1010                ExecutionInterval {
1011                    origin: "right".to_string(),
1012                    start_ns: 20,
1013                    end_ns: 40,
1014                },
1015            ],
1016        };
1017        let bindings = HashMap::from([
1018            ("left".to_string(), vec!["gpu0".to_string()]),
1019            ("right".to_string(), vec!["gpu0".to_string()]),
1020        ]);
1021        let mut active = Vec::new();
1022        let mut counts = BTreeMap::new();
1023        record_resource_overlaps(&trace, &bindings, &mut active, &mut counts);
1024        assert_eq!(
1025            counts.get(&("gpu0".to_string(), "left".to_string(), "right".to_string())),
1026            Some(&1)
1027        );
1028    }
1029
1030    #[test]
1031    fn current_execution_cluster_excludes_carried_forward_slots() {
1032        let intervals = vec![
1033            ExecutionInterval {
1034                origin: "stale_bridge".to_string(),
1035                start_ns: 100,
1036                end_ns: 200,
1037            },
1038            ExecutionInterval {
1039                origin: "source".to_string(),
1040                start_ns: 10_000_000,
1041                end_ns: 10_000_500,
1042            },
1043            ExecutionInterval {
1044                origin: "sink".to_string(),
1045                start_ns: 10_000_600,
1046                end_ns: 10_001_000,
1047            },
1048        ];
1049        let selected = select_current_execution_cluster(intervals);
1050        assert_eq!(selected.len(), 2);
1051        assert_eq!(selected[0].origin, "source");
1052        assert_eq!(selected[1].origin, "sink");
1053    }
1054}