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