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