1use crate::measure::{ClipMeasurements, MeasurementAvailability};
9use serde::Serialize;
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const DURATION_THRESHOLD_S: f64 = 0.017; pub const ROTATION_RANGE_THRESHOLD_DEG: f64 = 1.0;
16pub const LOOP_POSITION_THRESHOLD_M: f64 = 0.001;
18pub const LOOP_ROTATION_THRESHOLD_DEG: f64 = 0.1;
20pub const LOOP_VELOCITY_THRESHOLD_MPS: f64 = 0.01;
22pub const LOOP_ANGULAR_VELOCITY_THRESHOLD_DEGPS: f64 = 0.5;
24pub const SEAM_THRESHOLD: f64 = 0.05;
26pub const PHASE_THRESHOLD: f64 = 0.05; pub const AMPLITUDE_THRESHOLD_M: f64 = 0.005;
30pub const SPEED_THRESHOLD_MPS: f64 = 0.1;
32pub const ROOT_TRAJECTORY_TRANSLATION_THRESHOLD_M: f64 = 0.001;
34pub const ROOT_TRAJECTORY_YAW_THRESHOLD_DEG: f64 = 0.1;
36
37#[derive(Debug, Serialize)]
39pub struct MetricDelta {
40 pub clip: String,
42 pub metric: String,
45 #[serde(skip_serializing_if = "non_finite_or_none")]
49 pub before: Option<f64>,
50 #[serde(skip_serializing_if = "non_finite_or_none")]
54 pub after: Option<f64>,
55 pub note: String,
58}
59
60fn non_finite_or_none(value: &Option<f64>) -> bool {
61 value.is_none_or(|number| !number.is_finite())
62}
63
64fn push_availability(
80 deltas: &mut Vec<MetricDelta>,
81 clip: &str,
82 metric: &str,
83 a: MeasurementAvailability,
84 b: MeasurementAvailability,
85) {
86 if a == b {
87 return;
88 }
89 let note = match b {
90 MeasurementAvailability::Measured => "became measured",
91 MeasurementAvailability::NotApplicable => "no longer applicable",
92 MeasurementAvailability::Unavailable => "became unavailable",
93 };
94 deltas.push(MetricDelta {
95 clip: clip.into(),
96 metric: metric.into(),
97 before: None,
98 after: None,
99 note: note.into(),
100 });
101}
102
103pub fn diff_measurements(
110 a: &BTreeMap<String, ClipMeasurements>,
111 b: &BTreeMap<String, ClipMeasurements>,
112) -> Vec<MetricDelta> {
113 let finite = |value: Option<f64>| value.filter(|value| value.is_finite());
114 let mut deltas = Vec::new();
115 let delta =
116 |clip: &str, metric: &str, before: Option<f64>, after: Option<f64>, note: String| {
117 MetricDelta {
118 clip: clip.into(),
119 metric: metric.into(),
120 before,
121 after,
122 note,
123 }
124 };
125
126 for (clip, ma) in a {
127 let Some(mb) = b.get(clip) else {
128 deltas.push(delta(clip, "clip", None, None, "clip removed".into()));
129 continue;
130 };
131 let mut push_num = |metric: &str,
132 va: Option<f64>,
133 vb: Option<f64>,
134 threshold: f64,
135 circular_period: Option<f64>| {
136 let moved = match (va, vb) {
137 (Some(x), Some(y)) => {
138 if let Some(period) = circular_period {
139 let d = (x - y).rem_euclid(period);
140 let d = d.min(period - d);
141 let roundoff = 16.0
142 * f64::EPSILON
143 * d.abs().max(threshold.abs()).max(period.abs()).max(1.0);
144 d > threshold + roundoff
145 } else {
146 (x - y).abs() > threshold
147 }
148 }
149 (None, None) => false,
150 _ => true, };
152 if moved {
153 deltas.push(MetricDelta {
154 clip: clip.clone(),
155 metric: metric.into(),
156 before: va,
157 after: vb,
158 note: match (va, vb) {
159 (Some(_), Some(_)) => "moved".into(),
160 (None, Some(_)) => "appeared".into(),
161 _ => "disappeared".into(),
162 },
163 });
164 }
165 };
166
167 push_num(
168 "duration_s",
169 finite(Some(ma.duration_s)),
170 finite(Some(mb.duration_s)),
171 DURATION_THRESHOLD_S,
172 None,
173 );
174 push_num(
175 "frame_count",
176 Some(ma.frame_count as f64),
177 Some(mb.frame_count as f64),
178 0.5,
179 None,
180 );
181 push_num(
182 "loop_seam_ratio",
183 finite(ma.loop_seam_ratio),
184 finite(mb.loop_seam_ratio),
185 SEAM_THRESHOLD,
186 None,
187 );
188 push_num(
189 "gait.phase",
190 finite(ma.gait.as_ref().and_then(|g| g.phase)),
191 finite(mb.gait.as_ref().and_then(|g| g.phase)),
192 PHASE_THRESHOLD,
193 Some(1.0),
194 );
195 push_num(
196 "gait.lr_amplitude_m",
197 finite(ma.gait.as_ref().map(|g| g.lr_amplitude_m)),
198 finite(mb.gait.as_ref().map(|g| g.lr_amplitude_m)),
199 AMPLITUDE_THRESHOLD_M,
200 None,
201 );
202 push_num(
203 "speed_mps",
204 finite(ma.speed_mps),
205 finite(mb.speed_mps),
206 SPEED_THRESHOLD_MPS,
207 None,
208 );
209
210 let a_translation = ma
211 .root_trajectory
212 .as_ref()
213 .and_then(|trajectory| trajectory.translation.as_ref());
214 let b_translation = mb
215 .root_trajectory
216 .as_ref()
217 .and_then(|trajectory| trajectory.translation.as_ref());
218 for (field, va, vb) in [
219 (
220 "horizontal_displacement_x_m",
221 a_translation.map(|translation| translation.horizontal_displacement_x_m),
222 b_translation.map(|translation| translation.horizontal_displacement_x_m),
223 ),
224 (
225 "horizontal_displacement_z_m",
226 a_translation.map(|translation| translation.horizontal_displacement_z_m),
227 b_translation.map(|translation| translation.horizontal_displacement_z_m),
228 ),
229 (
230 "horizontal_travel_m",
231 a_translation.map(|translation| translation.horizontal_travel_m),
232 b_translation.map(|translation| translation.horizontal_travel_m),
233 ),
234 (
235 "vertical_displacement_m",
236 a_translation.map(|translation| translation.vertical_displacement_m),
237 b_translation.map(|translation| translation.vertical_displacement_m),
238 ),
239 (
240 "vertical_min_displacement_m",
241 a_translation.map(|translation| translation.vertical_min_displacement_m),
242 b_translation.map(|translation| translation.vertical_min_displacement_m),
243 ),
244 (
245 "vertical_max_displacement_m",
246 a_translation.map(|translation| translation.vertical_max_displacement_m),
247 b_translation.map(|translation| translation.vertical_max_displacement_m),
248 ),
249 ] {
250 push_num(
251 &format!("root_trajectory.translation.{field}"),
252 finite(va),
253 finite(vb),
254 ROOT_TRAJECTORY_TRANSLATION_THRESHOLD_M,
255 None,
256 );
257 }
258 let a_yaw = ma
259 .root_trajectory
260 .as_ref()
261 .and_then(|trajectory| trajectory.yaw.as_ref());
262 let b_yaw = mb
263 .root_trajectory
264 .as_ref()
265 .and_then(|trajectory| trajectory.yaw.as_ref());
266 push_num(
267 "root_trajectory.yaw.net_yaw_deg",
268 finite(a_yaw.map(|yaw| yaw.net_yaw_deg)),
269 finite(b_yaw.map(|yaw| yaw.net_yaw_deg)),
270 ROOT_TRAJECTORY_YAW_THRESHOLD_DEG,
271 Some(360.0),
272 );
273 push_num(
274 "root_trajectory.yaw.unwrapped_yaw_deg",
275 finite(a_yaw.map(|yaw| yaw.unwrapped_yaw_deg)),
276 finite(b_yaw.map(|yaw| yaw.unwrapped_yaw_deg)),
277 ROOT_TRAJECTORY_YAW_THRESHOLD_DEG,
278 None,
279 );
280 push_num(
281 "root_trajectory.yaw.yaw_travel_deg",
282 finite(a_yaw.map(|yaw| yaw.yaw_travel_deg)),
283 finite(b_yaw.map(|yaw| yaw.yaw_travel_deg)),
284 ROOT_TRAJECTORY_YAW_THRESHOLD_DEG,
285 None,
286 );
287
288 let a_loop_bones: BTreeMap<_, _> = ma
289 .loop_continuity
290 .as_ref()
291 .into_iter()
292 .flat_map(|continuity| &continuity.bones)
293 .map(|bone| (bone.bone_index, bone))
294 .collect();
295 let b_loop_bones: BTreeMap<_, _> = mb
296 .loop_continuity
297 .as_ref()
298 .into_iter()
299 .flat_map(|continuity| &continuity.bones)
300 .map(|bone| (bone.bone_index, bone))
301 .collect();
302 for bone_index in a_loop_bones
303 .keys()
304 .chain(b_loop_bones.keys())
305 .copied()
306 .collect::<BTreeSet<_>>()
307 {
308 let a_bone = a_loop_bones.get(&bone_index).copied();
309 let b_bone = b_loop_bones.get(&bone_index).copied();
310 let metric = |field: &str| format!("loop_continuity.bones[{bone_index}].{field}");
311 push_num(
312 &metric("position_delta_m"),
313 finite(a_bone.map(|bone| bone.position_delta_m)),
314 finite(b_bone.map(|bone| bone.position_delta_m)),
315 LOOP_POSITION_THRESHOLD_M,
316 None,
317 );
318 push_num(
319 &metric("rotation_delta_deg"),
320 finite(a_bone.map(|bone| bone.rotation_delta_deg)),
321 finite(b_bone.map(|bone| bone.rotation_delta_deg)),
322 LOOP_ROTATION_THRESHOLD_DEG,
323 None,
324 );
325 push_num(
326 &metric("seam_velocity_delta_mps"),
327 finite(a_bone.map(|bone| bone.seam_velocity_delta_mps)),
328 finite(b_bone.map(|bone| bone.seam_velocity_delta_mps)),
329 LOOP_VELOCITY_THRESHOLD_MPS,
330 None,
331 );
332 push_num(
333 &metric("seam_angular_velocity_delta_degps"),
334 finite(a_bone.map(|bone| bone.seam_angular_velocity_delta_degps)),
335 finite(b_bone.map(|bone| bone.seam_angular_velocity_delta_degps)),
336 LOOP_ANGULAR_VELOCITY_THRESHOLD_DEGPS,
337 None,
338 );
339 }
340
341 if let (Some(a_trajectory), Some(b_trajectory)) =
342 (ma.root_trajectory.as_ref(), mb.root_trajectory.as_ref())
343 {
344 if (
345 a_trajectory.bone_index,
346 &a_trajectory.bone_name,
347 a_trajectory.source_role,
348 ) != (
349 b_trajectory.bone_index,
350 &b_trajectory.bone_name,
351 b_trajectory.source_role,
352 ) {
353 deltas.push(delta(
354 clip,
355 "root_trajectory.selected_bone",
356 Some(a_trajectory.bone_index as f64),
357 Some(b_trajectory.bone_index as f64),
358 format!(
359 "changed from #{}:{:?} ({}) to #{}:{:?} ({})",
360 a_trajectory.bone_index,
361 a_trajectory.bone_name,
362 a_trajectory.source_role.as_str(),
363 b_trajectory.bone_index,
364 b_trajectory.bone_name,
365 b_trajectory.source_role.as_str(),
366 ),
367 ));
368 }
369 if let (Some(a_yaw), Some(b_yaw)) =
370 (a_trajectory.yaw.as_ref(), b_trajectory.yaw.as_ref())
371 && a_yaw.heading_axis != b_yaw.heading_axis
372 {
373 deltas.push(delta(
374 clip,
375 "root_trajectory.yaw.heading_axis",
376 None,
377 None,
378 format!(
379 "changed from {} to {}",
380 a_yaw.heading_axis.label(),
381 b_yaw.heading_axis.label()
382 ),
383 ));
384 }
385 }
386
387 push_availability(
388 &mut deltas,
389 clip,
390 "loop_continuity_availability",
391 ma.loop_continuity_availability,
392 mb.loop_continuity_availability,
393 );
394 push_availability(
395 &mut deltas,
396 clip,
397 "loop_endpoint_mode_availability",
398 ma.loop_endpoint_mode_availability,
399 mb.loop_endpoint_mode_availability,
400 );
401 push_availability(
402 &mut deltas,
403 clip,
404 "frame_grid_availability",
405 ma.frame_grid_availability,
406 mb.frame_grid_availability,
407 );
408 push_availability(
409 &mut deltas,
410 clip,
411 "loop_seam_ratio_availability",
412 ma.loop_seam_ratio_availability,
413 mb.loop_seam_ratio_availability,
414 );
415 push_availability(
416 &mut deltas,
417 clip,
418 "gait_availability",
419 ma.gait_availability,
420 mb.gait_availability,
421 );
422 push_availability(
423 &mut deltas,
424 clip,
425 "root_trajectory_availability",
426 ma.root_trajectory_availability,
427 mb.root_trajectory_availability,
428 );
429 push_availability(
430 &mut deltas,
431 clip,
432 "speed_mps_availability",
433 ma.speed_mps_availability,
434 mb.speed_mps_availability,
435 );
436 if let (Some(ga), Some(gb)) = (ma.gait.as_ref(), mb.gait.as_ref()) {
444 push_availability(
445 &mut deltas,
446 clip,
447 "gait.phase_availability",
448 ga.phase_availability,
449 gb.phase_availability,
450 );
451 }
452 if let (Some(ta), Some(tb)) = (ma.root_trajectory.as_ref(), mb.root_trajectory.as_ref()) {
453 push_availability(
454 &mut deltas,
455 clip,
456 "root_trajectory.translation_availability",
457 ta.translation_availability,
458 tb.translation_availability,
459 );
460 push_availability(
461 &mut deltas,
462 clip,
463 "root_trajectory.yaw_availability",
464 ta.yaw_availability,
465 tb.yaw_availability,
466 );
467 }
468
469 for bone in ma
470 .bone_rotation_range_deg
471 .keys()
472 .chain(mb.bone_rotation_range_deg.keys())
473 .collect::<BTreeSet<_>>()
474 {
475 let va = finite(ma.bone_rotation_range_deg.get(bone).copied());
476 let vb = finite(mb.bone_rotation_range_deg.get(bone).copied());
477 let moved = match (va, vb) {
478 (Some(x), Some(y)) => (x - y).abs() > ROTATION_RANGE_THRESHOLD_DEG,
479 (None, None) => false,
480 _ => true,
481 };
482 if moved {
483 deltas.push(delta(
484 clip,
485 &format!("bone_rotation_range_deg[{bone}]"),
486 va,
487 vb,
488 match (va, vb) {
489 (Some(_), Some(_)) => "moved".into(),
490 (None, Some(_)) => "bone now animated".into(),
491 _ => "bone no longer animated".into(),
492 },
493 ));
494 }
495 }
496
497 if ma.animated_bones != mb.animated_bones {
498 let a_set: BTreeSet<_> = ma.animated_bones.iter().collect();
499 let b_set: BTreeSet<_> = mb.animated_bones.iter().collect();
500 let gained: Vec<_> = b_set.difference(&a_set).map(|s| s.as_str()).collect();
501 let lost: Vec<_> = a_set.difference(&b_set).map(|s| s.as_str()).collect();
502 deltas.push(delta(
503 clip,
504 "animated_bones",
505 Some(ma.animated_bones.len() as f64),
506 Some(mb.animated_bones.len() as f64),
507 format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
508 ));
509 }
510
511 let a_channels: BTreeMap<_, _> = ma
512 .bone_channels
513 .iter()
514 .flat_map(|bone| {
515 bone.properties
516 .iter()
517 .copied()
518 .map(move |property| ((bone.bone_index, property), bone.bone_name.as_str()))
519 })
520 .collect();
521 let b_channels: BTreeMap<_, _> = mb
522 .bone_channels
523 .iter()
524 .flat_map(|bone| {
525 bone.properties
526 .iter()
527 .copied()
528 .map(move |property| ((bone.bone_index, property), bone.bone_name.as_str()))
529 })
530 .collect();
531 let a_channel_keys: BTreeSet<_> = a_channels.keys().copied().collect();
532 let b_channel_keys: BTreeSet<_> = b_channels.keys().copied().collect();
533 if a_channel_keys != b_channel_keys {
534 let gained: Vec<_> = b_channel_keys
535 .difference(&a_channel_keys)
536 .map(|(bone_index, property)| {
537 let bone_name = b_channels[&(*bone_index, *property)];
538 format!("#{bone_index}:{bone_name:?}.{}", property.as_str())
539 })
540 .collect();
541 let lost: Vec<_> = a_channel_keys
542 .difference(&b_channel_keys)
543 .map(|(bone_index, property)| {
544 let bone_name = a_channels[&(*bone_index, *property)];
545 format!("#{bone_index}:{bone_name:?}.{}", property.as_str())
546 })
547 .collect();
548 deltas.push(delta(
549 clip,
550 "bone_channels",
551 Some(a_channel_keys.len() as f64),
552 Some(b_channel_keys.len() as f64),
553 format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
554 ));
555 }
556 }
557 for clip in b.keys() {
558 if !a.contains_key(clip) {
559 deltas.push(MetricDelta {
560 clip: clip.clone(),
561 metric: "clip".into(),
562 before: None,
563 after: None,
564 note: "clip added".into(),
565 });
566 }
567 }
568 deltas
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::measure::{
575 BoneChannelCoverage, BoneLoopContinuityMeasurement, ClipMeasurements, FrameGridMeasurement,
576 GaitMeasurement, LoopContinuityMeasurement, LoopEndpointMode, MeasurementAvailability,
577 RootTrajectoryMeasurement, RootTrajectorySourceRole, RootTranslationMeasurement,
578 RootYawMeasurement,
579 };
580 use crate::metrics::RootYawHeadingAxis;
581 use crate::model::Property;
582
583 fn clip_measurements() -> ClipMeasurements {
584 ClipMeasurements {
585 duration_s: 1.0,
586 frame_count: 31,
587 animated_bones: vec!["hips".into()],
588 bone_channels: vec![BoneChannelCoverage {
589 bone_index: 0,
590 bone_name: "hips".into(),
591 properties: vec![Property::Translation],
592 }],
593 bone_rotation_range_deg: BTreeMap::from([("hips".into(), 10.0)]),
594 loop_continuity: Some(LoopContinuityMeasurement {
595 bones: vec![BoneLoopContinuityMeasurement {
596 bone_index: 0,
597 bone_name: "hips".into(),
598 position_delta_m: 0.02,
599 rotation_delta_deg: 2.0,
600 seam_velocity_delta_mps: 0.2,
601 seam_angular_velocity_delta_degps: 10.0,
602 }],
603 }),
604 loop_continuity_availability: MeasurementAvailability::Measured,
605 loop_endpoint_mode: None,
606 loop_endpoint_mode_availability: MeasurementAvailability::NotApplicable,
607 frame_grid: None,
608 frame_grid_availability: MeasurementAvailability::NotApplicable,
609 loop_seam_ratio: Some(0.2),
610 loop_seam_ratio_availability: MeasurementAvailability::Measured,
611 gait: Some(GaitMeasurement {
612 phase: Some(0.25),
613 phase_availability: MeasurementAvailability::Measured,
614 lr_amplitude_m: 0.1,
615 }),
616 gait_availability: MeasurementAvailability::Measured,
617 root_trajectory: Some(RootTrajectoryMeasurement {
618 bone_index: 0,
619 bone_name: "hips".into(),
620 source_role: RootTrajectorySourceRole::HipsFallback,
621 translation: Some(RootTranslationMeasurement {
622 horizontal_displacement_x_m: 0.0,
623 horizontal_displacement_z_m: 1.0,
624 horizontal_travel_m: 1.0,
625 vertical_displacement_m: 0.0,
626 vertical_min_displacement_m: 0.0,
627 vertical_max_displacement_m: 0.0,
628 }),
629 translation_availability: MeasurementAvailability::Measured,
630 yaw: Some(RootYawMeasurement {
631 heading_axis: RootYawHeadingAxis::PositiveZ,
632 net_yaw_deg: 0.0,
633 unwrapped_yaw_deg: 0.0,
634 yaw_travel_deg: 0.0,
635 }),
636 yaw_availability: MeasurementAvailability::Measured,
637 }),
638 root_trajectory_availability: MeasurementAvailability::Measured,
639 speed_mps: Some(1.0),
640 speed_mps_availability: MeasurementAvailability::Measured,
641 }
642 }
643
644 fn measurement_map(
645 clip: &str,
646 measurements: ClipMeasurements,
647 ) -> BTreeMap<String, ClipMeasurements> {
648 BTreeMap::from([(clip.into(), measurements)])
649 }
650
651 fn delta_for<'a>(deltas: &'a [MetricDelta], metric: &str) -> &'a MetricDelta {
652 deltas
653 .iter()
654 .find(|d| d.metric == metric)
655 .unwrap_or_else(|| {
656 panic!(
657 "missing metric delta {metric}; got {:?}",
658 delta_metrics(deltas)
659 )
660 })
661 }
662
663 fn delta_metrics(deltas: &[MetricDelta]) -> Vec<&str> {
664 deltas.iter().map(|d| d.metric.as_str()).collect()
665 }
666
667 #[test]
668 fn reports_moved_appeared_and_disappeared_metrics() {
669 let mut before = clip_measurements();
670 before.speed_mps = None;
671
672 let mut after = before.clone();
673 after.duration_s += DURATION_THRESHOLD_S * 2.0;
674 after.loop_seam_ratio = None;
675 after.speed_mps = Some(1.0);
676
677 let deltas = diff_measurements(
678 &measurement_map("walk", before),
679 &measurement_map("walk", after),
680 );
681
682 assert_eq!(deltas.len(), 3, "{:?}", delta_metrics(&deltas));
683 assert_eq!(delta_for(&deltas, "duration_s").note, "moved");
684 assert_eq!(delta_for(&deltas, "loop_seam_ratio").note, "disappeared");
685 assert_eq!(delta_for(&deltas, "speed_mps").note, "appeared");
686 }
687
688 #[test]
689 fn reports_per_bone_loop_continuity_appearance_by_stable_index() {
690 let before = clip_measurements();
691 let mut after = before.clone();
692 after.loop_continuity = None;
693
694 let disappeared = diff_measurements(
695 &measurement_map("walk", before.clone()),
696 &measurement_map("walk", after.clone()),
697 );
698 assert_eq!(disappeared.len(), 4, "{:?}", delta_metrics(&disappeared));
699 assert!(disappeared.iter().all(|delta| delta.note == "disappeared"));
700 assert!(
701 disappeared
702 .iter()
703 .all(|delta| { delta.metric.starts_with("loop_continuity.bones[0].") })
704 );
705
706 let appeared = diff_measurements(
707 &measurement_map("walk", after),
708 &measurement_map("walk", before),
709 );
710 assert_eq!(appeared.len(), 4, "{:?}", delta_metrics(&appeared));
711 assert!(appeared.iter().all(|delta| delta.note == "appeared"));
712 }
713
714 #[test]
715 fn compares_nonzero_loop_bone_indices_even_when_names_repeat() {
716 let mut before = clip_measurements();
717 before
718 .loop_continuity
719 .as_mut()
720 .unwrap()
721 .bones
722 .push(BoneLoopContinuityMeasurement {
723 bone_index: 1,
724 bone_name: "hips".into(),
725 position_delta_m: 0.03,
726 rotation_delta_deg: 3.0,
727 seam_velocity_delta_mps: 0.3,
728 seam_angular_velocity_delta_degps: 11.0,
729 });
730 let mut after = before.clone();
731 let changed = &mut after.loop_continuity.as_mut().unwrap().bones[1];
732 changed.position_delta_m = 0.032;
733 changed.rotation_delta_deg = 3.2;
734 changed.seam_velocity_delta_mps = 0.32;
735 changed.seam_angular_velocity_delta_degps = 11.6;
736
737 let deltas = diff_measurements(
738 &measurement_map("walk", before),
739 &measurement_map("walk", after),
740 );
741 assert_eq!(deltas.len(), 4, "{:?}", delta_metrics(&deltas));
742 assert!(
743 deltas
744 .iter()
745 .all(|delta| delta.metric.starts_with("loop_continuity.bones[1]."))
746 );
747 }
748
749 #[test]
750 fn loop_continuity_diff_floors_are_inclusive() {
751 let mut before = clip_measurements();
752 let before_bone = &mut before.loop_continuity.as_mut().unwrap().bones[0];
753 before_bone.position_delta_m = 0.0;
754 before_bone.rotation_delta_deg = 0.0;
755 before_bone.seam_velocity_delta_mps = 0.0;
756 before_bone.seam_angular_velocity_delta_degps = 0.0;
757
758 let mut at_floor = before.clone();
759 let after_bone = &mut at_floor.loop_continuity.as_mut().unwrap().bones[0];
760 after_bone.position_delta_m = 0.001;
761 after_bone.rotation_delta_deg = 0.1;
762 after_bone.seam_velocity_delta_mps = 0.01;
763 after_bone.seam_angular_velocity_delta_degps = 0.5;
764
765 assert!(
766 diff_measurements(
767 &measurement_map("walk", before),
768 &measurement_map("walk", at_floor),
769 )
770 .is_empty(),
771 "movement exactly at a significance floor is noise"
772 );
773 }
774
775 #[test]
776 fn reports_clip_added_and_removed() {
777 let deltas = diff_measurements(
778 &measurement_map("removed", clip_measurements()),
779 &measurement_map("added", clip_measurements()),
780 );
781
782 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
783 assert!(
784 deltas
785 .iter()
786 .any(|d| d.clip == "removed" && d.metric == "clip" && d.note == "clip removed")
787 );
788 assert!(
789 deltas
790 .iter()
791 .any(|d| d.clip == "added" && d.metric == "clip" && d.note == "clip added")
792 );
793 }
794
795 #[test]
804 fn literal_stimuli_pin_documented_thresholds() {
805 struct Case {
809 metric: &'static str,
810 over: fn(&mut ClipMeasurements), under: fn(&mut ClipMeasurements), }
813 let cases = [
814 Case {
815 metric: "duration_s", over: |m| m.duration_s = 1.02,
817 under: |m| m.duration_s = 1.01,
818 },
819 Case {
820 metric: "loop_seam_ratio", over: |m| m.loop_seam_ratio = Some(0.27),
822 under: |m| m.loop_seam_ratio = Some(0.23),
823 },
824 Case {
825 metric: "gait.lr_amplitude_m", over: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.11,
827 under: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.102,
828 },
829 Case {
830 metric: "speed_mps", over: |m| m.speed_mps = Some(1.15),
832 under: |m| m.speed_mps = Some(1.05),
833 },
834 Case {
835 metric: "bone_rotation_range_deg[hips]", over: |m| {
837 m.bone_rotation_range_deg.insert("hips".into(), 13.0);
838 },
839 under: |m| {
840 m.bone_rotation_range_deg.insert("hips".into(), 10.5);
841 },
842 },
843 Case {
844 metric: "loop_continuity.bones[0].position_delta_m", over: |m| {
846 m.loop_continuity.as_mut().unwrap().bones[0].position_delta_m = 0.022;
847 },
848 under: |m| {
849 m.loop_continuity.as_mut().unwrap().bones[0].position_delta_m = 0.0205;
850 },
851 },
852 Case {
853 metric: "loop_continuity.bones[0].rotation_delta_deg", over: |m| {
855 m.loop_continuity.as_mut().unwrap().bones[0].rotation_delta_deg = 2.2;
856 },
857 under: |m| {
858 m.loop_continuity.as_mut().unwrap().bones[0].rotation_delta_deg = 2.05;
859 },
860 },
861 Case {
862 metric: "loop_continuity.bones[0].seam_velocity_delta_mps", over: |m| {
864 m.loop_continuity.as_mut().unwrap().bones[0].seam_velocity_delta_mps = 0.22;
865 },
866 under: |m| {
867 m.loop_continuity.as_mut().unwrap().bones[0].seam_velocity_delta_mps = 0.205;
868 },
869 },
870 Case {
871 metric: "loop_continuity.bones[0].seam_angular_velocity_delta_degps", over: |m| {
873 m.loop_continuity.as_mut().unwrap().bones[0]
874 .seam_angular_velocity_delta_degps = 10.5001;
875 },
876 under: |m| {
877 m.loop_continuity.as_mut().unwrap().bones[0]
878 .seam_angular_velocity_delta_degps = 10.4999;
879 },
880 },
881 ];
882
883 for case in cases {
884 let before = clip_measurements();
885
886 let mut over = before.clone();
887 (case.over)(&mut over);
888 let deltas = diff_measurements(
889 &measurement_map("walk", before.clone()),
890 &measurement_map("walk", over),
891 );
892 assert_eq!(
893 delta_metrics(&deltas),
894 vec![case.metric],
895 "over-threshold literal must report exactly {}",
896 case.metric
897 );
898
899 let mut under = before.clone();
900 (case.under)(&mut under);
901 let deltas = diff_measurements(
902 &measurement_map("walk", before),
903 &measurement_map("walk", under),
904 );
905 assert!(
906 deltas.is_empty(),
907 "under-threshold literal for {} must be silent: {:?}",
908 case.metric,
909 delta_metrics(&deltas)
910 );
911 }
912 }
913
914 #[test]
915 fn compares_gait_phase_on_a_cycle() {
916 let mut before = clip_measurements();
917 before.gait.as_mut().unwrap().phase = Some(0.98);
918 let mut after = before.clone();
919 after.gait.as_mut().unwrap().phase = Some(0.02);
920
921 let deltas = diff_measurements(
922 &measurement_map("walk", before),
923 &measurement_map("walk", after),
924 );
925
926 assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
927 }
928
929 #[test]
930 fn reports_significant_gait_phase_moves() {
931 let mut before = clip_measurements();
932 before.gait.as_mut().unwrap().phase = Some(0.9);
933 let mut after = before.clone();
934 after.gait.as_mut().unwrap().phase = Some(0.1);
935
936 let deltas = diff_measurements(
937 &measurement_map("walk", before),
938 &measurement_map("walk", after),
939 );
940
941 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
942 let delta = delta_for(&deltas, "gait.phase");
943 assert_eq!(delta.note, "moved");
944 assert_eq!(delta.before, Some(0.9));
945 assert_eq!(delta.after, Some(0.1));
946 }
947
948 #[test]
951 fn reports_frame_count_move_including_a_decrease() {
952 let before = clip_measurements(); let mut after = before.clone();
954 after.frame_count = 20;
955
956 let deltas = diff_measurements(
957 &measurement_map("walk", before),
958 &measurement_map("walk", after),
959 );
960
961 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
962 let delta = delta_for(&deltas, "frame_count");
963 assert_eq!(delta.note, "moved");
964 assert_eq!(delta.before, Some(31.0));
965 assert_eq!(delta.after, Some(20.0));
966 assert!(
967 delta.before.unwrap() > delta.after.unwrap(),
968 "a decrease must be captured, not dropped"
969 );
970 }
971
972 #[test]
976 fn single_frame_change_crosses_the_frame_count_threshold() {
977 let before = clip_measurements(); let mut after = before.clone();
979 after.frame_count = 32; let deltas = diff_measurements(
982 &measurement_map("walk", before),
983 &measurement_map("walk", after),
984 );
985
986 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
987 let delta = delta_for(&deltas, "frame_count");
988 assert_eq!(delta.note, "moved");
989 assert_eq!(delta.before, Some(31.0));
990 assert_eq!(delta.after, Some(32.0));
991 }
992
993 #[test]
994 fn reports_gait_amplitude_move() {
995 let before = clip_measurements(); let mut after = before.clone();
997 after.gait.as_mut().unwrap().lr_amplitude_m = 0.1 + AMPLITUDE_THRESHOLD_M * 2.0;
998
999 let deltas = diff_measurements(
1000 &measurement_map("walk", before),
1001 &measurement_map("walk", after),
1002 );
1003
1004 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1005 let delta = delta_for(&deltas, "gait.lr_amplitude_m");
1006 assert_eq!(delta.note, "moved");
1007 assert_eq!(delta.before, Some(0.1));
1008 assert_eq!(delta.after, Some(0.1 + AMPLITUDE_THRESHOLD_M * 2.0));
1009 }
1010
1011 #[test]
1012 fn reports_bone_rotation_range_moved() {
1013 let before = clip_measurements(); let mut after = before.clone();
1015 after
1016 .bone_rotation_range_deg
1017 .insert("hips".into(), 10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0);
1018
1019 let deltas = diff_measurements(
1020 &measurement_map("walk", before),
1021 &measurement_map("walk", after),
1022 );
1023
1024 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1025 let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
1026 assert_eq!(delta.note, "moved");
1027 assert_eq!(delta.before, Some(10.0));
1028 assert_eq!(delta.after, Some(10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0));
1029 }
1030
1031 #[test]
1032 fn reports_bone_rotation_range_appeared_and_disappeared() {
1033 let before = clip_measurements();
1034 let mut after = before.clone();
1035 after.bone_rotation_range_deg.insert("spine".into(), 5.0);
1036 let deltas = diff_measurements(
1037 &measurement_map("walk", before),
1038 &measurement_map("walk", after),
1039 );
1040 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1041 let delta = delta_for(&deltas, "bone_rotation_range_deg[spine]");
1042 assert_eq!(delta.note, "bone now animated");
1043 assert_eq!(delta.before, None);
1044 assert_eq!(delta.after, Some(5.0));
1045
1046 let before = clip_measurements();
1047 let mut after = before.clone();
1048 after.bone_rotation_range_deg.remove("hips");
1049 let deltas = diff_measurements(
1050 &measurement_map("walk", before),
1051 &measurement_map("walk", after),
1052 );
1053 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1054 let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
1055 assert_eq!(delta.note, "bone no longer animated");
1056 assert_eq!(delta.before, Some(10.0));
1057 assert_eq!(delta.after, None);
1058 }
1059
1060 #[test]
1061 fn reports_animated_bones_gained_and_lost() {
1062 let before = clip_measurements(); let mut after = before.clone();
1064 after.animated_bones = vec!["spine".into(), "tail".into()];
1065
1066 let deltas = diff_measurements(
1067 &measurement_map("walk", before),
1068 &measurement_map("walk", after),
1069 );
1070
1071 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1072 let delta = delta_for(&deltas, "animated_bones");
1073 assert_eq!(delta.before, Some(1.0));
1074 assert_eq!(delta.after, Some(2.0));
1075 assert_eq!(delta.note, "gained [spine, tail], lost [hips]");
1076 }
1077
1078 #[test]
1079 fn reports_bone_channel_properties_gained_and_lost_by_index() {
1080 let before = clip_measurements();
1081 let mut after = before.clone();
1082 after.bone_channels[0].properties = vec![Property::Rotation, Property::Scale];
1083
1084 let deltas = diff_measurements(
1085 &measurement_map("walk", before),
1086 &measurement_map("walk", after),
1087 );
1088
1089 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1090 let delta = delta_for(&deltas, "bone_channels");
1091 assert_eq!(delta.before, Some(1.0));
1092 assert_eq!(delta.after, Some(2.0));
1093 assert_eq!(
1094 delta.note,
1095 "gained [#0:\"hips\".rotation, #0:\"hips\".scale], lost [#0:\"hips\".translation]"
1096 );
1097 }
1098
1099 #[test]
1100 fn bone_channel_diff_uses_index_not_display_name_as_identity() {
1101 let before = clip_measurements();
1102 let mut after = before.clone();
1103 after.animated_bones = vec!["pelvis".into()];
1104 after.bone_channels[0].bone_name = "pelvis".into();
1105
1106 let deltas = diff_measurements(
1107 &measurement_map("walk", before),
1108 &measurement_map("walk", after),
1109 );
1110
1111 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1112 assert_eq!(deltas[0].metric, "animated_bones");
1113 }
1114
1115 #[test]
1116 fn reports_every_root_trajectory_numeric_field_in_both_directions() {
1117 let mut before = clip_measurements();
1118 let trajectory = before.root_trajectory.as_mut().unwrap();
1119 let translation = trajectory.translation.as_mut().unwrap();
1120 translation.horizontal_displacement_x_m = 0.011;
1121 translation.horizontal_displacement_z_m = 0.022;
1122 translation.horizontal_travel_m = 0.055;
1123 translation.vertical_displacement_m = 0.033;
1124 translation.vertical_min_displacement_m = -0.044;
1125 translation.vertical_max_displacement_m = 0.066;
1126 let yaw = trajectory.yaw.as_mut().unwrap();
1127 yaw.net_yaw_deg = 11.0;
1128 yaw.unwrapped_yaw_deg = 371.0;
1129 yaw.yaw_travel_deg = 412.0;
1130
1131 let mut after = before.clone();
1132 let trajectory = after.root_trajectory.as_mut().unwrap();
1133 let translation = trajectory.translation.as_mut().unwrap();
1134 translation.horizontal_displacement_x_m = 0.111;
1135 translation.horizontal_displacement_z_m = 0.222;
1136 translation.horizontal_travel_m = 0.555;
1137 translation.vertical_displacement_m = 0.333;
1138 translation.vertical_min_displacement_m = -0.444;
1139 translation.vertical_max_displacement_m = 0.666;
1140 let yaw = trajectory.yaw.as_mut().unwrap();
1141 yaw.net_yaw_deg = 21.0;
1142 yaw.unwrapped_yaw_deg = 741.0;
1143 yaw.yaw_travel_deg = 842.0;
1144
1145 let expected = [
1146 (
1147 "root_trajectory.translation.horizontal_displacement_x_m",
1148 0.011,
1149 0.111,
1150 ),
1151 (
1152 "root_trajectory.translation.horizontal_displacement_z_m",
1153 0.022,
1154 0.222,
1155 ),
1156 (
1157 "root_trajectory.translation.horizontal_travel_m",
1158 0.055,
1159 0.555,
1160 ),
1161 (
1162 "root_trajectory.translation.vertical_displacement_m",
1163 0.033,
1164 0.333,
1165 ),
1166 (
1167 "root_trajectory.translation.vertical_min_displacement_m",
1168 -0.044,
1169 -0.444,
1170 ),
1171 (
1172 "root_trajectory.translation.vertical_max_displacement_m",
1173 0.066,
1174 0.666,
1175 ),
1176 ("root_trajectory.yaw.net_yaw_deg", 11.0, 21.0),
1177 ("root_trajectory.yaw.unwrapped_yaw_deg", 371.0, 741.0),
1178 ("root_trajectory.yaw.yaw_travel_deg", 412.0, 842.0),
1179 ];
1180
1181 for (from, to, reverse) in [
1182 (before.clone(), after.clone(), false),
1183 (after, before, true),
1184 ] {
1185 let deltas =
1186 diff_measurements(&measurement_map("walk", from), &measurement_map("walk", to));
1187 assert_eq!(
1188 deltas.len(),
1189 expected.len(),
1190 "{:#?}",
1191 delta_metrics(&deltas)
1192 );
1193 for (delta, (metric, before_value, after_value)) in
1194 deltas.iter().zip(expected.iter().copied())
1195 {
1196 let (before_value, after_value) = if reverse {
1197 (after_value, before_value)
1198 } else {
1199 (before_value, after_value)
1200 };
1201 assert_eq!(delta.clip, "walk");
1202 assert_eq!(delta.metric, metric);
1203 assert_eq!(delta.before, Some(before_value), "{metric}");
1204 assert_eq!(delta.after, Some(after_value), "{metric}");
1205 assert_eq!(delta.note, "moved", "{metric}");
1206 }
1207 }
1208 }
1209
1210 #[test]
1211 fn root_trajectory_thresholds_are_inclusive_and_sign_symmetric() {
1212 for sign in [-1.0, 1.0] {
1213 let before = clip_measurements();
1214 let mut at_threshold = before.clone();
1215 let trajectory = at_threshold.root_trajectory.as_mut().unwrap();
1216 trajectory
1217 .translation
1218 .as_mut()
1219 .unwrap()
1220 .horizontal_displacement_x_m = sign * ROOT_TRAJECTORY_TRANSLATION_THRESHOLD_M;
1221 let yaw = trajectory.yaw.as_mut().unwrap();
1222 yaw.net_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG;
1223 yaw.unwrapped_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG;
1224 yaw.yaw_travel_deg = ROOT_TRAJECTORY_YAW_THRESHOLD_DEG;
1225
1226 let deltas = diff_measurements(
1227 &measurement_map("walk", before.clone()),
1228 &measurement_map("walk", at_threshold),
1229 );
1230 assert!(
1231 deltas.is_empty(),
1232 "exact thresholds must stay silent for sign {sign}: {:?}",
1233 delta_metrics(&deltas)
1234 );
1235
1236 let mut over_threshold = before.clone();
1237 let trajectory = over_threshold.root_trajectory.as_mut().unwrap();
1238 trajectory
1239 .translation
1240 .as_mut()
1241 .unwrap()
1242 .horizontal_displacement_x_m =
1243 sign * ROOT_TRAJECTORY_TRANSLATION_THRESHOLD_M * 1.01;
1244 let yaw = trajectory.yaw.as_mut().unwrap();
1245 yaw.net_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG * 1.01;
1246 yaw.unwrapped_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG * 1.01;
1247 yaw.yaw_travel_deg = ROOT_TRAJECTORY_YAW_THRESHOLD_DEG * 1.01;
1248
1249 let deltas = diff_measurements(
1250 &measurement_map("walk", before),
1251 &measurement_map("walk", over_threshold),
1252 );
1253 let metrics = delta_metrics(&deltas);
1254 for expected in [
1255 "root_trajectory.translation.horizontal_displacement_x_m",
1256 "root_trajectory.yaw.net_yaw_deg",
1257 "root_trajectory.yaw.unwrapped_yaw_deg",
1258 "root_trajectory.yaw.yaw_travel_deg",
1259 ] {
1260 assert!(metrics.contains(&expected), "{expected}: {metrics:?}");
1261 }
1262 }
1263 }
1264
1265 #[test]
1266 fn root_yaw_diff_uses_circular_net_but_retains_full_turn_evidence() {
1267 let mut before_silent_seam = clip_measurements();
1268 let yaw = before_silent_seam
1269 .root_trajectory
1270 .as_mut()
1271 .unwrap()
1272 .yaw
1273 .as_mut()
1274 .unwrap();
1275 yaw.net_yaw_deg = 179.96;
1276 yaw.unwrapped_yaw_deg = 179.96;
1277 yaw.yaw_travel_deg = 179.96;
1278 let mut after_silent_seam = before_silent_seam.clone();
1279 let yaw = after_silent_seam
1280 .root_trajectory
1281 .as_mut()
1282 .unwrap()
1283 .yaw
1284 .as_mut()
1285 .unwrap();
1286 yaw.net_yaw_deg = -179.96;
1287 yaw.unwrapped_yaw_deg = 180.04;
1288 yaw.yaw_travel_deg = 180.04;
1289 assert!(
1290 diff_measurements(
1291 &measurement_map("walk", before_silent_seam),
1292 &measurement_map("walk", after_silent_seam),
1293 )
1294 .is_empty(),
1295 "a 0.08-degree circular seam crossing must stay below the floor"
1296 );
1297
1298 let mut before_seam = clip_measurements();
1299 let yaw = before_seam
1300 .root_trajectory
1301 .as_mut()
1302 .unwrap()
1303 .yaw
1304 .as_mut()
1305 .unwrap();
1306 yaw.net_yaw_deg = 179.9;
1307 yaw.unwrapped_yaw_deg = 179.9;
1308 yaw.yaw_travel_deg = 179.9;
1309
1310 let mut after_seam = before_seam.clone();
1311 let yaw = after_seam
1312 .root_trajectory
1313 .as_mut()
1314 .unwrap()
1315 .yaw
1316 .as_mut()
1317 .unwrap();
1318 yaw.net_yaw_deg = -179.9;
1319 yaw.unwrapped_yaw_deg = 180.1;
1320 yaw.yaw_travel_deg = 180.1;
1321
1322 let seam_deltas = diff_measurements(
1323 &measurement_map("walk", before_seam),
1324 &measurement_map("walk", after_seam),
1325 );
1326 assert_eq!(
1327 delta_metrics(&seam_deltas),
1328 [
1329 "root_trajectory.yaw.net_yaw_deg",
1330 "root_trajectory.yaw.unwrapped_yaw_deg",
1331 "root_trajectory.yaw.yaw_travel_deg",
1332 ]
1333 );
1334 let net = delta_for(&seam_deltas, "root_trajectory.yaw.net_yaw_deg");
1335 assert_eq!(net.before, Some(179.9));
1336 assert_eq!(net.after, Some(-179.9));
1337
1338 let before_turn = clip_measurements();
1339 let mut after_turn = before_turn.clone();
1340 let yaw = after_turn
1341 .root_trajectory
1342 .as_mut()
1343 .unwrap()
1344 .yaw
1345 .as_mut()
1346 .unwrap();
1347 yaw.unwrapped_yaw_deg = 360.0;
1348 yaw.yaw_travel_deg = 360.0;
1349
1350 let turn_deltas = diff_measurements(
1351 &measurement_map("walk", before_turn),
1352 &measurement_map("walk", after_turn),
1353 );
1354 assert_eq!(
1355 delta_metrics(&turn_deltas),
1356 [
1357 "root_trajectory.yaw.unwrapped_yaw_deg",
1358 "root_trajectory.yaw.yaw_travel_deg",
1359 ]
1360 );
1361 }
1362
1363 #[test]
1364 fn root_trajectory_diff_reports_nested_status_identity_and_basis_changes() {
1365 let before = clip_measurements();
1366 let mut after = before.clone();
1367 let trajectory = after.root_trajectory.as_mut().unwrap();
1368 trajectory.bone_index = 1;
1369 trajectory.bone_name = "root".into();
1370 trajectory.source_role = RootTrajectorySourceRole::Root;
1371 trajectory.translation = None;
1372 trajectory.translation_availability = MeasurementAvailability::Unavailable;
1373 trajectory.yaw.as_mut().unwrap().heading_axis = RootYawHeadingAxis::PositiveX;
1374
1375 let deltas = diff_measurements(
1376 &measurement_map("walk", before),
1377 &measurement_map("walk", after),
1378 );
1379 let metrics = deltas
1380 .iter()
1381 .map(|delta| delta.metric.as_str())
1382 .collect::<BTreeSet<_>>();
1383 let selected_bone = delta_for(&deltas, "root_trajectory.selected_bone");
1384 assert_eq!(selected_bone.before, Some(0.0));
1385 assert_eq!(selected_bone.after, Some(1.0));
1386 assert_eq!(
1387 selected_bone.note,
1388 "changed from #0:\"hips\" (hips_fallback) to #1:\"root\" (root)"
1389 );
1390 let heading_axis = delta_for(&deltas, "root_trajectory.yaw.heading_axis");
1391 assert_eq!(heading_axis.before, None);
1392 assert_eq!(heading_axis.after, None);
1393 assert_eq!(heading_axis.note, "changed from +Z to +X");
1394 assert!(metrics.contains("root_trajectory.translation_availability"));
1395 for field in [
1396 "horizontal_displacement_x_m",
1397 "horizontal_displacement_z_m",
1398 "horizontal_travel_m",
1399 "vertical_displacement_m",
1400 "vertical_min_displacement_m",
1401 "vertical_max_displacement_m",
1402 ] {
1403 assert!(metrics.contains(format!("root_trajectory.translation.{field}").as_str()));
1404 }
1405 }
1406
1407 #[test]
1408 fn root_trajectory_diff_checks_each_selected_identity_component() {
1409 type SelectionMutation = (
1410 fn(&mut RootTrajectoryMeasurement),
1411 Option<f64>,
1412 Option<f64>,
1413 &'static str,
1414 );
1415 let mutations: [SelectionMutation; 3] = [
1416 (
1417 |trajectory| trajectory.bone_index = 1,
1418 Some(0.0),
1419 Some(1.0),
1420 "changed from #0:\"hips\" (hips_fallback) to #1:\"hips\" (hips_fallback)",
1421 ),
1422 (
1423 |trajectory| trajectory.bone_name = "root".into(),
1424 Some(0.0),
1425 Some(0.0),
1426 "changed from #0:\"hips\" (hips_fallback) to #0:\"root\" (hips_fallback)",
1427 ),
1428 (
1429 |trajectory| trajectory.source_role = RootTrajectorySourceRole::Root,
1430 Some(0.0),
1431 Some(0.0),
1432 "changed from #0:\"hips\" (hips_fallback) to #0:\"hips\" (root)",
1433 ),
1434 ];
1435
1436 for (mutate, expected_before, expected_after, expected_note) in mutations {
1437 let before = clip_measurements();
1438 let mut after = before.clone();
1439 mutate(after.root_trajectory.as_mut().unwrap());
1440 let deltas = diff_measurements(
1441 &measurement_map("walk", before),
1442 &measurement_map("walk", after),
1443 );
1444 assert_eq!(delta_metrics(&deltas), ["root_trajectory.selected_bone"]);
1445 let delta = delta_for(&deltas, "root_trajectory.selected_bone");
1446 assert_eq!(delta.before, expected_before);
1447 assert_eq!(delta.after, expected_after);
1448 assert_eq!(delta.note, expected_note);
1449 }
1450 }
1451
1452 #[test]
1453 fn root_trajectory_diff_reports_outer_and_nested_availability_both_directions() {
1454 let all_numeric = [
1455 "root_trajectory.translation.horizontal_displacement_x_m",
1456 "root_trajectory.translation.horizontal_displacement_z_m",
1457 "root_trajectory.translation.horizontal_travel_m",
1458 "root_trajectory.translation.vertical_displacement_m",
1459 "root_trajectory.translation.vertical_min_displacement_m",
1460 "root_trajectory.translation.vertical_max_displacement_m",
1461 "root_trajectory.yaw.net_yaw_deg",
1462 "root_trajectory.yaw.unwrapped_yaw_deg",
1463 "root_trajectory.yaw.yaw_travel_deg",
1464 ];
1465 let translation_numeric = &all_numeric[..6];
1466 let yaw_numeric = &all_numeric[6..];
1467
1468 let measured = clip_measurements();
1469 let mut outer_unavailable = measured.clone();
1470 outer_unavailable.root_trajectory = None;
1471 outer_unavailable.root_trajectory_availability = MeasurementAvailability::Unavailable;
1472
1473 let mut translation_unavailable = measured.clone();
1474 let trajectory = translation_unavailable.root_trajectory.as_mut().unwrap();
1475 trajectory.translation = None;
1476 trajectory.translation_availability = MeasurementAvailability::Unavailable;
1477
1478 let mut yaw_unavailable = measured.clone();
1479 let trajectory = yaw_unavailable.root_trajectory.as_mut().unwrap();
1480 trajectory.yaw = None;
1481 trajectory.yaw_availability = MeasurementAvailability::Unavailable;
1482
1483 for (available, unavailable, status, numeric) in [
1484 (
1485 measured.clone(),
1486 outer_unavailable,
1487 "root_trajectory_availability",
1488 all_numeric.as_slice(),
1489 ),
1490 (
1491 measured.clone(),
1492 translation_unavailable,
1493 "root_trajectory.translation_availability",
1494 translation_numeric,
1495 ),
1496 (
1497 measured.clone(),
1498 yaw_unavailable,
1499 "root_trajectory.yaw_availability",
1500 yaw_numeric,
1501 ),
1502 ] {
1503 for (before, after, expected_note) in [
1504 (available.clone(), unavailable.clone(), "became unavailable"),
1505 (unavailable, available, "became measured"),
1506 ] {
1507 let deltas = diff_measurements(
1508 &measurement_map("walk", before),
1509 &measurement_map("walk", after),
1510 );
1511 let metrics = delta_metrics(&deltas);
1512 assert_eq!(metrics.len(), numeric.len() + 1, "{metrics:?}");
1513 assert!(metrics.contains(&status), "{metrics:?}");
1514 for field in numeric {
1515 assert!(metrics.contains(field), "{field}: {metrics:?}");
1516 }
1517 assert_eq!(delta_for(&deltas, status).note, expected_note);
1518 }
1519 }
1520
1521 let mut not_applicable = measured;
1522 not_applicable.root_trajectory = None;
1523 not_applicable.root_trajectory_availability = MeasurementAvailability::NotApplicable;
1524 let mut unavailable = not_applicable.clone();
1525 unavailable.root_trajectory_availability = MeasurementAvailability::Unavailable;
1526 for (before, after, note) in [
1527 (
1528 not_applicable.clone(),
1529 unavailable.clone(),
1530 "became unavailable",
1531 ),
1532 (unavailable, not_applicable, "no longer applicable"),
1533 ] {
1534 let deltas = diff_measurements(
1535 &measurement_map("walk", before),
1536 &measurement_map("walk", after),
1537 );
1538 assert_eq!(delta_metrics(&deltas), ["root_trajectory_availability"]);
1539 assert_eq!(
1540 delta_for(&deltas, "root_trajectory_availability").note,
1541 note
1542 );
1543 }
1544 }
1545
1546 #[test]
1547 fn metric_delta_omits_non_finite_public_values() {
1548 let delta = MetricDelta {
1549 clip: "walk".into(),
1550 metric: "duration_s".into(),
1551 before: Some(f64::NAN),
1552 after: Some(f64::INFINITY),
1553 note: "moved".into(),
1554 };
1555 let json = serde_json::to_value(delta).expect("delta serializes");
1556 assert!(json.get("before").is_none());
1557 assert!(json.get("after").is_none());
1558 }
1559
1560 #[test]
1561 fn reports_not_applicable_to_unavailable_availability_transitions() {
1562 let mut before = clip_measurements();
1563 before.speed_mps = None;
1564 before.speed_mps_availability = MeasurementAvailability::NotApplicable;
1565
1566 let mut after = before.clone();
1567 after.speed_mps_availability = MeasurementAvailability::Unavailable;
1568
1569 let deltas = diff_measurements(
1570 &measurement_map("walk", before),
1571 &measurement_map("walk", after),
1572 );
1573
1574 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1575 let delta = delta_for(&deltas, "speed_mps_availability");
1576 assert_eq!(delta.note, "became unavailable");
1577 assert_eq!(delta.before, None);
1578 assert_eq!(delta.after, None);
1579 }
1580
1581 #[test]
1582 fn reports_unavailable_to_not_applicable_availability_transitions() {
1583 let mut before = clip_measurements();
1584 before.speed_mps = None;
1585 before.speed_mps_availability = MeasurementAvailability::Unavailable;
1586
1587 let mut after = before.clone();
1588 after.speed_mps_availability = MeasurementAvailability::NotApplicable;
1589
1590 let deltas = diff_measurements(
1591 &measurement_map("walk", before),
1592 &measurement_map("walk", after),
1593 );
1594
1595 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1596 let delta = delta_for(&deltas, "speed_mps_availability");
1597 assert_eq!(delta.note, "no longer applicable");
1598 }
1599
1600 #[test]
1601 fn availability_stays_silent_only_when_unchanged() {
1602 let before = clip_measurements();
1603 let after = before.clone();
1604
1605 let deltas = diff_measurements(
1606 &measurement_map("walk", before),
1607 &measurement_map("walk", after),
1608 );
1609 assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
1610 }
1611
1612 #[test]
1620 fn measured_to_unavailable_reports_both_value_and_availability_deltas() {
1621 let before = clip_measurements();
1622 let mut after = before.clone();
1623 after.speed_mps = None;
1624 after.speed_mps_availability = MeasurementAvailability::Unavailable;
1625
1626 let deltas = diff_measurements(
1627 &measurement_map("walk", before),
1628 &measurement_map("walk", after),
1629 );
1630
1631 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1632 assert_eq!(delta_for(&deltas, "speed_mps").note, "disappeared");
1633 assert_eq!(
1634 delta_for(&deltas, "speed_mps_availability").note,
1635 "became unavailable"
1636 );
1637 }
1638
1639 #[test]
1640 fn reports_gait_phase_not_applicable_to_unavailable_transitions() {
1641 let mut before = clip_measurements();
1642 before.gait.as_mut().unwrap().phase = None;
1643 before.gait.as_mut().unwrap().phase_availability = MeasurementAvailability::NotApplicable;
1644
1645 let mut after = before.clone();
1646 after.gait.as_mut().unwrap().phase_availability = MeasurementAvailability::Unavailable;
1647
1648 let deltas = diff_measurements(
1649 &measurement_map("walk", before),
1650 &measurement_map("walk", after),
1651 );
1652
1653 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1654 let delta = delta_for(&deltas, "gait.phase_availability");
1655 assert_eq!(delta.note, "became unavailable");
1656 }
1657
1658 #[test]
1659 fn regenerated_flat_gait_reports_phase_disappearance_and_non_applicability() {
1660 let mut before = clip_measurements();
1661 before.gait.as_mut().unwrap().lr_amplitude_m = 0.0;
1662
1663 let mut after = before.clone();
1664 after.gait.as_mut().unwrap().phase = None;
1665 after.gait.as_mut().unwrap().phase_availability = MeasurementAvailability::NotApplicable;
1666
1667 let deltas = diff_measurements(
1668 &measurement_map("walk", before),
1669 &measurement_map("walk", after),
1670 );
1671
1672 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1673 assert_eq!(delta_for(&deltas, "gait.phase").note, "disappeared");
1674 assert_eq!(
1675 delta_for(&deltas, "gait.phase_availability").note,
1676 "no longer applicable"
1677 );
1678 }
1679
1680 #[test]
1688 fn gait_phase_availability_is_not_compared_when_gait_itself_disappears() {
1689 let before = clip_measurements();
1690 let mut after = before.clone();
1691 after.gait = None;
1692 after.gait_availability = MeasurementAvailability::Unavailable;
1693
1694 let deltas = diff_measurements(
1695 &measurement_map("walk", before),
1696 &measurement_map("walk", after),
1697 );
1698
1699 assert!(
1700 deltas
1701 .iter()
1702 .all(|delta| delta.metric != "gait.phase_availability"),
1703 "gait.phase_availability has no valid comparison when gait is absent on one side: {:?}",
1704 delta_metrics(&deltas)
1705 );
1706 assert_eq!(delta_for(&deltas, "gait.phase").note, "disappeared");
1707 assert_eq!(
1708 delta_for(&deltas, "gait_availability").note,
1709 "became unavailable"
1710 );
1711 }
1712
1713 #[test]
1719 fn measured_to_unavailable_reports_endpoint_and_frame_grid_status_deltas() {
1720 let mut before = clip_measurements();
1721 before.loop_endpoint_mode = Some(LoopEndpointMode::UniqueCycle);
1722 before.loop_endpoint_mode_availability = MeasurementAvailability::Measured;
1723 before.frame_grid = Some(FrameGridMeasurement {
1724 fps: 30.0,
1725 frame_intervals: 30,
1726 });
1727 before.frame_grid_availability = MeasurementAvailability::Measured;
1728
1729 let mut after = before.clone();
1730 after.loop_endpoint_mode = None;
1731 after.loop_endpoint_mode_availability = MeasurementAvailability::Unavailable;
1732 after.frame_grid = None;
1733 after.frame_grid_availability = MeasurementAvailability::Unavailable;
1734
1735 let deltas = diff_measurements(
1736 &measurement_map("walk", before),
1737 &measurement_map("walk", after),
1738 );
1739
1740 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1741 assert_eq!(
1742 delta_for(&deltas, "loop_endpoint_mode_availability").note,
1743 "became unavailable"
1744 );
1745 assert_eq!(
1746 delta_for(&deltas, "frame_grid_availability").note,
1747 "became unavailable"
1748 );
1749 }
1750
1751 #[test]
1752 fn reports_endpoint_and_frame_grid_not_applicable_to_unavailable_transitions() {
1753 let mut before = clip_measurements();
1754 before.loop_endpoint_mode_availability = MeasurementAvailability::NotApplicable;
1755 before.frame_grid_availability = MeasurementAvailability::NotApplicable;
1756
1757 let mut after = before.clone();
1758 after.loop_endpoint_mode_availability = MeasurementAvailability::Unavailable;
1759 after.frame_grid_availability = MeasurementAvailability::Unavailable;
1760
1761 let deltas = diff_measurements(
1762 &measurement_map("walk", before),
1763 &measurement_map("walk", after),
1764 );
1765
1766 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1767 assert_eq!(
1768 delta_for(&deltas, "loop_endpoint_mode_availability").note,
1769 "became unavailable"
1770 );
1771 assert_eq!(
1772 delta_for(&deltas, "frame_grid_availability").note,
1773 "became unavailable"
1774 );
1775 }
1776}