1pub mod model;
28pub mod observe;
29pub mod standardize;
30pub mod synthetic;
31
32pub use model::{TasteConfig, TasteModel, TastePosterior, TasteSample, MAX_NORMAL_SD};
33pub use observe::{
34 Feedback, FitSet, Observation, ObservationLog, Provenance, PHI_SCHEMA, PHI_SCHEMA_STANDARDIZED,
35};
36pub use standardize::Standardizer;
37pub use synthetic::{IdealPointUser, MixtureSyntheticUser, SyntheticUser};
38
39#[cfg(test)]
40mod tests {
41 use super::*;
42 use rand::rngs::StdRng;
43 use rand::{Rng, SeedableRng};
44 use synthetic::cosine;
45
46 const D: usize = 16;
47
48 fn random_phi<R: Rng>(rng: &mut R) -> Vec<f64> {
49 (0..D)
51 .map(|_| {
52 let (u1, u2): (f64, f64) = (rng.gen(), rng.gen());
53 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
54 })
55 .collect()
56 }
57
58 fn ground_truth() -> SyntheticUser {
59 let mut theta = vec![0.0; D];
61 theta[0] = 1.8;
62 theta[1] = -1.2;
63 theta[3] = 1.0;
64 theta[7] = -0.8;
65 theta[10] = 0.5;
66 SyntheticUser {
67 theta,
68 tau: 0.4,
69 cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
70 }
71 }
72
73 #[test]
76 fn duels_recover_theta() {
77 let mut rng = StdRng::seed_from_u64(11);
78 let user = ground_truth();
79
80 let mut log = ObservationLog::new();
81 for _ in 0..400 {
82 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
83 log.push(user.observe_duel(&mut rng, a, b, 0));
84 }
85
86 let model = TasteModel::new(TasteConfig::linear(D));
87 let posterior = model.fit(&mut rng, &FitSet::as_is(&log), 30_000, 10_000);
88
89 let theta_hat = posterior.theta_mean(0);
90 let cos = cosine(&theta_hat, &user.theta);
91 assert!(cos > 0.85, "theta recovery cosine {cos} too low");
92
93 let mut correct = 0;
97 let n_test = 300;
98 for _ in 0..n_test {
99 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
100 let truth = user.utility(&a) > user.utility(&b);
101 let pred = posterior.prob_prefers(&a, &b) > 0.5;
102 if pred == truth {
103 correct += 1;
104 }
105 }
106 let acc = correct as f64 / n_test as f64;
107 assert!(acc > 0.8, "held-out duel accuracy {acc} too low");
108 }
109
110 #[test]
118 fn fusing_costs_one_site_per_style_and_nothing_when_unused() {
119 let flat = TasteConfig::mixture(40, 5);
120 let flat_sites = model::SiteAddrs::new(&flat, 1).site_count();
121 assert_eq!(flat_sites, 40 * 5 + 1 + 5, "the documented 206");
122
123 let mut named_only = flat.clone();
126 named_only.fused = vec![vec![2, 5, 0]];
127 assert_eq!(
128 model::SiteAddrs::new(&named_only, 1).site_count(),
129 flat_sites,
130 "a named group at rho = 0 must add no sites — off means off"
131 );
132
133 let mut fused = named_only.clone();
134 fused.fused_rho = Some(0.25);
135 let fused_sites = model::SiteAddrs::new(&fused, 1).site_count();
136 assert_eq!(
137 fused_sites,
138 flat_sites + 5,
139 "one latent mean per style, and no other new site"
140 );
141 }
142
143 #[test]
157 fn a_fused_prior_beats_a_flat_one_on_a_correlated_cluster() {
158 let mut rng = StdRng::seed_from_u64(0xB817);
159 let mut theta = vec![0.0; D];
160 theta[0] = 1.2;
161 theta[1] = 1.0;
162 theta[2] = 0.9;
163 theta[8] = -1.1;
164 let user = SyntheticUser {
165 theta,
166 tau: 0.4,
167 cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
168 };
169
170 let correlated = |rng: &mut StdRng| -> Vec<f64> {
173 let mut x = random_phi(rng);
174 let shared = x[0];
175 x[1] = 0.93 * shared + 0.37 * x[1];
176 x[2] = 0.90 * shared + 0.44 * x[2];
177 x
178 };
179
180 let mut log = ObservationLog::new();
181 for _ in 0..40 {
182 let (a, b) = (correlated(&mut rng), correlated(&mut rng));
183 log.push(user.observe_duel(&mut rng, a, b, 0));
184 }
185 let data = FitSet::as_is(&log);
186
187 let fit = |cfg: TasteConfig, seed: u64| {
188 let mut r = StdRng::seed_from_u64(seed);
189 let p = TasteModel::new(cfg).fit(&mut r, &data, 20_000, 6_000);
190 cosine(&p.theta_mean(0), &user.theta)
191 };
192
193 let mut wins = 0;
197 let (mut sum_flat, mut sum_fused) = (0.0, 0.0);
198 for seed in [7u64, 19, 23, 41, 57, 63, 71, 89, 97, 103, 111, 127] {
199 let flat = fit(TasteConfig::linear(D), seed);
200 let mut cfg = TasteConfig::linear(D);
201 cfg.fused = vec![vec![0, 1, 2]];
202 cfg.fused_rho = Some(0.25);
203 let fused = fit(cfg, seed);
204 println!(
205 "seed {seed}: flat {flat:.3} fused {fused:.3} ({:+.3})",
206 fused - flat
207 );
208 sum_flat += flat;
209 sum_fused += fused;
210 if fused > flat {
211 wins += 1;
212 }
213 }
214 let n = 12.0;
215 let (flat, fused) = (sum_flat / n, sum_fused / n);
216 println!("mean: flat {flat:.3} fused {fused:.3}");
217 assert!(
218 fused > flat && wins >= 8,
219 "fusing the cluster did not help: flat {flat:.3}, fused {fused:.3}, {wins}/12 wins"
220 );
221 }
222
223 #[test]
234 fn imputation_costs_confidence_on_keep_kill_but_not_on_duels() {
235 let mut theta = vec![0.0; D];
236 theta[0] = 1.5;
237 theta[1] = 1.5;
238 theta[2] = 0.8;
239 let s = TasteSample {
240 theta: vec![theta],
241 tau: vec![0.0],
242 cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
243 };
244
245 let mut x = vec![0.0; D];
246 x[2] = 1.0;
247 let keep = Feedback::KeepKill {
248 x: x.clone(),
249 kept: true,
250 };
251
252 let measured = s.loglik_with(&keep, 0, &[]);
255 let imputed = s.loglik_with(&keep, 0, &[0, 1]);
256 assert!(
257 imputed < measured,
258 "imputing two weighted axes did not reduce confidence: measured {measured:.4}, imputed {imputed:.4}"
259 );
260 let coin = 0.5f64.ln();
262 assert!(
263 (imputed - coin).abs() < (measured - coin).abs(),
264 "the correction moved the verdict away from 0.5 instead of toward it"
265 );
266
267 let mut theta_z = vec![0.0; D];
270 theta_z[2] = 0.8;
271 let s0 = TasteSample {
272 theta: vec![theta_z],
273 ..s.clone()
274 };
275 assert!(
276 (s0.loglik_with(&keep, 0, &[0, 1]) - s0.loglik_with(&keep, 0, &[])).abs() < 1e-12,
277 "an imputed axis with zero weight must be free"
278 );
279
280 let duel = Feedback::Duel {
282 a: x.clone(),
283 b: vec![0.0; D],
284 chose_a: true,
285 };
286 assert!(
287 (s.loglik_with(&duel, 0, &[0, 1]) - s.loglik_with(&duel, 0, &[])).abs() < 1e-12,
288 "a duel must not be attenuated — the imputed term cancels in u_a − u_b"
289 );
290 }
291
292 #[test]
295 fn mixed_modalities_recover() {
296 let mut rng = StdRng::seed_from_u64(22);
297 let user = ground_truth();
298
299 let mut log = ObservationLog::new();
300 for _ in 0..150 {
301 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
302 log.push(user.observe_duel(&mut rng, a, b, 0));
303 }
304 for _ in 0..150 {
305 let x = random_phi(&mut rng);
306 let kept = user.keep(&mut rng, &x);
307 log.push(Observation::new(Feedback::KeepKill { x, kept }, 0, &[]));
308 }
309 for _ in 0..150 {
310 let x = random_phi(&mut rng);
311 let rating = user.stars(&mut rng, &x);
312 log.push(Observation::new(Feedback::Stars { x, rating }, 0, &[]));
313 }
314
315 let model = TasteModel::new(TasteConfig::linear(D));
316 let posterior = model.fit(&mut rng, &FitSet::as_is(&log), 30_000, 10_000);
317
318 let cos = cosine(&posterior.theta_mean(0), &user.theta);
319 assert!(cos > 0.85, "mixed-modality recovery cosine {cos} too low");
320
321 let tau_mean: f64 = posterior.samples.iter().map(|s| s.tau[0]).sum::<f64>()
323 / posterior.samples.len() as f64;
324 assert!(
325 (tau_mean - user.tau).abs() < 0.6,
326 "tau posterior mean {tau_mean} far from truth {}",
327 user.tau
328 );
329 }
330
331 #[test]
334 fn posterior_ranks_a_pool() {
335 let mut rng = StdRng::seed_from_u64(33);
336 let user = ground_truth();
337
338 let mut log = ObservationLog::new();
339 for _ in 0..300 {
340 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
341 log.push(user.observe_duel(&mut rng, a, b, 0));
342 }
343 let model = TasteModel::new(TasteConfig::linear(D));
344 let posterior = model.fit(&mut rng, &FitSet::as_is(&log), 30_000, 10_000);
345
346 let pool: Vec<Vec<f64>> = (0..100).map(|_| random_phi(&mut rng)).collect();
348 let mut by_model: Vec<usize> = (0..pool.len()).collect();
349 by_model.sort_by(|&i, &j| {
350 posterior
351 .utility(&pool[j], 0)
352 .0
353 .total_cmp(&posterior.utility(&pool[i], 0).0)
354 });
355 let mut by_truth: Vec<usize> = (0..pool.len()).collect();
356 by_truth.sort_by(|&i, &j| user.utility(&pool[j]).total_cmp(&user.utility(&pool[i])));
357
358 let top_model: std::collections::HashSet<usize> = by_model[..10].iter().copied().collect();
359 let overlap = by_truth[..10]
360 .iter()
361 .filter(|i| top_model.contains(i))
362 .count();
363 assert!(
364 overlap >= 6,
365 "only {overlap}/10 of the true best candidates in the model's top 10"
366 );
367 }
368
369 #[test]
384 fn misspecified_user_is_learned_partially_and_detectably() {
385 let mut rng = StdRng::seed_from_u64(0x1DEA);
386 let mut center = vec![0.0; D];
387 let mut weights = vec![0.15; D];
388 center[0] = 0.8;
390 center[1] = -0.6;
391 center[3] = 0.4;
392 weights[0] = 0.9;
393 weights[1] = 0.7;
394 weights[3] = 0.5;
395 let curved = IdealPointUser { center, weights };
396 let linear = ground_truth();
397
398 let accuracy = |rng: &mut StdRng, use_curved: bool| -> f64 {
400 let mut log = ObservationLog::new();
401 for _ in 0..300 {
402 let (a, b) = (random_phi(rng), random_phi(rng));
403 log.push(if use_curved {
404 curved.observe_duel(rng, a, b, 0)
405 } else {
406 linear.observe_duel(rng, a, b, 0)
407 });
408 }
409 let posterior = TasteModel::new(TasteConfig::mixture(D, 2)).fit(
410 rng,
411 &FitSet::as_is(&log),
412 20_000,
413 6_000,
414 );
415 let mut correct = 0;
416 let n_test = 400;
417 for _ in 0..n_test {
418 let (a, b) = (random_phi(rng), random_phi(rng));
419 let truth = if use_curved {
420 curved.utility(&a) > curved.utility(&b)
421 } else {
422 linear.utility(&a) > linear.utility(&b)
423 };
424 if (posterior.prob_prefers(&a, &b) > 0.5) == truth {
425 correct += 1;
426 }
427 }
428 correct as f64 / n_test as f64
429 };
430
431 let acc_curved = accuracy(&mut rng, true);
432 let acc_linear = accuracy(&mut rng, false);
433 println!("misspecified acc {acc_curved:.3} vs well-specified {acc_linear:.3}");
434
435 assert!(
436 acc_curved > 0.60,
437 "a concave user should still be ranked well above chance, got {acc_curved}"
438 );
439 assert!(
440 acc_curved < acc_linear,
441 "the harness cannot tell a misspecified user ({acc_curved}) from a \
442 well-specified one ({acc_linear}) — it would not catch a real one"
443 );
444 }
445
446 #[test]
449 fn log_roundtrips() {
450 let mut rng = StdRng::seed_from_u64(44);
451 let user = ground_truth();
452 let mut log = ObservationLog::new();
453 for s in 0..3 {
454 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
455 log.push(user.observe_duel(&mut rng, a, b, s));
456 }
457 let dir = std::env::temp_dir().join("auracle-taste-test");
458 std::fs::create_dir_all(&dir).unwrap();
459 let path = dir.join("log.json");
460 log.save(&path).unwrap();
461 let back = ObservationLog::load(&path).unwrap();
462 assert_eq!(back, log);
463 assert_eq!(back.n_sessions(), 3);
464 }
465
466 #[test]
475 fn standardizer_standardizes() {
476 let mut rng = StdRng::seed_from_u64(55);
477 let rows: Vec<Vec<f64>> = (0..500)
478 .map(|_| vec![rng.gen::<f64>() * 100.0, 5.0, rng.gen::<f64>() - 3.0])
479 .collect();
480 let sz = Standardizer::fit(&rows);
481 assert_eq!(sz.dimension(), 3);
482 let transformed: Vec<Vec<f64>> = rows.iter().map(|r| sz.transform(r)).collect();
483 for dim in [0, 2] {
484 let col: Vec<f64> = transformed.iter().map(|r| r[dim]).collect();
485 let mean = col.iter().sum::<f64>() / col.len() as f64;
486 let var = col.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / col.len() as f64;
487 assert!(mean.abs() < 1e-9, "dim {dim} is not centred: {mean}");
488 assert!((var - 1.0).abs() < 1e-9, "dim {dim} scale drifted: {var}");
489 }
490 assert!(transformed.iter().all(|r| r[1].abs() < 1e-9));
492 }
493
494 #[test]
498 fn legacy_logs_still_load() {
499 let json = r#"{"observations":[
500 {"Duel":{"a":[1.0,2.0],"b":[3.0,4.0],"chose_a":true,"session":0}},
501 {"KeepKill":{"x":[0.5,0.25],"kept":false,"session":1}},
502 {"Stars":{"x":[0.1,0.2],"rating":3,"session":1}}
503 ]}"#;
504 let log: ObservationLog = serde_json::from_str(json).unwrap();
505 assert_eq!(log.len(), 3);
506 assert_eq!(log.n_sessions(), 2);
507 assert!(
508 log.observations.iter().all(|o| !o.is_raw()),
509 "legacy observations must not claim to be raw"
510 );
511 assert!(log
513 .raw_rows(&[String::from("a"), String::from("b")])
514 .is_empty());
515 }
516
517 #[test]
523 fn observations_reproject_by_name() {
524 let names_then: Vec<String> = ["bright", "noisy"].iter().map(|s| s.to_string()).collect();
525 let mut log = ObservationLog::new();
526 log.push(Observation::new(
527 Feedback::Duel {
528 a: vec![10.0, 1.0],
529 b: vec![0.0, 3.0],
530 chose_a: true,
531 },
532 0,
533 &names_then,
534 ));
535 let names_now: Vec<String> = ["noisy", "warm", "bright"]
537 .iter()
538 .map(|s| s.to_string())
539 .collect();
540 let sz = Standardizer {
541 mean: vec![2.0, 7.0, 5.0],
542 std: vec![1.0, 2.0, 5.0],
543 };
544 let fit = FitSet::build(&log, &names_now, &sz);
545 let Feedback::Duel { a, b, chose_a } = &fit.rows[0].0 else {
546 panic!("modality changed");
547 };
548 assert!(chose_a);
549 assert_eq!(a, &vec![-1.0, 0.0, 1.0]);
551 assert_eq!(b, &vec![1.0, 0.0, -1.0]);
552 assert_eq!(log.raw_rows(&names_then).len(), 2, "raw rows are fittable");
553 }
554
555 #[test]
560 fn sigma_theta_compensates_the_k_schedule() {
561 let s1 = TasteConfig::linear(D).sigma_theta();
562 let mut prev = s1;
563 for k in 2..=5 {
564 let s = TasteConfig::mixture(D, k).sigma_theta();
565 assert!(s > prev, "sigma did not widen from K={} to K={k}", k - 1);
566 prev = s;
567 }
568 assert!(
569 (s1 - 1.0 / (D as f64).sqrt()).abs() < 1e-12,
570 "K=1 unchanged"
571 );
572 for k in 1..=5 {
574 let s = TasteConfig::mixture(D, k).sigma_theta();
575 let sd_u = s * (D as f64).sqrt() * MAX_NORMAL_SD[k - 1];
576 assert!((sd_u - 1.0).abs() < 1e-9, "K={k} utility SD {sd_u}");
577 }
578 let mut cfg = TasteConfig::mixture(D, 5);
580 cfg.theta_prior_std = Some(0.3);
581 assert_eq!(cfg.sigma_theta(), 0.3);
582 }
583
584 #[test]
588 fn importance_updates_track_new_evidence() {
589 let mut rng = StdRng::seed_from_u64(88);
590 let user = ground_truth();
591 let mut log = ObservationLog::new();
592 for _ in 0..40 {
593 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
594 log.push(user.observe_duel(&mut rng, a, b, 0));
595 }
596 let p = TasteModel::new(TasteConfig::linear(D)).fit(
597 &mut rng,
598 &FitSet::as_is(&log),
599 8_000,
600 3_000,
601 );
602 assert!(
603 (p.ess() - p.samples.len() as f64).abs() < 1e-6,
604 "fit is uniform"
605 );
606
607 let mut a = vec![0.0; D];
610 a[0] = 3.0;
611 let mut b = vec![0.0; D];
612 b[0] = -3.0;
613 let before = p.prob_prefers(&a, &b);
614 let after = p
615 .reweighted(
616 &Feedback::Duel {
617 a: a.clone(),
618 b: b.clone(),
619 chose_a: true,
620 },
621 0,
622 )
623 .reweighted(
624 &Feedback::Duel {
625 a: a.clone(),
626 b: b.clone(),
627 chose_a: true,
628 },
629 0,
630 );
631 assert!(
632 after.prob_prefers(&a, &b) > before,
633 "reweighting ignored the evidence: {before} → {}",
634 after.prob_prefers(&a, &b)
635 );
636 assert!(
637 after.ess() < p.ess(),
638 "ESS must show the cost of the update"
639 );
640 assert!((after.weights.iter().sum::<f64>() - 1.0).abs() < 1e-9);
641
642 let re = after.resampled();
643 assert_eq!(re.samples.len(), after.samples.len());
644 assert!(
645 (re.ess() - re.samples.len() as f64).abs() < 1e-6,
646 "resampling restores uniform weights"
647 );
648 assert!((re.prob_prefers(&a, &b) - after.prob_prefers(&a, &b)).abs() < 0.05);
650 }
651
652 #[test]
655 fn k2_smoke() {
656 let mut rng = StdRng::seed_from_u64(66);
657 let user = ground_truth();
658 let mut log = ObservationLog::new();
659 for s in 0..2 {
660 for _ in 0..30 {
661 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
662 log.push(user.observe_duel(&mut rng, a, b, s));
663 }
664 }
665 let posterior = TasteModel::new(TasteConfig::mixture(D, 2))
666 .fit(&mut rng, &FitSet::as_is(&log), 4_000, 2_000)
667 .aligned();
668 let phi = random_phi(&mut rng);
669 for style in 0..2 {
670 let (m, s) = posterior.utility(&phi, style);
671 assert!(m.is_finite() && s.is_finite());
672 }
673 let (m, s) = posterior.utility_mix(&phi);
674 assert!(m.is_finite() && s.is_finite());
675 let r = posterior.responsibilities(&phi);
676 assert!((r.iter().sum::<f64>() - 1.0).abs() < 1e-9);
677 }
678
679 #[test]
685 fn mixture_captures_bimodal_taste() {
686 let mut rng = StdRng::seed_from_u64(77);
687 let mut theta_a = vec![0.0; D];
691 theta_a[0] = 2.4;
692 theta_a[1] = 1.2;
693 theta_a[2] = 0.8;
694 let mut theta_b = vec![0.0; D];
695 theta_b[0] = -2.4;
696 theta_b[1] = 1.2;
697 theta_b[3] = 0.8;
698 let user = MixtureSyntheticUser {
699 thetas: vec![theta_a.clone(), theta_b.clone()],
700 };
701
702 let mut log = ObservationLog::new();
703 for _ in 0..350 {
704 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
705 log.push(user.observe_duel(&mut rng, a, b, 0));
706 }
707
708 let p1 = TasteModel::new(TasteConfig::linear(D)).fit(
709 &mut rng,
710 &FitSet::as_is(&log),
711 25_000,
712 8_000,
713 );
714 let p2 = TasteModel::new(TasteConfig::mixture(D, 2)).fit(
715 &mut rng,
716 &FitSet::as_is(&log),
717 45_000,
718 15_000,
719 );
720
721 let mut correct = [0usize; 2];
723 let n_test = 400;
724 for _ in 0..n_test {
725 let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
726 let truth = user.utility(&a) > user.utility(&b);
727 if (p1.prob_prefers(&a, &b) > 0.5) == truth {
728 correct[0] += 1;
729 }
730 if (p2.prob_prefers(&a, &b) > 0.5) == truth {
731 correct[1] += 1;
732 }
733 }
734 let acc1 = correct[0] as f64 / n_test as f64;
735 let acc2 = correct[1] as f64 / n_test as f64;
736 assert!(
737 acc2 > acc1 + 0.02,
738 "mixture ({acc2}) does not beat linear ({acc1}) on a bimodal user"
739 );
740 assert!(acc2 > 0.75, "mixture accuracy {acc2} too low");
741
742 let aligned = p2.aligned();
744 let best_cos = |truth: &[f64]| -> f64 {
745 (0..2)
746 .map(|k| cosine(&aligned.theta_mean(k), truth))
747 .fold(f64::NEG_INFINITY, f64::max)
748 };
749 let (ca, cb) = (best_cos(&theta_a), best_cos(&theta_b));
750 assert!(
751 ca > 0.6 && cb > 0.6,
752 "style recovery too weak: cos_a={ca:.2} cos_b={cb:.2}"
753 );
754 }
755
756 #[test]
763 fn a_log_written_before_provenance_still_loads() {
764 let old = r#"{"observations":[
765 {"feedback":{"Duel":{"a":[0.5],"b":[0.25],"chose_a":true}},
766 "session":0,"feature_names":["x"],"schema_version":2},
767 {"KeepKill":{"x":[0.1],"kept":true,"session":1}}
768 ]}"#;
769 let log: ObservationLog = serde_json::from_str(old).expect("an old log parses");
770 assert_eq!(log.len(), 2);
771 assert!(log
772 .observations
773 .iter()
774 .all(|o| o.provenance == Provenance::Duel));
775 assert_eq!(log.n_with(Provenance::Duel), 2);
776 assert_eq!(log.n_with(Provenance::SelfReport), 0);
777
778 let mut log = log;
781 log.push(Observation::tagged(
782 Feedback::KeepKill {
783 x: vec![0.7],
784 kept: false,
785 },
786 2,
787 &["x".to_string()],
788 Provenance::SelfReport,
789 ));
790 let json = serde_json::to_string(&log).unwrap();
791 assert!(json.contains("self_report"));
792 assert_eq!(
793 json.matches("provenance").count(),
794 1,
795 "the default was serialized"
796 );
797 let back: ObservationLog = serde_json::from_str(&json).unwrap();
798 assert_eq!(back, log);
799 }
800}