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