Skip to main content

animsmith_core/
diff.rs

1//! Compare measurement maps and report per-metric movement beyond
2//! significance thresholds.
3//!
4//! Per-metric significance thresholds treat movement below these values
5//! as noise (f32 quantization, re-export dust), not a change worth
6//! reporting.
7
8use crate::measure::ClipMeasurements;
9use serde::Serialize;
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Duration movement threshold, in seconds.
13pub const DURATION_THRESHOLD_S: f64 = 0.017; // half a frame at 30 fps
14/// Bone rotation range movement threshold, in degrees.
15pub const ROTATION_RANGE_THRESHOLD_DEG: f64 = 1.0;
16/// Per-bone loop-closure position movement threshold, in metres.
17pub const LOOP_POSITION_THRESHOLD_M: f64 = 0.001;
18/// Per-bone loop-closure rotation movement threshold, in degrees.
19pub const LOOP_ROTATION_THRESHOLD_DEG: f64 = 0.1;
20/// Per-bone seam-velocity movement threshold, in metres per second.
21pub const LOOP_VELOCITY_THRESHOLD_MPS: f64 = 0.01;
22/// Per-bone seam angular-velocity movement threshold, in degrees per second.
23pub const LOOP_ANGULAR_VELOCITY_THRESHOLD_DEGPS: f64 = 0.5;
24/// Loop-seam ratio movement threshold.
25pub const SEAM_THRESHOLD: f64 = 0.05;
26/// Gait phase movement threshold, in circular cycle fraction.
27pub const PHASE_THRESHOLD: f64 = 0.05; // cycle fraction, circular
28/// Gait amplitude movement threshold, in metres.
29pub const AMPLITUDE_THRESHOLD_M: f64 = 0.005;
30/// Root-motion speed movement threshold, in metres per second.
31pub const SPEED_THRESHOLD_MPS: f64 = 0.1;
32
33/// One significant metric difference between two measurement maps.
34#[derive(Debug, Serialize)]
35pub struct MetricDelta {
36    /// Clip that owns the changed metric, or the added/removed clip.
37    pub clip: String,
38    /// Metric path, for example `"duration_s"` or
39    /// `"bone_rotation_range_deg[hips]"`.
40    pub metric: String,
41    /// Value in the before map, absent when a metric appeared, a clip was
42    /// added/removed, or a publicly constructed delta carries a non-finite
43    /// value that cannot be represented by the JSON contract.
44    #[serde(skip_serializing_if = "non_finite_or_none")]
45    pub before: Option<f64>,
46    /// Value in the after map, absent when a metric disappeared, a clip was
47    /// added/removed, or a publicly constructed delta carries a non-finite
48    /// value that cannot be represented by the JSON contract.
49    #[serde(skip_serializing_if = "non_finite_or_none")]
50    pub after: Option<f64>,
51    /// Short cause such as `"moved"`, `"appeared"`, or
52    /// `"bone no longer animated"`.
53    pub note: String,
54}
55
56fn non_finite_or_none(value: &Option<f64>) -> bool {
57    value.is_none_or(|number| !number.is_finite())
58}
59
60/// Compare two measurement maps and return only significant deltas.
61///
62/// The thresholds are intentionally fixed public constants so CLI and
63/// embedding callers agree on what counts as re-export noise. Gait phase
64/// uses circular distance, so phases near `0.0` and `1.0` compare as
65/// adjacent rather than far apart.
66pub fn diff_measurements(
67    a: &BTreeMap<String, ClipMeasurements>,
68    b: &BTreeMap<String, ClipMeasurements>,
69) -> Vec<MetricDelta> {
70    let finite = |value: Option<f64>| value.filter(|value| value.is_finite());
71    let mut deltas = Vec::new();
72    let delta =
73        |clip: &str, metric: &str, before: Option<f64>, after: Option<f64>, note: String| {
74            MetricDelta {
75                clip: clip.into(),
76                metric: metric.into(),
77                before,
78                after,
79                note,
80            }
81        };
82
83    for (clip, ma) in a {
84        let Some(mb) = b.get(clip) else {
85            deltas.push(delta(clip, "clip", None, None, "clip removed".into()));
86            continue;
87        };
88        let mut push_num =
89            |metric: &str, va: Option<f64>, vb: Option<f64>, threshold: f64, circular: bool| {
90                let moved = match (va, vb) {
91                    (Some(x), Some(y)) => {
92                        let d = if circular {
93                            let d = (x - y).rem_euclid(1.0);
94                            d.min(1.0 - d)
95                        } else {
96                            (x - y).abs()
97                        };
98                        d > threshold
99                    }
100                    (None, None) => false,
101                    _ => true, // appeared or disappeared
102                };
103                if moved {
104                    deltas.push(MetricDelta {
105                        clip: clip.clone(),
106                        metric: metric.into(),
107                        before: va,
108                        after: vb,
109                        note: match (va, vb) {
110                            (Some(_), Some(_)) => "moved".into(),
111                            (None, Some(_)) => "appeared".into(),
112                            _ => "disappeared".into(),
113                        },
114                    });
115                }
116            };
117
118        push_num(
119            "duration_s",
120            finite(Some(ma.duration_s)),
121            finite(Some(mb.duration_s)),
122            DURATION_THRESHOLD_S,
123            false,
124        );
125        push_num(
126            "frame_count",
127            Some(ma.frame_count as f64),
128            Some(mb.frame_count as f64),
129            0.5,
130            false,
131        );
132        push_num(
133            "loop_seam_ratio",
134            finite(ma.loop_seam_ratio),
135            finite(mb.loop_seam_ratio),
136            SEAM_THRESHOLD,
137            false,
138        );
139        push_num(
140            "gait.phase",
141            finite(ma.gait.as_ref().and_then(|g| g.phase)),
142            finite(mb.gait.as_ref().and_then(|g| g.phase)),
143            PHASE_THRESHOLD,
144            true,
145        );
146        push_num(
147            "gait.lr_amplitude_m",
148            finite(ma.gait.as_ref().map(|g| g.lr_amplitude_m)),
149            finite(mb.gait.as_ref().map(|g| g.lr_amplitude_m)),
150            AMPLITUDE_THRESHOLD_M,
151            false,
152        );
153        push_num(
154            "speed_mps",
155            finite(ma.speed_mps),
156            finite(mb.speed_mps),
157            SPEED_THRESHOLD_MPS,
158            false,
159        );
160
161        let a_loop_bones: BTreeMap<_, _> = ma
162            .loop_continuity
163            .as_ref()
164            .into_iter()
165            .flat_map(|continuity| &continuity.bones)
166            .map(|bone| (bone.bone_index, bone))
167            .collect();
168        let b_loop_bones: BTreeMap<_, _> = mb
169            .loop_continuity
170            .as_ref()
171            .into_iter()
172            .flat_map(|continuity| &continuity.bones)
173            .map(|bone| (bone.bone_index, bone))
174            .collect();
175        for bone_index in a_loop_bones
176            .keys()
177            .chain(b_loop_bones.keys())
178            .copied()
179            .collect::<BTreeSet<_>>()
180        {
181            let a_bone = a_loop_bones.get(&bone_index).copied();
182            let b_bone = b_loop_bones.get(&bone_index).copied();
183            let metric = |field: &str| format!("loop_continuity.bones[{bone_index}].{field}");
184            push_num(
185                &metric("position_delta_m"),
186                finite(a_bone.map(|bone| bone.position_delta_m)),
187                finite(b_bone.map(|bone| bone.position_delta_m)),
188                LOOP_POSITION_THRESHOLD_M,
189                false,
190            );
191            push_num(
192                &metric("rotation_delta_deg"),
193                finite(a_bone.map(|bone| bone.rotation_delta_deg)),
194                finite(b_bone.map(|bone| bone.rotation_delta_deg)),
195                LOOP_ROTATION_THRESHOLD_DEG,
196                false,
197            );
198            push_num(
199                &metric("seam_velocity_delta_mps"),
200                finite(a_bone.map(|bone| bone.seam_velocity_delta_mps)),
201                finite(b_bone.map(|bone| bone.seam_velocity_delta_mps)),
202                LOOP_VELOCITY_THRESHOLD_MPS,
203                false,
204            );
205            push_num(
206                &metric("seam_angular_velocity_delta_degps"),
207                finite(a_bone.map(|bone| bone.seam_angular_velocity_delta_degps)),
208                finite(b_bone.map(|bone| bone.seam_angular_velocity_delta_degps)),
209                LOOP_ANGULAR_VELOCITY_THRESHOLD_DEGPS,
210                false,
211            );
212        }
213
214        for bone in ma
215            .bone_rotation_range_deg
216            .keys()
217            .chain(mb.bone_rotation_range_deg.keys())
218            .collect::<BTreeSet<_>>()
219        {
220            let va = finite(ma.bone_rotation_range_deg.get(bone).copied());
221            let vb = finite(mb.bone_rotation_range_deg.get(bone).copied());
222            let moved = match (va, vb) {
223                (Some(x), Some(y)) => (x - y).abs() > ROTATION_RANGE_THRESHOLD_DEG,
224                (None, None) => false,
225                _ => true,
226            };
227            if moved {
228                deltas.push(delta(
229                    clip,
230                    &format!("bone_rotation_range_deg[{bone}]"),
231                    va,
232                    vb,
233                    match (va, vb) {
234                        (Some(_), Some(_)) => "moved".into(),
235                        (None, Some(_)) => "bone now animated".into(),
236                        _ => "bone no longer animated".into(),
237                    },
238                ));
239            }
240        }
241
242        if ma.animated_bones != mb.animated_bones {
243            let a_set: BTreeSet<_> = ma.animated_bones.iter().collect();
244            let b_set: BTreeSet<_> = mb.animated_bones.iter().collect();
245            let gained: Vec<_> = b_set.difference(&a_set).map(|s| s.as_str()).collect();
246            let lost: Vec<_> = a_set.difference(&b_set).map(|s| s.as_str()).collect();
247            deltas.push(delta(
248                clip,
249                "animated_bones",
250                Some(ma.animated_bones.len() as f64),
251                Some(mb.animated_bones.len() as f64),
252                format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
253            ));
254        }
255    }
256    for clip in b.keys() {
257        if !a.contains_key(clip) {
258            deltas.push(MetricDelta {
259                clip: clip.clone(),
260                metric: "clip".into(),
261                before: None,
262                after: None,
263                note: "clip added".into(),
264            });
265        }
266    }
267    deltas
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::measure::{
274        BoneLoopContinuityMeasurement, ClipMeasurements, GaitMeasurement, LoopContinuityMeasurement,
275    };
276
277    fn clip_measurements() -> ClipMeasurements {
278        ClipMeasurements {
279            duration_s: 1.0,
280            frame_count: 31,
281            animated_bones: vec!["hips".into()],
282            bone_rotation_range_deg: BTreeMap::from([("hips".into(), 10.0)]),
283            loop_continuity: Some(LoopContinuityMeasurement {
284                bones: vec![BoneLoopContinuityMeasurement {
285                    bone_index: 0,
286                    bone_name: "hips".into(),
287                    position_delta_m: 0.02,
288                    rotation_delta_deg: 2.0,
289                    seam_velocity_delta_mps: 0.2,
290                    seam_angular_velocity_delta_degps: 10.0,
291                }],
292            }),
293            loop_endpoint_mode: None,
294            frame_grid: None,
295            loop_seam_ratio: Some(0.2),
296            gait: Some(GaitMeasurement {
297                phase: Some(0.25),
298                lr_amplitude_m: 0.1,
299            }),
300            speed_mps: Some(1.0),
301        }
302    }
303
304    fn measurement_map(
305        clip: &str,
306        measurements: ClipMeasurements,
307    ) -> BTreeMap<String, ClipMeasurements> {
308        BTreeMap::from([(clip.into(), measurements)])
309    }
310
311    fn delta_for<'a>(deltas: &'a [MetricDelta], metric: &str) -> &'a MetricDelta {
312        deltas
313            .iter()
314            .find(|d| d.metric == metric)
315            .unwrap_or_else(|| {
316                panic!(
317                    "missing metric delta {metric}; got {:?}",
318                    delta_metrics(deltas)
319                )
320            })
321    }
322
323    fn delta_metrics(deltas: &[MetricDelta]) -> Vec<&str> {
324        deltas.iter().map(|d| d.metric.as_str()).collect()
325    }
326
327    #[test]
328    fn reports_moved_appeared_and_disappeared_metrics() {
329        let mut before = clip_measurements();
330        before.speed_mps = None;
331
332        let mut after = before.clone();
333        after.duration_s += DURATION_THRESHOLD_S * 2.0;
334        after.loop_seam_ratio = None;
335        after.speed_mps = Some(1.0);
336
337        let deltas = diff_measurements(
338            &measurement_map("walk", before),
339            &measurement_map("walk", after),
340        );
341
342        assert_eq!(deltas.len(), 3, "{:?}", delta_metrics(&deltas));
343        assert_eq!(delta_for(&deltas, "duration_s").note, "moved");
344        assert_eq!(delta_for(&deltas, "loop_seam_ratio").note, "disappeared");
345        assert_eq!(delta_for(&deltas, "speed_mps").note, "appeared");
346    }
347
348    #[test]
349    fn reports_per_bone_loop_continuity_appearance_by_stable_index() {
350        let before = clip_measurements();
351        let mut after = before.clone();
352        after.loop_continuity = None;
353
354        let disappeared = diff_measurements(
355            &measurement_map("walk", before.clone()),
356            &measurement_map("walk", after.clone()),
357        );
358        assert_eq!(disappeared.len(), 4, "{:?}", delta_metrics(&disappeared));
359        assert!(disappeared.iter().all(|delta| delta.note == "disappeared"));
360        assert!(
361            disappeared
362                .iter()
363                .all(|delta| { delta.metric.starts_with("loop_continuity.bones[0].") })
364        );
365
366        let appeared = diff_measurements(
367            &measurement_map("walk", after),
368            &measurement_map("walk", before),
369        );
370        assert_eq!(appeared.len(), 4, "{:?}", delta_metrics(&appeared));
371        assert!(appeared.iter().all(|delta| delta.note == "appeared"));
372    }
373
374    #[test]
375    fn compares_nonzero_loop_bone_indices_even_when_names_repeat() {
376        let mut before = clip_measurements();
377        before
378            .loop_continuity
379            .as_mut()
380            .unwrap()
381            .bones
382            .push(BoneLoopContinuityMeasurement {
383                bone_index: 1,
384                bone_name: "hips".into(),
385                position_delta_m: 0.03,
386                rotation_delta_deg: 3.0,
387                seam_velocity_delta_mps: 0.3,
388                seam_angular_velocity_delta_degps: 11.0,
389            });
390        let mut after = before.clone();
391        let changed = &mut after.loop_continuity.as_mut().unwrap().bones[1];
392        changed.position_delta_m = 0.032;
393        changed.rotation_delta_deg = 3.2;
394        changed.seam_velocity_delta_mps = 0.32;
395        changed.seam_angular_velocity_delta_degps = 11.6;
396
397        let deltas = diff_measurements(
398            &measurement_map("walk", before),
399            &measurement_map("walk", after),
400        );
401        assert_eq!(deltas.len(), 4, "{:?}", delta_metrics(&deltas));
402        assert!(
403            deltas
404                .iter()
405                .all(|delta| delta.metric.starts_with("loop_continuity.bones[1]."))
406        );
407    }
408
409    #[test]
410    fn loop_continuity_diff_floors_are_inclusive() {
411        let mut before = clip_measurements();
412        let before_bone = &mut before.loop_continuity.as_mut().unwrap().bones[0];
413        before_bone.position_delta_m = 0.0;
414        before_bone.rotation_delta_deg = 0.0;
415        before_bone.seam_velocity_delta_mps = 0.0;
416        before_bone.seam_angular_velocity_delta_degps = 0.0;
417
418        let mut at_floor = before.clone();
419        let after_bone = &mut at_floor.loop_continuity.as_mut().unwrap().bones[0];
420        after_bone.position_delta_m = 0.001;
421        after_bone.rotation_delta_deg = 0.1;
422        after_bone.seam_velocity_delta_mps = 0.01;
423        after_bone.seam_angular_velocity_delta_degps = 0.5;
424
425        assert!(
426            diff_measurements(
427                &measurement_map("walk", before),
428                &measurement_map("walk", at_floor),
429            )
430            .is_empty(),
431            "movement exactly at a significance floor is noise"
432        );
433    }
434
435    #[test]
436    fn reports_clip_added_and_removed() {
437        let deltas = diff_measurements(
438            &measurement_map("removed", clip_measurements()),
439            &measurement_map("added", clip_measurements()),
440        );
441
442        assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
443        assert!(
444            deltas
445                .iter()
446                .any(|d| d.clip == "removed" && d.metric == "clip" && d.note == "clip removed")
447        );
448        assert!(
449            deltas
450                .iter()
451                .any(|d| d.clip == "added" && d.metric == "clip" && d.note == "clip added")
452        );
453    }
454
455    /// #52: anchor every documented threshold to literal stimuli.
456    /// Deriving a metric's fixture from the constant under test
457    /// (`THRESHOLD * 2`, `THRESHOLD / 2`) hides a fat-fingered constant:
458    /// for example, `DURATION_THRESHOLD_S` 0.017 -> 0.17 would still pass.
459    /// Concrete numbers straddling the documented threshold catch such a
460    /// typo in either direction. `gait.phase` (circular) and `frame_count`
461    /// (integer) do not fit this over/under numeric straddle; each has its
462    /// own literal anchor.
463    #[test]
464    fn literal_stimuli_pin_documented_thresholds() {
465        // Base fixture: duration_s 1.0, loop_seam_ratio 0.2,
466        // lr_amplitude_m 0.1, speed_mps 1.0, hips rotation 10.0,
467        // loop position 0.02 m, rotation 2.0 deg, velocity 0.2 m/s.
468        struct Case {
469            metric: &'static str,
470            over: fn(&mut ClipMeasurements),  // clears the threshold
471            under: fn(&mut ClipMeasurements), // stays within noise
472        }
473        let cases = [
474            Case {
475                metric: "duration_s", // threshold 0.017 s
476                over: |m| m.duration_s = 1.02,
477                under: |m| m.duration_s = 1.01,
478            },
479            Case {
480                metric: "loop_seam_ratio", // threshold 0.05
481                over: |m| m.loop_seam_ratio = Some(0.27),
482                under: |m| m.loop_seam_ratio = Some(0.23),
483            },
484            Case {
485                metric: "gait.lr_amplitude_m", // threshold 0.005 m
486                over: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.11,
487                under: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.102,
488            },
489            Case {
490                metric: "speed_mps", // threshold 0.1 m/s
491                over: |m| m.speed_mps = Some(1.15),
492                under: |m| m.speed_mps = Some(1.05),
493            },
494            Case {
495                metric: "bone_rotation_range_deg[hips]", // threshold 1.0 deg
496                over: |m| {
497                    m.bone_rotation_range_deg.insert("hips".into(), 13.0);
498                },
499                under: |m| {
500                    m.bone_rotation_range_deg.insert("hips".into(), 10.5);
501                },
502            },
503            Case {
504                metric: "loop_continuity.bones[0].position_delta_m", // threshold 0.001 m
505                over: |m| {
506                    m.loop_continuity.as_mut().unwrap().bones[0].position_delta_m = 0.022;
507                },
508                under: |m| {
509                    m.loop_continuity.as_mut().unwrap().bones[0].position_delta_m = 0.0205;
510                },
511            },
512            Case {
513                metric: "loop_continuity.bones[0].rotation_delta_deg", // threshold 0.1 deg
514                over: |m| {
515                    m.loop_continuity.as_mut().unwrap().bones[0].rotation_delta_deg = 2.2;
516                },
517                under: |m| {
518                    m.loop_continuity.as_mut().unwrap().bones[0].rotation_delta_deg = 2.05;
519                },
520            },
521            Case {
522                metric: "loop_continuity.bones[0].seam_velocity_delta_mps", // threshold 0.01 m/s
523                over: |m| {
524                    m.loop_continuity.as_mut().unwrap().bones[0].seam_velocity_delta_mps = 0.22;
525                },
526                under: |m| {
527                    m.loop_continuity.as_mut().unwrap().bones[0].seam_velocity_delta_mps = 0.205;
528                },
529            },
530            Case {
531                metric: "loop_continuity.bones[0].seam_angular_velocity_delta_degps", // threshold 0.5 deg/s
532                over: |m| {
533                    m.loop_continuity.as_mut().unwrap().bones[0]
534                        .seam_angular_velocity_delta_degps = 10.5001;
535                },
536                under: |m| {
537                    m.loop_continuity.as_mut().unwrap().bones[0]
538                        .seam_angular_velocity_delta_degps = 10.4999;
539                },
540            },
541        ];
542
543        for case in cases {
544            let before = clip_measurements();
545
546            let mut over = before.clone();
547            (case.over)(&mut over);
548            let deltas = diff_measurements(
549                &measurement_map("walk", before.clone()),
550                &measurement_map("walk", over),
551            );
552            assert_eq!(
553                delta_metrics(&deltas),
554                vec![case.metric],
555                "over-threshold literal must report exactly {}",
556                case.metric
557            );
558
559            let mut under = before.clone();
560            (case.under)(&mut under);
561            let deltas = diff_measurements(
562                &measurement_map("walk", before),
563                &measurement_map("walk", under),
564            );
565            assert!(
566                deltas.is_empty(),
567                "under-threshold literal for {} must be silent: {:?}",
568                case.metric,
569                delta_metrics(&deltas)
570            );
571        }
572    }
573
574    #[test]
575    fn compares_gait_phase_on_a_cycle() {
576        let mut before = clip_measurements();
577        before.gait.as_mut().unwrap().phase = Some(0.98);
578        let mut after = before.clone();
579        after.gait.as_mut().unwrap().phase = Some(0.02);
580
581        let deltas = diff_measurements(
582            &measurement_map("walk", before),
583            &measurement_map("walk", after),
584        );
585
586        assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
587    }
588
589    #[test]
590    fn reports_significant_gait_phase_moves() {
591        let mut before = clip_measurements();
592        before.gait.as_mut().unwrap().phase = Some(0.9);
593        let mut after = before.clone();
594        after.gait.as_mut().unwrap().phase = Some(0.1);
595
596        let deltas = diff_measurements(
597            &measurement_map("walk", before),
598            &measurement_map("walk", after),
599        );
600
601        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
602        let delta = delta_for(&deltas, "gait.phase");
603        assert_eq!(delta.note, "moved");
604        assert_eq!(delta.before, Some(0.9));
605        assert_eq!(delta.after, Some(0.1));
606    }
607
608    /// #53: `frame_count` is the wrong-sign guard; a decrease must still
609    /// report, so an impl that only diffed increases is caught.
610    #[test]
611    fn reports_frame_count_move_including_a_decrease() {
612        let before = clip_measurements(); // frame_count 31
613        let mut after = before.clone();
614        after.frame_count = 20;
615
616        let deltas = diff_measurements(
617            &measurement_map("walk", before),
618            &measurement_map("walk", after),
619        );
620
621        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
622        let delta = delta_for(&deltas, "frame_count");
623        assert_eq!(delta.note, "moved");
624        assert_eq!(delta.before, Some(31.0));
625        assert_eq!(delta.after, Some(20.0));
626        assert!(
627            delta.before.unwrap() > delta.after.unwrap(),
628            "a decrease must be captured, not dropped"
629        );
630    }
631
632    /// #52 item 2: pin the `frame_count` 0.5 threshold to a literal
633    /// one-frame move. `frame_count` is integer-valued, so the tightest
634    /// possible stimulus - a single-frame change - must report.
635    #[test]
636    fn single_frame_change_crosses_the_frame_count_threshold() {
637        let before = clip_measurements(); // frame_count 31
638        let mut after = before.clone();
639        after.frame_count = 32; // +1 frame, the smallest possible move
640
641        let deltas = diff_measurements(
642            &measurement_map("walk", before),
643            &measurement_map("walk", after),
644        );
645
646        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
647        let delta = delta_for(&deltas, "frame_count");
648        assert_eq!(delta.note, "moved");
649        assert_eq!(delta.before, Some(31.0));
650        assert_eq!(delta.after, Some(32.0));
651    }
652
653    #[test]
654    fn reports_gait_amplitude_move() {
655        let before = clip_measurements(); // lr_amplitude_m 0.1
656        let mut after = before.clone();
657        after.gait.as_mut().unwrap().lr_amplitude_m = 0.1 + AMPLITUDE_THRESHOLD_M * 2.0;
658
659        let deltas = diff_measurements(
660            &measurement_map("walk", before),
661            &measurement_map("walk", after),
662        );
663
664        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
665        let delta = delta_for(&deltas, "gait.lr_amplitude_m");
666        assert_eq!(delta.note, "moved");
667        assert_eq!(delta.before, Some(0.1));
668        assert_eq!(delta.after, Some(0.1 + AMPLITUDE_THRESHOLD_M * 2.0));
669    }
670
671    #[test]
672    fn reports_bone_rotation_range_moved() {
673        let before = clip_measurements(); // hips: 10.0
674        let mut after = before.clone();
675        after
676            .bone_rotation_range_deg
677            .insert("hips".into(), 10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0);
678
679        let deltas = diff_measurements(
680            &measurement_map("walk", before),
681            &measurement_map("walk", after),
682        );
683
684        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
685        let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
686        assert_eq!(delta.note, "moved");
687        assert_eq!(delta.before, Some(10.0));
688        assert_eq!(delta.after, Some(10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0));
689    }
690
691    #[test]
692    fn reports_bone_rotation_range_appeared_and_disappeared() {
693        let before = clip_measurements();
694        let mut after = before.clone();
695        after.bone_rotation_range_deg.insert("spine".into(), 5.0);
696        let deltas = diff_measurements(
697            &measurement_map("walk", before),
698            &measurement_map("walk", after),
699        );
700        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
701        let delta = delta_for(&deltas, "bone_rotation_range_deg[spine]");
702        assert_eq!(delta.note, "bone now animated");
703        assert_eq!(delta.before, None);
704        assert_eq!(delta.after, Some(5.0));
705
706        let before = clip_measurements();
707        let mut after = before.clone();
708        after.bone_rotation_range_deg.remove("hips");
709        let deltas = diff_measurements(
710            &measurement_map("walk", before),
711            &measurement_map("walk", after),
712        );
713        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
714        let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
715        assert_eq!(delta.note, "bone no longer animated");
716        assert_eq!(delta.before, Some(10.0));
717        assert_eq!(delta.after, None);
718    }
719
720    #[test]
721    fn reports_animated_bones_gained_and_lost() {
722        let before = clip_measurements(); // ["hips"]
723        let mut after = before.clone();
724        after.animated_bones = vec!["spine".into(), "tail".into()];
725
726        let deltas = diff_measurements(
727            &measurement_map("walk", before),
728            &measurement_map("walk", after),
729        );
730
731        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
732        let delta = delta_for(&deltas, "animated_bones");
733        assert_eq!(delta.before, Some(1.0));
734        assert_eq!(delta.after, Some(2.0));
735        assert_eq!(delta.note, "gained [spine, tail], lost [hips]");
736    }
737
738    #[test]
739    fn metric_delta_omits_non_finite_public_values() {
740        let delta = MetricDelta {
741            clip: "walk".into(),
742            metric: "duration_s".into(),
743            before: Some(f64::NAN),
744            after: Some(f64::INFINITY),
745            note: "moved".into(),
746        };
747        let json = serde_json::to_value(delta).expect("delta serializes");
748        assert!(json.get("before").is_none());
749        assert!(json.get("after").is_none());
750    }
751}