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 let mut loop_bone_availability_deltas = Vec::new();
303 for bone_index in a_loop_bones
304 .keys()
305 .chain(b_loop_bones.keys())
306 .copied()
307 .collect::<BTreeSet<_>>()
308 {
309 let a_bone = a_loop_bones.get(&bone_index).copied();
310 let b_bone = b_loop_bones.get(&bone_index).copied();
311 let metric = |field: &str| format!("loop_continuity.bones[{bone_index}].{field}");
312 if let (Some(a_bone), Some(b_bone)) = (a_bone, b_bone) {
313 push_availability(
314 &mut loop_bone_availability_deltas,
315 clip,
316 &metric("availability"),
317 a_bone.availability,
318 b_bone.availability,
319 );
320 }
321 push_num(
322 &metric("position_delta_m"),
323 finite(a_bone.and_then(|bone| bone.position_delta_m)),
324 finite(b_bone.and_then(|bone| bone.position_delta_m)),
325 LOOP_POSITION_THRESHOLD_M,
326 None,
327 );
328 push_num(
329 &metric("rotation_delta_deg"),
330 finite(a_bone.and_then(|bone| bone.rotation_delta_deg)),
331 finite(b_bone.and_then(|bone| bone.rotation_delta_deg)),
332 LOOP_ROTATION_THRESHOLD_DEG,
333 None,
334 );
335 push_num(
336 &metric("seam_velocity_delta_mps"),
337 finite(a_bone.and_then(|bone| bone.seam_velocity_delta_mps)),
338 finite(b_bone.and_then(|bone| bone.seam_velocity_delta_mps)),
339 LOOP_VELOCITY_THRESHOLD_MPS,
340 None,
341 );
342 push_num(
343 &metric("seam_angular_velocity_delta_degps"),
344 finite(a_bone.and_then(|bone| bone.seam_angular_velocity_delta_degps)),
345 finite(b_bone.and_then(|bone| bone.seam_angular_velocity_delta_degps)),
346 LOOP_ANGULAR_VELOCITY_THRESHOLD_DEGPS,
347 None,
348 );
349 }
350 deltas.append(&mut loop_bone_availability_deltas);
351
352 if let (Some(a_trajectory), Some(b_trajectory)) =
353 (ma.root_trajectory.as_ref(), mb.root_trajectory.as_ref())
354 {
355 if (
356 a_trajectory.bone_index,
357 &a_trajectory.bone_name,
358 a_trajectory.source_role,
359 ) != (
360 b_trajectory.bone_index,
361 &b_trajectory.bone_name,
362 b_trajectory.source_role,
363 ) {
364 deltas.push(delta(
365 clip,
366 "root_trajectory.selected_bone",
367 Some(a_trajectory.bone_index as f64),
368 Some(b_trajectory.bone_index as f64),
369 format!(
370 "changed from #{}:{:?} ({}) to #{}:{:?} ({})",
371 a_trajectory.bone_index,
372 a_trajectory.bone_name,
373 a_trajectory.source_role.as_str(),
374 b_trajectory.bone_index,
375 b_trajectory.bone_name,
376 b_trajectory.source_role.as_str(),
377 ),
378 ));
379 }
380 if let (Some(a_yaw), Some(b_yaw)) =
381 (a_trajectory.yaw.as_ref(), b_trajectory.yaw.as_ref())
382 && a_yaw.heading_axis != b_yaw.heading_axis
383 {
384 deltas.push(delta(
385 clip,
386 "root_trajectory.yaw.heading_axis",
387 None,
388 None,
389 format!(
390 "changed from {} to {}",
391 a_yaw.heading_axis.label(),
392 b_yaw.heading_axis.label()
393 ),
394 ));
395 }
396 }
397
398 push_availability(
399 &mut deltas,
400 clip,
401 "loop_continuity_availability",
402 ma.loop_continuity_availability,
403 mb.loop_continuity_availability,
404 );
405 push_availability(
406 &mut deltas,
407 clip,
408 "loop_endpoint_mode_availability",
409 ma.loop_endpoint_mode_availability,
410 mb.loop_endpoint_mode_availability,
411 );
412 push_availability(
413 &mut deltas,
414 clip,
415 "frame_grid_availability",
416 ma.frame_grid_availability,
417 mb.frame_grid_availability,
418 );
419 push_availability(
420 &mut deltas,
421 clip,
422 "loop_seam_ratio_availability",
423 ma.loop_seam_ratio_availability,
424 mb.loop_seam_ratio_availability,
425 );
426 push_availability(
427 &mut deltas,
428 clip,
429 "gait_availability",
430 ma.gait_availability,
431 mb.gait_availability,
432 );
433 push_availability(
434 &mut deltas,
435 clip,
436 "root_trajectory_availability",
437 ma.root_trajectory_availability,
438 mb.root_trajectory_availability,
439 );
440 push_availability(
441 &mut deltas,
442 clip,
443 "speed_mps_availability",
444 ma.speed_mps_availability,
445 mb.speed_mps_availability,
446 );
447 if let (Some(ga), Some(gb)) = (ma.gait.as_ref(), mb.gait.as_ref()) {
455 push_availability(
456 &mut deltas,
457 clip,
458 "gait.phase_availability",
459 ga.phase_availability,
460 gb.phase_availability,
461 );
462 }
463 if let (Some(ta), Some(tb)) = (ma.root_trajectory.as_ref(), mb.root_trajectory.as_ref()) {
464 push_availability(
465 &mut deltas,
466 clip,
467 "root_trajectory.translation_availability",
468 ta.translation_availability,
469 tb.translation_availability,
470 );
471 push_availability(
472 &mut deltas,
473 clip,
474 "root_trajectory.yaw_availability",
475 ta.yaw_availability,
476 tb.yaw_availability,
477 );
478 }
479
480 for bone in ma
481 .bone_rotation_range_deg
482 .keys()
483 .chain(mb.bone_rotation_range_deg.keys())
484 .collect::<BTreeSet<_>>()
485 {
486 let va = finite(ma.bone_rotation_range_deg.get(bone).copied());
487 let vb = finite(mb.bone_rotation_range_deg.get(bone).copied());
488 let moved = match (va, vb) {
489 (Some(x), Some(y)) => (x - y).abs() > ROTATION_RANGE_THRESHOLD_DEG,
490 (None, None) => false,
491 _ => true,
492 };
493 if moved {
494 deltas.push(delta(
495 clip,
496 &format!("bone_rotation_range_deg[{bone}]"),
497 va,
498 vb,
499 match (va, vb) {
500 (Some(_), Some(_)) => "moved".into(),
501 (None, Some(_)) => "bone now animated".into(),
502 _ => "bone no longer animated".into(),
503 },
504 ));
505 }
506 }
507
508 if ma.animated_bones != mb.animated_bones {
509 let a_set: BTreeSet<_> = ma.animated_bones.iter().collect();
510 let b_set: BTreeSet<_> = mb.animated_bones.iter().collect();
511 let gained: Vec<_> = b_set.difference(&a_set).map(|s| s.as_str()).collect();
512 let lost: Vec<_> = a_set.difference(&b_set).map(|s| s.as_str()).collect();
513 deltas.push(delta(
514 clip,
515 "animated_bones",
516 Some(ma.animated_bones.len() as f64),
517 Some(mb.animated_bones.len() as f64),
518 format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
519 ));
520 }
521
522 let a_channels: BTreeMap<_, _> = ma
523 .bone_channels
524 .iter()
525 .flat_map(|bone| {
526 bone.properties
527 .iter()
528 .copied()
529 .map(move |property| ((bone.bone_index, property), bone.bone_name.as_str()))
530 })
531 .collect();
532 let b_channels: BTreeMap<_, _> = mb
533 .bone_channels
534 .iter()
535 .flat_map(|bone| {
536 bone.properties
537 .iter()
538 .copied()
539 .map(move |property| ((bone.bone_index, property), bone.bone_name.as_str()))
540 })
541 .collect();
542 let a_channel_keys: BTreeSet<_> = a_channels.keys().copied().collect();
543 let b_channel_keys: BTreeSet<_> = b_channels.keys().copied().collect();
544 if a_channel_keys != b_channel_keys {
545 let gained: Vec<_> = b_channel_keys
546 .difference(&a_channel_keys)
547 .map(|(bone_index, property)| {
548 let bone_name = b_channels[&(*bone_index, *property)];
549 format!("#{bone_index}:{bone_name:?}.{}", property.as_str())
550 })
551 .collect();
552 let lost: Vec<_> = a_channel_keys
553 .difference(&b_channel_keys)
554 .map(|(bone_index, property)| {
555 let bone_name = a_channels[&(*bone_index, *property)];
556 format!("#{bone_index}:{bone_name:?}.{}", property.as_str())
557 })
558 .collect();
559 deltas.push(delta(
560 clip,
561 "bone_channels",
562 Some(a_channel_keys.len() as f64),
563 Some(b_channel_keys.len() as f64),
564 format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
565 ));
566 }
567 }
568 for clip in b.keys() {
569 if !a.contains_key(clip) {
570 deltas.push(MetricDelta {
571 clip: clip.clone(),
572 metric: "clip".into(),
573 before: None,
574 after: None,
575 note: "clip added".into(),
576 });
577 }
578 }
579 deltas
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::measure::{
586 BoneChannelCoverage, BoneLoopContinuityMeasurement, ClipMeasurements, FrameGridMeasurement,
587 GaitMeasurement, LoopContinuityMeasurement, LoopEndpointMode, MeasurementAvailability,
588 RootTrajectoryMeasurement, RootTrajectorySourceRole, RootTranslationMeasurement,
589 RootYawMeasurement,
590 };
591 use crate::metrics::RootYawHeadingAxis;
592 use crate::model::Property;
593
594 fn clip_measurements() -> ClipMeasurements {
595 ClipMeasurements {
596 duration_s: 1.0,
597 frame_count: 31,
598 animated_bones: vec!["hips".into()],
599 bone_channels: vec![BoneChannelCoverage {
600 bone_index: 0,
601 bone_name: "hips".into(),
602 properties: vec![Property::Translation],
603 }],
604 bone_rotation_range_deg: BTreeMap::from([("hips".into(), 10.0)]),
605 loop_continuity: Some(LoopContinuityMeasurement {
606 bones: vec![BoneLoopContinuityMeasurement {
607 bone_index: 0,
608 bone_name: "hips".into(),
609 availability: MeasurementAvailability::Measured,
610 availability_was_present: true,
611 position_delta_m: Some(0.02),
612 rotation_delta_deg: Some(2.0),
613 seam_velocity_delta_mps: Some(0.2),
614 seam_angular_velocity_delta_degps: Some(10.0),
615 }],
616 }),
617 loop_continuity_availability: MeasurementAvailability::Measured,
618 loop_endpoint_mode: None,
619 loop_endpoint_mode_availability: MeasurementAvailability::NotApplicable,
620 frame_grid: None,
621 frame_grid_availability: MeasurementAvailability::NotApplicable,
622 loop_seam_ratio: Some(0.2),
623 loop_seam_ratio_availability: MeasurementAvailability::Measured,
624 gait: Some(GaitMeasurement {
625 phase: Some(0.25),
626 phase_availability: MeasurementAvailability::Measured,
627 lr_amplitude_m: 0.1,
628 }),
629 gait_availability: MeasurementAvailability::Measured,
630 root_trajectory: Some(RootTrajectoryMeasurement {
631 bone_index: 0,
632 bone_name: "hips".into(),
633 source_role: RootTrajectorySourceRole::HipsFallback,
634 translation: Some(RootTranslationMeasurement {
635 horizontal_displacement_x_m: 0.0,
636 horizontal_displacement_z_m: 1.0,
637 horizontal_travel_m: 1.0,
638 vertical_displacement_m: 0.0,
639 vertical_min_displacement_m: 0.0,
640 vertical_max_displacement_m: 0.0,
641 }),
642 translation_availability: MeasurementAvailability::Measured,
643 yaw: Some(RootYawMeasurement {
644 heading_axis: RootYawHeadingAxis::PositiveZ,
645 net_yaw_deg: 0.0,
646 unwrapped_yaw_deg: 0.0,
647 yaw_travel_deg: 0.0,
648 }),
649 yaw_availability: MeasurementAvailability::Measured,
650 }),
651 root_trajectory_availability: MeasurementAvailability::Measured,
652 speed_mps: Some(1.0),
653 speed_mps_availability: MeasurementAvailability::Measured,
654 }
655 }
656
657 fn measurement_map(
658 clip: &str,
659 measurements: ClipMeasurements,
660 ) -> BTreeMap<String, ClipMeasurements> {
661 BTreeMap::from([(clip.into(), measurements)])
662 }
663
664 fn delta_for<'a>(deltas: &'a [MetricDelta], metric: &str) -> &'a MetricDelta {
665 deltas
666 .iter()
667 .find(|d| d.metric == metric)
668 .unwrap_or_else(|| {
669 panic!(
670 "missing metric delta {metric}; got {:?}",
671 delta_metrics(deltas)
672 )
673 })
674 }
675
676 fn delta_metrics(deltas: &[MetricDelta]) -> Vec<&str> {
677 deltas.iter().map(|d| d.metric.as_str()).collect()
678 }
679
680 #[test]
681 fn reports_moved_appeared_and_disappeared_metrics() {
682 let mut before = clip_measurements();
683 before.speed_mps = None;
684
685 let mut after = before.clone();
686 after.duration_s += DURATION_THRESHOLD_S * 2.0;
687 after.loop_seam_ratio = None;
688 after.speed_mps = Some(1.0);
689
690 let deltas = diff_measurements(
691 &measurement_map("walk", before),
692 &measurement_map("walk", after),
693 );
694
695 assert_eq!(deltas.len(), 3, "{:?}", delta_metrics(&deltas));
696 assert_eq!(delta_for(&deltas, "duration_s").note, "moved");
697 assert_eq!(delta_for(&deltas, "loop_seam_ratio").note, "disappeared");
698 assert_eq!(delta_for(&deltas, "speed_mps").note, "appeared");
699 }
700
701 #[test]
702 fn reports_per_bone_loop_continuity_appearance_by_stable_index() {
703 let before = clip_measurements();
704 let mut after = before.clone();
705 after.loop_continuity = None;
706
707 let disappeared = diff_measurements(
708 &measurement_map("walk", before.clone()),
709 &measurement_map("walk", after.clone()),
710 );
711 assert_eq!(disappeared.len(), 4, "{:?}", delta_metrics(&disappeared));
712 assert!(disappeared.iter().all(|delta| delta.note == "disappeared"));
713 assert!(
714 disappeared
715 .iter()
716 .all(|delta| { delta.metric.starts_with("loop_continuity.bones[0].") })
717 );
718
719 let appeared = diff_measurements(
720 &measurement_map("walk", after),
721 &measurement_map("walk", before),
722 );
723 assert_eq!(appeared.len(), 4, "{:?}", delta_metrics(&appeared));
724 assert!(appeared.iter().all(|delta| delta.note == "appeared"));
725 }
726
727 #[test]
728 fn compares_nonzero_loop_bone_indices_even_when_names_repeat() {
729 let mut before = clip_measurements();
730 before
731 .loop_continuity
732 .as_mut()
733 .unwrap()
734 .bones
735 .push(BoneLoopContinuityMeasurement {
736 bone_index: 1,
737 bone_name: "hips".into(),
738 availability: MeasurementAvailability::Measured,
739 availability_was_present: true,
740 position_delta_m: Some(0.03),
741 rotation_delta_deg: Some(3.0),
742 seam_velocity_delta_mps: Some(0.3),
743 seam_angular_velocity_delta_degps: Some(11.0),
744 });
745 let mut after = before.clone();
746 let changed = &mut after.loop_continuity.as_mut().unwrap().bones[1];
747 changed.position_delta_m = Some(0.032);
748 changed.rotation_delta_deg = Some(3.2);
749 changed.seam_velocity_delta_mps = Some(0.32);
750 changed.seam_angular_velocity_delta_degps = Some(11.6);
751
752 let deltas = diff_measurements(
753 &measurement_map("walk", before),
754 &measurement_map("walk", after),
755 );
756 assert_eq!(deltas.len(), 4, "{:?}", delta_metrics(&deltas));
757 assert!(
758 deltas
759 .iter()
760 .all(|delta| delta.metric.starts_with("loop_continuity.bones[1]."))
761 );
762 }
763
764 #[test]
765 fn loop_continuity_diff_floors_are_inclusive() {
766 let mut before = clip_measurements();
767 let before_bone = &mut before.loop_continuity.as_mut().unwrap().bones[0];
768 before_bone.position_delta_m = Some(0.0);
769 before_bone.rotation_delta_deg = Some(0.0);
770 before_bone.seam_velocity_delta_mps = Some(0.0);
771 before_bone.seam_angular_velocity_delta_degps = Some(0.0);
772
773 let mut at_floor = before.clone();
774 let after_bone = &mut at_floor.loop_continuity.as_mut().unwrap().bones[0];
775 after_bone.position_delta_m = Some(0.001);
776 after_bone.rotation_delta_deg = Some(0.1);
777 after_bone.seam_velocity_delta_mps = Some(0.01);
778 after_bone.seam_angular_velocity_delta_degps = Some(0.5);
779
780 assert!(
781 diff_measurements(
782 &measurement_map("walk", before),
783 &measurement_map("walk", at_floor),
784 )
785 .is_empty(),
786 "movement exactly at a significance floor is noise"
787 );
788 }
789
790 #[test]
791 fn loop_continuity_diff_reports_per_bone_unavailability() {
792 let before = clip_measurements();
793 let mut after = before.clone();
794 let bone = &mut after.loop_continuity.as_mut().unwrap().bones[0];
795 bone.availability = MeasurementAvailability::Unavailable;
796 bone.position_delta_m = None;
797 bone.rotation_delta_deg = None;
798 bone.seam_velocity_delta_mps = None;
799 bone.seam_angular_velocity_delta_degps = None;
800
801 let deltas = diff_measurements(
802 &measurement_map("walk", before.clone()),
803 &measurement_map("walk", after.clone()),
804 );
805 assert_eq!(
806 delta_for(&deltas, "loop_continuity.bones[0].availability").note,
807 "became unavailable"
808 );
809 assert_eq!(
810 delta_for(&deltas, "loop_continuity.bones[0].position_delta_m").note,
811 "disappeared"
812 );
813
814 let reverse = diff_measurements(
815 &measurement_map("walk", after),
816 &measurement_map("walk", before),
817 );
818 assert_eq!(
819 delta_for(&reverse, "loop_continuity.bones[0].availability").note,
820 "became measured"
821 );
822 assert_eq!(
823 delta_for(&reverse, "loop_continuity.bones[0].position_delta_m").note,
824 "appeared"
825 );
826 }
827
828 #[test]
829 fn reports_clip_added_and_removed() {
830 let deltas = diff_measurements(
831 &measurement_map("removed", clip_measurements()),
832 &measurement_map("added", clip_measurements()),
833 );
834
835 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
836 assert!(
837 deltas
838 .iter()
839 .any(|d| d.clip == "removed" && d.metric == "clip" && d.note == "clip removed")
840 );
841 assert!(
842 deltas
843 .iter()
844 .any(|d| d.clip == "added" && d.metric == "clip" && d.note == "clip added")
845 );
846 }
847
848 #[test]
857 fn literal_stimuli_pin_documented_thresholds() {
858 struct Case {
862 metric: &'static str,
863 over: fn(&mut ClipMeasurements), under: fn(&mut ClipMeasurements), }
866 let cases = [
867 Case {
868 metric: "duration_s", over: |m| m.duration_s = 1.02,
870 under: |m| m.duration_s = 1.01,
871 },
872 Case {
873 metric: "loop_seam_ratio", over: |m| m.loop_seam_ratio = Some(0.27),
875 under: |m| m.loop_seam_ratio = Some(0.23),
876 },
877 Case {
878 metric: "gait.lr_amplitude_m", over: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.11,
880 under: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.102,
881 },
882 Case {
883 metric: "speed_mps", over: |m| m.speed_mps = Some(1.15),
885 under: |m| m.speed_mps = Some(1.05),
886 },
887 Case {
888 metric: "bone_rotation_range_deg[hips]", over: |m| {
890 m.bone_rotation_range_deg.insert("hips".into(), 13.0);
891 },
892 under: |m| {
893 m.bone_rotation_range_deg.insert("hips".into(), 10.5);
894 },
895 },
896 Case {
897 metric: "loop_continuity.bones[0].position_delta_m", over: |m| {
899 m.loop_continuity.as_mut().unwrap().bones[0].position_delta_m = Some(0.022);
900 },
901 under: |m| {
902 m.loop_continuity.as_mut().unwrap().bones[0].position_delta_m = Some(0.0205);
903 },
904 },
905 Case {
906 metric: "loop_continuity.bones[0].rotation_delta_deg", over: |m| {
908 m.loop_continuity.as_mut().unwrap().bones[0].rotation_delta_deg = Some(2.2);
909 },
910 under: |m| {
911 m.loop_continuity.as_mut().unwrap().bones[0].rotation_delta_deg = Some(2.05);
912 },
913 },
914 Case {
915 metric: "loop_continuity.bones[0].seam_velocity_delta_mps", over: |m| {
917 m.loop_continuity.as_mut().unwrap().bones[0].seam_velocity_delta_mps =
918 Some(0.22);
919 },
920 under: |m| {
921 m.loop_continuity.as_mut().unwrap().bones[0].seam_velocity_delta_mps =
922 Some(0.205);
923 },
924 },
925 Case {
926 metric: "loop_continuity.bones[0].seam_angular_velocity_delta_degps", over: |m| {
928 m.loop_continuity.as_mut().unwrap().bones[0]
929 .seam_angular_velocity_delta_degps = Some(10.5001);
930 },
931 under: |m| {
932 m.loop_continuity.as_mut().unwrap().bones[0]
933 .seam_angular_velocity_delta_degps = Some(10.4999);
934 },
935 },
936 ];
937
938 for case in cases {
939 let before = clip_measurements();
940
941 let mut over = before.clone();
942 (case.over)(&mut over);
943 let deltas = diff_measurements(
944 &measurement_map("walk", before.clone()),
945 &measurement_map("walk", over),
946 );
947 assert_eq!(
948 delta_metrics(&deltas),
949 vec![case.metric],
950 "over-threshold literal must report exactly {}",
951 case.metric
952 );
953
954 let mut under = before.clone();
955 (case.under)(&mut under);
956 let deltas = diff_measurements(
957 &measurement_map("walk", before),
958 &measurement_map("walk", under),
959 );
960 assert!(
961 deltas.is_empty(),
962 "under-threshold literal for {} must be silent: {:?}",
963 case.metric,
964 delta_metrics(&deltas)
965 );
966 }
967 }
968
969 #[test]
970 fn compares_gait_phase_on_a_cycle() {
971 let mut before = clip_measurements();
972 before.gait.as_mut().unwrap().phase = Some(0.98);
973 let mut after = before.clone();
974 after.gait.as_mut().unwrap().phase = Some(0.02);
975
976 let deltas = diff_measurements(
977 &measurement_map("walk", before),
978 &measurement_map("walk", after),
979 );
980
981 assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
982 }
983
984 #[test]
985 fn reports_significant_gait_phase_moves() {
986 let mut before = clip_measurements();
987 before.gait.as_mut().unwrap().phase = Some(0.9);
988 let mut after = before.clone();
989 after.gait.as_mut().unwrap().phase = Some(0.1);
990
991 let deltas = diff_measurements(
992 &measurement_map("walk", before),
993 &measurement_map("walk", after),
994 );
995
996 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
997 let delta = delta_for(&deltas, "gait.phase");
998 assert_eq!(delta.note, "moved");
999 assert_eq!(delta.before, Some(0.9));
1000 assert_eq!(delta.after, Some(0.1));
1001 }
1002
1003 #[test]
1006 fn reports_frame_count_move_including_a_decrease() {
1007 let before = clip_measurements(); let mut after = before.clone();
1009 after.frame_count = 20;
1010
1011 let deltas = diff_measurements(
1012 &measurement_map("walk", before),
1013 &measurement_map("walk", after),
1014 );
1015
1016 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1017 let delta = delta_for(&deltas, "frame_count");
1018 assert_eq!(delta.note, "moved");
1019 assert_eq!(delta.before, Some(31.0));
1020 assert_eq!(delta.after, Some(20.0));
1021 assert!(
1022 delta.before.unwrap() > delta.after.unwrap(),
1023 "a decrease must be captured, not dropped"
1024 );
1025 }
1026
1027 #[test]
1031 fn single_frame_change_crosses_the_frame_count_threshold() {
1032 let before = clip_measurements(); let mut after = before.clone();
1034 after.frame_count = 32; let deltas = diff_measurements(
1037 &measurement_map("walk", before),
1038 &measurement_map("walk", after),
1039 );
1040
1041 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1042 let delta = delta_for(&deltas, "frame_count");
1043 assert_eq!(delta.note, "moved");
1044 assert_eq!(delta.before, Some(31.0));
1045 assert_eq!(delta.after, Some(32.0));
1046 }
1047
1048 #[test]
1049 fn reports_gait_amplitude_move() {
1050 let before = clip_measurements(); let mut after = before.clone();
1052 after.gait.as_mut().unwrap().lr_amplitude_m = 0.1 + AMPLITUDE_THRESHOLD_M * 2.0;
1053
1054 let deltas = diff_measurements(
1055 &measurement_map("walk", before),
1056 &measurement_map("walk", after),
1057 );
1058
1059 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1060 let delta = delta_for(&deltas, "gait.lr_amplitude_m");
1061 assert_eq!(delta.note, "moved");
1062 assert_eq!(delta.before, Some(0.1));
1063 assert_eq!(delta.after, Some(0.1 + AMPLITUDE_THRESHOLD_M * 2.0));
1064 }
1065
1066 #[test]
1067 fn reports_bone_rotation_range_moved() {
1068 let before = clip_measurements(); let mut after = before.clone();
1070 after
1071 .bone_rotation_range_deg
1072 .insert("hips".into(), 10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0);
1073
1074 let deltas = diff_measurements(
1075 &measurement_map("walk", before),
1076 &measurement_map("walk", after),
1077 );
1078
1079 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1080 let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
1081 assert_eq!(delta.note, "moved");
1082 assert_eq!(delta.before, Some(10.0));
1083 assert_eq!(delta.after, Some(10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0));
1084 }
1085
1086 #[test]
1087 fn reports_bone_rotation_range_appeared_and_disappeared() {
1088 let before = clip_measurements();
1089 let mut after = before.clone();
1090 after.bone_rotation_range_deg.insert("spine".into(), 5.0);
1091 let deltas = diff_measurements(
1092 &measurement_map("walk", before),
1093 &measurement_map("walk", after),
1094 );
1095 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1096 let delta = delta_for(&deltas, "bone_rotation_range_deg[spine]");
1097 assert_eq!(delta.note, "bone now animated");
1098 assert_eq!(delta.before, None);
1099 assert_eq!(delta.after, Some(5.0));
1100
1101 let before = clip_measurements();
1102 let mut after = before.clone();
1103 after.bone_rotation_range_deg.remove("hips");
1104 let deltas = diff_measurements(
1105 &measurement_map("walk", before),
1106 &measurement_map("walk", after),
1107 );
1108 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1109 let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
1110 assert_eq!(delta.note, "bone no longer animated");
1111 assert_eq!(delta.before, Some(10.0));
1112 assert_eq!(delta.after, None);
1113 }
1114
1115 #[test]
1116 fn reports_animated_bones_gained_and_lost() {
1117 let before = clip_measurements(); let mut after = before.clone();
1119 after.animated_bones = vec!["spine".into(), "tail".into()];
1120
1121 let deltas = diff_measurements(
1122 &measurement_map("walk", before),
1123 &measurement_map("walk", after),
1124 );
1125
1126 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1127 let delta = delta_for(&deltas, "animated_bones");
1128 assert_eq!(delta.before, Some(1.0));
1129 assert_eq!(delta.after, Some(2.0));
1130 assert_eq!(delta.note, "gained [spine, tail], lost [hips]");
1131 }
1132
1133 #[test]
1134 fn reports_bone_channel_properties_gained_and_lost_by_index() {
1135 let before = clip_measurements();
1136 let mut after = before.clone();
1137 after.bone_channels[0].properties = vec![Property::Rotation, Property::Scale];
1138
1139 let deltas = diff_measurements(
1140 &measurement_map("walk", before),
1141 &measurement_map("walk", after),
1142 );
1143
1144 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1145 let delta = delta_for(&deltas, "bone_channels");
1146 assert_eq!(delta.before, Some(1.0));
1147 assert_eq!(delta.after, Some(2.0));
1148 assert_eq!(
1149 delta.note,
1150 "gained [#0:\"hips\".rotation, #0:\"hips\".scale], lost [#0:\"hips\".translation]"
1151 );
1152 }
1153
1154 #[test]
1155 fn bone_channel_diff_uses_index_not_display_name_as_identity() {
1156 let before = clip_measurements();
1157 let mut after = before.clone();
1158 after.animated_bones = vec!["pelvis".into()];
1159 after.bone_channels[0].bone_name = "pelvis".into();
1160
1161 let deltas = diff_measurements(
1162 &measurement_map("walk", before),
1163 &measurement_map("walk", after),
1164 );
1165
1166 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1167 assert_eq!(deltas[0].metric, "animated_bones");
1168 }
1169
1170 #[test]
1171 fn reports_every_root_trajectory_numeric_field_in_both_directions() {
1172 let mut before = clip_measurements();
1173 let trajectory = before.root_trajectory.as_mut().unwrap();
1174 let translation = trajectory.translation.as_mut().unwrap();
1175 translation.horizontal_displacement_x_m = 0.011;
1176 translation.horizontal_displacement_z_m = 0.022;
1177 translation.horizontal_travel_m = 0.055;
1178 translation.vertical_displacement_m = 0.033;
1179 translation.vertical_min_displacement_m = -0.044;
1180 translation.vertical_max_displacement_m = 0.066;
1181 let yaw = trajectory.yaw.as_mut().unwrap();
1182 yaw.net_yaw_deg = 11.0;
1183 yaw.unwrapped_yaw_deg = 371.0;
1184 yaw.yaw_travel_deg = 412.0;
1185
1186 let mut after = before.clone();
1187 let trajectory = after.root_trajectory.as_mut().unwrap();
1188 let translation = trajectory.translation.as_mut().unwrap();
1189 translation.horizontal_displacement_x_m = 0.111;
1190 translation.horizontal_displacement_z_m = 0.222;
1191 translation.horizontal_travel_m = 0.555;
1192 translation.vertical_displacement_m = 0.333;
1193 translation.vertical_min_displacement_m = -0.444;
1194 translation.vertical_max_displacement_m = 0.666;
1195 let yaw = trajectory.yaw.as_mut().unwrap();
1196 yaw.net_yaw_deg = 21.0;
1197 yaw.unwrapped_yaw_deg = 741.0;
1198 yaw.yaw_travel_deg = 842.0;
1199
1200 let expected = [
1201 (
1202 "root_trajectory.translation.horizontal_displacement_x_m",
1203 0.011,
1204 0.111,
1205 ),
1206 (
1207 "root_trajectory.translation.horizontal_displacement_z_m",
1208 0.022,
1209 0.222,
1210 ),
1211 (
1212 "root_trajectory.translation.horizontal_travel_m",
1213 0.055,
1214 0.555,
1215 ),
1216 (
1217 "root_trajectory.translation.vertical_displacement_m",
1218 0.033,
1219 0.333,
1220 ),
1221 (
1222 "root_trajectory.translation.vertical_min_displacement_m",
1223 -0.044,
1224 -0.444,
1225 ),
1226 (
1227 "root_trajectory.translation.vertical_max_displacement_m",
1228 0.066,
1229 0.666,
1230 ),
1231 ("root_trajectory.yaw.net_yaw_deg", 11.0, 21.0),
1232 ("root_trajectory.yaw.unwrapped_yaw_deg", 371.0, 741.0),
1233 ("root_trajectory.yaw.yaw_travel_deg", 412.0, 842.0),
1234 ];
1235
1236 for (from, to, reverse) in [
1237 (before.clone(), after.clone(), false),
1238 (after, before, true),
1239 ] {
1240 let deltas =
1241 diff_measurements(&measurement_map("walk", from), &measurement_map("walk", to));
1242 assert_eq!(
1243 deltas.len(),
1244 expected.len(),
1245 "{:#?}",
1246 delta_metrics(&deltas)
1247 );
1248 for (delta, (metric, before_value, after_value)) in
1249 deltas.iter().zip(expected.iter().copied())
1250 {
1251 let (before_value, after_value) = if reverse {
1252 (after_value, before_value)
1253 } else {
1254 (before_value, after_value)
1255 };
1256 assert_eq!(delta.clip, "walk");
1257 assert_eq!(delta.metric, metric);
1258 assert_eq!(delta.before, Some(before_value), "{metric}");
1259 assert_eq!(delta.after, Some(after_value), "{metric}");
1260 assert_eq!(delta.note, "moved", "{metric}");
1261 }
1262 }
1263 }
1264
1265 #[test]
1266 fn root_trajectory_thresholds_are_inclusive_and_sign_symmetric() {
1267 for sign in [-1.0, 1.0] {
1268 let before = clip_measurements();
1269 let mut at_threshold = before.clone();
1270 let trajectory = at_threshold.root_trajectory.as_mut().unwrap();
1271 trajectory
1272 .translation
1273 .as_mut()
1274 .unwrap()
1275 .horizontal_displacement_x_m = sign * ROOT_TRAJECTORY_TRANSLATION_THRESHOLD_M;
1276 let yaw = trajectory.yaw.as_mut().unwrap();
1277 yaw.net_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG;
1278 yaw.unwrapped_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG;
1279 yaw.yaw_travel_deg = ROOT_TRAJECTORY_YAW_THRESHOLD_DEG;
1280
1281 let deltas = diff_measurements(
1282 &measurement_map("walk", before.clone()),
1283 &measurement_map("walk", at_threshold),
1284 );
1285 assert!(
1286 deltas.is_empty(),
1287 "exact thresholds must stay silent for sign {sign}: {:?}",
1288 delta_metrics(&deltas)
1289 );
1290
1291 let mut over_threshold = before.clone();
1292 let trajectory = over_threshold.root_trajectory.as_mut().unwrap();
1293 trajectory
1294 .translation
1295 .as_mut()
1296 .unwrap()
1297 .horizontal_displacement_x_m =
1298 sign * ROOT_TRAJECTORY_TRANSLATION_THRESHOLD_M * 1.01;
1299 let yaw = trajectory.yaw.as_mut().unwrap();
1300 yaw.net_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG * 1.01;
1301 yaw.unwrapped_yaw_deg = sign * ROOT_TRAJECTORY_YAW_THRESHOLD_DEG * 1.01;
1302 yaw.yaw_travel_deg = ROOT_TRAJECTORY_YAW_THRESHOLD_DEG * 1.01;
1303
1304 let deltas = diff_measurements(
1305 &measurement_map("walk", before),
1306 &measurement_map("walk", over_threshold),
1307 );
1308 let metrics = delta_metrics(&deltas);
1309 for expected in [
1310 "root_trajectory.translation.horizontal_displacement_x_m",
1311 "root_trajectory.yaw.net_yaw_deg",
1312 "root_trajectory.yaw.unwrapped_yaw_deg",
1313 "root_trajectory.yaw.yaw_travel_deg",
1314 ] {
1315 assert!(metrics.contains(&expected), "{expected}: {metrics:?}");
1316 }
1317 }
1318 }
1319
1320 #[test]
1321 fn root_yaw_diff_uses_circular_net_but_retains_full_turn_evidence() {
1322 let mut before_silent_seam = clip_measurements();
1323 let yaw = before_silent_seam
1324 .root_trajectory
1325 .as_mut()
1326 .unwrap()
1327 .yaw
1328 .as_mut()
1329 .unwrap();
1330 yaw.net_yaw_deg = 179.96;
1331 yaw.unwrapped_yaw_deg = 179.96;
1332 yaw.yaw_travel_deg = 179.96;
1333 let mut after_silent_seam = before_silent_seam.clone();
1334 let yaw = after_silent_seam
1335 .root_trajectory
1336 .as_mut()
1337 .unwrap()
1338 .yaw
1339 .as_mut()
1340 .unwrap();
1341 yaw.net_yaw_deg = -179.96;
1342 yaw.unwrapped_yaw_deg = 180.04;
1343 yaw.yaw_travel_deg = 180.04;
1344 assert!(
1345 diff_measurements(
1346 &measurement_map("walk", before_silent_seam),
1347 &measurement_map("walk", after_silent_seam),
1348 )
1349 .is_empty(),
1350 "a 0.08-degree circular seam crossing must stay below the floor"
1351 );
1352
1353 let mut before_seam = clip_measurements();
1354 let yaw = before_seam
1355 .root_trajectory
1356 .as_mut()
1357 .unwrap()
1358 .yaw
1359 .as_mut()
1360 .unwrap();
1361 yaw.net_yaw_deg = 179.9;
1362 yaw.unwrapped_yaw_deg = 179.9;
1363 yaw.yaw_travel_deg = 179.9;
1364
1365 let mut after_seam = before_seam.clone();
1366 let yaw = after_seam
1367 .root_trajectory
1368 .as_mut()
1369 .unwrap()
1370 .yaw
1371 .as_mut()
1372 .unwrap();
1373 yaw.net_yaw_deg = -179.9;
1374 yaw.unwrapped_yaw_deg = 180.1;
1375 yaw.yaw_travel_deg = 180.1;
1376
1377 let seam_deltas = diff_measurements(
1378 &measurement_map("walk", before_seam),
1379 &measurement_map("walk", after_seam),
1380 );
1381 assert_eq!(
1382 delta_metrics(&seam_deltas),
1383 [
1384 "root_trajectory.yaw.net_yaw_deg",
1385 "root_trajectory.yaw.unwrapped_yaw_deg",
1386 "root_trajectory.yaw.yaw_travel_deg",
1387 ]
1388 );
1389 let net = delta_for(&seam_deltas, "root_trajectory.yaw.net_yaw_deg");
1390 assert_eq!(net.before, Some(179.9));
1391 assert_eq!(net.after, Some(-179.9));
1392
1393 let before_turn = clip_measurements();
1394 let mut after_turn = before_turn.clone();
1395 let yaw = after_turn
1396 .root_trajectory
1397 .as_mut()
1398 .unwrap()
1399 .yaw
1400 .as_mut()
1401 .unwrap();
1402 yaw.unwrapped_yaw_deg = 360.0;
1403 yaw.yaw_travel_deg = 360.0;
1404
1405 let turn_deltas = diff_measurements(
1406 &measurement_map("walk", before_turn),
1407 &measurement_map("walk", after_turn),
1408 );
1409 assert_eq!(
1410 delta_metrics(&turn_deltas),
1411 [
1412 "root_trajectory.yaw.unwrapped_yaw_deg",
1413 "root_trajectory.yaw.yaw_travel_deg",
1414 ]
1415 );
1416 }
1417
1418 #[test]
1419 fn root_trajectory_diff_reports_nested_status_identity_and_basis_changes() {
1420 let before = clip_measurements();
1421 let mut after = before.clone();
1422 let trajectory = after.root_trajectory.as_mut().unwrap();
1423 trajectory.bone_index = 1;
1424 trajectory.bone_name = "root".into();
1425 trajectory.source_role = RootTrajectorySourceRole::Root;
1426 trajectory.translation = None;
1427 trajectory.translation_availability = MeasurementAvailability::Unavailable;
1428 trajectory.yaw.as_mut().unwrap().heading_axis = RootYawHeadingAxis::PositiveX;
1429
1430 let deltas = diff_measurements(
1431 &measurement_map("walk", before),
1432 &measurement_map("walk", after),
1433 );
1434 let metrics = deltas
1435 .iter()
1436 .map(|delta| delta.metric.as_str())
1437 .collect::<BTreeSet<_>>();
1438 let selected_bone = delta_for(&deltas, "root_trajectory.selected_bone");
1439 assert_eq!(selected_bone.before, Some(0.0));
1440 assert_eq!(selected_bone.after, Some(1.0));
1441 assert_eq!(
1442 selected_bone.note,
1443 "changed from #0:\"hips\" (hips_fallback) to #1:\"root\" (root)"
1444 );
1445 let heading_axis = delta_for(&deltas, "root_trajectory.yaw.heading_axis");
1446 assert_eq!(heading_axis.before, None);
1447 assert_eq!(heading_axis.after, None);
1448 assert_eq!(heading_axis.note, "changed from +Z to +X");
1449 assert!(metrics.contains("root_trajectory.translation_availability"));
1450 for field in [
1451 "horizontal_displacement_x_m",
1452 "horizontal_displacement_z_m",
1453 "horizontal_travel_m",
1454 "vertical_displacement_m",
1455 "vertical_min_displacement_m",
1456 "vertical_max_displacement_m",
1457 ] {
1458 assert!(metrics.contains(format!("root_trajectory.translation.{field}").as_str()));
1459 }
1460 }
1461
1462 #[test]
1463 fn root_trajectory_diff_checks_each_selected_identity_component() {
1464 type SelectionMutation = (
1465 fn(&mut RootTrajectoryMeasurement),
1466 Option<f64>,
1467 Option<f64>,
1468 &'static str,
1469 );
1470 let mutations: [SelectionMutation; 3] = [
1471 (
1472 |trajectory| trajectory.bone_index = 1,
1473 Some(0.0),
1474 Some(1.0),
1475 "changed from #0:\"hips\" (hips_fallback) to #1:\"hips\" (hips_fallback)",
1476 ),
1477 (
1478 |trajectory| trajectory.bone_name = "root".into(),
1479 Some(0.0),
1480 Some(0.0),
1481 "changed from #0:\"hips\" (hips_fallback) to #0:\"root\" (hips_fallback)",
1482 ),
1483 (
1484 |trajectory| trajectory.source_role = RootTrajectorySourceRole::Root,
1485 Some(0.0),
1486 Some(0.0),
1487 "changed from #0:\"hips\" (hips_fallback) to #0:\"hips\" (root)",
1488 ),
1489 ];
1490
1491 for (mutate, expected_before, expected_after, expected_note) in mutations {
1492 let before = clip_measurements();
1493 let mut after = before.clone();
1494 mutate(after.root_trajectory.as_mut().unwrap());
1495 let deltas = diff_measurements(
1496 &measurement_map("walk", before),
1497 &measurement_map("walk", after),
1498 );
1499 assert_eq!(delta_metrics(&deltas), ["root_trajectory.selected_bone"]);
1500 let delta = delta_for(&deltas, "root_trajectory.selected_bone");
1501 assert_eq!(delta.before, expected_before);
1502 assert_eq!(delta.after, expected_after);
1503 assert_eq!(delta.note, expected_note);
1504 }
1505 }
1506
1507 #[test]
1508 fn root_trajectory_diff_reports_outer_and_nested_availability_both_directions() {
1509 let all_numeric = [
1510 "root_trajectory.translation.horizontal_displacement_x_m",
1511 "root_trajectory.translation.horizontal_displacement_z_m",
1512 "root_trajectory.translation.horizontal_travel_m",
1513 "root_trajectory.translation.vertical_displacement_m",
1514 "root_trajectory.translation.vertical_min_displacement_m",
1515 "root_trajectory.translation.vertical_max_displacement_m",
1516 "root_trajectory.yaw.net_yaw_deg",
1517 "root_trajectory.yaw.unwrapped_yaw_deg",
1518 "root_trajectory.yaw.yaw_travel_deg",
1519 ];
1520 let translation_numeric = &all_numeric[..6];
1521 let yaw_numeric = &all_numeric[6..];
1522
1523 let measured = clip_measurements();
1524 let mut outer_unavailable = measured.clone();
1525 outer_unavailable.root_trajectory = None;
1526 outer_unavailable.root_trajectory_availability = MeasurementAvailability::Unavailable;
1527
1528 let mut translation_unavailable = measured.clone();
1529 let trajectory = translation_unavailable.root_trajectory.as_mut().unwrap();
1530 trajectory.translation = None;
1531 trajectory.translation_availability = MeasurementAvailability::Unavailable;
1532
1533 let mut yaw_unavailable = measured.clone();
1534 let trajectory = yaw_unavailable.root_trajectory.as_mut().unwrap();
1535 trajectory.yaw = None;
1536 trajectory.yaw_availability = MeasurementAvailability::Unavailable;
1537
1538 for (available, unavailable, status, numeric) in [
1539 (
1540 measured.clone(),
1541 outer_unavailable,
1542 "root_trajectory_availability",
1543 all_numeric.as_slice(),
1544 ),
1545 (
1546 measured.clone(),
1547 translation_unavailable,
1548 "root_trajectory.translation_availability",
1549 translation_numeric,
1550 ),
1551 (
1552 measured.clone(),
1553 yaw_unavailable,
1554 "root_trajectory.yaw_availability",
1555 yaw_numeric,
1556 ),
1557 ] {
1558 for (before, after, expected_note) in [
1559 (available.clone(), unavailable.clone(), "became unavailable"),
1560 (unavailable, available, "became measured"),
1561 ] {
1562 let deltas = diff_measurements(
1563 &measurement_map("walk", before),
1564 &measurement_map("walk", after),
1565 );
1566 let metrics = delta_metrics(&deltas);
1567 assert_eq!(metrics.len(), numeric.len() + 1, "{metrics:?}");
1568 assert!(metrics.contains(&status), "{metrics:?}");
1569 for field in numeric {
1570 assert!(metrics.contains(field), "{field}: {metrics:?}");
1571 }
1572 assert_eq!(delta_for(&deltas, status).note, expected_note);
1573 }
1574 }
1575
1576 let mut not_applicable = measured;
1577 not_applicable.root_trajectory = None;
1578 not_applicable.root_trajectory_availability = MeasurementAvailability::NotApplicable;
1579 let mut unavailable = not_applicable.clone();
1580 unavailable.root_trajectory_availability = MeasurementAvailability::Unavailable;
1581 for (before, after, note) in [
1582 (
1583 not_applicable.clone(),
1584 unavailable.clone(),
1585 "became unavailable",
1586 ),
1587 (unavailable, not_applicable, "no longer applicable"),
1588 ] {
1589 let deltas = diff_measurements(
1590 &measurement_map("walk", before),
1591 &measurement_map("walk", after),
1592 );
1593 assert_eq!(delta_metrics(&deltas), ["root_trajectory_availability"]);
1594 assert_eq!(
1595 delta_for(&deltas, "root_trajectory_availability").note,
1596 note
1597 );
1598 }
1599 }
1600
1601 #[test]
1602 fn metric_delta_omits_non_finite_public_values() {
1603 let delta = MetricDelta {
1604 clip: "walk".into(),
1605 metric: "duration_s".into(),
1606 before: Some(f64::NAN),
1607 after: Some(f64::INFINITY),
1608 note: "moved".into(),
1609 };
1610 let json = serde_json::to_value(delta).expect("delta serializes");
1611 assert!(json.get("before").is_none());
1612 assert!(json.get("after").is_none());
1613 }
1614
1615 #[test]
1616 fn reports_not_applicable_to_unavailable_availability_transitions() {
1617 let mut before = clip_measurements();
1618 before.speed_mps = None;
1619 before.speed_mps_availability = MeasurementAvailability::NotApplicable;
1620
1621 let mut after = before.clone();
1622 after.speed_mps_availability = MeasurementAvailability::Unavailable;
1623
1624 let deltas = diff_measurements(
1625 &measurement_map("walk", before),
1626 &measurement_map("walk", after),
1627 );
1628
1629 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1630 let delta = delta_for(&deltas, "speed_mps_availability");
1631 assert_eq!(delta.note, "became unavailable");
1632 assert_eq!(delta.before, None);
1633 assert_eq!(delta.after, None);
1634 }
1635
1636 #[test]
1637 fn reports_unavailable_to_not_applicable_availability_transitions() {
1638 let mut before = clip_measurements();
1639 before.speed_mps = None;
1640 before.speed_mps_availability = MeasurementAvailability::Unavailable;
1641
1642 let mut after = before.clone();
1643 after.speed_mps_availability = MeasurementAvailability::NotApplicable;
1644
1645 let deltas = diff_measurements(
1646 &measurement_map("walk", before),
1647 &measurement_map("walk", after),
1648 );
1649
1650 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1651 let delta = delta_for(&deltas, "speed_mps_availability");
1652 assert_eq!(delta.note, "no longer applicable");
1653 }
1654
1655 #[test]
1656 fn availability_stays_silent_only_when_unchanged() {
1657 let before = clip_measurements();
1658 let after = before.clone();
1659
1660 let deltas = diff_measurements(
1661 &measurement_map("walk", before),
1662 &measurement_map("walk", after),
1663 );
1664 assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
1665 }
1666
1667 #[test]
1675 fn measured_to_unavailable_reports_both_value_and_availability_deltas() {
1676 let before = clip_measurements();
1677 let mut after = before.clone();
1678 after.speed_mps = None;
1679 after.speed_mps_availability = MeasurementAvailability::Unavailable;
1680
1681 let deltas = diff_measurements(
1682 &measurement_map("walk", before),
1683 &measurement_map("walk", after),
1684 );
1685
1686 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1687 assert_eq!(delta_for(&deltas, "speed_mps").note, "disappeared");
1688 assert_eq!(
1689 delta_for(&deltas, "speed_mps_availability").note,
1690 "became unavailable"
1691 );
1692 }
1693
1694 #[test]
1695 fn reports_gait_phase_not_applicable_to_unavailable_transitions() {
1696 let mut before = clip_measurements();
1697 before.gait.as_mut().unwrap().phase = None;
1698 before.gait.as_mut().unwrap().phase_availability = MeasurementAvailability::NotApplicable;
1699
1700 let mut after = before.clone();
1701 after.gait.as_mut().unwrap().phase_availability = MeasurementAvailability::Unavailable;
1702
1703 let deltas = diff_measurements(
1704 &measurement_map("walk", before),
1705 &measurement_map("walk", after),
1706 );
1707
1708 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
1709 let delta = delta_for(&deltas, "gait.phase_availability");
1710 assert_eq!(delta.note, "became unavailable");
1711 }
1712
1713 #[test]
1714 fn regenerated_flat_gait_reports_phase_disappearance_and_non_applicability() {
1715 let mut before = clip_measurements();
1716 before.gait.as_mut().unwrap().lr_amplitude_m = 0.0;
1717
1718 let mut after = before.clone();
1719 after.gait.as_mut().unwrap().phase = None;
1720 after.gait.as_mut().unwrap().phase_availability = MeasurementAvailability::NotApplicable;
1721
1722 let deltas = diff_measurements(
1723 &measurement_map("walk", before),
1724 &measurement_map("walk", after),
1725 );
1726
1727 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1728 assert_eq!(delta_for(&deltas, "gait.phase").note, "disappeared");
1729 assert_eq!(
1730 delta_for(&deltas, "gait.phase_availability").note,
1731 "no longer applicable"
1732 );
1733 }
1734
1735 #[test]
1743 fn gait_phase_availability_is_not_compared_when_gait_itself_disappears() {
1744 let before = clip_measurements();
1745 let mut after = before.clone();
1746 after.gait = None;
1747 after.gait_availability = MeasurementAvailability::Unavailable;
1748
1749 let deltas = diff_measurements(
1750 &measurement_map("walk", before),
1751 &measurement_map("walk", after),
1752 );
1753
1754 assert!(
1755 deltas
1756 .iter()
1757 .all(|delta| delta.metric != "gait.phase_availability"),
1758 "gait.phase_availability has no valid comparison when gait is absent on one side: {:?}",
1759 delta_metrics(&deltas)
1760 );
1761 assert_eq!(delta_for(&deltas, "gait.phase").note, "disappeared");
1762 assert_eq!(
1763 delta_for(&deltas, "gait_availability").note,
1764 "became unavailable"
1765 );
1766 }
1767
1768 #[test]
1774 fn measured_to_unavailable_reports_endpoint_and_frame_grid_status_deltas() {
1775 let mut before = clip_measurements();
1776 before.loop_endpoint_mode = Some(LoopEndpointMode::UniqueCycle);
1777 before.loop_endpoint_mode_availability = MeasurementAvailability::Measured;
1778 before.frame_grid = Some(FrameGridMeasurement {
1779 fps: 30.0,
1780 frame_intervals: 30,
1781 });
1782 before.frame_grid_availability = MeasurementAvailability::Measured;
1783
1784 let mut after = before.clone();
1785 after.loop_endpoint_mode = None;
1786 after.loop_endpoint_mode_availability = MeasurementAvailability::Unavailable;
1787 after.frame_grid = None;
1788 after.frame_grid_availability = MeasurementAvailability::Unavailable;
1789
1790 let deltas = diff_measurements(
1791 &measurement_map("walk", before),
1792 &measurement_map("walk", after),
1793 );
1794
1795 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1796 assert_eq!(
1797 delta_for(&deltas, "loop_endpoint_mode_availability").note,
1798 "became unavailable"
1799 );
1800 assert_eq!(
1801 delta_for(&deltas, "frame_grid_availability").note,
1802 "became unavailable"
1803 );
1804 }
1805
1806 #[test]
1807 fn reports_endpoint_and_frame_grid_not_applicable_to_unavailable_transitions() {
1808 let mut before = clip_measurements();
1809 before.loop_endpoint_mode_availability = MeasurementAvailability::NotApplicable;
1810 before.frame_grid_availability = MeasurementAvailability::NotApplicable;
1811
1812 let mut after = before.clone();
1813 after.loop_endpoint_mode_availability = MeasurementAvailability::Unavailable;
1814 after.frame_grid_availability = MeasurementAvailability::Unavailable;
1815
1816 let deltas = diff_measurements(
1817 &measurement_map("walk", before),
1818 &measurement_map("walk", after),
1819 );
1820
1821 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
1822 assert_eq!(
1823 delta_for(&deltas, "loop_endpoint_mode_availability").note,
1824 "became unavailable"
1825 );
1826 assert_eq!(
1827 delta_for(&deltas, "frame_grid_availability").note,
1828 "became unavailable"
1829 );
1830 }
1831}