1use crate::measure::ClipMeasurements;
9use serde::Serialize;
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const DURATION_THRESHOLD_S: f64 = 0.017; pub const ROTATION_RANGE_THRESHOLD_DEG: f64 = 1.0;
16pub const SEAM_THRESHOLD: f64 = 0.05;
18pub const PHASE_THRESHOLD: f64 = 0.05; pub const AMPLITUDE_THRESHOLD_M: f64 = 0.005;
22pub const SPEED_THRESHOLD_MPS: f64 = 0.1;
24
25#[derive(Debug, Serialize)]
27pub struct MetricDelta {
28 pub clip: String,
30 pub metric: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
36 pub before: Option<f64>,
37 #[serde(skip_serializing_if = "Option::is_none")]
40 pub after: Option<f64>,
41 pub note: String,
44}
45
46pub fn diff_measurements(
53 a: &BTreeMap<String, ClipMeasurements>,
54 b: &BTreeMap<String, ClipMeasurements>,
55) -> Vec<MetricDelta> {
56 let mut deltas = Vec::new();
57 let delta =
58 |clip: &str, metric: &str, before: Option<f64>, after: Option<f64>, note: String| {
59 MetricDelta {
60 clip: clip.into(),
61 metric: metric.into(),
62 before,
63 after,
64 note,
65 }
66 };
67
68 for (clip, ma) in a {
69 let Some(mb) = b.get(clip) else {
70 deltas.push(delta(clip, "clip", None, None, "clip removed".into()));
71 continue;
72 };
73 let mut push_num =
74 |metric: &str, va: Option<f64>, vb: Option<f64>, threshold: f64, circular: bool| {
75 let moved = match (va, vb) {
76 (Some(x), Some(y)) => {
77 let d = if circular {
78 let d = (x - y).rem_euclid(1.0);
79 d.min(1.0 - d)
80 } else {
81 (x - y).abs()
82 };
83 d > threshold
84 }
85 (None, None) => false,
86 _ => true, };
88 if moved {
89 deltas.push(MetricDelta {
90 clip: clip.clone(),
91 metric: metric.into(),
92 before: va,
93 after: vb,
94 note: match (va, vb) {
95 (Some(_), Some(_)) => "moved".into(),
96 (None, Some(_)) => "appeared".into(),
97 _ => "disappeared".into(),
98 },
99 });
100 }
101 };
102
103 push_num(
104 "duration_s",
105 Some(ma.duration_s),
106 Some(mb.duration_s),
107 DURATION_THRESHOLD_S,
108 false,
109 );
110 push_num(
111 "frame_count",
112 Some(ma.frame_count as f64),
113 Some(mb.frame_count as f64),
114 0.5,
115 false,
116 );
117 push_num(
118 "loop_seam_ratio",
119 ma.loop_seam_ratio,
120 mb.loop_seam_ratio,
121 SEAM_THRESHOLD,
122 false,
123 );
124 push_num(
125 "gait.phase",
126 ma.gait.as_ref().and_then(|g| g.phase),
127 mb.gait.as_ref().and_then(|g| g.phase),
128 PHASE_THRESHOLD,
129 true,
130 );
131 push_num(
132 "gait.lr_amplitude_m",
133 ma.gait.as_ref().map(|g| g.lr_amplitude_m),
134 mb.gait.as_ref().map(|g| g.lr_amplitude_m),
135 AMPLITUDE_THRESHOLD_M,
136 false,
137 );
138 push_num(
139 "speed_mps",
140 ma.speed_mps,
141 mb.speed_mps,
142 SPEED_THRESHOLD_MPS,
143 false,
144 );
145
146 for bone in ma
147 .bone_rotation_range_deg
148 .keys()
149 .chain(mb.bone_rotation_range_deg.keys())
150 .collect::<BTreeSet<_>>()
151 {
152 let va = ma.bone_rotation_range_deg.get(bone).copied();
153 let vb = mb.bone_rotation_range_deg.get(bone).copied();
154 let moved = match (va, vb) {
155 (Some(x), Some(y)) => (x - y).abs() > ROTATION_RANGE_THRESHOLD_DEG,
156 _ => true,
157 };
158 if moved {
159 deltas.push(delta(
160 clip,
161 &format!("bone_rotation_range_deg[{bone}]"),
162 va,
163 vb,
164 match (va, vb) {
165 (Some(_), Some(_)) => "moved".into(),
166 (None, Some(_)) => "bone now animated".into(),
167 _ => "bone no longer animated".into(),
168 },
169 ));
170 }
171 }
172
173 if ma.animated_bones != mb.animated_bones {
174 let a_set: BTreeSet<_> = ma.animated_bones.iter().collect();
175 let b_set: BTreeSet<_> = mb.animated_bones.iter().collect();
176 let gained: Vec<_> = b_set.difference(&a_set).map(|s| s.as_str()).collect();
177 let lost: Vec<_> = a_set.difference(&b_set).map(|s| s.as_str()).collect();
178 deltas.push(delta(
179 clip,
180 "animated_bones",
181 Some(ma.animated_bones.len() as f64),
182 Some(mb.animated_bones.len() as f64),
183 format!("gained [{}], lost [{}]", gained.join(", "), lost.join(", ")),
184 ));
185 }
186 }
187 for clip in b.keys() {
188 if !a.contains_key(clip) {
189 deltas.push(MetricDelta {
190 clip: clip.clone(),
191 metric: "clip".into(),
192 before: None,
193 after: None,
194 note: "clip added".into(),
195 });
196 }
197 }
198 deltas
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::measure::{ClipMeasurements, GaitMeasurement};
205
206 fn clip_measurements() -> ClipMeasurements {
207 ClipMeasurements {
208 duration_s: 1.0,
209 frame_count: 31,
210 animated_bones: vec!["hips".into()],
211 bone_rotation_range_deg: BTreeMap::from([("hips".into(), 10.0)]),
212 loop_seam_ratio: Some(0.2),
213 gait: Some(GaitMeasurement {
214 phase: Some(0.25),
215 lr_amplitude_m: 0.1,
216 }),
217 speed_mps: Some(1.0),
218 }
219 }
220
221 fn measurement_map(
222 clip: &str,
223 measurements: ClipMeasurements,
224 ) -> BTreeMap<String, ClipMeasurements> {
225 BTreeMap::from([(clip.into(), measurements)])
226 }
227
228 fn delta_for<'a>(deltas: &'a [MetricDelta], metric: &str) -> &'a MetricDelta {
229 deltas
230 .iter()
231 .find(|d| d.metric == metric)
232 .unwrap_or_else(|| {
233 panic!(
234 "missing metric delta {metric}; got {:?}",
235 delta_metrics(deltas)
236 )
237 })
238 }
239
240 fn delta_metrics(deltas: &[MetricDelta]) -> Vec<&str> {
241 deltas.iter().map(|d| d.metric.as_str()).collect()
242 }
243
244 #[test]
245 fn reports_moved_appeared_and_disappeared_metrics() {
246 let mut before = clip_measurements();
247 before.speed_mps = None;
248
249 let mut after = before.clone();
250 after.duration_s += DURATION_THRESHOLD_S * 2.0;
251 after.loop_seam_ratio = None;
252 after.speed_mps = Some(1.0);
253
254 let deltas = diff_measurements(
255 &measurement_map("walk", before),
256 &measurement_map("walk", after),
257 );
258
259 assert_eq!(deltas.len(), 3, "{:?}", delta_metrics(&deltas));
260 assert_eq!(delta_for(&deltas, "duration_s").note, "moved");
261 assert_eq!(delta_for(&deltas, "loop_seam_ratio").note, "disappeared");
262 assert_eq!(delta_for(&deltas, "speed_mps").note, "appeared");
263 }
264
265 #[test]
266 fn reports_clip_added_and_removed() {
267 let deltas = diff_measurements(
268 &measurement_map("removed", clip_measurements()),
269 &measurement_map("added", clip_measurements()),
270 );
271
272 assert_eq!(deltas.len(), 2, "{:?}", delta_metrics(&deltas));
273 assert!(
274 deltas
275 .iter()
276 .any(|d| d.clip == "removed" && d.metric == "clip" && d.note == "clip removed")
277 );
278 assert!(
279 deltas
280 .iter()
281 .any(|d| d.clip == "added" && d.metric == "clip" && d.note == "clip added")
282 );
283 }
284
285 #[test]
294 fn literal_stimuli_pin_documented_thresholds() {
295 struct Case {
298 metric: &'static str,
299 over: fn(&mut ClipMeasurements), under: fn(&mut ClipMeasurements), }
302 let cases = [
303 Case {
304 metric: "duration_s", over: |m| m.duration_s = 1.02,
306 under: |m| m.duration_s = 1.01,
307 },
308 Case {
309 metric: "loop_seam_ratio", over: |m| m.loop_seam_ratio = Some(0.27),
311 under: |m| m.loop_seam_ratio = Some(0.23),
312 },
313 Case {
314 metric: "gait.lr_amplitude_m", over: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.11,
316 under: |m| m.gait.as_mut().unwrap().lr_amplitude_m = 0.102,
317 },
318 Case {
319 metric: "speed_mps", over: |m| m.speed_mps = Some(1.15),
321 under: |m| m.speed_mps = Some(1.05),
322 },
323 Case {
324 metric: "bone_rotation_range_deg[hips]", over: |m| {
326 m.bone_rotation_range_deg.insert("hips".into(), 13.0);
327 },
328 under: |m| {
329 m.bone_rotation_range_deg.insert("hips".into(), 10.5);
330 },
331 },
332 ];
333
334 for case in cases {
335 let before = clip_measurements();
336
337 let mut over = before.clone();
338 (case.over)(&mut over);
339 let deltas = diff_measurements(
340 &measurement_map("walk", before.clone()),
341 &measurement_map("walk", over),
342 );
343 assert_eq!(
344 delta_metrics(&deltas),
345 vec![case.metric],
346 "over-threshold literal must report exactly {}",
347 case.metric
348 );
349
350 let mut under = before.clone();
351 (case.under)(&mut under);
352 let deltas = diff_measurements(
353 &measurement_map("walk", before),
354 &measurement_map("walk", under),
355 );
356 assert!(
357 deltas.is_empty(),
358 "under-threshold literal for {} must be silent: {:?}",
359 case.metric,
360 delta_metrics(&deltas)
361 );
362 }
363 }
364
365 #[test]
366 fn compares_gait_phase_on_a_cycle() {
367 let mut before = clip_measurements();
368 before.gait.as_mut().unwrap().phase = Some(0.98);
369 let mut after = before.clone();
370 after.gait.as_mut().unwrap().phase = Some(0.02);
371
372 let deltas = diff_measurements(
373 &measurement_map("walk", before),
374 &measurement_map("walk", after),
375 );
376
377 assert!(deltas.is_empty(), "{:?}", delta_metrics(&deltas));
378 }
379
380 #[test]
381 fn reports_significant_gait_phase_moves() {
382 let mut before = clip_measurements();
383 before.gait.as_mut().unwrap().phase = Some(0.9);
384 let mut after = before.clone();
385 after.gait.as_mut().unwrap().phase = Some(0.1);
386
387 let deltas = diff_measurements(
388 &measurement_map("walk", before),
389 &measurement_map("walk", after),
390 );
391
392 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
393 let delta = delta_for(&deltas, "gait.phase");
394 assert_eq!(delta.note, "moved");
395 assert_eq!(delta.before, Some(0.9));
396 assert_eq!(delta.after, Some(0.1));
397 }
398
399 #[test]
402 fn reports_frame_count_move_including_a_decrease() {
403 let before = clip_measurements(); let mut after = before.clone();
405 after.frame_count = 20;
406
407 let deltas = diff_measurements(
408 &measurement_map("walk", before),
409 &measurement_map("walk", after),
410 );
411
412 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
413 let delta = delta_for(&deltas, "frame_count");
414 assert_eq!(delta.note, "moved");
415 assert_eq!(delta.before, Some(31.0));
416 assert_eq!(delta.after, Some(20.0));
417 assert!(
418 delta.before.unwrap() > delta.after.unwrap(),
419 "a decrease must be captured, not dropped"
420 );
421 }
422
423 #[test]
427 fn single_frame_change_crosses_the_frame_count_threshold() {
428 let before = clip_measurements(); let mut after = before.clone();
430 after.frame_count = 32; let deltas = diff_measurements(
433 &measurement_map("walk", before),
434 &measurement_map("walk", after),
435 );
436
437 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
438 let delta = delta_for(&deltas, "frame_count");
439 assert_eq!(delta.note, "moved");
440 assert_eq!(delta.before, Some(31.0));
441 assert_eq!(delta.after, Some(32.0));
442 }
443
444 #[test]
445 fn reports_gait_amplitude_move() {
446 let before = clip_measurements(); let mut after = before.clone();
448 after.gait.as_mut().unwrap().lr_amplitude_m = 0.1 + AMPLITUDE_THRESHOLD_M * 2.0;
449
450 let deltas = diff_measurements(
451 &measurement_map("walk", before),
452 &measurement_map("walk", after),
453 );
454
455 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
456 let delta = delta_for(&deltas, "gait.lr_amplitude_m");
457 assert_eq!(delta.note, "moved");
458 assert_eq!(delta.before, Some(0.1));
459 assert_eq!(delta.after, Some(0.1 + AMPLITUDE_THRESHOLD_M * 2.0));
460 }
461
462 #[test]
463 fn reports_bone_rotation_range_moved() {
464 let before = clip_measurements(); let mut after = before.clone();
466 after
467 .bone_rotation_range_deg
468 .insert("hips".into(), 10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0);
469
470 let deltas = diff_measurements(
471 &measurement_map("walk", before),
472 &measurement_map("walk", after),
473 );
474
475 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
476 let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
477 assert_eq!(delta.note, "moved");
478 assert_eq!(delta.before, Some(10.0));
479 assert_eq!(delta.after, Some(10.0 + ROTATION_RANGE_THRESHOLD_DEG * 2.0));
480 }
481
482 #[test]
483 fn reports_bone_rotation_range_appeared_and_disappeared() {
484 let before = clip_measurements();
485 let mut after = before.clone();
486 after.bone_rotation_range_deg.insert("spine".into(), 5.0);
487 let deltas = diff_measurements(
488 &measurement_map("walk", before),
489 &measurement_map("walk", after),
490 );
491 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
492 let delta = delta_for(&deltas, "bone_rotation_range_deg[spine]");
493 assert_eq!(delta.note, "bone now animated");
494 assert_eq!(delta.before, None);
495 assert_eq!(delta.after, Some(5.0));
496
497 let before = clip_measurements();
498 let mut after = before.clone();
499 after.bone_rotation_range_deg.remove("hips");
500 let deltas = diff_measurements(
501 &measurement_map("walk", before),
502 &measurement_map("walk", after),
503 );
504 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
505 let delta = delta_for(&deltas, "bone_rotation_range_deg[hips]");
506 assert_eq!(delta.note, "bone no longer animated");
507 assert_eq!(delta.before, Some(10.0));
508 assert_eq!(delta.after, None);
509 }
510
511 #[test]
512 fn reports_animated_bones_gained_and_lost() {
513 let before = clip_measurements(); let mut after = before.clone();
515 after.animated_bones = vec!["spine".into(), "tail".into()];
516
517 let deltas = diff_measurements(
518 &measurement_map("walk", before),
519 &measurement_map("walk", after),
520 );
521
522 assert_eq!(deltas.len(), 1, "{:?}", delta_metrics(&deltas));
523 let delta = delta_for(&deltas, "animated_bones");
524 assert_eq!(delta.before, Some(1.0));
525 assert_eq!(delta.after, Some(2.0));
526 assert_eq!(delta.note, "gained [spine, tail], lost [hips]");
527 }
528}