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};
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).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) -> CuResult<Vec<OutputSlot>> {
651    let specs = P::get_output_specs();
652    if specs.is_empty() {
653        return build_output_slots_from_plan(config, graph);
654    }
655    Ok(specs
656        .iter()
657        .map(|spec| OutputSlot {
658            edges: graph
659                .edges()
660                .filter(|edge| edge.msg == spec.msg_type && edge_matches_origin(edge, spec.task_id))
661                .map(edge_key_from_connection)
662                .collect(),
663        })
664        .collect())
665}
666
667fn edge_matches_origin(edge: &cu29::config::Cnx, origin: &str) -> bool {
668    if edge.src == origin {
669        return true;
670    }
671    let Some(rest) = origin.strip_prefix("bridge::") else {
672        return false;
673    };
674    let mut parts = rest.split("::");
675    let (Some(bridge), Some(direction), Some(channel), None) =
676        (parts.next(), parts.next(), parts.next(), parts.next())
677    else {
678        return false;
679    };
680    direction == "rx" && edge.src == bridge && edge.src_channel.as_deref() == Some(channel)
681}
682
683fn edge_key_from_connection(cnx: &cu29::config::Cnx) -> EdgeKey {
684    EdgeKey {
685        src: cnx.src.clone(),
686        src_channel: cnx.src_channel.clone(),
687        dst: cnx.dst.clone(),
688        dst_channel: cnx.dst_channel.clone(),
689        msg: cnx.msg.clone(),
690    }
691}
692
693/// Recover the culist slot layout from the config alone, for logs whose payload
694/// type carries no output specs. Each slot lists the graph edges feeding it.
695fn build_output_slots_from_plan(config: &CuConfig, graph: &CuGraph) -> CuResult<Vec<OutputSlot>> {
696    // Share the generated-runtime construction path (bridge stages, slot
697    // indices) so this cannot drift from the compiled plan.
698    let plan = assemble_runtime_plan(config, graph)?;
699
700    let mut packs: Vec<(u32, String, Vec<String>)> = Vec::new();
701    for unit in &plan.execution.steps {
702        let CuExecutionUnit::Step(step) = unit else {
703            continue;
704        };
705        // Anytime refine steps reuse their base step's culist slot; counting
706        // them would emit duplicate slots and misalign every later slot.
707        if step.phase == CuStepPhase::AnytimeRefine {
708            continue;
709        }
710        let Some(output_pack) = &step.output_msg_pack else {
711            continue;
712        };
713        let entity = &plan.entities[step.node_id as usize];
714        let origin = match entity.kind {
715            PlanEntityKind::Task { .. } => entity.label.clone(),
716            PlanEntityKind::BridgeRx { .. } | PlanEntityKind::BridgeTx { .. } => {
717                format!("bridge::{}", entity.label)
718            }
719        };
720        packs.push((
721            output_pack.culist_index,
722            origin,
723            output_pack.msg_types.clone(),
724        ));
725    }
726
727    packs.sort_by_key(|(culist_index, _, _)| *culist_index);
728    Ok(packs
729        .into_iter()
730        .flat_map(|(_, origin, msg_types)| {
731            msg_types.into_iter().map(move |msg| OutputSlot {
732                edges: graph
733                    .edges()
734                    .filter(|edge| edge.msg == msg && edge_matches_origin(edge, &origin))
735                    .map(edge_key_from_connection)
736                    .collect(),
737            })
738        })
739        .collect())
740}
741
742fn build_edge_accumulators(graph: &CuGraph) -> HashMap<EdgeKey, EdgeAccumulator> {
743    let mut acc = HashMap::new();
744    for cnx in graph.edges() {
745        let key = EdgeKey {
746            src: cnx.src.clone(),
747            src_channel: cnx.src_channel.clone(),
748            dst: cnx.dst.clone(),
749            dst_channel: cnx.dst_channel.clone(),
750            msg: cnx.msg.clone(),
751        };
752        acc.entry(key).or_default();
753    }
754    acc
755}
756
757fn compute_end_to_end_latency(
758    msgs: &[&dyn cu29::prelude::ErasedCuStampedData],
759) -> Option<CuDuration> {
760    let start = msgs
761        .first()
762        .and_then(|msg| extract_start_time_ns(msg.metadata()))?;
763    let end = msgs
764        .last()
765        .and_then(|msg| extract_end_time_ns(msg.metadata()))?;
766    end.checked_sub(start).map(CuDuration::from_nanos)
767}
768
769fn extract_start_time_ns(meta: &dyn CuMsgMetadataTrait) -> Option<u64> {
770    option_time_ns(meta.process_time().start)
771}
772
773fn extract_end_time_ns(meta: &dyn CuMsgMetadataTrait) -> Option<u64> {
774    option_time_ns(meta.process_time().end)
775}
776
777fn option_time_ns(value: OptionCuTime) -> Option<u64> {
778    Option::<cu29::clock::CuTime>::from(value).map(|t| t.as_nanos())
779}
780
781fn duration_stats_from(stats: &CuDurationStatistics) -> DurationStats {
782    if stats.is_empty() {
783        return DurationStats::default();
784    }
785    DurationStats {
786        min_ns: Some(stats.min().as_nanos()),
787        max_ns: Some(stats.max().as_nanos()),
788        mean_ns: Some(stats.mean().as_nanos() as f64),
789        stddev_ns: Some(stats.stddev().as_nanos() as f64),
790        p50_ns: None,
791        p95_ns: None,
792    }
793}
794
795fn jitter_stats_from(stats: &CuDurationStatistics) -> DurationStats {
796    if stats.len() < 2 {
797        return DurationStats::default();
798    }
799    DurationStats {
800        min_ns: Some(stats.jitter_min().as_nanos()),
801        max_ns: Some(stats.jitter_max().as_nanos()),
802        mean_ns: Some(stats.jitter_mean().as_nanos() as f64),
803        stddev_ns: Some(stats.jitter_stddev().as_nanos() as f64),
804        p50_ns: None,
805        p95_ns: None,
806    }
807}
808
809fn build_graph_signature(graph: &CuGraph, mission: Option<&str>) -> String {
810    let mut parts = Vec::new();
811    parts.push(format!("mission={}", mission.unwrap_or("default")));
812
813    let mut nodes: Vec<_> = graph.get_all_nodes();
814    nodes.sort_by_key(|a| a.1.get_id());
815    for (_, node) in nodes {
816        parts.push(format!(
817            "node|{}|{}|{}",
818            node.get_id(),
819            node.get_type(),
820            flavor_label(node.get_flavor())
821        ));
822    }
823
824    let mut edges: Vec<String> = graph
825        .edges()
826        .map(|cnx| {
827            format!(
828                "edge|{}|{}|{}",
829                format_endpoint(cnx.src.as_str(), cnx.src_channel.as_deref()),
830                format_endpoint(cnx.dst.as_str(), cnx.dst_channel.as_deref()),
831                cnx.msg
832            )
833        })
834        .collect();
835    edges.sort();
836    parts.extend(edges);
837
838    let joined = parts.join("\n");
839    format!("fnv1a64:{:016x}", fnv1a64(joined.as_bytes()))
840}
841
842fn flavor_label(flavor: Flavor) -> &'static str {
843    match flavor {
844        Flavor::Task => "task",
845        Flavor::Bridge => "bridge",
846    }
847}
848
849fn format_endpoint(node: &str, channel: Option<&str>) -> String {
850    match channel {
851        Some(ch) => format!("{node}/{ch}"),
852        None => node.to_string(),
853    }
854}
855
856fn fnv1a64(data: &[u8]) -> u64 {
857    const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
858    const PRIME: u64 = 0x100000001b3;
859    let mut hash = OFFSET_BASIS;
860    for byte in data {
861        hash ^= u64::from(*byte);
862        hash = hash.wrapping_mul(PRIME);
863    }
864    hash
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    #[test]
872    fn plan_output_slots_skip_anytime_refine_duplicates() {
873        // A foreground anytime task (max_refines: 2) expands into one base step
874        // plus refine steps that reuse the base's culist slot.
875        let config = CuConfig::deserialize_ron(
876            r#"(
877                tasks: [
878                    (id: "src", type: "demo::Src"),
879                    (id: "any", type: "demo::Any", anytime: (max_refines: 2)),
880                    (id: "sink", type: "demo::Sink"),
881                ],
882                cnx: [
883                    (src: "src", dst: "any", msg: "u32"),
884                    (src: "any", dst: "sink", msg: "u32"),
885                ],
886            )"#,
887        )
888        .expect("valid anytime config");
889        let graph = config.get_graph(None).unwrap();
890
891        let plan = assemble_runtime_plan(&config, graph).unwrap();
892        // The regression can only trigger if refine steps are actually present.
893        assert!(
894            plan.execution.steps.iter().any(|unit| matches!(
895                unit,
896                CuExecutionUnit::Step(step) if step.phase == CuStepPhase::AnytimeRefine
897            )),
898            "config must exercise anytime refine steps"
899        );
900        // One slot per emitted culist slot: sum of msg types over non-refine
901        // steps, and each culist index appears exactly once.
902        let mut expected_indices = Vec::new();
903        let mut expected_slots = 0usize;
904        for unit in &plan.execution.steps {
905            if let CuExecutionUnit::Step(step) = unit
906                && step.phase != CuStepPhase::AnytimeRefine
907                && let Some(pack) = &step.output_msg_pack
908            {
909                expected_indices.push(pack.culist_index);
910                expected_slots += pack.msg_types.len();
911            }
912        }
913        let mut deduped = expected_indices.clone();
914        deduped.sort_unstable();
915        deduped.dedup();
916        assert_eq!(deduped.len(), expected_indices.len(), "culist indices dup");
917
918        let slots = build_output_slots_from_plan(&config, graph).unwrap();
919        assert_eq!(slots.len(), expected_slots);
920    }
921
922    fn edge_key() -> EdgeKey {
923        EdgeKey {
924            src: "src".to_string(),
925            src_channel: None,
926            dst: "dst".to_string(),
927            dst_channel: None,
928            msg: "Msg".to_string(),
929        }
930    }
931
932    #[test]
933    fn edge_stats_average_and_rate() {
934        let mut acc = EdgeAccumulator::default();
935        acc.record_sample(Some(100), Some(1_000_000_000));
936        acc.record_sample(Some(300), Some(2_000_000_000));
937        let stats = acc.finalize(edge_key());
938
939        assert_eq!(stats.samples, 2);
940        assert_eq!(stats.none_samples, 0);
941        assert_eq!(stats.total_raw_bytes, 400);
942        assert!((stats.avg_raw_bytes.unwrap() - 200.0).abs() < 1e-6);
943        assert!((stats.rate_hz.unwrap() - 1.0).abs() < 1e-6);
944        assert!((stats.throughput_bytes_per_sec.unwrap() - 400.0).abs() < 1e-6);
945    }
946
947    #[test]
948    fn edge_stats_handles_missing_times() {
949        let mut acc = EdgeAccumulator::default();
950        acc.record_sample(Some(64), None);
951        let stats = acc.finalize(edge_key());
952        assert_eq!(stats.samples, 1);
953        assert_eq!(stats.valid_time_samples, 0);
954        assert!(stats.rate_hz.is_none());
955        assert!(stats.throughput_bytes_per_sec.is_none());
956    }
957
958    #[test]
959    fn perf_stats_skip_missing_latency() {
960        let mut perf = PerfAccumulator::new();
961        perf.record_sample(Some(CuDuration::from_nanos(1_000)));
962        perf.record_sample(None);
963        let stats = perf.finalize();
964
965        assert_eq!(stats.samples, 2);
966        assert_eq!(stats.valid_time_samples, 1);
967        assert_eq!(stats.end_to_end.min_ns, Some(1_000));
968        assert_eq!(stats.end_to_end.max_ns, Some(1_000));
969        assert_eq!(stats.jitter.min_ns, None);
970    }
971
972    #[test]
973    fn timing_accumulator_reports_bounded_quantiles() {
974        let mut timings = TimingAccumulator::default();
975        for value in 1..=10_000 {
976            timings.record(value);
977        }
978        let stats = timings.stats();
979        assert_eq!(stats.min_ns, Some(1));
980        assert_eq!(stats.max_ns, Some(10_000));
981        assert!(stats.p50_ns.unwrap().abs_diff(5_000) < 250);
982        assert!(stats.p95_ns.unwrap().abs_diff(9_500) < 250);
983        assert!(timings.quantiles.samples.len() <= MAX_QUANTILE_SAMPLES);
984    }
985
986    #[test]
987    fn resource_overlap_requires_time_and_declared_target_overlap() {
988        let trace = ExecutionTrace {
989            kind: String::new(),
990            culist_id: 1,
991            duration_ns: 30,
992            wall_span_ns: 30,
993            excluded_intervals: 0,
994            residual_before_ns: None,
995            intervals: vec![
996                ExecutionInterval {
997                    origin: "left".to_string(),
998                    start_ns: 10,
999                    end_ns: 30,
1000                },
1001                ExecutionInterval {
1002                    origin: "right".to_string(),
1003                    start_ns: 20,
1004                    end_ns: 40,
1005                },
1006            ],
1007        };
1008        let bindings = HashMap::from([
1009            ("left".to_string(), vec!["gpu0".to_string()]),
1010            ("right".to_string(), vec!["gpu0".to_string()]),
1011        ]);
1012        let mut active = Vec::new();
1013        let mut counts = BTreeMap::new();
1014        record_resource_overlaps(&trace, &bindings, &mut active, &mut counts);
1015        assert_eq!(
1016            counts.get(&("gpu0".to_string(), "left".to_string(), "right".to_string())),
1017            Some(&1)
1018        );
1019    }
1020
1021    #[test]
1022    fn current_execution_cluster_excludes_carried_forward_slots() {
1023        let intervals = vec![
1024            ExecutionInterval {
1025                origin: "stale_bridge".to_string(),
1026                start_ns: 100,
1027                end_ns: 200,
1028            },
1029            ExecutionInterval {
1030                origin: "source".to_string(),
1031                start_ns: 10_000_000,
1032                end_ns: 10_000_500,
1033            },
1034            ExecutionInterval {
1035                origin: "sink".to_string(),
1036                start_ns: 10_000_600,
1037                end_ns: 10_001_000,
1038            },
1039        ];
1040        let selected = select_current_execution_cluster(intervals);
1041        assert_eq!(selected.len(), 2);
1042        assert_eq!(selected[0].origin, "source");
1043        assert_eq!(selected[1].origin, "sink");
1044    }
1045}