Skip to main content

spatialrust_viewer/
edge_partition.rs

1//! Portable state for explicit edge-to-host partition execution.
2//!
3//! The state is intentionally independent of ROS, SQLite, and a transport
4//! implementation. An adapter records the graph/queue decisions here, while
5//! the viewer can render the same receipt and keep mapping admission separate
6//! from successful packet transfer.
7
8use std::collections::BTreeSet;
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12/// Current serialized edge-partition state schema version.
13pub const EDGE_PARTITION_STATE_VERSION: u32 = 1;
14
15/// One named execution partition shown in the receipt.
16#[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 EdgePartition {
20    /// Stable partition identifier.
21    pub id: String,
22    /// Placement label, such as edge or host.
23    pub placement: String,
24    /// Node identifiers owned by the partition.
25    pub node_ids: Vec<String>,
26}
27
28impl EdgePartition {
29    /// Creates and validates a named execution partition.
30    pub fn try_new(
31        id: impl Into<String>,
32        placement: impl Into<String>,
33        node_ids: Vec<String>,
34    ) -> ViewerResult<Self> {
35        let partition = Self { id: id.into(), placement: placement.into(), node_ids };
36        partition.validate()?;
37        Ok(partition)
38    }
39
40    /// Validates partition identity and node uniqueness.
41    pub fn validate(&self) -> ViewerResult<()> {
42        if self.id.trim().is_empty() || self.placement.trim().is_empty() || self.node_ids.is_empty()
43        {
44            return Err(ViewerError::InvalidState(
45                "edge partitions require an id, placement, and at least one node".into(),
46            ));
47        }
48        let mut nodes = BTreeSet::new();
49        for node_id in &self.node_ids {
50            if node_id.trim().is_empty() || !nodes.insert(node_id) {
51                return Err(ViewerError::InvalidState(
52                    "edge partition node IDs must be non-empty and unique".into(),
53                ));
54            }
55        }
56        Ok(())
57    }
58}
59
60/// One bounded packet transfer between two named execution nodes.
61#[derive(Clone, Debug, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
64pub struct EdgePartitionTransfer {
65    /// Source packet sequence from the upstream live-publish receipt.
66    pub sequence: u64,
67    /// Source ROS topic carried by this transfer.
68    pub source_topic: String,
69    /// Source execution node.
70    pub from_node: String,
71    /// Destination execution node.
72    pub to_node: String,
73    /// Payload bytes declared by the upstream packet.
74    pub payload_bytes: u64,
75    /// Measurable explicit-copy bytes recorded by the transfer ledger.
76    pub counted_copy_bytes: u64,
77    /// Backpressure signal observed at admission.
78    pub queue_signal: String,
79    /// Whether the transfer reached the destination ledger.
80    pub completed: bool,
81}
82
83impl EdgePartitionTransfer {
84    /// Creates and validates one explicit packet transfer receipt.
85    #[allow(clippy::too_many_arguments)]
86    pub fn try_new(
87        sequence: u64,
88        source_topic: impl Into<String>,
89        from_node: impl Into<String>,
90        to_node: impl Into<String>,
91        payload_bytes: u64,
92        counted_copy_bytes: u64,
93        queue_signal: impl Into<String>,
94        completed: bool,
95    ) -> ViewerResult<Self> {
96        let transfer = Self {
97            sequence,
98            source_topic: source_topic.into(),
99            from_node: from_node.into(),
100            to_node: to_node.into(),
101            payload_bytes,
102            counted_copy_bytes,
103            queue_signal: queue_signal.into(),
104            completed,
105        };
106        transfer.validate()?;
107        Ok(transfer)
108    }
109
110    /// Validates transfer endpoints, payload accounting, and queue evidence.
111    pub fn validate(&self) -> ViewerResult<()> {
112        if self.source_topic.trim().is_empty()
113            || self.from_node.trim().is_empty()
114            || self.to_node.trim().is_empty()
115            || self.from_node == self.to_node
116            || self.payload_bytes == 0
117            || self.counted_copy_bytes == 0
118            || self.counted_copy_bytes > self.payload_bytes
119            || self.queue_signal.trim().is_empty()
120        {
121            return Err(ViewerError::InvalidState(
122                "edge partition transfers require distinct nodes and consistent byte receipts"
123                    .into(),
124            ));
125        }
126        Ok(())
127    }
128}
129
130/// Aggregate graph, queue, source, and calibration gates for one run.
131#[derive(Clone, Debug, PartialEq, Eq)]
132#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
133#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
134pub struct EdgePartitionSummary {
135    /// Number of packets present in the upstream live-publish receipt.
136    pub source_packet_count: u64,
137    /// Number of packets admitted to the transfer queue.
138    pub admitted_transfer_count: u64,
139    /// Number of packets recorded as completed transfers.
140    pub completed_transfer_count: u64,
141    /// Sum of admitted transfer payload bytes.
142    pub payload_bytes: u64,
143    /// Sum of measurable explicit-copy bytes.
144    pub counted_copy_bytes: u64,
145    /// Maximum queue depth observed during the run.
146    pub max_queue_depth: u64,
147    /// Number of soft-watermark observations.
148    pub soft_limit_trips: u64,
149    /// Number of hard-limit rejections.
150    pub hard_rejects: u64,
151    /// Whether packet sequences were transferred in deterministic order.
152    pub deterministic_order_verified: bool,
153    /// Whether the upstream live-publish state passed its own admission gate.
154    pub upstream_publish_ready: bool,
155    /// Whether the current operation's source checksum matches.
156    pub source_identity_match: bool,
157    /// Whether the upstream source/frame gate passed.
158    pub frame_identity_match: bool,
159    /// Whether source-bound calibration registration is present.
160    pub calibration_registered: bool,
161    /// Whether a clock/frame calibration was actually applied.
162    pub calibration_applied: bool,
163    /// Timestamp domain retained by the upstream packets.
164    pub time_basis: String,
165}
166
167impl EdgePartitionSummary {
168    /// Creates and validates aggregate edge-partition counters.
169    #[allow(clippy::too_many_arguments)]
170    pub fn try_new(
171        source_packet_count: u64,
172        admitted_transfer_count: u64,
173        completed_transfer_count: u64,
174        payload_bytes: u64,
175        counted_copy_bytes: u64,
176        max_queue_depth: u64,
177        soft_limit_trips: u64,
178        hard_rejects: u64,
179        deterministic_order_verified: bool,
180        upstream_publish_ready: bool,
181        source_identity_match: bool,
182        frame_identity_match: bool,
183        calibration_registered: bool,
184        calibration_applied: bool,
185        time_basis: impl Into<String>,
186    ) -> ViewerResult<Self> {
187        let summary = Self {
188            source_packet_count,
189            admitted_transfer_count,
190            completed_transfer_count,
191            payload_bytes,
192            counted_copy_bytes,
193            max_queue_depth,
194            soft_limit_trips,
195            hard_rejects,
196            deterministic_order_verified,
197            upstream_publish_ready,
198            source_identity_match,
199            frame_identity_match,
200            calibration_registered,
201            calibration_applied,
202            time_basis: time_basis.into(),
203        };
204        summary.validate()?;
205        Ok(summary)
206    }
207
208    /// Validates monotonic transfer, queue, and calibration counters.
209    pub fn validate(&self) -> ViewerResult<()> {
210        if self.time_basis.trim().is_empty()
211            || self.admitted_transfer_count > self.source_packet_count
212            || self.completed_transfer_count > self.admitted_transfer_count
213            || (self.admitted_transfer_count > 0 && self.payload_bytes == 0)
214            || (self.completed_transfer_count > 0 && self.counted_copy_bytes == 0)
215            || self.counted_copy_bytes > self.payload_bytes
216            || (self.max_queue_depth > 0 && self.admitted_transfer_count == 0)
217            || (self.calibration_applied && !self.calibration_registered)
218        {
219            return Err(ViewerError::InvalidState(
220                "edge partition summary has invalid counters or calibration ordering".into(),
221            ));
222        }
223        Ok(())
224    }
225}
226
227/// Portable source-bound receipt for explicit edge-to-host execution.
228#[derive(Clone, Debug, PartialEq, Eq)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
230#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
231pub struct EdgePartitionState {
232    /// Serialized state schema version.
233    pub version: u32,
234    /// User-facing dashboard title.
235    pub title: String,
236    /// Exact source identity used by this partition run.
237    pub source: StudioSource,
238    /// Absolute path to the upstream live-publish JSON receipt.
239    pub upstream_live_publish_path: String,
240    /// Absolute path to the calibration readiness receipt.
241    pub calibration_readiness_path: String,
242    /// Graph partitions represented by the receipt.
243    pub partitions: Vec<EdgePartition>,
244    /// Ordered packet transfer receipts.
245    pub transfers: Vec<EdgePartitionTransfer>,
246    /// Aggregate partition and admission metrics.
247    pub summary: EdgePartitionSummary,
248    /// Checksummed upstream and output artifacts.
249    pub artifacts: Vec<ReplayArtifact>,
250    /// Whether edge-to-host packet execution passed all source/queue gates.
251    pub partition_ready: bool,
252    /// Whether calibrated-world mapping is admitted.
253    pub mapping_admitted: bool,
254    /// Human-readable fail-closed reasons.
255    pub blockers: Vec<String>,
256}
257
258impl EdgePartitionState {
259    /// Creates state and derives partition/mapping admission from its receipts.
260    #[allow(clippy::too_many_arguments)]
261    pub fn try_new(
262        title: impl Into<String>,
263        source: StudioSource,
264        upstream_live_publish_path: impl Into<String>,
265        calibration_readiness_path: impl Into<String>,
266        partitions: Vec<EdgePartition>,
267        transfers: Vec<EdgePartitionTransfer>,
268        summary: EdgePartitionSummary,
269        artifacts: Vec<ReplayArtifact>,
270        blockers: Vec<String>,
271    ) -> ViewerResult<Self> {
272        let partition_ready = source.identity_matches
273            && summary.source_identity_match
274            && summary.upstream_publish_ready
275            && summary.frame_identity_match
276            && summary.deterministic_order_verified
277            && summary.source_packet_count > 0
278            && summary.admitted_transfer_count == summary.source_packet_count
279            && summary.completed_transfer_count == summary.admitted_transfer_count
280            && summary.hard_rejects == 0;
281        let mapping_admitted = partition_ready && summary.calibration_applied;
282        let state = Self {
283            version: EDGE_PARTITION_STATE_VERSION,
284            title: title.into(),
285            source,
286            upstream_live_publish_path: upstream_live_publish_path.into(),
287            calibration_readiness_path: calibration_readiness_path.into(),
288            partitions,
289            transfers,
290            summary,
291            artifacts,
292            partition_ready,
293            mapping_admitted,
294            blockers,
295        };
296        state.validate()?;
297        Ok(state)
298    }
299
300    /// Validates graph membership, transfer accounting, and admission gates.
301    pub fn validate(&self) -> ViewerResult<()> {
302        if self.version != EDGE_PARTITION_STATE_VERSION {
303            return Err(ViewerError::InvalidState(format!(
304                "unsupported edge partition state version {}",
305                self.version
306            )));
307        }
308        if self.title.trim().is_empty()
309            || self.upstream_live_publish_path.trim().is_empty()
310            || self.calibration_readiness_path.trim().is_empty()
311            || self.partitions.len() < 2
312        {
313            return Err(ViewerError::InvalidState(
314                "edge partition state requires title, input paths, and at least two partitions"
315                    .into(),
316            ));
317        }
318        self.source.validate()?;
319        self.summary.validate()?;
320
321        let mut partition_ids = BTreeSet::new();
322        let mut node_ids = BTreeSet::new();
323        let mut node_partition = std::collections::BTreeMap::new();
324        for partition in &self.partitions {
325            partition.validate()?;
326            if !partition_ids.insert(&partition.id) {
327                return Err(ViewerError::InvalidState("edge partition IDs must be unique".into()));
328            }
329            for node_id in &partition.node_ids {
330                if !node_ids.insert(node_id) {
331                    return Err(ViewerError::InvalidState(
332                        "edge partition nodes must belong to exactly one partition".into(),
333                    ));
334                }
335                node_partition.insert(node_id, &partition.id);
336            }
337        }
338
339        let mut transfer_payload_bytes = 0_u64;
340        let mut transfer_copy_bytes = 0_u64;
341        let mut completed_transfer_count = 0_u64;
342        for (expected_sequence, transfer) in self.transfers.iter().enumerate() {
343            transfer.validate()?;
344            if transfer.sequence != u64::try_from(expected_sequence).unwrap_or(u64::MAX)
345                || !node_partition.contains_key(&transfer.from_node)
346                || !node_partition.contains_key(&transfer.to_node)
347                || node_partition.get(&transfer.from_node) == node_partition.get(&transfer.to_node)
348            {
349                return Err(ViewerError::InvalidState(
350                    "edge partition transfers have invalid order or graph membership".into(),
351                ));
352            }
353            transfer_payload_bytes =
354                transfer_payload_bytes.checked_add(transfer.payload_bytes).ok_or_else(|| {
355                    ViewerError::InvalidState("edge partition payload count overflow".into())
356                })?;
357            transfer_copy_bytes =
358                transfer_copy_bytes.checked_add(transfer.counted_copy_bytes).ok_or_else(|| {
359                    ViewerError::InvalidState("edge partition copy count overflow".into())
360                })?;
361            if transfer.completed {
362                completed_transfer_count =
363                    completed_transfer_count.checked_add(1).ok_or_else(|| {
364                        ViewerError::InvalidState("edge partition completion count overflow".into())
365                    })?;
366            }
367        }
368        if transfer_payload_bytes != self.summary.payload_bytes
369            || transfer_copy_bytes != self.summary.counted_copy_bytes
370            || u64::try_from(self.transfers.len()).unwrap_or(u64::MAX)
371                != self.summary.admitted_transfer_count
372            || completed_transfer_count != self.summary.completed_transfer_count
373        {
374            return Err(ViewerError::InvalidState(
375                "edge partition transfer totals disagree with summary".into(),
376            ));
377        }
378        if (!self.source.identity_matches || !self.summary.upstream_publish_ready)
379            && !self.transfers.is_empty()
380        {
381            return Err(ViewerError::InvalidState(
382                "source- or upstream-mismatched edge state cannot contain transfers".into(),
383            ));
384        }
385
386        let calculated_partition_ready = self.source.identity_matches
387            && self.summary.source_identity_match
388            && self.summary.upstream_publish_ready
389            && self.summary.frame_identity_match
390            && self.summary.deterministic_order_verified
391            && self.summary.source_packet_count > 0
392            && self.summary.admitted_transfer_count == self.summary.source_packet_count
393            && self.summary.completed_transfer_count == self.summary.admitted_transfer_count
394            && self.summary.hard_rejects == 0;
395        if self.partition_ready != calculated_partition_ready {
396            return Err(ViewerError::InvalidState(
397                "partition_ready disagrees with source, upstream, graph, or queue gates".into(),
398            ));
399        }
400        let calculated_mapping = self.partition_ready && self.summary.calibration_applied;
401        if self.mapping_admitted != calculated_mapping {
402            return Err(ViewerError::InvalidState(
403                "mapping_admitted disagrees with partition and calibration gates".into(),
404            ));
405        }
406        if self.mapping_admitted && !self.blockers.is_empty() {
407            return Err(ViewerError::InvalidState(
408                "admitted edge partition mapping cannot contain blockers".into(),
409            ));
410        }
411        if !self.mapping_admitted && self.blockers.is_empty() {
412            return Err(ViewerError::InvalidState(
413                "blocked edge partition mapping must expose at least one blocker".into(),
414            ));
415        }
416        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
417            return Err(ViewerError::InvalidState(
418                "edge partition blockers must not contain empty messages".into(),
419            ));
420        }
421
422        let mut artifact_roles = BTreeSet::new();
423        let mut artifact_paths = BTreeSet::new();
424        for artifact in &self.artifacts {
425            artifact.validate()?;
426            if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
427                return Err(ViewerError::InvalidState(
428                    "edge partition artifacts must have unique roles and paths".into(),
429                ));
430            }
431        }
432        Ok(())
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
441
442    fn source(identity_matches: bool) -> StudioSource {
443        StudioSource::try_new(
444            "canonical bag",
445            "/media/input.db3",
446            SHA,
447            if identity_matches {
448                SHA
449            } else {
450                "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
451            },
452            identity_matches,
453        )
454        .unwrap()
455    }
456
457    fn partitions() -> Vec<EdgePartition> {
458        vec![
459            EdgePartition::try_new(
460                "edge",
461                "edge-host-0",
462                vec!["packet-gate".into(), "live-publish".into()],
463            )
464            .unwrap(),
465            EdgePartition::try_new(
466                "host",
467                "mapping-host-0",
468                vec!["host-receive".into(), "mapping".into()],
469            )
470            .unwrap(),
471        ]
472    }
473
474    fn summary(source_identity_match: bool) -> EdgePartitionSummary {
475        EdgePartitionSummary::try_new(
476            1,
477            if source_identity_match { 1 } else { 0 },
478            if source_identity_match { 1 } else { 0 },
479            if source_identity_match { 128 } else { 0 },
480            if source_identity_match { 128 } else { 0 },
481            if source_identity_match { 1 } else { 0 },
482            0,
483            0,
484            source_identity_match,
485            source_identity_match,
486            source_identity_match,
487            source_identity_match,
488            false,
489            false,
490            "PointCloud2 header stamp; no clock calibration applied",
491        )
492        .unwrap()
493    }
494
495    #[test]
496    fn healthy_partition_is_ready_while_mapping_stays_blocked() {
497        let transfer = EdgePartitionTransfer::try_new(
498            0,
499            "/lidar_front/points_raw",
500            "packet-gate",
501            "host-receive",
502            128,
503            128,
504            "soft-limit",
505            true,
506        )
507        .unwrap();
508        let state = EdgePartitionState::try_new(
509            "Edge Partition",
510            source(true),
511            "/media/live-publish.json",
512            "/media/readiness.json",
513            partitions(),
514            vec![transfer],
515            summary(true),
516            Vec::new(),
517            vec!["clock/frame calibration was not applied".into()],
518        )
519        .unwrap();
520        assert!(state.partition_ready);
521        assert!(!state.mapping_admitted);
522        state.validate().unwrap();
523    }
524
525    #[test]
526    fn source_mismatch_withholds_transfers() {
527        let state = EdgePartitionState::try_new(
528            "Edge Partition",
529            source(false),
530            "/media/live-publish.json",
531            "/media/readiness.json",
532            partitions(),
533            Vec::new(),
534            summary(false),
535            Vec::new(),
536            vec!["source SHA-256 mismatch".into()],
537        )
538        .unwrap();
539        assert!(!state.partition_ready);
540        assert!(!state.mapping_admitted);
541        assert!(state.transfers.is_empty());
542    }
543
544    #[test]
545    fn rejects_transfer_within_one_partition() {
546        let transfer = EdgePartitionTransfer::try_new(
547            0,
548            "/lidar_front/points_raw",
549            "packet-gate",
550            "live-publish",
551            128,
552            128,
553            "ok",
554            true,
555        )
556        .unwrap();
557        let result = EdgePartitionState::try_new(
558            "Edge Partition",
559            source(true),
560            "/media/live-publish.json",
561            "/media/readiness.json",
562            partitions(),
563            vec![transfer],
564            summary(true),
565            Vec::new(),
566            vec!["invalid graph edge".into()],
567        );
568        assert!(result.is_err());
569    }
570}