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/// Loop-seam ratio movement threshold.
17pub const SEAM_THRESHOLD: f64 = 0.05;
18/// Gait phase movement threshold, in circular cycle fraction.
19pub const PHASE_THRESHOLD: f64 = 0.05; // cycle fraction, circular
20/// Gait amplitude movement threshold, in metres.
21pub const AMPLITUDE_THRESHOLD_M: f64 = 0.005;
22/// Root-motion speed movement threshold, in metres per second.
23pub const SPEED_THRESHOLD_MPS: f64 = 0.1;
24
25/// One significant metric difference between two measurement maps.
26#[derive(Debug, Serialize)]
27pub struct MetricDelta {
28    /// Clip that owns the changed metric, or the added/removed clip.
29    pub clip: String,
30    /// Metric path, for example `"duration_s"` or
31    /// `"bone_rotation_range_deg[hips]"`.
32    pub metric: String,
33    /// Value in the before map, absent when a metric appeared or a clip
34    /// was added/removed.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub before: Option<f64>,
37    /// Value in the after map, absent when a metric disappeared or a
38    /// clip was added/removed.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub after: Option<f64>,
41    /// Short cause such as `"moved"`, `"appeared"`, or
42    /// `"bone no longer animated"`.
43    pub note: String,
44}
45
46/// Compare two measurement maps and return only significant deltas.
47///
48/// The thresholds are intentionally fixed public constants so CLI and
49/// embedding callers agree on what counts as re-export noise. Gait phase
50/// uses circular distance, so phases near `0.0` and `1.0` compare as
51/// adjacent rather than far apart.
52pub fn diff_measurements(
53    a: &BTreeMap<String, ClipMeasurements>,
54    b: &BTreeMap<String, ClipMeasurements>,
55) -> Vec<MetricDelta> {
56    let mut deltas = Vec::new();
57    let delta =
58        |clip: &str, metric: &str, before: Option<f64>, after: Option<f64>, note: String| {
59            MetricDelta {
60                clip: clip.into(),
61                metric: metric.into(),
62                before,
63                after,
64                note,
65            }
66        };
67
68    for (clip, ma) in a {
69        let Some(mb) = b.get(clip) else {
70            deltas.push(delta(clip, "clip", None, None, "clip removed".into()));
71            continue;
72        };
73        let mut push_num =
74            |metric: &str, va: Option<f64>, vb: Option<f64>, threshold: f64, circular: bool| {
75                let moved = match (va, vb) {
76                    (Some(x), Some(y)) => {
77                        let d = if circular {
78                            let d = (x - y).rem_euclid(1.0);
79                            d.min(1.0 - d)
80                        } else {
81                            (x - y).abs()
82                        };
83                        d > threshold
84                    }
85                    (None, None) => false,
86                    _ => true, // appeared or disappeared
87                };
88                if moved {
89                    deltas.push(MetricDelta {
90                        clip: clip.clone(),
91                        metric: metric.into(),
92                        before: va,
93                        after: vb,
94                        note: match (va, vb) {
95                            (Some(_), Some(_)) => "moved".into(),
96                            (None, Some(_)) => "appeared".into(),
97                            _ => "disappeared".into(),
98                        },
99                    });
100                }
101            };
102
103        push_num(
104            "duration_s",
105            Some(ma.duration_s),
106            Some(mb.duration_s),
107            DURATION_THRESHOLD_S,
108            false,
109        );
110        push_num(
111            "frame_count",
112            Some(ma.frame_count as f64),
113            Some(mb.frame_count as f64),
114            0.5,
115            false,
116        );
117        push_num(
118            "loop_seam_ratio",
119            ma.loop_seam_ratio,
120            mb.loop_seam_ratio,
121            SEAM_THRESHOLD,
122            false,
123        );
124        push_num(
125            "gait.phase",
126            ma.gait.as_ref().and_then(|g| g.phase),
127            mb.gait.as_ref().and_then(|g| g.phase),
128            PHASE_THRESHOLD,
129            true,
130        );
131        push_num(
132            "gait.lr_amplitude_m",
133            ma.gait.as_ref().map(|g| g.lr_amplitude_m),
134            mb.gait.as_ref().map(|g| g.lr_amplitude_m),
135            AMPLITUDE_THRESHOLD_M,
136            false,
137        );
138        push_num(
139            "speed_mps",
140            ma.speed_mps,
141            mb.speed_mps,
142            SPEED_THRESHOLD_MPS,
143            false,
144        );
145
146        for bone in ma
147            .bone_rotation_range_deg
148            .keys()
149            .chain(mb.bone_rotation_range_deg.keys())
150            .collect::<BTreeSet<_>>()
151        {
152            let va = ma.bone_rotation_range_deg.get(bone).copied();
153            let vb = mb.bone_rotation_range_deg.get(bone).copied();
154            let moved = match (va, vb) {
155                (Some(x), Some(y)) => (x - y).abs() > ROTATION_RANGE_THRESHOLD_DEG,
156                _ => true,
157            };
158            if moved {
159                deltas.push(delta(
160                    clip,
161                    &format!("bone_rotation_range_deg[{bone}]"),
162                    va,
163                    vb,
164                    match (va, vb) {
165                        (Some(_), Some(_)) => "moved".into(),
166                        (None, Some(_)) => "bone now animated".into(),
167                        _ => "bone no longer animated".into(),
168                    },
169                ));
170            }
171        }
172
173        if ma.animated_bones != mb.animated_bones {
174            let a_set: BTreeSet<_> = ma.animated_bones.iter().collect();
175            let b_set: BTreeSet<_> = mb.animated_bones.iter().collect();
176            let gained: Vec<_> = b_set.difference(&a_set).map(|s| s.as_str()).collect();
177            let lost: Vec<_> = a_set.difference(&b_set).map(|s| s.as_str()).collect();
178            deltas.push(delta(
179                clip,
180                "animated_bones",
181                Some(ma.animated_bones.len() as f64),
182                Some(mb.animated_bones.len() as f64),
183                format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
184            ));
185        }
186    }
187    for clip in b.keys() {
188        if !a.contains_key(clip) {
189            deltas.push(MetricDelta {
190                clip: clip.clone(),
191                metric: "clip".into(),
192                before: None,
193                after: None,
194                note: "clip added".into(),
195            });
196        }
197    }
198    deltas
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::measure::{ClipMeasurements, GaitMeasurement};
205
206    fn clip_measurements() -> ClipMeasurements {
207        ClipMeasurements {
208            duration_s: 1.0,
209            frame_count: 31,
210            animated_bones: vec!["hips".into()],
211            bone_rotation_range_deg: BTreeMap::from([("hips".into(), 10.0)]),
212            loop_seam_ratio: Some(0.2),
213            gait: Some(GaitMeasurement {
214                phase: Some(0.25),
215                lr_amplitude_m: 0.1,
216            }),
217            speed_mps: Some(1.0),
218        }
219    }
220
221    fn measurement_map(
222        clip: &str,
223        measurements: ClipMeasurements,
224    ) -> BTreeMap<String, ClipMeasurements> {
225        BTreeMap::from([(clip.into(), measurements)])
226    }
227
228    fn delta_for<'a>(deltas: &'a [MetricDelta], metric: &str) -> &'a MetricDelta {
229        deltas
230            .iter()
231            .find(|d| d.metric == metric)
232            .unwrap_or_else(|| {
233                panic!(
234                    "missing metric delta {metric}; got {:?}",
235                    delta_metrics(deltas)
236                )
237            })
238    }
239
240    fn delta_metrics(deltas: &[MetricDelta]) -> Vec<&str> {
241        deltas.iter().map(|d| d.metric.as_str()).collect()
242    }
243
244    #[test]
245    fn reports_moved_appeared_and_disappeared_metrics() {
246        let mut before = clip_measurements();
247        before.speed_mps = None;
248
249        let mut after = before.clone();
250        after.duration_s += DURATION_THRESHOLD_S * 2.0;
251        after.loop_seam_ratio = None;
252        after.speed_mps = Some(1.0);
253
254        let deltas = diff_measurements(
255            &measurement_map("walk", before),
256            &measurement_map("walk", after),
257        );
258
259        assert_eq!(deltas.len(), 3, "{:?}", delta_metrics(&deltas));
260        assert_eq!(delta_for(&deltas, "duration_s").note, "moved");
261        assert_eq!(delta_for(&deltas, "loop_seam_ratio").note, "disappeared");
262        assert_eq!(delta_for(&deltas, "speed_mps").note, "appeared");
263    }
264
265    #[test]
266    fn reports_clip_added_and_removed() {
267        let deltas = diff_measurements(
268            &measurement_map("removed", clip_measurements()),
269            &measurement_map("added", clip_measurements()),
270        );
271
272        assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
273        assert!(
274            deltas
275                .iter()
276                .any(|d| d.clip == "removed" && d.metric == "clip" && d.note == "clip removed")
277        );
278        assert!(
279            deltas
280                .iter()
281                .any(|d| d.clip == "added" && d.metric == "clip" && d.note == "clip added")
282        );
283    }
284
285    /// #52: anchor every documented threshold to literal stimuli.
286    /// Deriving a metric's fixture from the constant under test
287    /// (`THRESHOLD * 2`, `THRESHOLD / 2`) hides a fat-fingered constant:
288    /// for example, `DURATION_THRESHOLD_S` 0.017 -> 0.17 would still pass.
289    /// Concrete numbers straddling the documented threshold catch such a
290    /// typo in either direction. `gait.phase` (circular) and `frame_count`
291    /// (integer) do not fit this over/under numeric straddle; each has its
292    /// own literal anchor.
293    #[test]
294    fn literal_stimuli_pin_documented_thresholds() {
295        // Base fixture: duration_s 1.0, loop_seam_ratio 0.2,
296        // lr_amplitude_m 0.1, speed_mps 1.0, hips rotation 10.0.
297        struct Case {
298            metric: &'static str,
299            over: fn(&mut ClipMeasurements),  // clears the threshold
300            under: fn(&mut ClipMeasurements), // stays within noise
301        }
302        let cases = [
303            Case {
304                metric: "duration_s", // threshold 0.017 s
305                over: |m| m.duration_s = 1.02,
306                under: |m| m.duration_s = 1.01,
307            },
308            Case {
309                metric: "loop_seam_ratio", // threshold 0.05
310                over: |m| m.loop_seam_ratio = Some(0.27),
311                under: |m| m.loop_seam_ratio = Some(0.23),
312            },
313            Case {
314                metric: "gait.lr_amplitude_m", // threshold 0.005 m
315                over: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.11,
316                under: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.102,
317            },
318            Case {
319                metric: "speed_mps", // threshold 0.1 m/s
320                over: |m| m.speed_mps = Some(1.15),
321                under: |m| m.speed_mps = Some(1.05),
322            },
323            Case {
324                metric: "bone_rotation_range_deg[hips]", // threshold 1.0 deg
325                over: |m| {
326                    m.bone_rotation_range_deg.insert("hips".into(), 13.0);
327                },
328                under: |m| {
329                    m.bone_rotation_range_deg.insert("hips".into(), 10.5);
330                },
331            },
332        ];
333
334        for case in cases {
335            let before = clip_measurements();
336
337            let mut over = before.clone();
338            (case.over)(&mut over);
339            let deltas = diff_measurements(
340                &measurement_map("walk", before.clone()),
341                &measurement_map("walk", over),
342            );
343            assert_eq!(
344                delta_metrics(&deltas),
345                vec![case.metric],
346                "over-threshold literal must report exactly {}",
347                case.metric
348            );
349
350            let mut under = before.clone();
351            (case.under)(&mut under);
352            let deltas = diff_measurements(
353                &measurement_map("walk", before),
354                &measurement_map("walk", under),
355            );
356            assert!(
357                deltas.is_empty(),
358                "under-threshold literal for {} must be silent: {:?}",
359                case.metric,
360                delta_metrics(&deltas)
361            );
362        }
363    }
364
365    #[test]
366    fn compares_gait_phase_on_a_cycle() {
367        let mut before = clip_measurements();
368        before.gait.as_mut().unwrap().phase = Some(0.98);
369        let mut after = before.clone();
370        after.gait.as_mut().unwrap().phase = Some(0.02);
371
372        let deltas = diff_measurements(
373            &measurement_map("walk", before),
374            &measurement_map("walk", after),
375        );
376
377        assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
378    }
379
380    #[test]
381    fn reports_significant_gait_phase_moves() {
382        let mut before = clip_measurements();
383        before.gait.as_mut().unwrap().phase = Some(0.9);
384        let mut after = before.clone();
385        after.gait.as_mut().unwrap().phase = Some(0.1);
386
387        let deltas = diff_measurements(
388            &measurement_map("walk", before),
389            &measurement_map("walk", after),
390        );
391
392        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
393        let delta = delta_for(&deltas, "gait.phase");
394        assert_eq!(delta.note, "moved");
395        assert_eq!(delta.before, Some(0.9));
396        assert_eq!(delta.after, Some(0.1));
397    }
398
399    /// #53: `frame_count` is the wrong-sign guard; a decrease must still
400    /// report, so an impl that only diffed increases is caught.
401    #[test]
402    fn reports_frame_count_move_including_a_decrease() {
403        let before = clip_measurements(); // frame_count 31
404        let mut after = before.clone();
405        after.frame_count = 20;
406
407        let deltas = diff_measurements(
408            &measurement_map("walk", before),
409            &measurement_map("walk", after),
410        );
411
412        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
413        let delta = delta_for(&deltas, "frame_count");
414        assert_eq!(delta.note, "moved");
415        assert_eq!(delta.before, Some(31.0));
416        assert_eq!(delta.after, Some(20.0));
417        assert!(
418            delta.before.unwrap() > delta.after.unwrap(),
419            "a decrease must be captured, not dropped"
420        );
421    }
422
423    /// #52 item 2: pin the `frame_count` 0.5 threshold to a literal
424    /// one-frame move. `frame_count` is integer-valued, so the tightest
425    /// possible stimulus - a single-frame change - must report.
426    #[test]
427    fn single_frame_change_crosses_the_frame_count_threshold() {
428        let before = clip_measurements(); // frame_count 31
429        let mut after = before.clone();
430        after.frame_count = 32; // +1 frame, the smallest possible move
431
432        let deltas = diff_measurements(
433            &measurement_map("walk", before),
434            &measurement_map("walk", after),
435        );
436
437        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
438        let delta = delta_for(&deltas, "frame_count");
439        assert_eq!(delta.note, "moved");
440        assert_eq!(delta.before, Some(31.0));
441        assert_eq!(delta.after, Some(32.0));
442    }
443
444    #[test]
445    fn reports_gait_amplitude_move() {
446        let before = clip_measurements(); // lr_amplitude_m 0.1
447        let mut after = before.clone();
448        after.gait.as_mut().unwrap().lr_amplitude_m = 0.1 + AMPLITUDE_THRESHOLD_M * 2.0;
449
450        let deltas = diff_measurements(
451            &measurement_map("walk", before),
452            &measurement_map("walk", after),
453        );
454
455        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
456        let delta = delta_for(&deltas, "gait.lr_amplitude_m");
457        assert_eq!(delta.note, "moved");
458        assert_eq!(delta.before, Some(0.1));
459        assert_eq!(delta.after, Some(0.1 + AMPLITUDE_THRESHOLD_M * 2.0));
460    }
461
462    #[test]
463    fn reports_bone_rotation_range_moved() {
464        let before = clip_measurements(); // hips: 10.0
465        let mut after = before.clone();
466        after
467            .bone_rotation_range_deg
468            .insert("hips".into(), 10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0);
469
470        let deltas = diff_measurements(
471            &measurement_map("walk", before),
472            &measurement_map("walk", after),
473        );
474
475        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
476        let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
477        assert_eq!(delta.note, "moved");
478        assert_eq!(delta.before, Some(10.0));
479        assert_eq!(delta.after, Some(10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0));
480    }
481
482    #[test]
483    fn reports_bone_rotation_range_appeared_and_disappeared() {
484        let before = clip_measurements();
485        let mut after = before.clone();
486        after.bone_rotation_range_deg.insert("spine".into(), 5.0);
487        let deltas = diff_measurements(
488            &measurement_map("walk", before),
489            &measurement_map("walk", after),
490        );
491        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
492        let delta = delta_for(&deltas, "bone_rotation_range_deg[spine]");
493        assert_eq!(delta.note, "bone now animated");
494        assert_eq!(delta.before, None);
495        assert_eq!(delta.after, Some(5.0));
496
497        let before = clip_measurements();
498        let mut after = before.clone();
499        after.bone_rotation_range_deg.remove("hips");
500        let deltas = diff_measurements(
501            &measurement_map("walk", before),
502            &measurement_map("walk", after),
503        );
504        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
505        let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
506        assert_eq!(delta.note, "bone no longer animated");
507        assert_eq!(delta.before, Some(10.0));
508        assert_eq!(delta.after, None);
509    }
510
511    #[test]
512    fn reports_animated_bones_gained_and_lost() {
513        let before = clip_measurements(); // ["hips"]
514        let mut after = before.clone();
515        after.animated_bones = vec!["spine".into(), "tail".into()];
516
517        let deltas = diff_measurements(
518            &measurement_map("walk", before),
519            &measurement_map("walk", after),
520        );
521
522        assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
523        let delta = delta_for(&deltas, "animated_bones");
524        assert_eq!(delta.before, Some(1.0));
525        assert_eq!(delta.after, Some(2.0));
526        assert_eq!(delta.note, "gained [spine, tail], lost [hips]");
527    }
528}