Skip to main content

spatialrust_viewer/
mission_cockpit.rs

1//! Portable state for the interactive, source-bound mission cockpit.
2//!
3//! The cockpit is deliberately a bounded inspection contract.  A frame keeps
4//! a small, source-indexed sample for interaction while the original packet
5//! point count, topic, frame, timestamp, and transfer receipt remain visible.
6//! It never implies that a sample has been transformed into a calibrated world
7//! frame.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
12
13/// Current serialized mission-cockpit state schema version.
14pub const MISSION_COCKPIT_STATE_VERSION: u32 = 1;
15
16/// Maximum number of sampled points retained in one interactive frame.
17pub const MISSION_COCKPIT_MAX_SAMPLED_POINTS: usize = 4_096;
18
19/// One finite XYZ point retained for bounded cockpit interaction.
20#[derive(Clone, Copy, Debug, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
23pub struct MissionCockpitPoint {
24    /// X coordinate in the packet's original frame.
25    pub x: f32,
26    /// Y coordinate in the packet's original frame.
27    pub y: f32,
28    /// Z coordinate in the packet's original frame.
29    pub z: f32,
30}
31
32impl MissionCockpitPoint {
33    /// Creates a finite point.
34    pub fn try_new(x: f32, y: f32, z: f32) -> ViewerResult<Self> {
35        if !x.is_finite() || !y.is_finite() || !z.is_finite() {
36            return Err(ViewerError::InvalidState(
37                "mission cockpit points must contain finite XYZ values".into(),
38            ));
39        }
40        Ok(Self { x, y, z })
41    }
42
43    fn validate(&self) -> ViewerResult<()> {
44        Self::try_new(self.x, self.y, self.z).map(|_| ())
45    }
46}
47
48/// One packet frame with a bounded, source-indexed point sample.
49#[derive(Clone, Debug, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
52pub struct MissionCockpitFrame {
53    /// Zero-based packet sequence in deterministic replay order.
54    pub sequence: u64,
55    /// Source topic represented by the frame.
56    pub source_topic: String,
57    /// Published topic represented by the frame.
58    pub publish_topic: String,
59    /// Original packet frame identity.
60    pub frame_id: String,
61    /// Original PointCloud2 header timestamp in nanoseconds.
62    pub stamp_nanos: u64,
63    /// Full point count in the source packet.
64    pub point_count: u64,
65    /// Original source indices corresponding one-to-one with `sampled_points`.
66    pub sampled_source_indices: Vec<u64>,
67    /// Bounded XYZ sample retained for local interaction.
68    pub sampled_points: Vec<MissionCockpitPoint>,
69}
70
71impl MissionCockpitFrame {
72    /// Creates and validates one sampled packet frame.
73    #[allow(clippy::too_many_arguments)]
74    pub fn try_new(
75        sequence: u64,
76        source_topic: impl Into<String>,
77        publish_topic: impl Into<String>,
78        frame_id: impl Into<String>,
79        stamp_nanos: u64,
80        point_count: u64,
81        sampled_source_indices: Vec<u64>,
82        sampled_points: Vec<MissionCockpitPoint>,
83    ) -> ViewerResult<Self> {
84        let frame = Self {
85            sequence,
86            source_topic: source_topic.into(),
87            publish_topic: publish_topic.into(),
88            frame_id: frame_id.into(),
89            stamp_nanos,
90            point_count,
91            sampled_source_indices,
92            sampled_points,
93        };
94        frame.validate()?;
95        Ok(frame)
96    }
97
98    /// Validates identity, source-index ordering, and bounded geometry.
99    pub fn validate(&self) -> ViewerResult<()> {
100        if self.source_topic.trim().is_empty()
101            || self.publish_topic.trim().is_empty()
102            || self.frame_id.trim().is_empty()
103            || self.point_count == 0
104            || self.sampled_points.is_empty()
105            || self.sampled_points.len() != self.sampled_source_indices.len()
106            || self.sampled_points.len() > MISSION_COCKPIT_MAX_SAMPLED_POINTS
107        {
108            return Err(ViewerError::InvalidState(
109                "mission cockpit frames require bounded identity and point samples".into(),
110            ));
111        }
112        let mut previous = None;
113        for (source_index, point) in self.sampled_source_indices.iter().zip(&self.sampled_points) {
114            if *source_index >= self.point_count
115                || previous.is_some_and(|previous| *source_index <= previous)
116            {
117                return Err(ViewerError::InvalidState(
118                    "mission cockpit source indices must be strictly increasing and in range"
119                        .into(),
120                ));
121            }
122            point.validate()?;
123            previous = Some(*source_index);
124        }
125        Ok(())
126    }
127}
128
129/// One visual layer exposed by the cockpit.
130#[derive(Clone, Debug, PartialEq, Eq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
133pub struct MissionCockpitLayer {
134    /// Stable layer identity.
135    pub id: String,
136    /// User-facing layer label.
137    pub label: String,
138    /// Portable layer kind, such as `point-cloud` or `transfer-graph`.
139    pub kind: String,
140    /// Whether the layer is initially visible.
141    pub visible: bool,
142    /// Source topic identities represented by this layer.
143    pub frame_ids: Vec<String>,
144    /// RGB display color used by the portable dashboard.
145    pub color_rgb: [u8; 3],
146}
147
148impl MissionCockpitLayer {
149    /// Creates and validates one layer.
150    pub fn try_new(
151        id: impl Into<String>,
152        label: impl Into<String>,
153        kind: impl Into<String>,
154        visible: bool,
155        frame_ids: Vec<String>,
156        color_rgb: [u8; 3],
157    ) -> ViewerResult<Self> {
158        let layer = Self {
159            id: id.into(),
160            label: label.into(),
161            kind: kind.into(),
162            visible,
163            frame_ids,
164            color_rgb,
165        };
166        layer.validate()?;
167        Ok(layer)
168    }
169
170    /// Validates the layer identity and topic list.
171    pub fn validate(&self) -> ViewerResult<()> {
172        if self.id.trim().is_empty() || self.label.trim().is_empty() || self.kind.trim().is_empty()
173        {
174            return Err(ViewerError::InvalidState(
175                "mission cockpit layers require identity, label, and kind".into(),
176            ));
177        }
178        let mut ids = BTreeSet::new();
179        if self.frame_ids.iter().any(|id| id.trim().is_empty() || !ids.insert(id)) {
180            return Err(ViewerError::InvalidState(
181                "mission cockpit layer topic identities must be non-empty and unique".into(),
182            ));
183        }
184        Ok(())
185    }
186}
187
188/// One execution node shown in the transfer graph.
189#[derive(Clone, Debug, PartialEq)]
190#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
191#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
192pub struct MissionCockpitNode {
193    /// Stable node identity.
194    pub id: String,
195    /// Partition containing the node.
196    pub partition_id: String,
197    /// Placement label, for example `edge` or `host`.
198    pub placement: String,
199    /// Normalized display X coordinate in the graph panel.
200    pub display_x: f32,
201    /// Normalized display Y coordinate in the graph panel.
202    pub display_y: f32,
203}
204
205impl MissionCockpitNode {
206    /// Creates and validates one graph node.
207    pub fn try_new(
208        id: impl Into<String>,
209        partition_id: impl Into<String>,
210        placement: impl Into<String>,
211        display_x: f32,
212        display_y: f32,
213    ) -> ViewerResult<Self> {
214        let node = Self {
215            id: id.into(),
216            partition_id: partition_id.into(),
217            placement: placement.into(),
218            display_x,
219            display_y,
220        };
221        node.validate()?;
222        Ok(node)
223    }
224
225    /// Validates graph-node identity and display coordinates.
226    pub fn validate(&self) -> ViewerResult<()> {
227        if self.id.trim().is_empty()
228            || self.partition_id.trim().is_empty()
229            || self.placement.trim().is_empty()
230            || !self.display_x.is_finite()
231            || !self.display_y.is_finite()
232            || !(0.0..=1.0).contains(&self.display_x)
233            || !(0.0..=1.0).contains(&self.display_y)
234        {
235            return Err(ViewerError::InvalidState(
236                "mission cockpit nodes require bounded identity and display coordinates".into(),
237            ));
238        }
239        Ok(())
240    }
241}
242
243/// Aggregate receipt for one directed execution lane.
244#[derive(Clone, Debug, PartialEq, Eq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
246#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
247pub struct MissionCockpitLink {
248    /// Source node identity.
249    pub from_node: String,
250    /// Destination node identity.
251    pub to_node: String,
252    /// Number of packet transfers represented by this lane.
253    pub transfer_count: u64,
254    /// Number of completed packet transfers.
255    pub completed_transfer_count: u64,
256    /// Sum of packet payload bytes.
257    pub payload_bytes: u64,
258    /// Sum of explicit-copy bytes.
259    pub counted_copy_bytes: u64,
260}
261
262impl MissionCockpitLink {
263    /// Creates and validates one directed execution lane.
264    pub fn try_new(
265        from_node: impl Into<String>,
266        to_node: impl Into<String>,
267        transfer_count: u64,
268        completed_transfer_count: u64,
269        payload_bytes: u64,
270        counted_copy_bytes: u64,
271    ) -> ViewerResult<Self> {
272        let link = Self {
273            from_node: from_node.into(),
274            to_node: to_node.into(),
275            transfer_count,
276            completed_transfer_count,
277            payload_bytes,
278            counted_copy_bytes,
279        };
280        link.validate()?;
281        Ok(link)
282    }
283
284    /// Validates lane endpoints and byte/counter monotonicity.
285    pub fn validate(&self) -> ViewerResult<()> {
286        if self.from_node.trim().is_empty()
287            || self.to_node.trim().is_empty()
288            || self.from_node == self.to_node
289            || self.completed_transfer_count > self.transfer_count
290            || (self.transfer_count > 0 && self.payload_bytes == 0)
291            || self.counted_copy_bytes > self.payload_bytes
292        {
293            return Err(ViewerError::InvalidState(
294                "mission cockpit links require distinct nodes and consistent counters".into(),
295            ));
296        }
297        Ok(())
298    }
299}
300
301/// Cursor and bounds for the interactive packet timeline.
302#[derive(Clone, Debug, PartialEq, Eq)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
305pub struct MissionCockpitTimeline {
306    /// Timestamp domain used by the packet headers.
307    pub time_basis: String,
308    /// First admitted frame timestamp, or zero when unavailable.
309    pub start_nanos: u64,
310    /// Last admitted frame timestamp, or zero when unavailable.
311    pub end_nanos: u64,
312    /// Initial cursor timestamp.
313    pub cursor_nanos: u64,
314    /// Number of admitted packet frames.
315    pub frame_count: u64,
316}
317
318impl MissionCockpitTimeline {
319    /// Creates and validates timeline bounds.
320    pub fn try_new(
321        time_basis: impl Into<String>,
322        start_nanos: u64,
323        end_nanos: u64,
324        cursor_nanos: u64,
325        frame_count: u64,
326    ) -> ViewerResult<Self> {
327        let timeline = Self {
328            time_basis: time_basis.into(),
329            start_nanos,
330            end_nanos,
331            cursor_nanos,
332            frame_count,
333        };
334        timeline.validate()?;
335        Ok(timeline)
336    }
337
338    /// Validates the unavailable and admitted timeline representations.
339    pub fn validate(&self) -> ViewerResult<()> {
340        if self.time_basis.trim().is_empty() {
341            return Err(ViewerError::InvalidState(
342                "mission cockpit timeline requires a time basis".into(),
343            ));
344        }
345        if self.frame_count == 0 {
346            if self.start_nanos != 0 || self.end_nanos != 0 || self.cursor_nanos != 0 {
347                return Err(ViewerError::InvalidState(
348                    "empty mission cockpit timelines must have zero bounds".into(),
349                ));
350            }
351        } else if self.start_nanos > self.end_nanos
352            || self.cursor_nanos < self.start_nanos
353            || self.cursor_nanos > self.end_nanos
354        {
355            return Err(ViewerError::InvalidState(
356                "mission cockpit timeline bounds or cursor are invalid".into(),
357            ));
358        }
359        Ok(())
360    }
361}
362
363/// Aggregate counts and admission inputs shown by the cockpit.
364#[derive(Clone, Debug, PartialEq, Eq)]
365#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
366#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
367pub struct MissionCockpitSummary {
368    /// Number of upstream packets eligible for this cockpit.
369    pub source_packet_count: u64,
370    /// Number of packet frames retained in the cockpit.
371    pub frame_count: u64,
372    /// Sum of full source packet points.
373    pub total_point_count: u64,
374    /// Sum of bounded interactive sample points.
375    pub sampled_point_count: u64,
376    /// Number of packet transfers represented by graph links.
377    pub transfer_count: u64,
378    /// Number of completed packet transfers.
379    pub completed_transfer_count: u64,
380    /// Sum of packet payload bytes.
381    pub payload_bytes: u64,
382    /// Sum of explicit-copy bytes.
383    pub counted_copy_bytes: u64,
384    /// Upstream live-publish admission.
385    pub upstream_publish_ready: bool,
386    /// Upstream edge-partition admission.
387    pub upstream_partition_ready: bool,
388    /// Whether source-bound calibration registration exists.
389    pub calibration_registered: bool,
390    /// Whether calibrated transforms were applied.
391    pub calibration_applied: bool,
392    /// Timestamp domain shown in the timeline.
393    pub time_basis: String,
394}
395
396impl MissionCockpitSummary {
397    /// Creates and validates cockpit counters.
398    #[allow(clippy::too_many_arguments)]
399    pub fn try_new(
400        source_packet_count: u64,
401        frame_count: u64,
402        total_point_count: u64,
403        sampled_point_count: u64,
404        transfer_count: u64,
405        completed_transfer_count: u64,
406        payload_bytes: u64,
407        counted_copy_bytes: u64,
408        upstream_publish_ready: bool,
409        upstream_partition_ready: bool,
410        calibration_registered: bool,
411        calibration_applied: bool,
412        time_basis: impl Into<String>,
413    ) -> ViewerResult<Self> {
414        let summary = Self {
415            source_packet_count,
416            frame_count,
417            total_point_count,
418            sampled_point_count,
419            transfer_count,
420            completed_transfer_count,
421            payload_bytes,
422            counted_copy_bytes,
423            upstream_publish_ready,
424            upstream_partition_ready,
425            calibration_registered,
426            calibration_applied,
427            time_basis: time_basis.into(),
428        };
429        summary.validate()?;
430        Ok(summary)
431    }
432
433    /// Validates aggregate counters and calibration ordering.
434    pub fn validate(&self) -> ViewerResult<()> {
435        if self.time_basis.trim().is_empty()
436            || self.frame_count > self.source_packet_count
437            || self.completed_transfer_count > self.transfer_count
438            || (self.frame_count > 0 && self.total_point_count == 0)
439            || (self.sampled_point_count > self.total_point_count)
440            || (self.transfer_count > 0 && self.payload_bytes == 0)
441            || self.counted_copy_bytes > self.payload_bytes
442            || (self.calibration_applied && !self.calibration_registered)
443        {
444            return Err(ViewerError::InvalidState(
445                "mission cockpit summary has invalid counters or calibration ordering".into(),
446            ));
447        }
448        Ok(())
449    }
450}
451
452/// Portable source-bound state shared by the interactive cockpit surfaces.
453#[derive(Clone, Debug, PartialEq)]
454#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
455#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
456pub struct MissionCockpitState {
457    /// Serialized state schema version.
458    pub version: u32,
459    /// User-facing cockpit title.
460    pub title: String,
461    /// Exact canonical source identity.
462    pub source: StudioSource,
463    /// Source topic to expected frame map.
464    pub expected_frame_ids: BTreeMap<String, String>,
465    /// Interactive packet timeline.
466    pub timeline: MissionCockpitTimeline,
467    /// Bounded packet frames.
468    pub frames: Vec<MissionCockpitFrame>,
469    /// Initial visual layer state.
470    pub layers: Vec<MissionCockpitLayer>,
471    /// Execution graph nodes.
472    pub nodes: Vec<MissionCockpitNode>,
473    /// Aggregate execution graph links.
474    pub links: Vec<MissionCockpitLink>,
475    /// Aggregate counts and admission inputs.
476    pub summary: MissionCockpitSummary,
477    /// Checksummed source and upstream artifacts.
478    pub artifacts: Vec<ReplayArtifact>,
479    /// Whether source-bound packet inspection is admitted.
480    pub publish_ready: bool,
481    /// Whether source-bound edge execution is admitted.
482    pub partition_ready: bool,
483    /// Whether calibrated-world mapping is admitted.
484    pub mapping_admitted: bool,
485    /// Human-readable fail-closed reasons.
486    pub blockers: Vec<String>,
487}
488
489impl MissionCockpitState {
490    /// Creates state and derives its three admission levels from the receipts.
491    #[allow(clippy::too_many_arguments)]
492    pub fn try_new(
493        title: impl Into<String>,
494        source: StudioSource,
495        expected_frame_ids: BTreeMap<String, String>,
496        timeline: MissionCockpitTimeline,
497        frames: Vec<MissionCockpitFrame>,
498        layers: Vec<MissionCockpitLayer>,
499        nodes: Vec<MissionCockpitNode>,
500        links: Vec<MissionCockpitLink>,
501        summary: MissionCockpitSummary,
502        artifacts: Vec<ReplayArtifact>,
503        blockers: Vec<String>,
504    ) -> ViewerResult<Self> {
505        let publish_ready = source.identity_matches
506            && summary.upstream_publish_ready
507            && summary.source_packet_count > 0
508            && summary.frame_count == summary.source_packet_count
509            && summary.frame_count == u64::try_from(frames.len()).unwrap_or(u64::MAX);
510        let partition_ready = publish_ready
511            && summary.upstream_partition_ready
512            && summary.transfer_count == summary.source_packet_count
513            && summary.completed_transfer_count == summary.transfer_count;
514        let mapping_admitted = partition_ready && summary.calibration_applied;
515        let state = Self {
516            version: MISSION_COCKPIT_STATE_VERSION,
517            title: title.into(),
518            source,
519            expected_frame_ids,
520            timeline,
521            frames,
522            layers,
523            nodes,
524            links,
525            summary,
526            artifacts,
527            publish_ready,
528            partition_ready,
529            mapping_admitted,
530            blockers,
531        };
532        state.validate()?;
533        Ok(state)
534    }
535
536    /// Validates all frame, graph, receipt, and admission invariants.
537    pub fn validate(&self) -> ViewerResult<()> {
538        if self.version != MISSION_COCKPIT_STATE_VERSION {
539            return Err(ViewerError::InvalidState(format!(
540                "unsupported mission cockpit state version {}",
541                self.version
542            )));
543        }
544        if self.title.trim().is_empty() || self.expected_frame_ids.is_empty() {
545            return Err(ViewerError::InvalidState(
546                "mission cockpit state requires title and expected frame identities".into(),
547            ));
548        }
549        self.source.validate()?;
550        self.timeline.validate()?;
551        self.summary.validate()?;
552
553        let mut expected_frames = BTreeSet::new();
554        for (topic, frame_id) in &self.expected_frame_ids {
555            if topic.trim().is_empty()
556                || frame_id.trim().is_empty()
557                || !expected_frames.insert(topic)
558            {
559                return Err(ViewerError::InvalidState(
560                    "mission cockpit expected frame identities must be non-empty and unique".into(),
561                ));
562            }
563        }
564
565        let mut frame_topics = BTreeSet::new();
566        let mut total_points = 0_u64;
567        let mut sampled_points = 0_u64;
568        for (expected_sequence, frame) in self.frames.iter().enumerate() {
569            frame.validate()?;
570            if frame.sequence != u64::try_from(expected_sequence).unwrap_or(u64::MAX)
571                || !expected_frames.contains(&frame.source_topic)
572                || self.expected_frame_ids.get(&frame.source_topic) != Some(&frame.frame_id)
573            {
574                return Err(ViewerError::InvalidState(
575                    "mission cockpit frames have invalid sequence, topic, or frame identity".into(),
576                ));
577            }
578            frame_topics.insert(frame.source_topic.clone());
579            total_points = total_points.checked_add(frame.point_count).ok_or_else(|| {
580                ViewerError::InvalidState("mission cockpit point count overflow".into())
581            })?;
582            sampled_points = sampled_points
583                .checked_add(u64::try_from(frame.sampled_points.len()).unwrap_or(u64::MAX))
584                .ok_or_else(|| {
585                    ViewerError::InvalidState("mission cockpit sample count overflow".into())
586                })?;
587        }
588
589        let mut layer_ids = BTreeSet::new();
590        for layer in &self.layers {
591            layer.validate()?;
592            if !layer_ids.insert(&layer.id) {
593                return Err(ViewerError::InvalidState(
594                    "mission cockpit layer IDs must be unique".into(),
595                ));
596            }
597        }
598
599        let mut node_ids = BTreeSet::new();
600        for node in &self.nodes {
601            node.validate()?;
602            if !node_ids.insert(&node.id) {
603                return Err(ViewerError::InvalidState(
604                    "mission cockpit node IDs must be unique".into(),
605                ));
606            }
607        }
608        let mut link_transfer_count = 0_u64;
609        let mut link_completed_count = 0_u64;
610        let mut link_payload_bytes = 0_u64;
611        let mut link_copy_bytes = 0_u64;
612        for link in &self.links {
613            link.validate()?;
614            if !node_ids.contains(&link.from_node) || !node_ids.contains(&link.to_node) {
615                return Err(ViewerError::InvalidState(
616                    "mission cockpit links must reference known graph nodes".into(),
617                ));
618            }
619            link_transfer_count =
620                link_transfer_count.checked_add(link.transfer_count).ok_or_else(|| {
621                    ViewerError::InvalidState("mission cockpit transfer count overflow".into())
622                })?;
623            link_completed_count = link_completed_count
624                .checked_add(link.completed_transfer_count)
625                .ok_or_else(|| {
626                    ViewerError::InvalidState("mission cockpit completion count overflow".into())
627                })?;
628            link_payload_bytes =
629                link_payload_bytes.checked_add(link.payload_bytes).ok_or_else(|| {
630                    ViewerError::InvalidState("mission cockpit payload byte overflow".into())
631                })?;
632            link_copy_bytes =
633                link_copy_bytes.checked_add(link.counted_copy_bytes).ok_or_else(|| {
634                    ViewerError::InvalidState("mission cockpit copy byte overflow".into())
635                })?;
636        }
637        if total_points != self.summary.total_point_count
638            || sampled_points != self.summary.sampled_point_count
639            || link_transfer_count != self.summary.transfer_count
640            || link_completed_count != self.summary.completed_transfer_count
641            || link_payload_bytes != self.summary.payload_bytes
642            || link_copy_bytes != self.summary.counted_copy_bytes
643            || self.timeline.frame_count != u64::try_from(self.frames.len()).unwrap_or(u64::MAX)
644            || self.summary.frame_count != u64::try_from(self.frames.len()).unwrap_or(u64::MAX)
645        {
646            return Err(ViewerError::InvalidState(
647                "mission cockpit frame, link, or timeline totals disagree with summary".into(),
648            ));
649        }
650        if self.publish_ready && frame_topics.is_empty() {
651            return Err(ViewerError::InvalidState(
652                "admitted mission cockpit state must contain frames".into(),
653            ));
654        }
655        let calculated_publish = self.source.identity_matches
656            && self.summary.upstream_publish_ready
657            && self.summary.source_packet_count > 0
658            && self.summary.frame_count == self.summary.source_packet_count
659            && self.summary.frame_count == u64::try_from(self.frames.len()).unwrap_or(u64::MAX);
660        if self.publish_ready != calculated_publish {
661            return Err(ViewerError::InvalidState(
662                "publish_ready disagrees with source, upstream, or frame gates".into(),
663            ));
664        }
665        let calculated_partition = calculated_publish
666            && self.summary.upstream_partition_ready
667            && self.summary.transfer_count == self.summary.source_packet_count
668            && self.summary.completed_transfer_count == self.summary.transfer_count;
669        if self.partition_ready != calculated_partition {
670            return Err(ViewerError::InvalidState(
671                "partition_ready disagrees with source, upstream, or transfer gates".into(),
672            ));
673        }
674        let calculated_mapping = self.partition_ready && self.summary.calibration_applied;
675        if self.mapping_admitted != calculated_mapping {
676            return Err(ViewerError::InvalidState(
677                "mapping_admitted disagrees with partition and calibration gates".into(),
678            ));
679        }
680        if self.mapping_admitted && !self.blockers.is_empty() {
681            return Err(ViewerError::InvalidState(
682                "admitted mission cockpit mapping cannot contain blockers".into(),
683            ));
684        }
685        if !self.mapping_admitted && self.blockers.is_empty() {
686            return Err(ViewerError::InvalidState(
687                "blocked mission cockpit mapping must expose at least one blocker".into(),
688            ));
689        }
690        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
691            return Err(ViewerError::InvalidState(
692                "mission cockpit blockers must not contain empty messages".into(),
693            ));
694        }
695
696        let mut artifact_roles = BTreeSet::new();
697        let mut artifact_paths = BTreeSet::new();
698        for artifact in &self.artifacts {
699            artifact.validate()?;
700            if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
701                return Err(ViewerError::InvalidState(
702                    "mission cockpit artifacts must have unique roles and paths".into(),
703                ));
704            }
705        }
706        Ok(())
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
715
716    fn source(matches: bool) -> StudioSource {
717        StudioSource::try_new(
718            "canonical bag",
719            "/media/input.db3",
720            SHA,
721            if matches {
722                SHA
723            } else {
724                "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
725            },
726            matches,
727        )
728        .unwrap()
729    }
730
731    fn frame(sequence: u64) -> MissionCockpitFrame {
732        MissionCockpitFrame::try_new(
733            sequence,
734            "/lidar_front/points_raw",
735            "/spatialrust/lidar_front/points_raw",
736            "lidar_front",
737            10 + sequence,
738            2,
739            vec![0, 1],
740            vec![
741                MissionCockpitPoint::try_new(sequence as f32, 0.0, 0.0).unwrap(),
742                MissionCockpitPoint::try_new(sequence as f32, 1.0, 0.0).unwrap(),
743            ],
744        )
745        .unwrap()
746    }
747
748    fn layers() -> Vec<MissionCockpitLayer> {
749        vec![
750            MissionCockpitLayer::try_new(
751                "front",
752                "Front lidar",
753                "point-cloud",
754                true,
755                vec!["/lidar_front/points_raw".into()],
756                [91, 220, 255],
757            )
758            .unwrap(),
759            MissionCockpitLayer::try_new(
760                "graph",
761                "Execution graph",
762                "transfer-graph",
763                true,
764                Vec::new(),
765                [99, 231, 165],
766            )
767            .unwrap(),
768        ]
769    }
770
771    fn state(matches: bool) -> MissionCockpitState {
772        let frames = if matches { vec![frame(0)] } else { Vec::new() };
773        let nodes = vec![
774            MissionCockpitNode::try_new("edge", "edge", "edge", 0.2, 0.5).unwrap(),
775            MissionCockpitNode::try_new("host", "host", "host", 0.8, 0.5).unwrap(),
776        ];
777        let links = if matches {
778            vec![MissionCockpitLink::try_new("edge", "host", 1, 1, 128, 128).unwrap()]
779        } else {
780            Vec::new()
781        };
782        let summary = MissionCockpitSummary::try_new(
783            if matches { 1 } else { 0 },
784            u64::try_from(frames.len()).unwrap(),
785            if matches { 2 } else { 0 },
786            if matches { 2 } else { 0 },
787            if matches { 1 } else { 0 },
788            if matches { 1 } else { 0 },
789            if matches { 128 } else { 0 },
790            if matches { 128 } else { 0 },
791            matches,
792            matches,
793            false,
794            false,
795            "header stamp",
796        )
797        .unwrap();
798        MissionCockpitState::try_new(
799            "Mission Cockpit",
800            source(matches),
801            BTreeMap::from([("/lidar_front/points_raw".into(), "lidar_front".into())]),
802            MissionCockpitTimeline::try_new(
803                "header stamp",
804                if matches { 10 } else { 0 },
805                if matches { 10 } else { 0 },
806                if matches { 10 } else { 0 },
807                u64::try_from(frames.len()).unwrap(),
808            )
809            .unwrap(),
810            frames,
811            layers(),
812            nodes,
813            links,
814            summary,
815            Vec::new(),
816            if matches {
817                vec!["calibration not applied".into()]
818            } else {
819                vec!["source SHA mismatch".into()]
820            },
821        )
822        .unwrap()
823    }
824
825    #[test]
826    fn healthy_packet_and_partition_are_admitted_but_mapping_stays_blocked() {
827        let cockpit = state(true);
828        assert!(cockpit.publish_ready);
829        assert!(cockpit.partition_ready);
830        assert!(!cockpit.mapping_admitted);
831        cockpit.validate().unwrap();
832    }
833
834    #[test]
835    fn source_mismatch_withholds_frames_and_execution() {
836        let cockpit = state(false);
837        assert!(!cockpit.publish_ready);
838        assert!(!cockpit.partition_ready);
839        assert!(!cockpit.mapping_admitted);
840        assert!(cockpit.frames.is_empty());
841    }
842
843    #[test]
844    fn rejects_non_finite_sample() {
845        assert!(MissionCockpitPoint::try_new(f32::NAN, 0.0, 0.0).is_err());
846    }
847
848    #[test]
849    fn rejects_unsorted_source_indices() {
850        let result = MissionCockpitFrame::try_new(
851            0,
852            "topic",
853            "publish",
854            "frame",
855            1,
856            3,
857            vec![1, 0],
858            vec![
859                MissionCockpitPoint::try_new(0.0, 0.0, 0.0).unwrap(),
860                MissionCockpitPoint::try_new(1.0, 0.0, 0.0).unwrap(),
861            ],
862        );
863        assert!(result.is_err());
864    }
865}