1use std::collections::{BTreeMap, BTreeSet};
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12pub const LIVE_PUBLISH_STATE_VERSION: u32 = 1;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
19pub struct LivePublishTopic {
20 pub source_topic: String,
22 pub publish_topic: String,
24 pub message_type: String,
26 pub source_message_count: u64,
28 pub retained_record_count: u64,
30 pub retained_point_count: u64,
32 pub published_message_count: u64,
34 pub published_point_count: u64,
36 pub frame_ids: Vec<String>,
38}
39
40impl LivePublishTopic {
41 #[allow(clippy::too_many_arguments)]
43 pub fn try_new(
44 source_topic: impl Into<String>,
45 publish_topic: impl Into<String>,
46 message_type: impl Into<String>,
47 source_message_count: u64,
48 retained_record_count: u64,
49 retained_point_count: u64,
50 published_message_count: u64,
51 published_point_count: u64,
52 frame_ids: Vec<String>,
53 ) -> ViewerResult<Self> {
54 let topic = Self {
55 source_topic: source_topic.into(),
56 publish_topic: publish_topic.into(),
57 message_type: message_type.into(),
58 source_message_count,
59 retained_record_count,
60 retained_point_count,
61 published_message_count,
62 published_point_count,
63 frame_ids,
64 };
65 topic.validate()?;
66 Ok(topic)
67 }
68
69 pub fn validate(&self) -> ViewerResult<()> {
71 if self.source_topic.trim().is_empty()
72 || self.publish_topic.trim().is_empty()
73 || self.message_type.trim().is_empty()
74 || (self.retained_record_count > 0 && self.retained_point_count == 0)
75 || (self.published_message_count > 0 && self.published_point_count == 0)
76 {
77 return Err(ViewerError::InvalidState(
78 "live-publish topics require names, a message type, and consistent counters".into(),
79 ));
80 }
81 let mut frames = BTreeSet::new();
82 for frame_id in &self.frame_ids {
83 if frame_id.trim().is_empty() || !frames.insert(frame_id) {
84 return Err(ViewerError::InvalidState(
85 "live-publish topic frame IDs must be non-empty and unique".into(),
86 ));
87 }
88 }
89 Ok(())
90 }
91}
92
93#[derive(Clone, Debug, PartialEq, Eq)]
95#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
96#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
97pub struct LivePublishPacket {
98 pub sequence: u64,
100 pub source_topic: String,
102 pub publish_topic: String,
104 pub frame_id: String,
106 pub stamp_nanos: u64,
108 pub point_count: u64,
110 pub payload_bytes: u64,
112 pub roundtrip_payload_bytes: u64,
114 pub roundtrip_verified: bool,
116}
117
118impl LivePublishPacket {
119 #[allow(clippy::too_many_arguments)]
121 pub fn try_new(
122 sequence: u64,
123 source_topic: impl Into<String>,
124 publish_topic: impl Into<String>,
125 frame_id: impl Into<String>,
126 stamp_nanos: u64,
127 point_count: u64,
128 payload_bytes: u64,
129 roundtrip_payload_bytes: u64,
130 roundtrip_verified: bool,
131 ) -> ViewerResult<Self> {
132 let packet = Self {
133 sequence,
134 source_topic: source_topic.into(),
135 publish_topic: publish_topic.into(),
136 frame_id: frame_id.into(),
137 stamp_nanos,
138 point_count,
139 payload_bytes,
140 roundtrip_payload_bytes,
141 roundtrip_verified,
142 };
143 packet.validate()?;
144 Ok(packet)
145 }
146
147 pub fn validate(&self) -> ViewerResult<()> {
149 if self.source_topic.trim().is_empty()
150 || self.publish_topic.trim().is_empty()
151 || self.frame_id.trim().is_empty()
152 || self.point_count == 0
153 || self.payload_bytes == 0
154 || (self.roundtrip_verified && self.roundtrip_payload_bytes != self.payload_bytes)
155 {
156 return Err(ViewerError::InvalidState(
157 "live-publish packets require non-empty identity, points, and payload receipts"
158 .into(),
159 ));
160 }
161 if self.roundtrip_verified && self.roundtrip_payload_bytes == 0 {
162 return Err(ViewerError::InvalidState(
163 "verified live-publish packets require a non-zero round-trip payload".into(),
164 ));
165 }
166 Ok(())
167 }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
173#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
174pub struct LivePublishTransport {
175 pub adapter: String,
177 pub message_type: String,
179 pub queue_policy: String,
181 pub queue_capacity: u64,
183 pub published_message_count: u64,
185 pub received_message_count: u64,
187 pub backpressure_event_count: u64,
189 pub host_encode_bytes: u64,
191 pub host_decode_bytes: u64,
193 pub device_upload_bytes: u64,
195 pub device_readback_bytes: u64,
197}
198
199impl LivePublishTransport {
200 #[allow(clippy::too_many_arguments)]
202 pub fn try_new(
203 adapter: impl Into<String>,
204 message_type: impl Into<String>,
205 queue_policy: impl Into<String>,
206 queue_capacity: u64,
207 published_message_count: u64,
208 received_message_count: u64,
209 backpressure_event_count: u64,
210 host_encode_bytes: u64,
211 host_decode_bytes: u64,
212 device_upload_bytes: u64,
213 device_readback_bytes: u64,
214 ) -> ViewerResult<Self> {
215 let transport = Self {
216 adapter: adapter.into(),
217 message_type: message_type.into(),
218 queue_policy: queue_policy.into(),
219 queue_capacity,
220 published_message_count,
221 received_message_count,
222 backpressure_event_count,
223 host_encode_bytes,
224 host_decode_bytes,
225 device_upload_bytes,
226 device_readback_bytes,
227 };
228 transport.validate()?;
229 Ok(transport)
230 }
231
232 pub fn validate(&self) -> ViewerResult<()> {
234 if self.adapter.trim().is_empty()
235 || self.message_type.trim().is_empty()
236 || self.queue_policy.trim().is_empty()
237 || self.queue_capacity == 0
238 || self.received_message_count > self.published_message_count
239 {
240 return Err(ViewerError::InvalidState(
241 "live-publish transport has invalid adapter, queue, or message counters".into(),
242 ));
243 }
244 Ok(())
245 }
246}
247
248#[derive(Clone, Debug, PartialEq, Eq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
252pub struct LivePublishSummary {
253 pub source_message_count: u64,
255 pub selected_record_count: u64,
257 pub selected_point_count: u64,
259 pub selected_bytes: u64,
261 pub peak_source_bytes: u64,
263 pub published_message_count: u64,
265 pub received_message_count: u64,
267 pub published_point_count: u64,
269 pub deterministic_order_verified: bool,
271 pub frame_identity_match: bool,
273 pub calibration_registered: bool,
275 pub calibration_applied: bool,
277 pub time_basis: String,
279}
280
281impl LivePublishSummary {
282 #[allow(clippy::too_many_arguments)]
284 pub fn try_new(
285 source_message_count: u64,
286 selected_record_count: u64,
287 selected_point_count: u64,
288 selected_bytes: u64,
289 peak_source_bytes: u64,
290 published_message_count: u64,
291 received_message_count: u64,
292 published_point_count: u64,
293 deterministic_order_verified: bool,
294 frame_identity_match: bool,
295 calibration_registered: bool,
296 calibration_applied: bool,
297 time_basis: impl Into<String>,
298 ) -> ViewerResult<Self> {
299 let summary = Self {
300 source_message_count,
301 selected_record_count,
302 selected_point_count,
303 selected_bytes,
304 peak_source_bytes,
305 published_message_count,
306 received_message_count,
307 published_point_count,
308 deterministic_order_verified,
309 frame_identity_match,
310 calibration_registered,
311 calibration_applied,
312 time_basis: time_basis.into(),
313 };
314 summary.validate()?;
315 Ok(summary)
316 }
317
318 pub fn validate(&self) -> ViewerResult<()> {
320 if self.time_basis.trim().is_empty()
321 || self.published_message_count > self.selected_record_count
322 || self.received_message_count > self.published_message_count
323 || (self.selected_record_count > 0 && self.selected_point_count == 0)
324 || (self.published_message_count > 0 && self.published_point_count == 0)
325 || (self.calibration_applied && !self.calibration_registered)
326 {
327 return Err(ViewerError::InvalidState(
328 "live-publish summary has invalid counters or calibration ordering".into(),
329 ));
330 }
331 Ok(())
332 }
333}
334
335#[derive(Clone, Debug, PartialEq, Eq)]
337#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
338#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
339pub struct LivePublishState {
340 pub version: u32,
342 pub title: String,
344 pub source: StudioSource,
346 pub expected_frame_ids: BTreeMap<String, String>,
348 pub time_basis: String,
350 pub topics: Vec<LivePublishTopic>,
352 pub transport: LivePublishTransport,
354 pub packets: Vec<LivePublishPacket>,
356 pub summary: LivePublishSummary,
358 pub artifacts: Vec<ReplayArtifact>,
360 pub publish_ready: bool,
362 pub mapping_admitted: bool,
364 pub blockers: Vec<String>,
366}
367
368impl LivePublishState {
369 #[allow(clippy::too_many_arguments)]
371 pub fn try_new(
372 title: impl Into<String>,
373 source: StudioSource,
374 expected_frame_ids: BTreeMap<String, String>,
375 time_basis: impl Into<String>,
376 topics: Vec<LivePublishTopic>,
377 transport: LivePublishTransport,
378 packets: Vec<LivePublishPacket>,
379 summary: LivePublishSummary,
380 artifacts: Vec<ReplayArtifact>,
381 blockers: Vec<String>,
382 ) -> ViewerResult<Self> {
383 let all_roundtrips_verified = !packets.is_empty()
384 && packets.iter().all(|packet| {
385 packet.roundtrip_verified && packet.roundtrip_payload_bytes == packet.payload_bytes
386 });
387 let packet_count = u64::try_from(packets.len()).map_err(|_| {
388 ViewerError::InvalidState("live-publish packet count does not fit in u64".into())
389 })?;
390 let publish_ready = source.identity_matches
391 && summary.frame_identity_match
392 && summary.deterministic_order_verified
393 && summary.selected_record_count > 0
394 && packet_count == summary.selected_record_count
395 && summary.published_message_count == summary.selected_record_count
396 && summary.received_message_count == summary.published_message_count
397 && all_roundtrips_verified;
398 let mapping_admitted = publish_ready && summary.calibration_applied;
399 let state = Self {
400 version: LIVE_PUBLISH_STATE_VERSION,
401 title: title.into(),
402 source,
403 expected_frame_ids,
404 time_basis: time_basis.into(),
405 topics,
406 transport,
407 packets,
408 summary,
409 artifacts,
410 publish_ready,
411 mapping_admitted,
412 blockers,
413 };
414 state.validate()?;
415 Ok(state)
416 }
417
418 pub fn validate(&self) -> ViewerResult<()> {
420 if self.version != LIVE_PUBLISH_STATE_VERSION {
421 return Err(ViewerError::InvalidState(format!(
422 "unsupported live-publish state version {}",
423 self.version
424 )));
425 }
426 if self.title.trim().is_empty()
427 || self.time_basis.trim().is_empty()
428 || self.expected_frame_ids.is_empty()
429 {
430 return Err(ViewerError::InvalidState(
431 "live-publish state title, expected frame map, and time basis are required".into(),
432 ));
433 }
434 self.source.validate()?;
435 self.summary.validate()?;
436 self.transport.validate()?;
437
438 let mut source_topics = BTreeSet::new();
439 let mut publish_topics = BTreeSet::new();
440 for topic in &self.topics {
441 topic.validate()?;
442 if !source_topics.insert(&topic.source_topic)
443 || !publish_topics.insert(&topic.publish_topic)
444 {
445 return Err(ViewerError::InvalidState(
446 "live-publish topics must have unique source and publish names".into(),
447 ));
448 }
449 if topic.message_type != self.transport.message_type {
450 return Err(ViewerError::InvalidState(
451 "live-publish topic message type disagrees with transport".into(),
452 ));
453 }
454 let Some(frame_id) = self.expected_frame_ids.get(&topic.source_topic) else {
455 return Err(ViewerError::InvalidState(
456 "live-publish topic is missing an expected frame identity".into(),
457 ));
458 };
459 if frame_id.trim().is_empty() {
460 return Err(ViewerError::InvalidState(
461 "live-publish expected frame identities must be non-empty".into(),
462 ));
463 }
464 }
465
466 let mut packet_points = 0_u64;
467 let mut packet_payload_bytes = 0_u64;
468 let mut roundtrip_payload_bytes = 0_u64;
469 for (expected_sequence, packet) in self.packets.iter().enumerate() {
470 packet.validate()?;
471 if packet.sequence != u64::try_from(expected_sequence).unwrap_or(u64::MAX)
472 || !source_topics.contains(&packet.source_topic)
473 || !publish_topics.contains(&packet.publish_topic)
474 || self.expected_frame_ids.get(&packet.source_topic) != Some(&packet.frame_id)
475 {
476 return Err(ViewerError::InvalidState(
477 "live-publish packets have invalid sequence, topic, or frame identity".into(),
478 ));
479 }
480 packet_points = packet_points.checked_add(packet.point_count).ok_or_else(|| {
481 ViewerError::InvalidState("live-publish packet point count overflow".into())
482 })?;
483 packet_payload_bytes =
484 packet_payload_bytes.checked_add(packet.payload_bytes).ok_or_else(|| {
485 ViewerError::InvalidState("live-publish payload count overflow".into())
486 })?;
487 roundtrip_payload_bytes =
488 roundtrip_payload_bytes.checked_add(packet.roundtrip_payload_bytes).ok_or_else(
489 || ViewerError::InvalidState("live-publish round-trip count overflow".into()),
490 )?;
491 }
492 if !self.source.identity_matches && !self.packets.is_empty() {
493 return Err(ViewerError::InvalidState(
494 "source-mismatched live-publish state cannot contain packets".into(),
495 ));
496 }
497 if packet_points != self.summary.published_point_count
498 || packet_payload_bytes != self.transport.host_encode_bytes
499 || roundtrip_payload_bytes != self.transport.host_decode_bytes
500 {
501 return Err(ViewerError::InvalidState(
502 "live-publish packet totals disagree with transport summary".into(),
503 ));
504 }
505 if self.transport.published_message_count != self.summary.published_message_count
506 || self.transport.received_message_count != self.summary.received_message_count
507 || u64::try_from(self.packets.len()).unwrap_or(u64::MAX)
508 != self.transport.published_message_count
509 {
510 return Err(ViewerError::InvalidState(
511 "live-publish message totals disagree with transport summary".into(),
512 ));
513 }
514 let calculated_publish_ready = self.source.identity_matches
515 && self.summary.frame_identity_match
516 && self.summary.deterministic_order_verified
517 && self.summary.selected_record_count > 0
518 && u64::try_from(self.packets.len()).unwrap_or(u64::MAX)
519 == self.summary.selected_record_count
520 && self.summary.published_message_count == self.summary.selected_record_count
521 && self.summary.received_message_count == self.summary.published_message_count
522 && self.packets.iter().all(|packet| {
523 packet.roundtrip_verified && packet.roundtrip_payload_bytes == packet.payload_bytes
524 });
525 if self.publish_ready != calculated_publish_ready {
526 return Err(ViewerError::InvalidState(
527 "publish_ready disagrees with source, frame, packet, or round-trip gates".into(),
528 ));
529 }
530 let calculated_mapping = self.publish_ready && self.summary.calibration_applied;
531 if self.mapping_admitted != calculated_mapping {
532 return Err(ViewerError::InvalidState(
533 "mapping_admitted disagrees with publish and calibration gates".into(),
534 ));
535 }
536 if self.mapping_admitted && !self.blockers.is_empty() {
537 return Err(ViewerError::InvalidState(
538 "admitted live-publish mapping cannot contain blockers".into(),
539 ));
540 }
541 if !self.mapping_admitted && self.blockers.is_empty() {
542 return Err(ViewerError::InvalidState(
543 "blocked live-publish mapping must expose at least one blocker".into(),
544 ));
545 }
546 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
547 return Err(ViewerError::InvalidState(
548 "live-publish blockers must not contain empty messages".into(),
549 ));
550 }
551 let mut artifact_roles = BTreeSet::new();
552 let mut artifact_paths = BTreeSet::new();
553 for artifact in &self.artifacts {
554 artifact.validate()?;
555 if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
556 return Err(ViewerError::InvalidState(
557 "live-publish artifacts must have unique roles and paths".into(),
558 ));
559 }
560 }
561 Ok(())
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
570
571 fn source(identity_matches: bool) -> StudioSource {
572 StudioSource::try_new(
573 "canonical bag",
574 "/media/input.db3",
575 SHA,
576 if identity_matches {
577 SHA
578 } else {
579 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
580 },
581 identity_matches,
582 )
583 .unwrap()
584 }
585
586 fn topic() -> LivePublishTopic {
587 LivePublishTopic::try_new(
588 "/lidar_front/points_raw",
589 "/spatialrust/lidar_front/points_raw",
590 "sensor_msgs/msg/PointCloud2",
591 1,
592 1,
593 2,
594 1,
595 2,
596 vec!["lidar_front".into()],
597 )
598 .unwrap()
599 }
600
601 fn packet() -> LivePublishPacket {
602 LivePublishPacket::try_new(
603 0,
604 "/lidar_front/points_raw",
605 "/spatialrust/lidar_front/points_raw",
606 "lidar_front",
607 10,
608 2,
609 24,
610 24,
611 true,
612 )
613 .unwrap()
614 }
615
616 fn transport() -> LivePublishTransport {
617 LivePublishTransport::try_new(
618 "in-process-loopback",
619 "sensor_msgs/msg/PointCloud2",
620 "replace-latest-per-topic",
621 1,
622 1,
623 1,
624 0,
625 24,
626 24,
627 0,
628 0,
629 )
630 .unwrap()
631 }
632
633 fn summary(frame_identity_match: bool) -> LivePublishSummary {
634 LivePublishSummary::try_new(
635 1,
636 1,
637 2,
638 24,
639 24,
640 1,
641 1,
642 2,
643 true,
644 frame_identity_match,
645 false,
646 false,
647 "PointCloud2 header stamp; no clock calibration applied",
648 )
649 .unwrap()
650 }
651
652 #[test]
653 fn healthy_publish_is_ready_while_mapping_stays_blocked() {
654 let expected_frames =
655 BTreeMap::from([("/lidar_front/points_raw".to_owned(), "lidar_front".to_owned())]);
656 let state = LivePublishState::try_new(
657 "Live Publish",
658 source(true),
659 expected_frames,
660 "PointCloud2 header stamp",
661 vec![topic()],
662 transport(),
663 vec![packet()],
664 summary(true),
665 Vec::new(),
666 vec!["clock/frame calibration was not applied".into()],
667 )
668 .unwrap();
669 assert!(state.publish_ready);
670 assert!(!state.mapping_admitted);
671 state.validate().unwrap();
672 }
673
674 #[test]
675 fn source_mismatch_withholds_packets() {
676 let expected_frames =
677 BTreeMap::from([("/lidar_front/points_raw".to_owned(), "lidar_front".to_owned())]);
678 let state = LivePublishState::try_new(
679 "Live Publish",
680 source(false),
681 expected_frames,
682 "PointCloud2 header stamp",
683 vec![LivePublishTopic::try_new(
684 "/lidar_front/points_raw",
685 "/spatialrust/lidar_front/points_raw",
686 "sensor_msgs/msg/PointCloud2",
687 1,
688 0,
689 0,
690 0,
691 0,
692 Vec::new(),
693 )
694 .unwrap()],
695 LivePublishTransport::try_new(
696 "in-process-loopback",
697 "sensor_msgs/msg/PointCloud2",
698 "replace-latest-per-topic",
699 1,
700 0,
701 0,
702 0,
703 0,
704 0,
705 0,
706 0,
707 )
708 .unwrap(),
709 Vec::new(),
710 LivePublishSummary::try_new(
711 1,
712 0,
713 0,
714 0,
715 0,
716 0,
717 0,
718 0,
719 false,
720 false,
721 false,
722 false,
723 "PointCloud2 header stamp",
724 )
725 .unwrap(),
726 Vec::new(),
727 vec!["source SHA-256 mismatch".into()],
728 )
729 .unwrap();
730 assert!(!state.publish_ready);
731 assert!(!state.mapping_admitted);
732 assert!(state.packets.is_empty());
733 }
734}