1pub mod calib;
26pub mod engine;
27pub mod farm;
28pub mod map;
29pub mod migrate;
30pub mod naming;
31pub mod surrogate;
32
33pub use calib::{calibration, Calibration, Forecast, ProvenanceScore, ReliabilityBin};
34pub use engine::{
35 phi_names, tilt_weights, Acquisition, BankEntry, Candidate, Contribution, DuelChoice,
36 EditOutcome, Engine, Explanation, ImplicitEvent, LineageEvent, Origin, Profile, RefineKeep,
37 RenderPolicy, SessionConfig, SessionState,
38};
39pub use farm::{draw_seed, Draw, PreFeaturized};
40pub use map::{MapPoint, TasteMap};
41pub use naming::{claim_name, NameScale};
42pub use surrogate::{SurrogateFitness, QUARANTINE_FITNESS};
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47 use crate::calib::{calibration, Forecast};
48 use auracle_taste::Provenance;
49
50 fn fast() -> SessionConfig {
66 SessionConfig {
67 mcmc_samples: 6_000,
68 mcmc_warmup: 2_000,
69 ..Default::default()
70 }
71 }
72 use auracle_features::Features;
73 use auracle_grammar::PatchGrammarPrior;
74 use auracle_taste::synthetic::cosine;
75 use auracle_taste::SyntheticUser;
76 use rand::rngs::StdRng;
77 use rand::SeedableRng;
78
79 fn ground_truth() -> SyntheticUser {
83 let names = Features::phi_names();
84 let mut theta = vec![0.0; names.len()];
85 let mut set = |name: &str, w: f64| {
89 let i = names
90 .iter()
91 .position(|n| n.split(':').next() == Some(name))
92 .unwrap();
93 theta[i] = w;
94 };
95 set("centroid_mean", 2.0);
96 set("flatness_mean", -1.5);
97 set("attack_s", -1.5);
98 set("bass_fraction", 1.0);
99 set("n_filter", 0.8);
100 set("tail_ratio", 0.6);
101 SyntheticUser {
102 theta,
103 tau: 0.0,
104 cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
105 }
106 }
107
108 #[test]
112 fn proposal_tilt_follows_taste() {
113 let base = [0.2, 0.35, 0.15, 0.15, 0.15];
114 let tilts = [0.0, 0.0, -3.0, 3.0, 0.0];
116 let w = tilt_weights(&base, &tilts, 0.6);
117 assert!((w.iter().sum::<f64>() - 1.0).abs() < 1e-12, "normalized");
118 assert!(w[3] > base[3], "loved kind gains mass");
119 assert!(w[2] < base[2], "hated kind loses mass");
120 let extreme = tilt_weights(&base, &[-50.0, 50.0, 0.0, 0.0, 0.0], 1.0);
122 assert!(extreme[0] > 0.01, "clamped kind never starves");
123 let id = tilt_weights(&base, &tilts, 0.0);
125 for (a, b) in id.iter().zip(&base) {
126 assert!((a - b).abs() < 1e-12);
127 }
128 }
129
130 #[test]
141 fn partial_pool_becomes_duelable_and_the_scale_holds_still() {
142 let mut rng = StdRng::seed_from_u64(0xB007);
143 let cfg = SessionConfig {
147 pool_size: 32,
148 ..fast()
149 };
150 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
151 engine.begin_session();
152 assert!(
153 engine.fill_pool_step(&mut rng, 4) >= 2,
154 "pool too small to test"
155 );
156 assert!(
157 engine.pool.iter().all(|c| c.phi_std.is_empty()),
158 "a short pool standardized itself"
159 );
160 assert!(
161 engine.next_duel(&mut rng).is_none(),
162 "an un-standardized pool must not be duel-able"
163 );
164
165 engine.standardize_now();
166 assert!(engine.pool.iter().all(|c| !c.phi_std.is_empty()));
167 assert!(
168 engine.next_duel(&mut rng).is_some(),
169 "standardize_now did not make the partial pool duel-able"
170 );
171 let provisional = engine.standardizer.clone().expect("standardizer fit");
172
173 assert!(engine.fill_pool_step(&mut rng, 4) >= 1);
177 engine.standardize_now();
178 assert_eq!(
179 engine.standardizer.as_deref(),
180 Some(&*provisional),
181 "standardize_now replaced a live standardizer"
182 );
183 assert!(engine.pool.iter().all(|c| !c.phi_std.is_empty()));
184
185 engine.restandardize_if_untaught();
188 assert_ne!(
189 engine.standardizer.as_deref(),
190 Some(&*provisional),
191 "the completion re-fit did nothing"
192 );
193
194 engine.record_duel(0, 1, true);
197 engine.fit_posterior(&mut rng);
198 let taught = engine.standardizer.clone().expect("standardizer after fit");
199 assert!(engine.fill_pool_step(&mut rng, 2) >= 1);
200 engine.restandardize_if_untaught();
201 assert_eq!(
202 engine.standardizer.as_deref(),
203 Some(&*taught),
204 "re-standardized under a live posterior"
205 );
206 }
207
208 #[test]
217 fn a_pinned_patch_survives_eviction_pressure() {
218 let mut rng = StdRng::seed_from_u64(0x9111);
219 let mut engine = Engine::new(
220 PatchGrammarPrior::default(),
221 SessionConfig {
222 pool_size: 8,
223 ..fast()
224 },
225 );
226 engine.fill_pool(&mut rng);
227 assert_eq!(engine.pool.len(), 8, "pool did not fill");
228
229 let worst = engine.ranked().last().expect("a ranked pool").0;
230 let doomed = engine.pool[worst].id;
231 assert!(engine.set_pinned(doomed, true), "the pin was refused");
232
233 let mut inserted = 0;
234 for (name, tree) in auracle_grammar::presets() {
235 if engine.insert_preset(tree, name).is_some() {
236 inserted += 1;
237 }
238 }
239 assert!(
240 inserted >= 4,
241 "only {inserted} insertions — not enough to force eviction"
242 );
243 assert_eq!(engine.pool.len(), 8, "pool grew past its cap");
244 assert!(
245 engine.find(doomed).is_some(),
246 "the pinned patch was evicted anyway — a pin that does not hold is \
247 worse than no pin, because the UI promises it held"
248 );
249 }
250
251 #[test]
255 fn every_fit_records_what_each_lens_claimed() {
256 let mut rng = StdRng::seed_from_u64(0x5747);
257 let mut engine = Engine::new(
258 PatchGrammarPrior::default(),
259 SessionConfig {
260 pool_size: 12,
261 ..fast()
262 },
263 );
264 engine.begin_session();
265 engine.fill_pool(&mut rng);
266 assert!(
267 engine.style_shares().is_empty(),
268 "nothing fitted yet, so nothing to report"
269 );
270
271 for _ in 0..6 {
272 let (a, b) = engine.next_duel(&mut rng).unwrap();
273 engine.record_duel(a, b, true);
274 }
275 engine.fit_posterior(&mut rng);
276
277 let rows = engine.style_shares();
278 assert_eq!(rows.len(), 1, "one row per fit");
279 let r = &rows[0];
280 assert_eq!(r.observations, 6);
281 assert_eq!(r.shares.len(), r.k, "a share per lens the fit was allowed");
282 let total: f64 = r.shares.iter().sum();
283 assert!(
284 (total - 1.0).abs() < 1e-6,
285 "shares are a distribution over lenses, summing to 1, not {total}"
286 );
287
288 engine.fit_posterior(&mut rng);
292 assert_eq!(engine.style_shares().len(), 2);
293
294 let state = engine.export_state();
297 let mut restored = Engine::new(PatchGrammarPrior::default(), fast());
298 restored.import_state(state);
299 assert_eq!(
300 restored.style_shares().len(),
301 2,
302 "the register did not survive a reload, so it cannot accumulate"
303 );
304 }
305
306 #[test]
307 fn the_pin_budget_is_capped_and_refusal_is_reported() {
308 let mut rng = StdRng::seed_from_u64(0x9112);
309 let mut engine = Engine::new(
310 PatchGrammarPrior::default(),
311 SessionConfig {
312 pool_size: 8,
313 ..fast()
314 },
315 );
316 engine.fill_pool(&mut rng);
317 let ids: Vec<u64> = engine.pool.iter().map(|c| c.id).collect();
318 let cap = engine.pin_cap();
319 assert!(cap >= 1 && cap < ids.len(), "cap {cap} is not a real bound");
320
321 for id in ids.iter().take(cap) {
322 assert!(engine.set_pinned(*id, true), "pin within budget refused");
323 }
324 assert_eq!(engine.pinned_count(), cap);
325 assert!(
326 !engine.set_pinned(ids[cap], true),
327 "pinning past the cap must be refused, not silently ignored"
328 );
329 assert!(engine.set_pinned(ids[0], true), "idempotent re-pin refused");
331 assert!(engine.set_pinned(ids[0], false));
333 assert!(
334 engine.set_pinned(ids[cap], true),
335 "freed budget not reusable"
336 );
337 assert!(
338 !engine.set_pinned(9_999_999, true),
339 "unknown id reported ok"
340 );
341 }
342
343 #[test]
351 fn a_bank_entry_saved_before_pins_still_loads() {
352 let (_, tree) = auracle_grammar::presets().remove(0);
353 let legacy = serde_json::json!({
354 "id": 7,
355 "tree": tree,
356 "origin": "preset",
357 "name": "Saved Last Week",
358 });
359 let entry: BankEntry = serde_json::from_value(legacy).expect("legacy entry must load");
360 assert_eq!(entry.id, 7);
361 assert!(!entry.pinned, "a pre-pin entry must restore as unpinned");
362 }
363
364 #[test]
369 fn session_state_roundtrips() {
370 let mut rng = StdRng::seed_from_u64(0x5AFE);
371 let cfg = SessionConfig {
372 pool_size: 8,
373 ..fast()
374 };
375 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg.clone());
376 engine.begin_session();
377 engine.fill_pool(&mut rng);
378 assert!(engine.pool.len() >= 4, "pool too small to test");
379 engine.record_duel(0, 1, true);
380 engine.record_keep(2, false);
381 let named_id = engine.pool[0].id;
382 engine.set_name(named_id, "My Bass");
383 let pinned_id = engine.pool[3].id;
386 assert!(engine.set_pinned(pinned_id, true));
387
388 let json = serde_json::to_string(&engine.export_state()).unwrap();
389 let state: SessionState = serde_json::from_str(&json).unwrap();
390
391 let mut restored = Engine::new(PatchGrammarPrior::default(), cfg);
392 restored.begin_session();
393 let n = restored.import_state(state);
394 assert_eq!(n, engine.pool.len(), "bank entries lost in restore");
395 assert_eq!(restored.log.len(), 2, "observations lost");
396 for (a, b) in engine.pool.iter().zip(&restored.pool) {
397 assert_eq!(a.id, b.id);
398 assert_eq!(a.tree, b.tree);
399 assert_eq!(a.name, b.name);
400 assert_eq!(a.origin, b.origin);
401 assert_eq!(a.pinned, b.pinned, "a pin did not survive the reload");
402 for (x, y) in a.phi_std.iter().zip(&b.phi_std) {
404 assert!((x - y).abs() < 1e-9, "phi drifted across restore");
405 }
406 assert_eq!(
407 b.render.is_some(),
408 restored.cfg.render_policy == RenderPolicy::Eager,
409 "only an eager pool carries audition audio at admission"
410 );
411 assert_eq!(a.key, b.key, "content address must survive a round trip");
412 }
413 let max_old = engine.pool.iter().map(|c| c.id).max().unwrap();
415 let new_id = restored
416 .insert_preset(auracle_grammar::presets()[0].1.clone(), "p")
417 .unwrap();
418 assert!(new_id > max_old, "id allocation collided after restore");
419 }
420
421 #[test]
429 fn lazy_renders_are_bit_identical_and_bounded() {
430 let base = |policy| SessionConfig {
431 pool_size: 4,
432 render_policy: policy,
433 audio_cache: 2,
434 ..fast()
435 };
436
437 let mut rng = StdRng::seed_from_u64(0xA1D10);
438 let mut eager = Engine::new(PatchGrammarPrior::default(), base(RenderPolicy::Eager));
439 eager.fill_pool(&mut rng);
440
441 let mut rng = StdRng::seed_from_u64(0xA1D10);
443 let mut lazy = Engine::new(PatchGrammarPrior::default(), base(RenderPolicy::Lazy));
444 lazy.fill_pool(&mut rng);
445
446 assert!(eager.pool.len() >= 3, "pool too small to test");
447 assert_eq!(eager.pool.len(), lazy.pool.len(), "policy changed the pool");
448 assert!(
449 eager.pool.iter().all(|c| c.render.is_some()),
450 "eager pool dropped a buffer"
451 );
452 assert!(
453 lazy.pool.iter().all(|c| c.render.is_none()),
454 "lazy pool retained a buffer at admission"
455 );
456
457 lazy.memo().clear();
460
461 let ids: Vec<u64> = lazy.pool.iter().map(|c| c.id).collect();
462 for (k, id) in ids.iter().enumerate() {
463 let want = eager.pool[k].render.clone().expect("eager keeps audio");
464 let got = lazy.render_of(*id).expect("lazy materializes").clone();
465 assert_eq!(got.sample_rate, want.sample_rate);
466 assert_eq!(
467 got.samples, want.samples,
468 "lazily materialized audition drifted from the featurized render"
469 );
470 }
471 assert_eq!(
472 lazy.pool.iter().filter(|c| c.render.is_some()).count(),
473 2,
474 "audio_cache did not bound resident audition buffers"
475 );
476
477 let mut rng = StdRng::seed_from_u64(0xA1D10);
480 let mut none = Engine::new(PatchGrammarPrior::default(), base(RenderPolicy::None));
481 none.fill_pool(&mut rng);
482 let id = none.pool[0].id;
483 assert!(none.render_of(id).is_none());
484 }
485
486 #[test]
491 fn every_featurize_site_consults_the_memo() {
492 let cfg = SessionConfig {
493 pool_size: 5,
494 render_policy: RenderPolicy::Lazy,
495 ..fast()
496 };
497 let mut rng = StdRng::seed_from_u64(0xF00D);
498 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg.clone());
499 engine.fill_pool(&mut rng);
500 assert!(engine.pool.len() >= 3, "pool too small to test");
501
502 let before = engine.memo().stats();
503 assert!(
504 before.misses >= engine.pool.len() as u64,
505 "fill did not populate the memo"
506 );
507
508 let n = engine.pool.len();
509 let state = engine.export_state();
510 let mut restored = Engine::new(PatchGrammarPrior::default(), cfg);
511 restored.set_memo(engine.memo().clone());
512 assert_eq!(restored.import_state(state), n, "bank entries lost");
513
514 let after = restored.memo().stats();
515 assert_eq!(
516 after.misses,
517 before.misses,
518 "restore re-rendered {} terms the memo already held",
519 after.misses - before.misses
520 );
521 assert_eq!(after.hits, before.hits + n as u64);
522
523 for (a, b) in engine.pool.iter().zip(&restored.pool) {
526 assert_eq!(a.key, b.key);
527 assert_eq!(a.features.phi(), b.features.phi());
528 assert_eq!(a.features.gain_db, b.features.gain_db);
529 }
530 }
531
532 #[test]
537 fn the_memo_does_not_change_the_pool() {
538 let build = |memo: auracle_features::RenderMemo| {
539 let cfg = SessionConfig {
540 pool_size: 6,
541 ..fast()
542 };
543 let mut rng = StdRng::seed_from_u64(0x11EE);
544 let mut e = Engine::new(PatchGrammarPrior::default(), cfg);
545 e.set_memo(memo);
546 e.begin_session();
547 e.fill_pool(&mut rng);
548 e
549 };
550 let memoized = build(auracle_features::RenderMemo::default());
551 let plain = build(auracle_features::RenderMemo::disabled());
552
553 assert!(memoized.pool.len() >= 3, "pool too small to test");
554 assert_eq!(memoized.pool.len(), plain.pool.len(), "pool size changed");
555 for (a, b) in memoized.pool.iter().zip(&plain.pool) {
556 assert_eq!(a.id, b.id);
557 assert_eq!(a.tree, b.tree, "the memo changed which terms were drawn");
558 assert_eq!(a.key, b.key);
559 assert_eq!(a.features.phi(), b.features.phi(), "φ drifted");
560 assert_eq!(a.phi_std, b.phi_std);
561 }
562 assert_eq!(plain.memo().stats().features, 0, "disabled memo retained");
563 }
564
565 #[test]
589 fn closed_loop_learns_synthetic_taste() {
590 const SEEDS: [u64; 5] = [0xE05, 0x1, 0x2, 0x3, 0x4];
595
596 fn one(seed: u64) -> (f64, f64, f64, f64) {
598 let mut rng = StdRng::seed_from_u64(seed);
599 let user = ground_truth();
600
601 let cfg = SessionConfig {
602 pool_size: 48,
603 refine_steps: 0, mcmc_samples: SessionConfig::default().mcmc_samples,
609 mcmc_warmup: SessionConfig::default().mcmc_warmup,
610 ..fast()
611 };
612 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
613 engine.begin_session();
614 engine.fill_pool(&mut rng);
615 assert!(
616 engine.pool.len() >= 40,
617 "seed {seed:#x}: pool only filled to {}",
618 engine.pool.len()
619 );
620
621 for _ in 0..4 {
623 for _ in 0..15 {
624 let (a, b) = engine.next_duel(&mut rng).unwrap();
625 let chose_a =
626 user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
627 engine.record_duel(a, b, chose_a);
628 }
629 engine.fit_posterior(&mut rng);
630 }
631
632 let posterior = engine.posterior.as_ref().unwrap();
634 let (mut xs, mut ys) = (Vec::new(), Vec::new());
635 for c in &engine.pool {
636 xs.push(posterior.utility_mix(&c.phi_std).0);
637 ys.push(user.utility(&c.phi_std));
638 }
639 let r = pearson(&xs, &ys);
640
641 let top5: f64 = engine
643 .ranked()
644 .iter()
645 .take(5)
646 .map(|&(i, _, _)| user.utility(&engine.pool[i].phi_std))
647 .sum::<f64>()
648 / 5.0;
649 let pool_mean = ys.iter().sum::<f64>() / ys.len() as f64;
650 let pool_std = (ys
651 .iter()
652 .map(|y| (y - pool_mean) * (y - pool_mean))
653 .sum::<f64>()
654 / ys.len() as f64)
655 .sqrt();
656
657 let cos = (0..posterior.k_styles())
666 .map(|k| cosine(&posterior.theta_mean(k), &user.theta))
667 .fold(f64::NEG_INFINITY, f64::max);
668
669 (r, top5, pool_mean + 0.5 * pool_std, cos)
670 }
671
672 let rows: Vec<(u64, (f64, f64, f64, f64))> = std::thread::scope(|s| {
673 let handles: Vec<_> = SEEDS
674 .iter()
675 .map(|&seed| s.spawn(move || (seed, one(seed))))
676 .collect();
677 handles.into_iter().map(|h| h.join().unwrap()).collect()
678 });
679
680 for (seed, (r, _, _, cos)) in &rows {
681 assert!(
682 *r > 0.45,
683 "seed {seed:#x}: posterior/truth correlation {r:.3} under the per-seed floor"
684 );
685 assert!(
686 *cos > 0.2,
687 "seed {seed:#x}: best theta cosine {cos:.3} under the per-seed floor"
688 );
689 }
690
691 let n = rows.len() as f64;
692 let mean_r = rows.iter().map(|(_, m)| m.0).sum::<f64>() / n;
693 let mean_top5 = rows.iter().map(|(_, m)| m.1).sum::<f64>() / n;
694 let mean_bar = rows.iter().map(|(_, m)| m.2).sum::<f64>() / n;
695 let mean_cos = rows.iter().map(|(_, m)| m.3).sum::<f64>() / n;
696
697 assert!(
698 mean_r > 0.6,
699 "posterior/truth correlation {mean_r:.3} averaged over {} seeds too low",
700 rows.len()
701 );
702 assert!(
703 mean_top5 > mean_bar,
704 "mean top-5 true utility {mean_top5:.2} not above mean pool mean+0.5σ ({mean_bar:.2})"
705 );
706 assert!(
707 mean_cos > 0.3,
708 "mean best theta direction cosine {mean_cos:.3} too low"
709 );
710
711 for (seed, (r, top5, bar, cos)) in &rows {
715 println!(" seed {seed:#x}: r={r:.3} top5={top5:.3} (vs {bar:.3}) cos={cos:.3}");
716 }
717 println!(
718 "closed loop @ {}+{} steps, {} seeds: mean r={mean_r:.3} (gate 0.6) \
719 mean top5={mean_top5:.3} vs {mean_bar:.3} (gate) mean cos={mean_cos:.3} (gate 0.3)",
720 SessionConfig::default().mcmc_samples,
721 SessionConfig::default().mcmc_warmup,
722 rows.len(),
723 );
724 }
725
726 #[test]
767 fn refinement_improves_pool() {
768 const SEEDS: [u64; 16] = [
769 0xF00D, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF,
770 ];
771 const GENERATIONS: usize = 3;
772
773 fn one(seed: u64) -> (f64, f64, usize) {
776 let mut rng = StdRng::seed_from_u64(seed);
777 let user = ground_truth();
778 let cfg = SessionConfig {
779 pool_size: 24,
780 refine_steps: 12,
784 refine_seeds: 3,
785 ..fast()
786 };
787 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
788 engine.begin_session();
789 engine.fill_pool(&mut rng);
790 for _ in 0..40 {
791 let (a, b) = engine.next_duel(&mut rng).unwrap();
792 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
793 engine.record_duel(a, b, chose_a);
794 }
795 engine.fit_posterior(&mut rng);
796
797 let truth = |e: &Engine| -> (f64, f64) {
801 let us: Vec<f64> = e.pool.iter().map(|c| user.utility(&c.phi_std)).collect();
802 let mean = us.iter().sum::<f64>() / us.len() as f64;
803 let max = us.iter().copied().fold(f64::NEG_INFINITY, f64::max);
804 (mean, max)
805 };
806 let (mean_before, max_before) = truth(&engine);
807 for _ in 0..GENERATIONS {
808 engine.refine(&mut rng);
809 }
810 let (mean_after, max_after) = truth(&engine);
811
812 let n_refined = engine
813 .pool
814 .iter()
815 .filter(|c| c.origin == Origin::Refined)
816 .count();
817
818 assert!(engine.pool.len() <= engine.cfg.pool_size);
821 for ev in &engine.lineage {
822 assert_eq!(ev.kind, "refine");
823 assert!(!ev.diff.is_empty(), "seed {seed:#x}: a child with no diff");
824 }
825
826 let logged: std::collections::HashSet<u64> =
840 engine.lineage.iter().map(|ev| ev.child_id).collect();
841 for c in engine.pool.iter().filter(|c| c.origin == Origin::Refined) {
842 assert!(
843 logged.contains(&c.id),
844 "seed {seed:#x}: refined candidate {} has no lineage event",
845 c.id
846 );
847 }
848 assert!(
849 engine.lineage.len() >= n_refined,
850 "seed {seed:#x}: {n_refined} refined in pool but only {} lineage events",
851 engine.lineage.len()
852 );
853
854 (
855 mean_after - mean_before,
856 max_after - max_before,
857 engine.lineage.len(),
858 )
859 }
860
861 let rows: Vec<(u64, (f64, f64, usize))> = std::thread::scope(|s| {
862 let handles: Vec<_> = SEEDS
863 .iter()
864 .map(|&seed| s.spawn(move || (seed, one(seed))))
865 .collect();
866 handles.into_iter().map(|h| h.join().unwrap()).collect()
867 });
868
869 for (seed, (mean_gain, max_gain, injected)) in &rows {
870 println!(
871 " seed {seed:#x}: mean gain {mean_gain:+.3} max gain {max_gain:+.3} \
872 injected {injected}"
873 );
874 }
875
876 let n = rows.len();
877 let mean_gain = rows.iter().map(|(_, m)| m.0).sum::<f64>() / n as f64;
878 let mut sorted: Vec<f64> = rows.iter().map(|(_, m)| m.0).collect();
879 sorted.sort_by(f64::total_cmp);
880 let median_gain = (sorted[(n - 1) / 2] + sorted[n / 2]) / 2.0;
881 let improved = rows.iter().filter(|(_, m)| m.0 > 0.0).count();
882 let worst_max = rows.iter().map(|(_, m)| m.1).fold(f64::INFINITY, f64::min);
883 let total_injected: usize = rows.iter().map(|(_, m)| m.2).sum();
884 println!(
885 "refinement over {n} seeds x {GENERATIONS} generations: median gain \
886 {median_gain:+.3} mean {mean_gain:+.3} improved {improved}/{n} \
887 worst max gain {worst_max:+.3} injected {total_injected}"
888 );
889
890 assert!(
893 total_injected > 0,
894 "refinement injected no candidates across any seed"
895 );
896 assert!(
897 improved >= IMPROVED_GATE,
898 "only {improved}/{n} seeds improved, under the {IMPROVED_GATE} gate"
899 );
900 assert!(
901 median_gain > MEDIAN_GAIN_GATE,
902 "median pool gain {median_gain:+.3} is under the {MEDIAN_GAIN_GATE:+.3} gate"
903 );
904 assert!(
905 worst_max >= -1e-9,
906 "refinement degraded a pool's best member by {worst_max:+.3}"
907 );
908 }
909
910 const MEDIAN_GAIN_GATE: f64 = 0.5;
951 const IMPROVED_GATE: usize = 10;
952
953 #[test]
957 fn locked_refinement_respects_locks() {
958 let mut rng = StdRng::seed_from_u64(0x10C5);
959 let user = ground_truth();
960 let cfg = SessionConfig {
961 pool_size: 16,
962 refine_steps: 20,
963 ..fast()
964 };
965 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
966 engine.begin_session();
967 engine.fill_pool(&mut rng);
968 for _ in 0..20 {
969 let (a, b) = engine.next_duel(&mut rng).unwrap();
970 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
971 engine.record_duel(a, b, chose_a);
972 }
973 engine.fit_posterior(&mut rng);
974
975 let locked = vec![
976 "amp#attack".to_string(),
977 "amp#decay".to_string(),
978 "amp#sustain".to_string(),
979 "amp#release".to_string(),
980 ];
981 let mut children = 0;
982 for round in 0..6 {
983 let seed_id = engine.pool[round % engine.pool.len()].id;
984 let seed_amp = engine.pool[engine.find(seed_id).unwrap()].tree.amp.clone();
985 if let Some(child_id) = engine.refine_from(&mut rng, seed_id, &locked) {
986 children += 1;
987 let child = &engine.pool[engine.find(child_id).unwrap()];
988 assert_eq!(child.tree.amp, seed_amp, "locked amp env moved");
989 let ev = engine.lineage.last().unwrap();
990 assert_eq!(ev.child_id, child_id);
991 assert!(ev.diff.iter().all(|d| !d.addr.starts_with("amp#")));
992 }
993 if children >= 2 {
994 break;
995 }
996 }
997 assert!(children > 0, "no locked refinement ever accepted a move");
998 }
999
1000 #[test]
1012 fn refinement_carries_node_identity() {
1013 use auracle_grammar::describe;
1014 let mut rng = StdRng::seed_from_u64(0x1D3);
1015 let user = ground_truth();
1016 let cfg = SessionConfig {
1017 pool_size: 16,
1018 refine_steps: 20,
1019 ..fast()
1020 };
1021 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1022 engine.begin_session();
1023 engine.fill_pool(&mut rng);
1024 for _ in 0..20 {
1025 let (a, b) = engine.next_duel(&mut rng).unwrap();
1026 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
1027 engine.record_duel(a, b, chose_a);
1028 }
1029 engine.fit_posterior(&mut rng);
1030
1031 let mut checked = 0;
1032 for round in 0..8 {
1033 let seed_id = engine.pool[round % engine.pool.len()].id;
1034 let seed = describe::describe(&engine.pool[engine.find(seed_id).unwrap()].tree);
1035 let Some(child_id) = engine.refine_from(&mut rng, seed_id, &[]) else {
1036 continue;
1037 };
1038 let child = describe::describe(&engine.pool[engine.find(child_id).unwrap()].tree);
1039 let mut carried = 0;
1040 for cm in &child.modules {
1041 if cm.key == "amp" {
1042 assert_eq!(cm.uid, 0, "the amp is the envelope, not a node");
1043 continue;
1044 }
1045 assert_ne!(cm.uid, 0, "{} came back without an identity", cm.key);
1046 if let Some(sm) = seed.modules.iter().find(|m| m.key == cm.key) {
1049 if sm.kind == cm.kind {
1050 assert_eq!(sm.uid, cm.uid, "identity lost at {}", cm.key);
1051 carried += 1;
1052 }
1053 }
1054 }
1055 if carried == 0 {
1073 continue;
1074 }
1075 checked += 1;
1076 if checked >= 2 {
1077 break;
1078 }
1079 }
1080 assert!(
1081 checked > 0,
1082 "no refinement round preserved any structure, so identity carrying was never exercised"
1083 );
1084 }
1085
1086 #[test]
1089 fn commit_edit_inserts_and_observes() {
1090 let mut rng = StdRng::seed_from_u64(0xED17);
1091 let cfg = SessionConfig {
1092 pool_size: 12,
1093 ..fast()
1094 };
1095 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1096 engine.begin_session();
1097 engine.fill_pool(&mut rng);
1098 let original_id = engine.pool[0].id;
1099 let edited = auracle_grammar::set_param(
1100 &engine.pool[0].tree,
1101 "amp#attack",
1102 auracle_grammar::ParamValue::Continuous(0.05),
1103 )
1104 .unwrap();
1105
1106 let obs_before = engine.log.len();
1107 let child_id = engine
1108 .commit_edit(
1109 Some(original_id),
1110 edited.clone(),
1111 EditOutcome::Heard { edited_won: true },
1112 )
1113 .expect("edit commits");
1114 assert_eq!(engine.log.len(), obs_before + 1, "improvement duel logged");
1115 let child = &engine.pool[engine.find(child_id).unwrap()];
1116 assert_eq!(child.origin, Origin::Edited);
1117 assert_eq!(child.tree, edited);
1118 let ev = engine.lineage.last().unwrap();
1119 assert_eq!(ev.kind, "edit");
1120 assert_eq!((ev.parent_id, ev.child_id), (original_id, child_id));
1121 assert!(engine.find(original_id).is_some());
1123 }
1124
1125 #[test]
1133 fn a_heard_edit_that_lost_is_logged_as_a_loss_and_tagged() {
1134 let mut rng = StdRng::seed_from_u64(0x105E);
1135 let cfg = SessionConfig {
1136 pool_size: 12,
1137 ..fast()
1138 };
1139 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1140 engine.begin_session();
1141 engine.fill_pool(&mut rng);
1142 let original_id = engine.pool[0].id;
1143 let seed = engine.pool[0].tree.clone();
1144 let bend = |v: f64| {
1145 auracle_grammar::set_param(
1146 &seed,
1147 "amp#attack",
1148 auracle_grammar::ParamValue::Continuous(v),
1149 )
1150 .unwrap()
1151 };
1152
1153 engine
1154 .commit_edit(
1155 Some(original_id),
1156 bend(0.05),
1157 EditOutcome::Heard { edited_won: false },
1158 )
1159 .expect("a losing edit still commits — it is a candidate either way");
1160 let obs = engine.log.observations.last().unwrap();
1161 assert_eq!(obs.provenance, Provenance::HeardEdit);
1162 let auracle_taste::Feedback::Duel { chose_a, .. } = &obs.feedback else {
1163 panic!("a commit outcome is a duel");
1164 };
1165 assert!(!chose_a, "A is the edit, and the edit lost");
1166
1167 engine
1168 .commit_edit(Some(original_id), bend(0.09), EditOutcome::SelfReported)
1169 .expect("the express path still commits");
1170 let obs = engine.log.observations.last().unwrap();
1171 assert_eq!(obs.provenance, Provenance::SelfReport);
1172
1173 engine
1174 .commit_edit(Some(original_id), bend(0.13), EditOutcome::Untold)
1175 .expect("an untold commit still commits");
1176 assert_eq!(
1177 engine.log.len(),
1178 2,
1179 "an untold commit claims nothing, so it observes nothing"
1180 );
1181 assert_eq!(engine.log.n_with(Provenance::HeardEdit), 1);
1182 assert_eq!(engine.log.n_with(Provenance::SelfReport), 1);
1183
1184 let twin = engine.pool[1].tree.clone();
1189 let obs_before = engine.log.len();
1190 assert!(
1191 engine
1192 .commit_edit(
1193 Some(original_id),
1194 twin,
1195 EditOutcome::Heard { edited_won: false }
1196 )
1197 .is_none(),
1198 "a duplicate tree is not a new candidate"
1199 );
1200 assert_eq!(
1201 engine.log.len(),
1202 obs_before + 1,
1203 "the comparison was thrown away because the winner already existed"
1204 );
1205 assert_eq!(
1206 engine.log.observations.last().unwrap().provenance,
1207 Provenance::HeardEdit
1208 );
1209
1210 let names = phi_names();
1214 let sz = engine
1215 .standardizer
1216 .clone()
1217 .expect("a filled pool has a standardizer");
1218 let fit = auracle_taste::FitSet::build(&engine.log, &names, &sz);
1219 assert_eq!(fit.len(), engine.log.len());
1220 assert_eq!(
1221 fit.rows[0].0.phis().len(),
1222 2,
1223 "still a duel, whatever it was collected by"
1224 );
1225 }
1226
1227 #[test]
1230 fn taste_map_is_sane() {
1231 let mut rng = StdRng::seed_from_u64(0x3A9);
1232 let user = ground_truth();
1233 let cfg = SessionConfig {
1234 pool_size: 20,
1235 ..fast()
1236 };
1237 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1238 engine.begin_session();
1239 engine.fill_pool(&mut rng);
1240 for _ in 0..10 {
1241 let (a, b) = engine.next_duel(&mut rng).unwrap();
1242 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
1243 engine.record_duel(a, b, chose_a);
1244 }
1245 engine.fit_posterior(&mut rng);
1246 let map = engine.taste_map();
1247 let n_pool = engine.pool.len();
1248 assert_eq!(map.points.len(), n_pool + 20); assert!(map
1250 .points
1251 .iter()
1252 .all(|p| p.x.is_finite() && p.y.is_finite()));
1253 assert!(map.points[..n_pool].iter().all(|p| p.id.is_some()));
1254 assert!(map.points[n_pool..].iter().all(|p| p.id.is_none()));
1255 assert!(map.explained[0] >= map.explained[1]);
1256 assert!(map.explained[0] <= 1.0 + 1e-9);
1257 let xs: Vec<f64> = map.points.iter().map(|p| p.x).collect();
1259 let spread = xs.iter().cloned().fold(f64::MIN, f64::max)
1260 - xs.iter().cloned().fold(f64::MAX, f64::min);
1261 assert!(spread > 1e-6);
1262 assert_eq!(
1265 map.converged,
1266 [true, true],
1267 "a taste-map axis hit its iteration cap without converging"
1268 );
1269 }
1270
1271 #[test]
1277 fn taste_map_axes_are_sign_pinned() {
1278 let mut rng = StdRng::seed_from_u64(0x5E1);
1279 let user = ground_truth();
1280 let cfg = SessionConfig {
1281 pool_size: 20,
1282 ..fast()
1283 };
1284 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1285 engine.begin_session();
1286 engine.fill_pool(&mut rng);
1287 for _ in 0..10 {
1288 let (a, b) = engine.next_duel(&mut rng).unwrap();
1289 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
1290 engine.record_duel(a, b, chose_a);
1291 }
1292 engine.fit_posterior(&mut rng);
1293 let map = engine.taste_map();
1294 assert_eq!(map.converged, [true, true]);
1295 assert!(map.points.iter().any(|p| p.x.abs() > 1e-6));
1296 }
1297
1298 #[test]
1301 fn profile_roundtrip_carries_standardizer() {
1302 let mut rng = StdRng::seed_from_u64(0xB0B);
1303 let cfg = SessionConfig {
1304 pool_size: 10,
1305 ..fast()
1306 };
1307 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg.clone());
1308 engine.begin_session();
1309 engine.fill_pool(&mut rng);
1310 for _ in 0..5 {
1311 let (a, b) = engine.next_duel(&mut rng).unwrap();
1312 engine.record_duel(a, b, true);
1313 }
1314 let profile = engine.export_profile();
1315 let json = serde_json::to_string(&profile).unwrap();
1316 let back: Profile = serde_json::from_str(&json).unwrap();
1317 assert_eq!(back.log, engine.log);
1318 assert_eq!(back.standardizer.as_ref(), engine.standardizer.as_deref());
1319
1320 let mut fresh = Engine::new(PatchGrammarPrior::default(), cfg);
1323 let mut rng2 = StdRng::seed_from_u64(0xB0C);
1324 fresh.begin_session();
1325 fresh.fill_pool(&mut rng2);
1326 fresh.import_profile(back);
1327 assert_eq!(
1328 fresh.standardizer.as_deref(),
1329 engine.standardizer.as_deref()
1330 );
1331 assert_eq!(fresh.log, engine.log);
1332 let sz = fresh.standardizer.as_ref().unwrap();
1334 for c in &fresh.pool {
1335 assert_eq!(c.phi_std, sz.transform(&c.features.phi()));
1336 }
1337 }
1338
1339 #[test]
1345 fn locks_are_symmetric_over_births() {
1346 use fugue::runtime::trace::{Choice, ChoiceValue};
1347 use fugue::{Address, Trace};
1348
1349 let trace_with = |addrs: &[(&str, f64)]| {
1350 let mut t = Trace::default();
1351 for (a, v) in addrs {
1352 let addr = Address::from(a.to_string());
1353 t.choices.insert(
1354 addr.clone(),
1355 Choice {
1356 addr,
1357 value: ChoiceValue::F64(*v),
1358 logp: 0.0,
1359 },
1360 );
1361 }
1362 t
1363 };
1364 let locked: std::collections::HashSet<String> = ["amp#attack".to_string()].into();
1365
1366 let absent = trace_with(&[("osc#wave", 1.0)]);
1367 let present = trace_with(&[("osc#wave", 1.0), ("amp#attack", 0.3)]);
1368 let changed = trace_with(&[("osc#wave", 1.0), ("amp#attack", 0.9)]);
1369 let untouched = trace_with(&[("osc#wave", 2.0), ("amp#attack", 0.3)]);
1370
1371 assert!(Engine::violates_locks(&absent, &present, &locked), "birth");
1373 assert!(Engine::violates_locks(&present, &absent, &locked), "death");
1374 assert!(Engine::violates_locks(&present, &changed, &locked));
1376 assert!(Engine::violates_locks(&changed, &present, &locked));
1377 assert!(!Engine::violates_locks(&present, &untouched, &locked));
1379 assert!(!Engine::violates_locks(&untouched, &present, &locked));
1380 assert!(!Engine::violates_locks(
1382 &absent,
1383 &present,
1384 &std::collections::HashSet::new()
1385 ));
1386 }
1387
1388 #[test]
1392 fn acquisition_asks_different_questions() {
1393 let distinct_pairs = |acquisition: Acquisition| -> usize {
1394 let mut rng = StdRng::seed_from_u64(0xACC);
1395 let user = ground_truth();
1396 let cfg = SessionConfig {
1397 pool_size: 24,
1398 acquisition,
1399 duel_check_every: 0,
1400 ..fast()
1401 };
1402 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1403 engine.begin_session();
1404 engine.fill_pool(&mut rng);
1405 for _ in 0..10 {
1406 let (a, b) = engine.next_duel(&mut rng).unwrap();
1407 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
1408 engine.record_duel(a, b, chose_a);
1409 }
1410 engine.fit_posterior(&mut rng);
1411 let mut seen = std::collections::HashSet::new();
1413 for _ in 0..12 {
1414 let (a, b) = engine.next_duel(&mut rng).unwrap();
1415 let (x, y) = (engine.pool[a].id, engine.pool[b].id);
1416 seen.insert(if x <= y { (x, y) } else { (y, x) });
1417 }
1418 seen.len()
1419 };
1420 let bald = distinct_pairs(Acquisition::Bald);
1421 assert!(
1422 bald >= 10,
1423 "BALD offered only {bald} distinct pairs out of 12"
1424 );
1425 }
1436
1437 #[test]
1441 fn explanation_decomposes_utility_exactly() {
1442 let mut rng = StdRng::seed_from_u64(0xE8B);
1443 let user = ground_truth();
1444 let cfg = SessionConfig {
1445 pool_size: 16,
1446 ..fast()
1447 };
1448 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1449 engine.begin_session();
1450 engine.fill_pool(&mut rng);
1451 assert!(
1452 engine.explain(engine.pool[0].id).is_none(),
1453 "no posterior yet"
1454 );
1455 for _ in 0..14 {
1456 let (a, b) = engine.next_duel(&mut rng).unwrap();
1457 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
1458 engine.record_duel(a, b, chose_a);
1459 }
1460 engine.fit_posterior(&mut rng);
1461
1462 let id = engine.pool[engine.ranked()[0].0].id;
1463 let e = engine.explain(id).expect("explanation after a fit");
1464 let sum: f64 = e.contributions.iter().map(|c| c.contribution).sum();
1465 assert!(
1466 (sum - e.utility).abs() < 1e-9,
1467 "contributions {sum} != utility {}",
1468 e.utility
1469 );
1470 assert_eq!(e.contributions.len(), Features::phi_names().len());
1471 for w in e.contributions.windows(2) {
1473 assert!(w[0].contribution.abs() >= w[1].contribution.abs());
1474 }
1475 }
1476
1477 #[test]
1480 fn patches_get_unique_musical_names() {
1481 let mut rng = StdRng::seed_from_u64(0x9A3);
1482 let cfg = SessionConfig {
1483 pool_size: 32,
1484 ..fast()
1485 };
1486 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1487 engine.begin_session();
1488 engine.fill_pool(&mut rng);
1489
1490 let names = engine.display_names();
1491 assert_eq!(names.len(), engine.pool.len());
1492 let unique: std::collections::HashSet<&String> = names.values().collect();
1493 assert_eq!(unique.len(), names.len(), "names collide: {names:?}");
1494 for n in names.values() {
1495 assert!(n.split(' ').count() >= 2, "not a <character> <role>: {n}");
1496 assert!(n.chars().next().unwrap().is_uppercase());
1497 }
1498 let id = engine.pool[0].id;
1500 engine.set_name(id, "My Bass");
1501 assert_eq!(engine.display_names()[&id], "My Bass");
1502 }
1503
1504 #[test]
1513 fn names_spread_across_the_pool() {
1514 let mut rng = StdRng::seed_from_u64(0x9A3);
1515 let cfg = SessionConfig {
1516 pool_size: 40,
1517 ..fast()
1518 };
1519 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1520 engine.begin_session();
1521 engine.fill_pool(&mut rng);
1522 let n = engine.pool.len();
1523 assert!(n >= 32, "pool too small to say anything: {n}");
1524
1525 let names = engine.display_names();
1526 assert_eq!(names.len(), n);
1527 let unique: std::collections::HashSet<&String> = names.values().collect();
1528 assert_eq!(unique.len(), n, "names collide: {names:?}");
1529
1530 let base = |s: &String| -> String {
1532 match s.rsplit_once(' ') {
1533 Some((head, tail)) if tail.parse::<usize>().is_ok() => head.to_string(),
1534 _ => s.clone(),
1535 }
1536 };
1537 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1538 for v in names.values() {
1539 *counts.entry(base(v)).or_insert(0) += 1;
1540 }
1541 let (top_name, top) = counts
1542 .iter()
1543 .max_by_key(|(_, c)| **c)
1544 .map(|(k, v)| (k.clone(), *v))
1545 .unwrap();
1546 let share = top as f64 / n as f64;
1547 let mut hist: Vec<(&String, &usize)> = counts.iter().collect();
1548 hist.sort_by(|a, b| b.1.cmp(a.1));
1549 println!(
1550 "{n} patches -> {} distinct names, top `{top_name}` {top} ({share:.0}%)",
1551 counts.len(),
1552 share = share * 100.0
1553 );
1554 println!(" {hist:?}");
1555 assert!(
1556 share <= 0.20,
1557 "`{top_name}` takes {top}/{n} = {share:.2} of the bank; \
1558 the alphabet has collapsed again"
1559 );
1560 assert!(
1561 counts.len() >= 12,
1562 "only {} distinct names over {n} patches: {counts:?}",
1563 counts.len()
1564 );
1565 }
1566
1567 #[test]
1577 fn names_collapse_when_the_patches_are_alike() {
1578 use auracle_features::{featurize, PhraseSpec};
1579 let spec = PhraseSpec::default();
1580 let base = auracle_grammar::presets()
1581 .into_iter()
1582 .find(|(n, _)| *n == "Glass Pad")
1583 .expect("preset")
1584 .1;
1585
1586 let variants: Vec<auracle_features::Features> = (0..12)
1589 .map(|i| {
1590 let tweaked = auracle_grammar::set_param(
1591 &base,
1592 "op0#cutoff",
1593 auracle_grammar::ParamValue::Continuous(0.650 + i as f64 * 0.0005),
1594 )
1595 .unwrap_or_else(|_| base.clone());
1596 featurize(&tweaked, &spec).expect("vets").features
1597 })
1598 .collect();
1599
1600 let scale = NameScale::fit(variants.iter());
1601 let names: std::collections::HashSet<String> =
1602 variants.iter().map(|f| scale.name(f)).collect();
1603 assert!(
1604 names.len() <= 2,
1605 "{} distinct names for imperceptible variants: {names:?}",
1606 names.len()
1607 );
1608 }
1609
1610 #[test]
1615 fn explicit_names_participate_in_collisions() {
1616 let mut rng = StdRng::seed_from_u64(0x9A4);
1617 let cfg = SessionConfig {
1618 pool_size: 12,
1619 ..fast()
1620 };
1621 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1622 engine.begin_session();
1623 engine.fill_pool(&mut rng);
1624
1625 let ids: Vec<u64> = engine.pool.iter().map(|c| c.id).take(4).collect();
1628 let generated = engine.display_names()[&engine.pool[5].id].clone();
1629 for id in &ids[..3] {
1630 engine.set_name(*id, "Glass Pad");
1631 }
1632 engine.set_name(ids[3], &generated);
1633
1634 let names = engine.display_names();
1635 let unique: std::collections::HashSet<&String> = names.values().collect();
1636 assert_eq!(
1637 unique.len(),
1638 names.len(),
1639 "explicit names bypassed collision detection: {names:?}"
1640 );
1641 assert_eq!(
1642 names[&ids[0]], "Glass Pad",
1643 "first claim keeps the plain name"
1644 );
1645 }
1646
1647 #[test]
1660 fn duels_spread_over_candidates_not_just_pairs() {
1661 const N: usize = 12;
1662 let spread = |acquisition: Acquisition| -> (usize, f64, usize) {
1663 let mut rng = StdRng::seed_from_u64(0xD4E);
1664 let user = ground_truth();
1665 let cfg = SessionConfig {
1666 pool_size: 24,
1667 duel_check_every: 0,
1668 acquisition,
1669 ..fast()
1670 };
1671 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1672 engine.begin_session();
1673 engine.fill_pool(&mut rng);
1674 for _ in 0..N {
1675 let (a, b) = engine.next_duel(&mut rng).unwrap();
1676 let chose_a = user.duel(&mut rng, &engine.pool[a].phi_std, &engine.pool[b].phi_std);
1677 engine.record_duel(a, b, chose_a);
1678 }
1679 engine.fit_posterior(&mut rng);
1680
1681 let mut appearances: std::collections::HashMap<u64, usize> =
1684 std::collections::HashMap::new();
1685 let mut pairs = std::collections::HashSet::new();
1686 for _ in 0..N {
1687 let d = engine.next_duel_full(&mut rng).unwrap();
1688 let (x, y) = (engine.pool[d.a].id, engine.pool[d.b].id);
1689 *appearances.entry(x).or_insert(0) += 1;
1690 *appearances.entry(y).or_insert(0) += 1;
1691 pairs.insert(if x <= y { (x, y) } else { (y, x) });
1692 }
1693 let max_share = *appearances.values().max().unwrap() as f64 / N as f64;
1694 (appearances.len(), max_share, pairs.len())
1695 };
1696
1697 for acquisition in [Acquisition::Random, Acquisition::Bald] {
1698 let (distinct, max_share, n_pairs) = spread(acquisition);
1699 println!(
1700 "{acquisition:?}: {N} duels -> {distinct} distinct candidates, \
1701 max share {max_share:.2}, {n_pairs} distinct pairs"
1702 );
1703 let min_pairs = match acquisition {
1722 Acquisition::Bald => N,
1723 _ => N - 2,
1724 };
1725 assert!(
1726 n_pairs >= min_pairs,
1727 "{acquisition:?}: {n_pairs} distinct pairs out of {N}"
1728 );
1729 let min_distinct = match acquisition {
1740 Acquisition::Bald => 14,
1741 _ => 12,
1742 };
1743 assert!(
1744 distinct >= min_distinct,
1745 "{acquisition:?}: only {distinct} distinct candidates over {N} duels"
1746 );
1747 assert!(
1748 max_share <= 0.35,
1749 "{acquisition:?}: one candidate is in {max_share:.2} of duels \
1750 — best-arm degeneracy"
1751 );
1752 }
1753 }
1754
1755 #[test]
1759 fn brier_rewards_sharpness_that_hit_rate_cannot() {
1760 let confident: Vec<Forecast> = (0..20)
1761 .map(|_| Forecast {
1762 p_a: 0.95,
1763 chose_a: true,
1764 random_check: false,
1765 provenance: Provenance::Duel,
1766 })
1767 .collect();
1768 let hedging: Vec<Forecast> = (0..20)
1769 .map(|_| Forecast {
1770 p_a: 0.55,
1771 chose_a: true,
1772 random_check: false,
1773 provenance: Provenance::Duel,
1774 })
1775 .collect();
1776 let (c, h) = (calibration(&confident), calibration(&hedging));
1777 assert_eq!(c.hit_rate, h.hit_rate, "hit rate cannot tell these apart");
1778 assert!(
1779 c.skill > h.skill,
1780 "Brier skill must: {} vs {}",
1781 c.skill,
1782 h.skill
1783 );
1784 assert!(c.skill > 0.9 && h.skill < 0.2);
1785
1786 let mixed: Vec<Forecast> = (0..100)
1788 .map(|i| Forecast {
1789 p_a: 0.1,
1790 chose_a: i % 10 == 0,
1791 random_check: i % 10 == 0,
1792 provenance: Provenance::Duel,
1793 })
1794 .collect();
1795 let m = calibration(&mixed);
1796 let bin = m.bins.iter().find(|b| b.n > 0).unwrap();
1797 assert!((bin.predicted - bin.observed).abs() < 0.05, "{bin:?}");
1798 assert_eq!(m.check_n, 10, "check duels counted separately");
1799 assert_eq!(m.by_provenance.len(), 1, "one stream, one row");
1800 assert_eq!(m.by_provenance[0].provenance, "duel");
1801 }
1802
1803 #[test]
1809 fn calibration_scores_a_checkbox_apart_from_a_heard_comparison() {
1810 let f = |p: f64, won: bool, prov| Forecast {
1811 p_a: p,
1812 chose_a: won,
1813 random_check: false,
1814 provenance: prov,
1815 };
1816 let mut fs: Vec<Forecast> = (0..10)
1817 .map(|_| f(0.9, true, Provenance::HeardEdit))
1818 .collect();
1819 fs.extend((0..10).map(|_| f(0.9, false, Provenance::SelfReport)));
1822 let c = calibration(&fs);
1823 assert_eq!(c.n, 20, "the aggregate still sees every forecast");
1824 let row = |name: &str| {
1825 c.by_provenance
1826 .iter()
1827 .find(|r| r.provenance == name)
1828 .unwrap_or_else(|| panic!("{name} missing"))
1829 };
1830 assert_eq!(row("heard_edit").n, 10);
1831 assert_eq!(row("self_report").n, 10);
1832 assert!(
1833 row("heard_edit").skill > row("self_report").skill,
1834 "the split scored nothing: {:?}",
1835 c.by_provenance
1836 );
1837 assert!(c.by_provenance.iter().all(|r| r.provenance != "duel"));
1838 }
1839
1840 #[test]
1845 fn legacy_profile_migrates_into_the_new_feature_set() {
1846 use crate::migrate::SCHEMA1_NAMES;
1847 let d = SCHEMA1_NAMES.len();
1848 let sz = auracle_taste::Standardizer {
1851 mean: (0..d).map(|i| 0.1 + i as f64 * 0.01).collect(),
1852 std: vec![0.5; d],
1853 };
1854 let legacy = format!(
1855 r#"{{"log":{{"observations":[
1856 {{"Duel":{{"a":{a},"b":{b},"chose_a":true,"session":0}}}}
1857 ]}},"standardizer":{sz}}}"#,
1858 a = serde_json::to_string(&vec![0.4_f64; d]).unwrap(),
1859 b = serde_json::to_string(&vec![-0.4_f64; d]).unwrap(),
1860 sz = serde_json::to_string(&sz).unwrap(),
1861 );
1862 let profile: Profile = serde_json::from_str(&legacy).unwrap();
1863 assert!(
1864 profile.log.observations.iter().all(|o| !o.is_raw()),
1865 "fixture is not actually legacy"
1866 );
1867
1868 let mut rng = StdRng::seed_from_u64(0x11D);
1869 let cfg = SessionConfig {
1870 pool_size: 8,
1871 ..fast()
1872 };
1873 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
1874 engine.begin_session();
1875 engine.fill_pool(&mut rng);
1876 engine.import_profile(profile);
1877
1878 let names = phi_names();
1879 assert_eq!(engine.log.len(), 1);
1880 let o = &engine.log.observations[0];
1881 assert!(o.is_raw(), "observation not migrated");
1882 assert!(
1883 !o.feature_names.contains(&"size".to_string()),
1884 "`size` survived"
1885 );
1886 assert_eq!(
1891 o.feature_names,
1892 crate::migrate::v1_names(),
1893 "a migrated vote must land on the stimulus it was recorded under"
1894 );
1895 assert_eq!(engine.log.raw_rows(&names).len(), 0);
1897 assert_eq!(engine.log.raw_rows(&o.feature_names).len(), 2);
1899 let sz_now = engine.standardizer.as_ref().expect("standardizer refit");
1900 assert_eq!(sz_now.dimension(), names.len());
1901 let data = auracle_taste::FitSet::build(&engine.log, &names, sz_now);
1902 let auracle_taste::Feedback::Duel { a, b, chose_a } = &data.rows[0].0 else {
1903 panic!("modality changed in migration");
1904 };
1905 assert!(chose_a);
1906 assert_eq!(a.len(), names.len());
1907 assert!(a.iter().chain(b).all(|x| x.is_finite()));
1908 let audio_tagged = |n: &str| n.ends_with(":p2");
1913 for (j, name) in names.iter().enumerate() {
1914 if audio_tagged(name) {
1915 assert_eq!(a[j], 0.0, "old-stimulus audio leaked into {name}");
1916 assert_eq!(b[j], 0.0, "old-stimulus audio leaked into {name}");
1917 }
1918 }
1919 assert!(a.iter().zip(b).all(|(x, y)| x >= y));
1920 assert!(
1921 a.iter().zip(b).any(|(x, y)| x > y),
1922 "the structural evidence vanished entirely"
1923 );
1924 }
1925
1926 #[test]
1943 fn a_profile_written_under_a_narrower_phi_still_counts() {
1944 use auracle_taste::{Feedback, Observation, ObservationLog};
1945
1946 let names = phi_names();
1947 let old_names: Vec<String> = names[..names.len() - 3].to_vec();
1948 let d = old_names.len();
1949 let (a, b): (Vec<f64>, Vec<f64>) = (
1959 (0..d)
1960 .map(|i| (i as f64 + 1.0) / (d as f64 + 1.0))
1961 .collect(),
1962 vec![0.0; d],
1963 );
1964 let mut log = ObservationLog::new();
1965 log.push(Observation::new(
1966 Feedback::Duel {
1967 a: a.clone(),
1968 b: b.clone(),
1969 chose_a: true,
1970 },
1971 0,
1972 &old_names,
1973 ));
1974 let profile = Profile {
1975 log,
1976 standardizer: Some(auracle_taste::Standardizer {
1977 mean: vec![0.0; d],
1978 std: vec![1.0; d],
1979 }),
1980 };
1981
1982 let mut rng = StdRng::seed_from_u64(0x3C0);
1983 let mut engine = Engine::new(
1984 PatchGrammarPrior::default(),
1985 SessionConfig {
1986 pool_size: 8,
1987 ..fast()
1988 },
1989 );
1990 engine.begin_session();
1991 engine.fill_pool(&mut rng);
1992 engine.import_profile(profile);
1993
1994 let sz = engine.standardizer.as_ref().expect("standardizer refit");
1998 assert_eq!(sz.dimension(), names.len());
1999 assert_eq!(engine.log.observations[0].feature_names, old_names);
2003 assert_eq!(engine.log.raw_rows(&names).len(), 0);
2005
2006 let data = auracle_taste::FitSet::build(&engine.log, &names, sz);
2008 let auracle_taste::Feedback::Duel {
2009 a: za,
2010 b: zb,
2011 chose_a,
2012 } = &data.rows[0].0
2013 else {
2014 panic!("modality changed");
2015 };
2016 assert!(chose_a);
2017 assert_eq!(za.len(), names.len());
2018 for j in 0..d {
2019 assert_eq!(za[j], (a[j] - sz.mean[j]) / sz.std[j], "{} lost", names[j]);
2020 assert!(za[j] > zb[j], "{} flipped", names[j]);
2021 }
2022 for j in d..names.len() {
2025 assert_eq!(za[j], 0.0, "{} invented evidence", names[j]);
2026 assert_eq!(zb[j], 0.0, "{} invented evidence", names[j]);
2027 }
2028 }
2029
2030 #[test]
2051 fn v1_palette_session_still_loads() {
2052 use auracle_grammar::term::{AudioNode, ModNode};
2053
2054 let v1_bank = r#"[
2056 {"id":0,"tree":{"amp":{"attack":0.1,"decay":0.2,"sustain":0.5,"release":0.3},
2057 "root":{"Delay":{"time":0.4,"feedback":0.3,"mix":0.5,
2058 "input":{"Vco":{"wave":"Saw","octave":0,"detune":0.2}}}}},
2059 "origin":"prior","name":null,"pinned":false},
2060 {"id":1,"tree":{"amp":{"attack":0.1,"decay":0.2,"sustain":0.5,"release":0.3},
2061 "root":{"Chorus":{"rate":0.4,"depth":0.3,"mix":0.5,
2062 "input":{"Supersaw":{"octave":0,"detune":0.3,"mix":0.5}}}}},
2063 "origin":"prior","name":null,"pinned":false},
2064 {"id":2,"tree":{"amp":{"attack":0.1,"decay":0.2,"sustain":0.5,"release":0.3},
2065 "root":{"Reverb":{"size":0.4,"damp":0.3,"mix":0.5,
2066 "input":{"Filter":{"kind":"SvfLp","cutoff":0.6,"resonance":0.3,
2067 "mod_depth":0.2,"modulation":{"Lfo":{"wave":"Sine","rate":0.3}},
2068 "input":{"Vco":{"wave":"Square","octave":-1,"detune":0.1}}}}}}},
2069 "origin":"prior","name":null,"pinned":false},
2070 {"id":3,"tree":{"amp":{"attack":0.1,"decay":0.2,"sustain":0.5,"release":0.3},
2071 "root":{"Vco":{"wave":"Triangle","octave":1,"detune":0.75}}},
2072 "origin":"prior","name":null,"pinned":false},
2073 {"id":4,"tree":{"amp":{"attack":0.1,"decay":0.2,"sustain":0.5,"release":0.3},
2074 "root":{"Supersaw":{"octave":-1,"detune":0.65,"mix":0.4}}},
2075 "origin":"prior","name":null,"pinned":false}
2076 ]"#;
2077 let d = crate::migrate::SCHEMA1_NAMES.len();
2078 let sz = auracle_taste::Standardizer {
2079 mean: (0..d).map(|i| 0.1 + i as f64 * 0.01).collect(),
2080 std: vec![0.5; d],
2081 };
2082 let saved = format!(
2083 r#"{{"profile":{{"log":{{"observations":[
2084 {{"Duel":{{"a":{a},"b":{b},"chose_a":true,"session":0}}}}
2085 ]}},"standardizer":{sz}}},
2086 "bank":{v1_bank},"lineage":[],"generation":3}}"#,
2087 a = serde_json::to_string(&vec![0.4_f64; d]).unwrap(),
2088 b = serde_json::to_string(&vec![-0.4_f64; d]).unwrap(),
2089 sz = serde_json::to_string(&sz).unwrap(),
2090 );
2091
2092 let state: SessionState =
2093 serde_json::from_str(&saved).expect("a v1-palette save must still deserialize");
2094 assert_eq!(state.bank.len(), 5);
2095
2096 let AudioNode::Delay {
2098 time,
2099 mod_depth,
2100 modulation,
2101 ..
2102 } = &state.bank[0].tree.root
2103 else {
2104 panic!("delay did not survive the load");
2105 };
2106 assert_eq!(*time, 0.4, "a saved parameter changed value on load");
2107 assert_eq!(*mod_depth, 0.0, "new knob must default to inaudible");
2108 assert_eq!(*modulation, ModNode::None);
2109 assert!(matches!(
2110 &state.bank[1].tree.root,
2111 AudioNode::Chorus { mod_depth, modulation, .. }
2112 if *mod_depth == 0.0 && *modulation == ModNode::None
2113 ));
2114 let AudioNode::Reverb {
2117 mod_depth, input, ..
2118 } = &state.bank[2].tree.root
2119 else {
2120 panic!("reverb did not survive the load");
2121 };
2122 assert_eq!(*mod_depth, 0.0);
2123 assert!(
2124 matches!(&**input, AudioNode::Filter { modulation, .. }
2125 if matches!(modulation, ModNode::Lfo { .. })),
2126 "a slot that already existed in v1 lost its source"
2127 );
2128
2129 let AudioNode::Vco {
2136 wave,
2137 octave,
2138 detune,
2139 mod_depth,
2140 modulation,
2141 ..
2142 } = &state.bank[3].tree.root
2143 else {
2144 panic!("a v1-shaped vco did not survive the load");
2145 };
2146 assert_eq!(*wave, auracle_grammar::term::Waveform::Triangle);
2147 assert_eq!(*octave, 1);
2148 assert_eq!(*detune, 0.75, "a saved parameter changed value on load");
2149 assert_eq!(*mod_depth, 0.0, "new pitch knob must default to inaudible");
2150 assert_eq!(*modulation, ModNode::None);
2151 let AudioNode::Supersaw {
2152 octave,
2153 detune,
2154 mix,
2155 mod_depth,
2156 modulation,
2157 ..
2158 } = &state.bank[4].tree.root
2159 else {
2160 panic!("a v1-shaped supersaw did not survive the load");
2161 };
2162 assert_eq!(*octave, -1);
2163 assert_eq!(*detune, 0.65);
2164 assert_eq!(*mix, 0.4);
2165 assert_eq!(*mod_depth, 0.0);
2166 assert_eq!(*modulation, ModNode::None);
2167 let AudioNode::Delay { input, .. } = &state.bank[0].tree.root else {
2170 unreachable!("checked above")
2171 };
2172 assert!(
2173 matches!(&**input, AudioNode::Vco { mod_depth, modulation, .. }
2174 if *mod_depth == 0.0 && *modulation == ModNode::None),
2175 "a nested v1 vco lost its defaults"
2176 );
2177
2178 let mut rng = StdRng::seed_from_u64(0x71D);
2182 let mut engine = Engine::new(
2183 PatchGrammarPrior::default(),
2184 SessionConfig {
2185 pool_size: 8,
2186 ..fast()
2187 },
2188 );
2189 engine.begin_session();
2190 engine.fill_pool(&mut rng);
2191 let restored = engine.import_state(state);
2192 assert_eq!(restored, 5, "a v1 patch was dropped on restore");
2193 assert_eq!(engine.log.len(), 1, "the user's vote did not survive");
2194 assert!(
2195 engine.log.observations[0].is_raw(),
2196 "the schema-1 vote was not migrated"
2197 );
2198 assert!(
2199 engine.pool.iter().all(|c| !c.phi_std.is_empty()),
2200 "a restored v1 patch has no features"
2201 );
2202
2203 for c in &engine.pool {
2209 let rack = auracle_grammar::describe(&c.tree);
2210 let mut seen = std::collections::HashSet::new();
2211 for m in rack.modules.iter().filter(|m| m.key != "amp") {
2212 assert_ne!(m.uid, 0, "a restored node has no identity at {}", m.key);
2213 assert!(seen.insert(m.uid), "restored identities collide");
2214 }
2215 }
2216 }
2217
2218 fn pool_signature(engine: &Engine) -> Vec<(u64, String, Vec<f64>)> {
2231 engine
2232 .pool
2233 .iter()
2234 .map(|c| (c.id, c.tree.to_sexpr(), c.features.phi()))
2235 .collect()
2236 }
2237
2238 fn farm_fill(width: usize, fill_seed: u64, pool_size: usize) -> Engine {
2246 let cfg = SessionConfig {
2247 pool_size,
2248 ..fast()
2249 };
2250 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
2251 engine.begin_session();
2252 engine.set_fill_seed(fill_seed);
2253 let phrase = auracle_features::PhraseSpec::default();
2254
2255 let mut done: Vec<(u64, Option<PreFeaturized>)> = Vec::new();
2258 loop {
2259 let wave = engine.fill_draw(width.max(1));
2260 let issued = wave.len();
2261 for d in wave {
2262 let pre = if d.dup {
2263 None
2264 } else {
2265 PreFeaturized::render(d.tree, &phrase, false).ok()
2266 };
2267 done.push((d.index, pre));
2268 }
2269 if width >= 2 {
2272 done.reverse();
2273 }
2274 if width >= 5 && done.len() > 2 {
2275 let half = done.len() / 2;
2276 done.rotate_left(half);
2277 }
2278 let mut absorbed = 0;
2279 loop {
2280 let cursor = engine.draw_cursor();
2281 let Some(k) = done.iter().position(|(i, _)| *i == cursor) else {
2282 break;
2283 };
2284 let (index, pre) = done.remove(k);
2285 engine.absorb_prior(index, pre);
2286 absorbed += 1;
2287 }
2288 if engine.pool.len() >= pool_size {
2289 break;
2290 }
2291 if issued == 0 && absorbed == 0 {
2292 break; }
2294 }
2295 engine
2296 }
2297
2298 #[test]
2307 fn farm_width_does_not_change_the_pool() {
2308 const SEED: u64 = 0xC0FFEE;
2309 let base = pool_signature(&farm_fill(0, SEED, 6));
2310 assert!(base.len() >= 4, "pool too small to test");
2311 for width in [1usize, 2, 3, 5, 8] {
2312 let got = pool_signature(&farm_fill(width, SEED, 6));
2313 assert_eq!(base, got, "farm width {width} changed the pool");
2314 }
2315 }
2316
2317 #[test]
2325 fn farm_absorption_reproduces_the_serial_pool() {
2326 const SEED: u64 = 0x5EED_1234;
2327 let cfg = SessionConfig {
2328 pool_size: 6,
2329 ..fast()
2330 };
2331 let mut serial = Engine::new(PatchGrammarPrior::default(), cfg);
2332 serial.begin_session();
2333 serial.set_fill_seed(SEED);
2334 let mut rng = StdRng::seed_from_u64(0xDEAD);
2335 serial.fill_pool(&mut rng);
2336 assert!(serial.pool.len() >= 4, "pool too small to test");
2337 assert_eq!(
2338 pool_signature(&serial),
2339 pool_signature(&farm_fill(4, SEED, 6)),
2340 "the farm built a different pool than the serial fill"
2341 );
2342
2343 let mut chunked = Engine::new(
2346 PatchGrammarPrior::default(),
2347 SessionConfig {
2348 pool_size: 6,
2349 ..fast()
2350 },
2351 );
2352 chunked.begin_session();
2353 chunked.set_fill_seed(SEED);
2354 let mut rng = StdRng::seed_from_u64(0xDEAD);
2355 while chunked.pool.len() < 6 && chunked.fill_pool_step(&mut rng, 1) > 0 {}
2356 assert_eq!(
2357 pool_signature(&serial),
2358 pool_signature(&chunked),
2359 "chunking the fill changed the pool"
2360 );
2361 }
2362
2363 #[test]
2371 fn transported_audition_is_the_render_it_names() {
2372 const SEED: u64 = 0x000A_0D10;
2373 let cfg = || SessionConfig {
2374 pool_size: 3,
2375 render_policy: RenderPolicy::Eager,
2376 ..fast()
2377 };
2378 let mut serial = Engine::new(PatchGrammarPrior::default(), cfg());
2379 serial.begin_session();
2380 serial.set_fill_seed(SEED);
2381 let mut rng = StdRng::seed_from_u64(1);
2382 serial.fill_pool(&mut rng);
2383 assert!(!serial.pool.is_empty(), "pool too small to test");
2384
2385 let phrase = auracle_features::PhraseSpec::default();
2386 let mut farmed = Engine::new(PatchGrammarPrior::default(), cfg());
2387 farmed.begin_session();
2388 farmed.set_fill_seed(SEED);
2389 loop {
2390 let wave = farmed.fill_draw(2);
2391 if wave.is_empty() {
2392 break;
2393 }
2394 for d in wave {
2395 let index = d.index;
2396 let pre = if d.dup {
2397 None
2398 } else {
2399 PreFeaturized::render(d.tree, &phrase, true).ok().map(|p| {
2400 let samples = p.audition.expect("asked for audio").samples.clone();
2403 PreFeaturized {
2404 audition: Some(std::sync::Arc::new(auracle_features::Audition {
2405 samples,
2406 sample_rate: phrase.sample_rate,
2407 })),
2408 ..p
2409 }
2410 })
2411 };
2412 farmed.absorb_prior(index, pre);
2413 }
2414 if farmed.pool.len() >= 3 {
2415 break;
2416 }
2417 }
2418 assert_eq!(pool_signature(&serial), pool_signature(&farmed));
2419 for (a, b) in serial.pool.iter().zip(&farmed.pool) {
2420 let want = a.render.as_ref().expect("eager keeps audio");
2421 let got = b.render.as_ref().expect("absorbed audio was dropped");
2422 assert_eq!(got.sample_rate, want.sample_rate);
2423 assert_eq!(
2424 got.samples, want.samples,
2425 "a transported audition drifted from the render φ was measured on"
2426 );
2427 }
2428 }
2429
2430 #[test]
2437 fn deferred_restore_equals_import_state() {
2438 let mut rng = StdRng::seed_from_u64(0x2E570E);
2439 let cfg = SessionConfig {
2440 pool_size: 6,
2441 ..fast()
2442 };
2443 let mut engine = Engine::new(PatchGrammarPrior::default(), cfg.clone());
2444 engine.begin_session();
2445 engine.fill_pool(&mut rng);
2446 assert!(engine.pool.len() >= 4, "pool too small to test");
2447 engine.record_duel(0, 1, true);
2448 engine.record_keep(2, false);
2449 engine.set_name(engine.pool[0].id, "Kept One");
2450 let state = engine.export_state();
2451
2452 let mut serial = Engine::new(PatchGrammarPrior::default(), cfg.clone());
2453 serial.begin_session();
2454 let n_serial = serial.import_state(state.clone());
2455
2456 let phrase = auracle_features::PhraseSpec::default();
2457 let mut deferred = Engine::new(PatchGrammarPrior::default(), cfg);
2458 deferred.begin_session();
2459 let bank = deferred.import_state_deferred(state);
2460 assert_eq!(bank.len(), n_serial, "deferred restore lost a bank entry");
2461 for entry in bank {
2464 let Ok(pre) = PreFeaturized::render(entry.tree.clone(), &phrase, false) else {
2465 continue;
2466 };
2467 deferred.absorb_bank_entry(entry, pre);
2468 }
2469 let n_deferred = deferred.finish_restore();
2470
2471 assert_eq!(n_serial, n_deferred, "restore sizes disagree");
2472 assert_eq!(serial.log.len(), deferred.log.len(), "log lost");
2473 for (a, b) in serial.pool.iter().zip(&deferred.pool) {
2474 assert_eq!(a.id, b.id);
2475 assert_eq!(a.tree, b.tree);
2476 assert_eq!(a.name, b.name);
2477 assert_eq!(a.origin, b.origin);
2478 assert_eq!(a.key, b.key);
2479 assert_eq!(a.features.phi(), b.features.phi(), "raw φ drifted");
2480 assert_eq!(a.phi_std, b.phi_std, "standardized φ drifted");
2481 }
2482 let preset = auracle_grammar::presets()[0].1.clone();
2485 assert_eq!(
2486 serial.insert_preset(preset.clone(), "p"),
2487 deferred.insert_preset(preset, "p"),
2488 "id allocation diverged across a deferred restore"
2489 );
2490 }
2491
2492 fn pearson(xs: &[f64], ys: &[f64]) -> f64 {
2493 let n = xs.len() as f64;
2494 let mx = xs.iter().sum::<f64>() / n;
2495 let my = ys.iter().sum::<f64>() / n;
2496 let cov: f64 = xs.iter().zip(ys).map(|(x, y)| (x - mx) * (y - my)).sum();
2497 let vx: f64 = xs.iter().map(|x| (x - mx) * (x - mx)).sum();
2498 let vy: f64 = ys.iter().map(|y| (y - my) * (y - my)).sum();
2499 cov / (vx.sqrt() * vy.sqrt() + 1e-12)
2500 }
2501}