Skip to main content

spatialrust_sync/
replay.rs

1//! In-memory episodes and deterministic multimodal replay.
2
3use std::collections::{BTreeMap, HashMap};
4
5use crate::{StampedRecord, SyncResult};
6
7/// Logical multimodal topic / channel name.
8#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub struct TopicId(pub String);
10
11impl TopicId {
12    /// Creates a topic identifier.
13    #[must_use]
14    pub fn new(value: impl Into<String>) -> Self {
15        Self(value.into())
16    }
17
18    /// Borrows the topic string.
19    #[must_use]
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25impl From<&str> for TopicId {
26    fn from(value: &str) -> Self {
27        Self(value.to_owned())
28    }
29}
30
31impl From<String> for TopicId {
32    fn from(value: String) -> Self {
33        Self(value)
34    }
35}
36
37/// Inclusive time-window options for approximate sync.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39pub struct SyncWindow {
40    /// Maximum absolute time delta accepted when pairing topics.
41    pub max_delta_ns: u64,
42    /// Maximum sync uncertainty accepted on each stamp.
43    pub max_uncertainty_ns: u64,
44}
45
46impl Default for SyncWindow {
47    fn default() -> Self {
48        Self { max_delta_ns: 10_000_000, max_uncertainty_ns: 5_000_000 }
49    }
50}
51
52/// Ordered episode index keyed by timestamp then topic.
53#[derive(Clone, Debug, Default, PartialEq)]
54pub struct EpisodeIndex {
55    /// Stable sorted keys: (timestamp_ns, topic, insertion ordinal).
56    entries: BTreeMap<(u64, String, u64), usize>,
57}
58
59impl EpisodeIndex {
60    /// Builds an index over stamped records.
61    pub fn build(records: &[StampedRecord]) -> Self {
62        let mut entries = BTreeMap::new();
63        for (ordinal, record) in records.iter().enumerate() {
64            entries
65                .insert((record.stamp.as_nanos(), record.topic.0.clone(), ordinal as u64), ordinal);
66        }
67        Self { entries }
68    }
69
70    /// Returns record indices in deterministic sorted order.
71    pub fn ordered_indices(&self) -> impl Iterator<Item = usize> + '_ {
72        self.entries.values().copied()
73    }
74}
75
76/// In-memory multimodal episode used as the default MCAP substitute.
77#[derive(Clone, Debug, Default, PartialEq)]
78pub struct MemoryEpisode {
79    records: Vec<StampedRecord>,
80    index: EpisodeIndex,
81}
82
83impl MemoryEpisode {
84    /// Creates an episode and builds a deterministic index.
85    #[must_use]
86    pub fn from_records(mut records: Vec<StampedRecord>) -> Self {
87        // Stable pre-sort so insertion-order ties remain deterministic.
88        records.sort_by(|a, b| {
89            (a.stamp.as_nanos(), a.topic.as_str()).cmp(&(b.stamp.as_nanos(), b.topic.as_str()))
90        });
91        let index = EpisodeIndex::build(&records);
92        Self { records, index }
93    }
94
95    /// Returns all records.
96    #[must_use]
97    pub fn records(&self) -> &[StampedRecord] {
98        &self.records
99    }
100
101    /// Returns the deterministic index.
102    #[must_use]
103    pub fn index(&self) -> &EpisodeIndex {
104        &self.index
105    }
106}
107
108/// Replays an episode as a sorted stream of stamped records.
109pub struct DeterministicReplayer<'a> {
110    episode: &'a MemoryEpisode,
111    cursor: usize,
112    order: Vec<usize>,
113}
114
115impl<'a> DeterministicReplayer<'a> {
116    /// Creates a replayer that walks the episode in index order.
117    #[must_use]
118    pub fn new(episode: &'a MemoryEpisode) -> Self {
119        let order = episode.index.ordered_indices().collect();
120        Self { episode, cursor: 0, order }
121    }
122
123    /// Returns the next record, if any.
124    pub fn next_record(&mut self) -> Option<&'a StampedRecord> {
125        let index = *self.order.get(self.cursor)?;
126        self.cursor += 1;
127        self.episode.records.get(index)
128    }
129
130    /// Bundles nearest-neighbor matches across required topics around the next
131    /// primary-topic message.
132    pub fn next_bundle(
133        &mut self,
134        primary: &TopicId,
135        others: &[TopicId],
136        window: SyncWindow,
137    ) -> SyncResult<Option<HashMap<TopicId, &'a StampedRecord>>> {
138        while let Some(candidate) = self.next_record() {
139            if &candidate.topic != primary {
140                continue;
141            }
142            if !candidate.stamp.quality.is_tight(window.max_uncertainty_ns) {
143                continue;
144            }
145            let mut bundle = HashMap::new();
146            bundle.insert(primary.clone(), candidate);
147            let mut ok = true;
148            for topic in others {
149                match nearest_match(self.episode, topic, candidate.stamp.as_nanos(), window) {
150                    Some(matched) => {
151                        bundle.insert(topic.clone(), matched);
152                    }
153                    None => {
154                        ok = false;
155                        break;
156                    }
157                }
158            }
159            if ok {
160                return Ok(Some(bundle));
161            }
162        }
163        Ok(None)
164    }
165}
166
167fn nearest_match<'a>(
168    episode: &'a MemoryEpisode,
169    topic: &TopicId,
170    center_ns: u64,
171    window: SyncWindow,
172) -> Option<&'a StampedRecord> {
173    episode
174        .records
175        .iter()
176        .filter(|record| {
177            &record.topic == topic
178                && record.stamp.quality.is_tight(window.max_uncertainty_ns)
179                && record.stamp.as_nanos().abs_diff(center_ns) <= window.max_delta_ns
180        })
181        .min_by_key(|record| record.stamp.as_nanos().abs_diff(center_ns))
182}
183
184#[cfg(test)]
185mod tests {
186    use super::{DeterministicReplayer, MemoryEpisode, SyncWindow, TopicId};
187    use crate::{ClockDomain, StampedRecord, StampedTime};
188    use spatialrust_core::{
189        PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
190    };
191    use spatialrust_records::{SchemaVersion, SpatialRecord};
192
193    fn sample(topic: &str, nanos: u64, x: f32) -> StampedRecord {
194        let mut buffers = PointBufferSet::new();
195        buffers.insert("x", PointBuffer::from_f32(vec![x]));
196        buffers.insert("y", PointBuffer::from_f32(vec![0.0]));
197        buffers.insert("z", PointBuffer::from_f32(vec![0.0]));
198        let cloud = PointCloud::try_from_parts(
199            StandardSchemas::point_xyz(),
200            buffers,
201            SpatialMetadata::default(),
202        )
203        .unwrap();
204        let record =
205            SpatialRecord::try_from_cloud("point", SchemaVersion::new(1, 0), cloud).unwrap();
206        StampedRecord::new(
207            topic,
208            StampedTime::exact("host", ClockDomain::HostSteady, Timestamp::from_nanos(nanos)),
209            record,
210        )
211    }
212
213    #[test]
214    fn replays_in_timestamp_order_and_bundles_topics() {
215        let episode = MemoryEpisode::from_records(vec![
216            sample("lidar", 30, 3.0),
217            sample("camera", 10, 1.0),
218            sample("lidar", 12, 2.0),
219            sample("camera", 40, 4.0),
220        ]);
221        let mut replayer = DeterministicReplayer::new(&episode);
222        let first = replayer.next_record().unwrap();
223        assert_eq!(first.topic.as_str(), "camera");
224        assert_eq!(first.stamp.as_nanos(), 10);
225
226        let mut replayer = DeterministicReplayer::new(&episode);
227        let bundle = replayer
228            .next_bundle(
229                &TopicId::new("camera"),
230                &[TopicId::new("lidar")],
231                SyncWindow { max_delta_ns: 5, max_uncertainty_ns: 0 },
232            )
233            .unwrap()
234            .unwrap();
235        assert_eq!(bundle.get(&TopicId::new("camera")).unwrap().stamp.as_nanos(), 10);
236        assert_eq!(bundle.get(&TopicId::new("lidar")).unwrap().stamp.as_nanos(), 12);
237    }
238}