1use std::collections::BTreeSet;
9
10use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
11
12pub const MAP_DIFF_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 MapDiffBounds {
20 pub min_x_um: i64,
22 pub min_y_um: i64,
24 pub min_z_um: i64,
26 pub max_x_um: i64,
28 pub max_y_um: i64,
30 pub max_z_um: i64,
32}
33
34impl MapDiffBounds {
35 #[allow(clippy::too_many_arguments)]
37 pub fn try_new(
38 min_x_um: i64,
39 min_y_um: i64,
40 min_z_um: i64,
41 max_x_um: i64,
42 max_y_um: i64,
43 max_z_um: i64,
44 ) -> ViewerResult<Self> {
45 let bounds = Self { min_x_um, min_y_um, min_z_um, max_x_um, max_y_um, max_z_um };
46 bounds.validate()?;
47 Ok(bounds)
48 }
49
50 pub fn validate(&self) -> ViewerResult<()> {
52 if self.max_x_um < self.min_x_um
53 || self.max_y_um < self.min_y_um
54 || self.max_z_um < self.min_z_um
55 {
56 return Err(ViewerError::InvalidState(
57 "Map Diff bounds have a maximum below its minimum".into(),
58 ));
59 }
60 Ok(())
61 }
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
68pub struct MapDiffMap {
69 pub label: String,
71 pub source: StudioSource,
73 pub artifact: ReplayArtifact,
75 pub frame_id: String,
77 pub vertex_count: u64,
79 pub triangle_count: u64,
81 pub bounds: MapDiffBounds,
83}
84
85impl MapDiffMap {
86 #[allow(clippy::too_many_arguments)]
88 pub fn try_new(
89 label: impl Into<String>,
90 source: StudioSource,
91 artifact: ReplayArtifact,
92 frame_id: impl Into<String>,
93 vertex_count: u64,
94 triangle_count: u64,
95 bounds: MapDiffBounds,
96 ) -> ViewerResult<Self> {
97 let map = Self {
98 label: label.into(),
99 source,
100 artifact,
101 frame_id: frame_id.into(),
102 vertex_count,
103 triangle_count,
104 bounds,
105 };
106 map.validate()?;
107 Ok(map)
108 }
109
110 pub fn validate(&self) -> ViewerResult<()> {
112 if self.label.trim().is_empty() || self.frame_id.trim().is_empty() {
113 return Err(ViewerError::InvalidState(
114 "Map Diff map label and frame ID must not be empty".into(),
115 ));
116 }
117 self.source.validate()?;
118 self.artifact.validate()?;
119 self.bounds.validate()?;
120 if self.vertex_count == 0 || self.triangle_count == 0 {
121 return Err(ViewerError::InvalidState(
122 "Map Diff maps require non-empty vertex and triangle counts".into(),
123 ));
124 }
125 Ok(())
126 }
127}
128
129#[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 MapDiffCell {
134 pub index: u32,
136 pub base_vertex_count: u64,
138 pub candidate_vertex_count: u64,
140 pub compared_vertex_count: u64,
142 pub changed_vertex_count: u64,
144 pub max_displacement_um: u64,
146 pub mean_displacement_um: u64,
148}
149
150impl MapDiffCell {
151 #[allow(clippy::too_many_arguments)]
153 pub fn try_new(
154 index: u32,
155 base_vertex_count: u64,
156 candidate_vertex_count: u64,
157 compared_vertex_count: u64,
158 changed_vertex_count: u64,
159 max_displacement_um: u64,
160 mean_displacement_um: u64,
161 ) -> ViewerResult<Self> {
162 let cell = Self {
163 index,
164 base_vertex_count,
165 candidate_vertex_count,
166 compared_vertex_count,
167 changed_vertex_count,
168 max_displacement_um,
169 mean_displacement_um,
170 };
171 cell.validate()?;
172 Ok(cell)
173 }
174
175 pub fn validate(&self) -> ViewerResult<()> {
177 let max_compared = self.base_vertex_count;
181 if self.compared_vertex_count > max_compared
182 || self.changed_vertex_count > self.compared_vertex_count
183 || self.mean_displacement_um > self.max_displacement_um
184 || (self.compared_vertex_count == 0
185 && (self.changed_vertex_count > 0
186 || self.max_displacement_um > 0
187 || self.mean_displacement_um > 0))
188 {
189 return Err(ViewerError::InvalidState(
190 "Map Diff cell counts or displacement statistics are inconsistent".into(),
191 ));
192 }
193 Ok(())
194 }
195}
196
197#[derive(Clone, Debug, PartialEq, Eq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
201pub struct MapDiffSummary {
202 pub base_vertex_count: u64,
204 pub candidate_vertex_count: u64,
206 pub compared_vertex_count: u64,
208 pub added_vertex_count: u64,
210 pub removed_vertex_count: u64,
212 pub changed_vertex_count: u64,
214 pub change_threshold_um: u64,
216 pub max_displacement_um: u64,
218 pub mean_displacement_um: u64,
220 pub p95_displacement_um: u64,
222 pub cell_count: u64,
224 pub geometry_hash_equal: bool,
226 pub topology_equal: bool,
228 pub source_identity_match: bool,
230 pub frame_identity_match: bool,
232 pub calibration_applied: bool,
234}
235
236impl MapDiffSummary {
237 #[allow(clippy::too_many_arguments)]
239 pub fn try_new(
240 base_vertex_count: u64,
241 candidate_vertex_count: u64,
242 compared_vertex_count: u64,
243 added_vertex_count: u64,
244 removed_vertex_count: u64,
245 changed_vertex_count: u64,
246 change_threshold_um: u64,
247 max_displacement_um: u64,
248 mean_displacement_um: u64,
249 p95_displacement_um: u64,
250 cell_count: u64,
251 geometry_hash_equal: bool,
252 topology_equal: bool,
253 source_identity_match: bool,
254 frame_identity_match: bool,
255 calibration_applied: bool,
256 ) -> ViewerResult<Self> {
257 let summary = Self {
258 base_vertex_count,
259 candidate_vertex_count,
260 compared_vertex_count,
261 added_vertex_count,
262 removed_vertex_count,
263 changed_vertex_count,
264 change_threshold_um,
265 max_displacement_um,
266 mean_displacement_um,
267 p95_displacement_um,
268 cell_count,
269 geometry_hash_equal,
270 topology_equal,
271 source_identity_match,
272 frame_identity_match,
273 calibration_applied,
274 };
275 summary.validate()?;
276 Ok(summary)
277 }
278
279 pub fn validate(&self) -> ViewerResult<()> {
281 if self.change_threshold_um == 0 {
282 return Err(ViewerError::InvalidState(
283 "Map Diff change threshold must be greater than zero".into(),
284 ));
285 }
286 if self.compared_vertex_count > self.base_vertex_count.min(self.candidate_vertex_count)
287 || self.changed_vertex_count > self.compared_vertex_count
288 || self.mean_displacement_um > self.max_displacement_um
289 || self.p95_displacement_um > self.max_displacement_um
290 || (self.compared_vertex_count == 0
291 && (self.cell_count > 0
292 || self.changed_vertex_count > 0
293 || self.max_displacement_um > 0
294 || self.mean_displacement_um > 0
295 || self.p95_displacement_um > 0))
296 {
297 return Err(ViewerError::InvalidState(
298 "Map Diff summary counts or displacement statistics are inconsistent".into(),
299 ));
300 }
301 let expected_added = self.candidate_vertex_count.saturating_sub(self.base_vertex_count);
302 let expected_removed = self.base_vertex_count.saturating_sub(self.candidate_vertex_count);
303 if self.added_vertex_count != expected_added
304 || self.removed_vertex_count != expected_removed
305 {
306 return Err(ViewerError::InvalidState(
307 "Map Diff added/removed counts disagree with map vertex counts".into(),
308 ));
309 }
310 if self.geometry_hash_equal
311 && (!self.topology_equal
312 || self.base_vertex_count != self.candidate_vertex_count
313 || self.compared_vertex_count != self.base_vertex_count
314 || self.changed_vertex_count > 0
315 || self.max_displacement_um > 0
316 || self.mean_displacement_um > 0
317 || self.p95_displacement_um > 0)
318 {
319 return Err(ViewerError::InvalidState(
320 "equal Map Diff geometry hashes require identical geometry metrics".into(),
321 ));
322 }
323 if self.calibration_applied && (!self.source_identity_match || !self.frame_identity_match) {
324 return Err(ViewerError::InvalidState(
325 "Map Diff calibration cannot be applied to unbound or mixed-frame maps".into(),
326 ));
327 }
328 Ok(())
329 }
330}
331
332#[derive(Clone, Debug, PartialEq, Eq)]
334#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
335#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
336pub struct MapDiffState {
337 pub version: u32,
339 pub title: String,
341 pub base: MapDiffMap,
343 pub candidate: MapDiffMap,
345 pub summary: MapDiffSummary,
347 pub cells: Vec<MapDiffCell>,
349 pub artifacts: Vec<ReplayArtifact>,
351 pub compare_ready: bool,
353 pub mapping_admitted: bool,
355 pub blockers: Vec<String>,
357}
358
359impl MapDiffState {
360 pub fn try_new(
362 title: impl Into<String>,
363 base: MapDiffMap,
364 candidate: MapDiffMap,
365 summary: MapDiffSummary,
366 cells: Vec<MapDiffCell>,
367 artifacts: Vec<ReplayArtifact>,
368 blockers: Vec<String>,
369 ) -> ViewerResult<Self> {
370 let compare_ready = summary.source_identity_match
371 && summary.frame_identity_match
372 && summary.compared_vertex_count > 0
373 && summary.cell_count == u64::try_from(cells.len()).unwrap_or(u64::MAX);
374 let mapping_admitted = compare_ready && summary.calibration_applied;
375 let state = Self {
376 version: MAP_DIFF_STATE_VERSION,
377 title: title.into(),
378 base,
379 candidate,
380 summary,
381 cells,
382 artifacts,
383 compare_ready,
384 mapping_admitted,
385 blockers,
386 };
387 state.validate()?;
388 Ok(state)
389 }
390
391 pub fn validate(&self) -> ViewerResult<()> {
393 if self.version != MAP_DIFF_STATE_VERSION {
394 return Err(ViewerError::InvalidState(format!(
395 "unsupported Map Diff state version {}",
396 self.version
397 )));
398 }
399 if self.title.trim().is_empty() {
400 return Err(ViewerError::InvalidState("Map Diff title must not be empty".into()));
401 }
402 self.base.validate()?;
403 self.candidate.validate()?;
404 self.summary.validate()?;
405
406 let source_identity_match = self.base.source.identity_matches
407 && self.candidate.source.identity_matches
408 && self.base.source.expected_sha256 == self.candidate.source.expected_sha256
409 && self.base.source.observed_sha256 == self.candidate.source.observed_sha256;
410 if self.summary.source_identity_match != source_identity_match {
411 return Err(ViewerError::InvalidState(
412 "Map Diff source_identity_match disagrees with map source checksums".into(),
413 ));
414 }
415 let frame_identity_match = self.base.frame_id == self.candidate.frame_id;
416 if self.summary.frame_identity_match != frame_identity_match {
417 return Err(ViewerError::InvalidState(
418 "Map Diff frame_identity_match disagrees with map frame IDs".into(),
419 ));
420 }
421
422 for (expected_index, cell) in self.cells.iter().enumerate() {
423 cell.validate()?;
424 if cell.index != u32::try_from(expected_index).unwrap_or(u32::MAX) {
425 return Err(ViewerError::InvalidState(
426 "Map Diff cells must use contiguous row-major indices".into(),
427 ));
428 }
429 }
430 if self.summary.cell_count != u64::try_from(self.cells.len()).unwrap_or(u64::MAX) {
431 return Err(ViewerError::InvalidState(
432 "Map Diff cell_count disagrees with the heatmap cell list".into(),
433 ));
434 }
435
436 let calculated_compare_ready = source_identity_match
437 && frame_identity_match
438 && self.summary.compared_vertex_count > 0
439 && self.summary.cell_count > 0;
440 if self.compare_ready != calculated_compare_ready {
441 return Err(ViewerError::InvalidState(
442 "compare_ready disagrees with source, frame, or geometry admission".into(),
443 ));
444 }
445 let calculated_mapping = self.compare_ready && self.summary.calibration_applied;
446 if self.mapping_admitted != calculated_mapping {
447 return Err(ViewerError::InvalidState(
448 "mapping_admitted disagrees with comparison and calibration admission".into(),
449 ));
450 }
451
452 let mut artifact_roles = BTreeSet::new();
453 let mut artifact_paths = BTreeSet::new();
454 for artifact in &self.artifacts {
455 artifact.validate()?;
456 if !artifact_roles.insert(&artifact.role) || !artifact_paths.insert(&artifact.path) {
457 return Err(ViewerError::InvalidState(
458 "Map Diff artifacts must have unique roles and paths".into(),
459 ));
460 }
461 }
462 if !self.mapping_admitted && self.blockers.is_empty() {
463 return Err(ViewerError::InvalidState(
464 "blocked Map Diff mapping must expose at least one blocker".into(),
465 ));
466 }
467 if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
468 return Err(ViewerError::InvalidState(
469 "Map Diff blockers must not contain empty messages".into(),
470 ));
471 }
472 Ok(())
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
481 const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
482
483 fn source(observed_sha256: &str) -> StudioSource {
484 StudioSource::try_new(
485 "canonical bag",
486 "/media/input.db3",
487 SHA,
488 observed_sha256,
489 observed_sha256 == SHA,
490 )
491 .unwrap()
492 }
493
494 fn map(label: &str, observed_sha256: &str, artifact_role: &str) -> MapDiffMap {
495 MapDiffMap::try_new(
496 label,
497 source(observed_sha256),
498 ReplayArtifact::try_new(artifact_role, format!("/media/{label}.gltf"), 12, SHA)
499 .unwrap(),
500 "lidar_front",
501 2,
502 1,
503 MapDiffBounds::try_new(0, 0, 0, 1_000_000, 1_000_000, 1_000_000).unwrap(),
504 )
505 .unwrap()
506 }
507
508 fn summary(source_identity_match: bool) -> MapDiffSummary {
509 MapDiffSummary::try_new(
510 2,
511 2,
512 if source_identity_match { 2 } else { 0 },
513 0,
514 0,
515 0,
516 1_000,
517 0,
518 0,
519 0,
520 if source_identity_match { 1 } else { 0 },
521 source_identity_match,
522 true,
523 source_identity_match,
524 true,
525 false,
526 )
527 .unwrap()
528 }
529
530 fn cell() -> MapDiffCell {
531 MapDiffCell::try_new(0, 2, 2, 2, 0, 0, 0).unwrap()
532 }
533
534 #[test]
535 fn valid_diff_state_roundtrips_with_serde() {
536 let state = MapDiffState::try_new(
537 "Map Diff",
538 map("base", SHA, "base-map"),
539 map("candidate", SHA, "candidate-map"),
540 summary(true),
541 vec![cell()],
542 Vec::new(),
543 vec!["clock calibration not applied".into()],
544 )
545 .unwrap();
546 assert!(state.compare_ready);
547 assert!(!state.mapping_admitted);
548 #[cfg(feature = "serde")]
549 {
550 let json = serde_json::to_string(&state).unwrap();
551 assert_eq!(serde_json::from_str::<MapDiffState>(&json).unwrap(), state);
552 }
553 }
554
555 #[test]
556 fn source_mismatch_cannot_be_compare_ready() {
557 let state = MapDiffState::try_new(
558 "Map Diff",
559 map("base", SHA, "base-map"),
560 map("candidate", OTHER_SHA, "candidate-map"),
561 summary(false),
562 Vec::new(),
563 Vec::new(),
564 vec!["input SHA-256 mismatch".into()],
565 )
566 .unwrap();
567 assert!(!state.compare_ready);
568 assert!(!state.mapping_admitted);
569 }
570
571 #[test]
572 fn changed_cell_rejects_inconsistent_counts() {
573 assert!(MapDiffCell::try_new(0, 1, 1, 1, 2, 4, 1).is_err());
574 assert!(MapDiffSummary::try_new(
575 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 1, true, true, true, true, false
576 )
577 .is_err());
578 }
579}