Skip to main content

auracle_session/
lib.rs

1//! # auracle-session
2//!
3//! The **two-loop engine** (the reference: *The two loops*) every frontend
4//! drives:
5//!
6//! - **Patch loop** (fast, silent): vetted prior draws fill a pool; once a
7//!   posterior exists, a short typed-MH walk on
8//!   `π_β ∝ p_grammar · exp(β·E[u_θ])` moves the pool toward the user's taste
9//!   ([`engine::Engine::refine`]). Local refinement on that target, not a
10//!   draw from it.
11//! - **Taste loop** (slow, human-paced): feedback appends to the observation
12//!   log as raw φ; the posterior re-fits from it, standardizing at fit time
13//!   ([`engine::Engine::fit_posterior`]). Between fits each vote is folded in
14//!   by importance reweighting, so the next question responds to the last
15//!   answer.
16//! - **Acquisition** between them: BALD — expected information gain about θ
17//!   ([`engine::Engine::next_duel`]), which measurably beats the dueling
18//!   Thompson rule it replaced and ties uniformly-random pairing
19//!   ([`engine::Acquisition`] carries the numbers).
20//!
21//! The M4 gate is this crate's closed-loop test: engine + synthetic user,
22//! end-to-end through the *real* grammar → render → vet → features pipeline,
23//! asserting the learned taste ranks genuinely-preferred patches on top.
24
25pub 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    /// Test-scale engine config.
51    ///
52    /// Identical to the shipped default except for the MCMC budget. The gap
53    /// used to be 5× (6k against a shipped 30k) and existed because a suite
54    /// that fits dozens of times could not afford the shipped chain; the
55    /// shipped default is now 10k/3k, so the gap is 1.7× and this is a
56    /// trim rather than a different regime.
57    ///
58    /// It is kept, narrowed, for the tests whose subject is *machinery* —
59    /// that refinement injects lineage, that locks hold, that state
60    /// round-trips — where the posterior only has to be a posterior. The one
61    /// test whose subject is the posterior's *quality*
62    /// ([`closed_loop_learns_synthetic_taste`]) opts back up to the shipped
63    /// budget, because a quality gate measured on a chain no user runs is not
64    /// a gate on anything shipped.
65    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    /// A synthetic user over the REAL standardized feature space: likes
80    /// bright, bassy, filtered patches with fast attacks; dislikes noisy
81    /// (flat-spectrum) and slow-attack ones.
82    fn ground_truth() -> SyntheticUser {
83        let names = Features::phi_names();
84        let mut theta = vec![0.0; names.len()];
85        // Audio names carry a stimulus tag (`centroid_mean:p2`); the synthetic
86        // user's taste is about the perceptual axis, not the stimulus, so
87        // match on the base name.
88        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    /// The taste→grammar tilt: positive structural θ inflates its kind's
109    /// proposal weight, negative deflates, multipliers are clamped so no
110    /// kind starves, and the result is a normalized distribution.
111    #[test]
112    fn proposal_tilt_follows_taste() {
113        let base = [0.2, 0.35, 0.15, 0.15, 0.15];
114        // Loves delays (idx 3), hates folds (idx 2).
115        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        // Clamp: even an extreme tilt keeps every kind proposable.
121        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        // η = 0 is the identity (up to normalization).
124        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    /// Progressive boot, end to end at the engine layer:
131    ///
132    /// 1. a partially filled pool has **no duel in it** — `next_duel` skips
133    ///    un-standardized candidates, which is precisely what used to force a
134    ///    frontend to wait out the whole fill;
135    /// 2. `standardize_now` makes it duel-able without rendering anything;
136    /// 3. it never moves a standardizer that already exists, so candidates
137    ///    arriving behind the user join the scale their neighbours are on;
138    /// 4. `restandardize_if_untaught` widens the scale to the finished pool,
139    ///    but refuses once θ has been fit against it.
140    #[test]
141    fn partial_pool_becomes_duelable_and_the_scale_holds_still() {
142        let mut rng = StdRng::seed_from_u64(0xB007);
143        // A target far above what we draw, so `fill_pool_step`'s own
144        // "the pool reached pool_size" standardization never fires and we are
145        // testing the mid-fill state a progressive boot actually lives in.
146        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        // The fill continues behind the user. New members must be admitted on
174        // the *existing* scale — a standardizer that moved here would shift
175        // every utility on screen mid-session.
176        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        // Fill complete, still untaught: widening to the full pool is free
186        // and lossless, because the log keeps raw φ.
187        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        // Taught: the scale is now the one θ is denominated in, and must not
195        // move underneath it.
196        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    /// The whole point of a pin.
209    ///
210    /// Eviction takes the member with the *lowest* posterior utility, which is
211    /// exactly the patch a user loves before the model has learned why — so
212    /// before pins the bank was not merely careless with favourites, it was
213    /// biased toward destroying precisely the ones worth keeping. This test
214    /// pins the very patch the evictor would reach for first and then applies
215    /// more insertion pressure than there are free slots.
216    #[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    /// The budget is a real ceiling and refuses out loud. A `set_pinned` that
252    /// silently no-ops at the cap would reproduce, in the fix, the exact class
253    /// of bug the fix exists to remove.
254    #[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        // A second fit appends rather than replacing: the open question in
289        // `SessionConfig::k_styles` is about shares *across* a session, so a
290        // register that only kept the latest would not answer it.
291        engine.fit_posterior(&mut rng);
292        assert_eq!(engine.style_shares().len(), 2);
293
294        // And it survives a save/restore, because the evidence wanted is
295        // "across real sessions" and a session ends.
296        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        // Re-pinning something already pinned is not a new charge.
330        assert!(engine.set_pinned(ids[0], true), "idempotent re-pin refused");
331        // Unpinning frees budget again.
332        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    /// A session saved before pins existed must still load.
344    ///
345    /// The saved record is a single IndexedDB key with no schema version, so
346    /// backward compatibility cannot be checked at runtime — it has to hold by
347    /// construction, and `#[serde(default)]` is the construction. A bank entry
348    /// written by the previous build has no `pinned` key at all; it must
349    /// deserialize as "not pinned", which is exactly what it meant.
350    #[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    /// Persistence round-trip: export a session, restore it into a fresh
365    /// engine, and everything that matters survives — bank (ids, trees,
366    /// names, origins), log, standardizer geometry, lineage, and id
367    /// allocation (new ids never collide with restored ones).
368    #[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        // A pin that does not survive a reload is not a save at all — this is
384        // the one property the whole feature is for.
385        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            // φ must be re-standardized under the SAME standardizer.
403            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        // Fresh ids allocated after restore never collide.
414        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    /// Deferring the audition buffer must cost nothing but time: the buffer a
422    /// lazy pool materializes on demand is the *same buffer* an eager pool
423    /// kept, sample for sample. If it were not, the scope a user sees and the
424    /// audio they hear would drift apart from the render φ was measured on.
425    ///
426    /// Also pins the bound: `audio_cache` is what keeps a lazy pool's audition
427    /// memory flat no matter how much of the bank gets played.
428    #[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        // Same seed, same prior, same draws — only the retention policy differs.
442        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        // Emptying the memo forces the *re-render* path (`render_playback`)
458        // rather than a warm hit — the case that has to be bit-exact.
459        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        // Headless callers keep nothing and are told so, rather than being
478        // handed a buffer they never asked to pay for.
479        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    /// Every featurization the engine performs goes through the memo. The
487    /// sharpest way to say that: hand a restore the memo the fill populated
488    /// and it must not render *anything* — today `import_state` re-featurizes
489    /// every bank entry, which is why a returning user pays a full cold boot.
490    #[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        // A hit is indistinguishable from a miss — including in the raw φ that
524        // would enter the observation log.
525        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    /// The memo must be invisible to everything except wall time. Same seed,
533    /// same pool — every id, term, key, raw φ and standardized φ — whether a
534    /// featurization was computed or replayed. `RenderMemo::disabled()` is
535    /// behaviourally the un-memoized engine, so this is the A/B.
536    #[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    /// M4 gate: the headless closed loop. Fill a pool through the real
566    /// pipeline, run rounds of acquisition-chosen duels answered by the
567    /// synthetic user, re-fit between rounds, and assert:
568    /// 1. the posterior's ranking correlates with true utility on the pool;
569    /// 2. the engine's top picks are genuinely better than the pool average;
570    /// 3. the dominant style lens points roughly at the true θ.
571    ///
572    /// **Run over a fixed set of seeds, with the gates on the means.** One
573    /// run of this loop is a single draw — over the pool lottery, the duel
574    /// answers and the MH chain — and the seed-to-seed spread of `r` is
575    /// sd ≈ 0.08 across a range of ≈ 0.25, wider than the difference between
576    /// any two MCMC budgets from 6 000 steps up (the measurement is in
577    /// [`SessionConfig::mcmc_samples`]). At the shipped budget a single-seed
578    /// `r > 0.6` gate fails on ~2 of 13 draws, so a one-seed version of this
579    /// test would go red about 15 % of the time for any change that merely
580    /// perturbs the upstream RNG stream — grammar, features, render,
581    /// acquisition, or the fit itself — while telling you nothing about the
582    /// change. The seeds run concurrently, so the wall cost is ~one run.
583    ///
584    /// The surviving per-seed asserts are deliberately loose floors — "this
585    /// seed learned *something*" — set below the worst of 13 seeds at the
586    /// shipped budget (min r 0.551, min cos 0.315). They catch a loop that
587    /// stopped working; they are not the gate.
588    #[test]
589    fn closed_loop_learns_synthetic_taste() {
590        // Fixed, not drawn: a regression gate has to fail for the same
591        // reason twice. 0xE05 leads — it is the historical single seed, so
592        // its printed line still reproduces the numbers the budget tables
593        // were read off.
594        const SEEDS: [u64; 5] = [0xE05, 0x1, 0x2, 0x3, 0x4];
595
596        /// One closed loop. Returns `(r, top5, pool mean + 0.5σ, best cos)`.
597        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, // refinement exercised separately
604                // The shipped MCMC budget, not the suite's trimmed one: this
605                // is the gate on how good the posterior a real user gets is,
606                // so it must be measured on the chain a real user runs.
607                // Affordable now that the default is 10k rather than 30k.
608                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            // 4 rounds × 15 duels, refit after each round.
622            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            // 1. Pearson correlation between posterior-mean and true utility.
633            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            // 2. Top-5 by the model vs the pool average, in true utility.
642            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            // 3. The learned taste direction itself is interpretable: with a
658            // unimodal user, the *dominant* style lens should correlate with
659            // θ* (weaker than the synthetic-space gate because real features
660            // are correlated with each other; other lenses may idle near the
661            // prior). With dynamic K the taste spreads across several lenses
662            // even for a unimodal user, so per-lens directions are diluted
663            // relative to a K=1 fit — this is an interpretability sanity
664            // floor, not the gate (the predictive metrics are).
665            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        // Printed, not just asserted: these are the recovery metrics the MCMC
712        // budget is traded against, and a budget change is only defensible
713        // against their *margins* — per seed, so the spread stays visible.
714        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    /// **M4 gate 2: does refinement move the pool toward what the user
727    /// actually likes?**
728    ///
729    /// The taste-loop gate ([`closed_loop_learns_synthetic_taste`]) runs at
730    /// `refine_steps: 0`, so this is the only always-on test of the *other*
731    /// loop. It is a small, fixed-budget version of `search_health --climb`,
732    /// and it is graded the same way: on the synthetic user's **true** utility
733    /// over the pool, before and after real `Engine::refine` generations.
734    ///
735    /// ## What this test used to do, and why that was not a gate
736    ///
737    /// Three things, all of which looked like assertions and none of which
738    /// could fail for the right reason:
739    ///
740    /// - `assert!(best_after >= best_before)` on `ranked()` is true **by
741    ///   construction**. `insert_candidate` evicts the pool's lowest-utility
742    ///   member and refuses a refined child that does not beat it, so the top
743    ///   of the ranking cannot fall. It asserted the eviction rule, not the
744    ///   search.
745    /// - It graded children with `ranked()`, i.e. with the **surrogate that
746    ///   refinement is optimizing**. A search that had learned to fool its own
747    ///   fitness would have scored perfectly.
748    /// - `n_refined` was `println!`'d and never asserted, so a build where
749    ///   refinement injected nothing at all passed silently.
750    ///
751    /// The machinery assertions it did make — lineage parity, real diffs,
752    /// resolvable child ids, pool bound — were the good part and are kept.
753    ///
754    /// ## Why it is multi-seed, and gated on the **median**
755    ///
756    /// One run is a single draw over the pool lottery, the duel answers and the
757    /// MH chain, so a single-seed threshold is not worth setting — the same
758    /// reasoning as the taste-loop gate. Unlike that gate, the statistic here
759    /// is the median rather than the mean, because the per-seed distribution
760    /// has a heavy left tail that makes a mean over any affordable number of
761    /// seeds a coin flip. [`MEDIAN_GAIN_GATE`] has the measurement.
762    ///
763    /// Sixteen seeds, run concurrently: ~70 s wall, which is affordable in a
764    /// suite that already renders real audio, and enough that the middle of the
765    /// distribution is stable.
766    #[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        /// One search. Returns `(mean gain, max gain, children injected)` in
774        /// the synthetic user's true utility.
775        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                // Well under the shipped 40x10: this is a regression floor that
781                // runs on every commit, not the budget study. `search_health
782                // --budget-ab` is where the shipped split is chosen.
783                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            // True utility of the pool: the user is never shown to the search,
798            // which only ever sees the posterior, so a climb here is the whole
799            // surrogate path working end to end.
800            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            // The machinery invariants. Every injection is a lineage event
819            // with a real diff, and the pool never grows past its cap.
820            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            // **Pool ⊆ lineage, not lineage ⊆ pool**, and the direction matters.
827            //
828            // The single-generation version of this test asserted the reverse —
829            // that every lineage child is still findable in the pool — and that
830            // is only true when nothing has had a chance to be evicted yet.
831            // Across generations a child injected in generation 1 is an
832            // ordinary eviction candidate in generation 2, so the old assertion
833            // fails on a *correct* engine as soon as the horizon is longer than
834            // one round. (It did, on the first run of this widened test.)
835            //
836            // Lineage is permanent history; the pool is a fixed-size working
837            // set. The invariant that survives both is that the history explains
838            // every refined member the pool still holds.
839            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        // A generation that injects nothing anywhere is a broken search, not a
891        // conservative one.
892        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    /// Gates for [`refinement_improves_pool`], set from the observed 16-seed
911    /// spread rather than from a round number.
912    ///
913    /// ## Why the **median**, and not the mean
914    ///
915    /// The mean was the obvious choice and the measurement rejected it. Over
916    /// the 16 seeds the per-seed gains were
917    ///
918    /// ```text
919    /// -12.04  -5.55  -1.33  0.87  0.93  1.16  1.48  1.48
920    ///   1.48   1.50   1.51  2.01  2.10  2.55  2.57  2.72
921    /// ```
922    ///
923    /// — thirteen clear improvements and **two catastrophic seeds** that drag
924    /// the mean to +0.215 while the median sits at +1.481. The tail is not a
925    /// measurement artifact to be averaged away, and it makes the mean useless
926    /// as a gate: over *any* four of these seeds the mean ranges −4.51 to
927    /// +2.49 and is **negative 40 % of the time**. A four-seed mean gate — the
928    /// first version of this test — would have been a coin flip that failed for
929    /// reasons having nothing to do with the change under review.
930    ///
931    /// The median is stable for the same reason the mean is not: eleven of the
932    /// sixteen seeds sit between 0.87 and 2.72, and five of those within 0.04
933    /// of each other, so the middle of the distribution barely moves.
934    ///
935    /// ## What the two bad seeds are
936    ///
937    /// They are the surrogate doing its job too well. `insert_candidate` admits
938    /// a child that beats the pool's worst **by the model**, and evicts by the
939    /// same rule — so a posterior fitted on 40 duels at the suite's trimmed
940    /// MCMC budget can hand back nine candidates it likes and the synthetic
941    /// user does not, replacing nine the user did. That is the classic failure
942    /// of optimizing a surrogate, it is *not* a bug in the machinery, and it is
943    /// the reason [`RefineKeep::Best`] ships switched off: taking the argmax of
944    /// the same surrogate is the move most likely to make this worse, and
945    /// nothing has measured it yet.
946    ///
947    /// Gates below the observed values with real margin: 13 improved (gate 10),
948    /// median +1.481 (gate +0.5). Re-derive them by running this test with
949    /// `-- --nocapture` and reading the per-seed lines.
950    const MEDIAN_GAIN_GATE: f64 = 0.5;
951    const IMPROVED_GATE: usize = 10;
952
953    /// Locked refinement never touches a locked address: run `refine_from`
954    /// with every continuous amp-envelope site locked and assert the child's
955    /// amp env is bit-identical to the seed's while *something* else moved.
956    #[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    /// **R6.** A refined child keeps its seed's node identities wherever the
1001    /// structure survived the walk.
1002    ///
1003    /// Without this the panel cannot tell "the patch evolved" from "a different
1004    /// patch arrived", so every lock, hand-placed position and selection dies
1005    /// on the app's central action — and evolution is exactly the action the
1006    /// locks exist to be used *with*. Refinement gives identity no help at all:
1007    /// it proposes over the trace and rebuilds the genome from it on every
1008    /// accepted step, so what `refine_from` returns is anonymous until
1009    /// `record_child` re-keys it against the seed. This asserts the re-keying,
1010    /// through the rack view the panel actually reads.
1011    #[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                // Same key, same kind, before and after: the same module, and
1047                // the only honest answer is the same identity.
1048                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            // A round that shares no module with its seed has nothing to say
1056            // about identity, and is **skipped rather than failed**.
1057            //
1058            // This used to `assert!(carried > 0, "a refinement step that
1059            // changed everything is not a refinement")`, which conflates two
1060            // different claims: "identity survives where structure survives"
1061            // (this test's subject, asserted above and still strict) and "a
1062            // walk never restructures a whole term" (a claim about the search,
1063            // and not a true one). Forty MH steps over a small term can replace
1064            // the root's kind, after which no key/kind pair matches and there is
1065            // simply nothing to carry — no uid was lost, because none was
1066            // comparable. The φ shift from peak-capped normalization moved one
1067            // seed's trajectory into exactly that case, and the test failed
1068            // without anything being wrong.
1069            //
1070            // `checked` counts only rounds that genuinely exercised the
1071            // property, and the final assert still requires at least one.
1072            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    /// Hand edits: `commit_edit` inserts the edited tree, links lineage, and
1087    /// (when flagged) records the improvement duel.
1088    #[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        // The original survives (protected from eviction).
1122        assert!(engine.find(original_id).is_some());
1123    }
1124
1125    /// The losing direction is the half that used to be unrepresentable: a
1126    /// `false` in the old boolean API meant "said nothing", so an edit the
1127    /// player heard and rejected left no trace and the log only ever saw
1128    /// edits that won. It has to arrive as a duel the *original* wins, and
1129    /// the express checkbox has to be distinguishable from a heard one — in
1130    /// the log and in the forecast stream — without either of them changing
1131    /// what the likelihood sees.
1132    #[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        // A commit whose tree the bank already holds inserts nothing — but the
1185        // player still heard two patches and picked one, and the answer must
1186        // not be lost to a bookkeeping collision. It is scored against the
1187        // twin instead.
1188        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        // And the tag stays out of the fit: what the likelihood is handed is
1211        // (feedback, session), which is what it was handed before this field
1212        // existed. Two rows differing only in provenance are one row twice.
1213        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    /// The taste map projects every pool member plus history ghosts, with
1228    /// finite coordinates and sane explained-variance fractions.
1229    #[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); // 10 duels × 2 ghosts
1249        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        // The first axis should actually spread the points.
1258        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        // Both axes are solved, not merely returned after a fixed iteration
1263        // count. On real pool data this converges in far fewer than the cap.
1264        assert_eq!(
1265            map.converged,
1266            [true, true],
1267            "a taste-map axis hit its iteration cap without converging"
1268        );
1269    }
1270
1271    /// The map's axes carry the sign convention on real pool data.
1272    ///
1273    /// The property itself is unit-tested in [`crate::map`] against data built
1274    /// to violate it; this is the end-to-end check that the projection the app
1275    /// actually draws obeys it too.
1276    #[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    /// Profiles round-trip the log **with** its standardizer, and importing
1299    /// re-standardizes the pool under the imported standardizer.
1300    #[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        // A fresh engine (different pool → different standardizer) adopts
1321        // the imported one.
1322        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        // Pool φ re-standardized under the imported standardizer.
1333        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    /// The lock rejection region must be **symmetric**, or the
1340    /// Metropolis-within-Gibbs argument that makes locking exact does not
1341    /// hold. Scanning only the *previous* trace lets a birth at a locked
1342    /// address through while rejecting the death that would undo it, so the
1343    /// chain can wander into locked structure it can never leave.
1344    #[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        // Birth and death of a locked address are both violations.
1372        assert!(Engine::violates_locks(&absent, &present, &locked), "birth");
1373        assert!(Engine::violates_locks(&present, &absent, &locked), "death");
1374        // …and edits, in both directions.
1375        assert!(Engine::violates_locks(&present, &changed, &locked));
1376        assert!(Engine::violates_locks(&changed, &present, &locked));
1377        // Moving an *unlocked* site is always fine.
1378        assert!(!Engine::violates_locks(&present, &untouched, &locked));
1379        assert!(!Engine::violates_locks(&untouched, &present, &locked));
1380        // No locks, no rejections.
1381        assert!(!Engine::violates_locks(
1382            &absent,
1383            &present,
1384            &std::collections::HashSet::new()
1385        ));
1386    }
1387
1388    /// Duel selection must not keep asking the same question. Between refits
1389    /// the posterior barely moves, which is exactly when a best-arm rule
1390    /// locks onto one pair and shows it over and over.
1391    #[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            // Now hold the posterior still and ask for 12 duels in a row.
1412            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        // Deliberately NOT asserted: `bald > thompson`. That is a horse race
1426        // between two rules at one seed, and it is brittle in exactly the way
1427        // this suite must not be — Thompson's degeneracy needs a *sharp*
1428        // posterior to express (the shipped bug appeared after many refits),
1429        // and after 10 duels the posterior here is wide enough that Thompson
1430        // draws varied champions on some seeds. Rule-vs-rule quality is
1431        // established distributionally by `learn_synthetic --compare` (20
1432        // CRN-paired seeds, both regimes); a unit test's job is the
1433        // product property — the shipped rule must not lock onto one pair —
1434        // which is the assertion above.
1435    }
1436
1437    /// The local explanation is *exact*: utility is linear within a lens, so
1438    /// the contributions must sum to the utility, with no residual to
1439    /// apologize for.
1440    #[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        // Sorted by magnitude, so "the top three" is a meaningful phrase.
1472        for w in e.contributions.windows(2) {
1473            assert!(w[0].contribution.abs() >= w[1].contribution.abs());
1474        }
1475    }
1476
1477    /// Patches get names a musician could say out loud, and no two rows in
1478    /// the bank share one.
1479    #[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        // A user-given name always wins over the generated one.
1499        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    /// Names must **spread**, not merely be unique after numbering.
1505    ///
1506    /// The failure this guards was measured in the running app: 13 of 40 bank
1507    /// rows named `Glass Pad`, numerals to `Glass Pad 12`. The old test passed
1508    /// throughout, because uniqueness-after-disambiguation is exactly what a
1509    /// numeral suffix guarantees no matter how degenerate the generator is.
1510    /// Concentration is the property with product meaning, so concentration is
1511    /// what gets asserted.
1512    #[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        // Strip any disambiguating numeral to recover the generated bucket.
1531        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    /// Names must **collapse** when the patches really are alike.
1568    ///
1569    /// The counterpart to `names_spread_across_the_pool`, and the reason that
1570    /// test is not sufficient on its own. Quantiles put a third of the pool in
1571    /// each bucket whatever the pool is, so a scheme built only to spread will
1572    /// happily deal out thirty names for thirty imperceptible variations of
1573    /// one pad and tell the user they are thirty different sounds. Spreading
1574    /// is only a virtue when the pool is genuinely varied; here it would be a
1575    /// lie, and the just-noticeable-difference floors exist to stop it.
1576    #[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        // Twelve variants differing by a hair of filter cutoff — inaudible,
1587        // and certainly not twelve different instruments.
1588        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    /// A user or preset name must *compete* for its spelling, not squat on it.
1611    /// `Glass Pad` is a preset name and also something the generator can
1612    /// produce; substituting explicit names after disambiguation let both
1613    /// reach the bank.
1614    #[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        // Name three patches the same thing on purpose, and name a fourth
1626        // whatever the generator called a fifth.
1627        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    /// Duels must spread over *candidates*, not just over pairs.
1648    ///
1649    /// Measured in the shipped app: over twelve consecutive duels one
1650    /// candidate appeared in six, and the pair penalty could not see it —
1651    /// every pairing of that candidate is a distinct pair. This asserts the
1652    /// thing the user actually experiences, with the posterior held still,
1653    /// which is the regime between refits where degeneracy showed up.
1654    ///
1655    /// Both shippable rules are checked. The default is `Random`, which has
1656    /// no repetition machinery at all and does not need any; `Bald` has to
1657    /// *earn* its equivalent behaviour from the exposure penalty, so it is the
1658    /// one that could regress.
1659    #[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            // Hold the posterior still and ask for N duels, as the app does
1682            // between refits.
1683            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            // Pair distinctness is asserted per rule, at the level the rule
1704            // actually promises. `Bald` carries an exposure penalty whose job
1705            // is repeat avoidance, so it must deliver all-distinct pairs.
1706            // `Random` promises uniformity, and uniformity *collides*: 12
1707            // draws from C(24,2)=276 pairs repeat one with probability ~21%
1708            // (expected collisions 66/276 ≈ 0.24), so demanding zero repeats
1709            // of it asserts seed luck, not behaviour — that assertion held
1710            // until an unrelated refactor shifted rng consumption, which is
1711            // precisely the brittleness. Two collisions is p < 2%; more than
1712            // that would mean the sampler is not uniform.
1713            //
1714            // The bound now matches that last sentence, which the code did not.
1715            // `N - 1` admits **one** collision and therefore fires on 21% of
1716            // seeds — the very rate the paragraph above calls seed luck — and
1717            // it duly fired the first time an unrelated change (a seventh
1718            // source kind) shifted rng consumption again. `N - 2` admits the
1719            // two collisions the reasoning allows and fires at P(≥3) ≈ 0.19%,
1720            // which is a claim about the sampler rather than about the seed.
1721            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            // Distinct-candidate coverage splits the same way and for the
1730            // same reason. Twelve duels are 24 slots drawn from a pool of 24,
1731            // so a *uniform* rule is expected to reach
1732            // `24·(1 − (23/24)^24) ≈ 15.5` distinct candidates with a
1733            // standard deviation near 1.6 — 13 is an ordinary draw from that,
1734            // and asserting 14 of `Random` asserts seed luck. It held until
1735            // wave 2C's recursive mod sort moved rng consumption, which is
1736            // exactly the brittleness this comment already describes for
1737            // pairs. `Bald` is the rule that *promises* spread, through its
1738            // exposure penalty, so it keeps the stronger bound.
1739            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    /// The calibration export is a *proper* score. A confident-and-right
1756    /// forecaster must beat a hedging one, which is precisely what the
1757    /// running hit rate it replaces cannot tell you.
1758    #[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        // Reliability bins: a well-calibrated stream lands on the diagonal.
1787        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    /// Self-report and heard comparison are scored apart, because there is no
1804    /// reason to believe a checkbox and a heard A/B are equally reliable and
1805    /// the only way to find out is to keep the two streams separable. The
1806    /// aggregate still covers everything — this splits the score, it does not
1807    /// hide any of it.
1808    #[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        // The self-reports contradict a model that is right about the heard
1820        // ones — precisely the asymmetry this split exists to make visible.
1821        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    /// A profile written before raw-φ logging still loads and still means
1841    /// something: its standardized vectors are inverted back to raw values,
1842    /// re-projected by name, and the votes survive the feature-set change
1843    /// that motivated the whole exercise.
1844    #[test]
1845    fn legacy_profile_migrates_into_the_new_feature_set() {
1846        use crate::migrate::SCHEMA1_NAMES;
1847        let d = SCHEMA1_NAMES.len();
1848        // A schema-1 profile: standardized φ plus the standardizer they were
1849        // written under, which is exactly what makes them invertible.
1850        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        // Schema-1 values were measured under the v1 stimulus, so the vote
1887        // lands on the v1 names — not the current stimulus-tagged audio
1888        // names, which would launder old-stimulus evidence into coordinates
1889        // it was never commensurable with.
1890        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        // Old-stimulus rows must never feed the current standardizer …
1896        assert_eq!(engine.log.raw_rows(&names).len(), 0);
1897        // … but the vote itself is intact raw evidence under its own names.
1898        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        // Structural coordinates (stimulus-independent) carry the comparison
1909        // forward; stimulus-tagged audio coordinates are imputed to exactly
1910        // "no evidence" (z = 0) on both sides. The winner keeps its win, and
1911        // no coordinate flips.
1912        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    /// A profile written under the **previous φ width** still loads, and its
1927    /// votes still count for the coordinates they were measured on.
1928    ///
1929    /// This is the migration a *feature-set* change produces, as distinct from
1930    /// the schema change above: the log is already raw and already named, so
1931    /// nothing needs inverting — but the standardizer that shipped with the
1932    /// profile has the wrong dimension, and every vote is now short a few
1933    /// coordinates. Both halves have to be right or the failure is silent:
1934    /// keeping the old standardizer would transform vectors of one width
1935    /// against means of another, and dropping the votes would read as "this
1936    /// user has no opinion" about coordinates they voted on hundreds of times.
1937    ///
1938    /// Wave 3 is the case in hand — `chain_balance`, `frac_sidechained` and
1939    /// `mod_at_source` did not exist — but the test is written against
1940    /// "whatever the last three coordinates are" so it keeps testing the
1941    /// mechanism rather than this particular wave.
1942    #[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        // A vote whose winner is higher on every coordinate it knows about.
1950        //
1951        // Strictly *inside* [0,1] rather than the `1.0 + i·0.01` this used to
1952        // be, and the reason is a real gate rather than a cosmetic one:
1953        // `migrate::repair_log` now pulls the unit-bounded coordinates back
1954        // into their range on load, so a synthetic row that put `mod_density`
1955        // at 1.19 was arriving repaired and the assertion below was reading
1956        // the repair rather than the projection. The property under test —
1957        // every coordinate strictly higher on the winner — is unchanged.
1958        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        // The profile's standardizer is obsolete by width, so it is dropped
1995        // and a fresh one fit from the live pool. Carrying it would silently
1996        // mis-scale every coordinate.
1997        let sz = engine.standardizer.as_ref().expect("standardizer refit");
1998        assert_eq!(sz.dimension(), names.len());
1999        // The vote keeps the names it was recorded under — it is not
2000        // re-stamped, because it genuinely says nothing about the new
2001        // coordinates and claiming otherwise would be a fabricated zero.
2002        assert_eq!(engine.log.observations[0].feature_names, old_names);
2003        // It is therefore not eligible to fit the standardizer (wrong width)…
2004        assert_eq!(engine.log.raw_rows(&names).len(), 0);
2005
2006        // …and it still lands in the fit, projected by name.
2007        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        // The three that did not exist are imputed at the mean, which is
2023        // exactly zero in standardized space: "this vote says nothing here".
2024        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    /// A session saved under the **v1 palette** still loads — bank, votes and
2031    /// all — after the palette grew modulation slots on modules that already
2032    /// shipped.
2033    ///
2034    /// This is the failure mode a palette expansion produces and a schema
2035    /// migration does not catch, because nothing about the *log* changed. The
2036    /// v2 palette added `mod_depth` + `modulation` to `Delay`, `Chorus` and
2037    /// `Reverb`, and serde requires every field of a struct variant by
2038    /// default — so before those fields were `#[serde(default)]`, a single
2039    /// v1-era delay anywhere in a bank failed the `SessionState` deserialize.
2040    /// Not the patch: the **save**. Bank, observation log, lineage,
2041    /// calibration, all of it, for a user who did nothing but keep using the
2042    /// app. Roughly a third of v1 op draws were one of those three modules,
2043    /// so most real banks contained at least one.
2044    ///
2045    /// The fixture is hand-written v1-shaped JSON rather than a serialized
2046    /// current tree, because a current tree round-trips trivially and would
2047    /// assert nothing. The defaults must also be *v1 behaviour* — depth 0,
2048    /// no modulation source — so a restored patch sounds like the one that
2049    /// was saved, which the parameter asserts below check.
2050    #[test]
2051    fn v1_palette_session_still_loads() {
2052        use auracle_grammar::term::{AudioNode, ModNode};
2053
2054        // One tree per module that gained a slot, in the exact shape v1 wrote.
2055        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        // The added knobs default to "as it sounded in v1".
2097        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        // Reverb's own slot defaults, but the filter *below* it had a slot in
2115        // v1 and must keep the source that was saved in it.
2116        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        // Wave 2A put a pitch-modulation slot on the two oldest sources, and
2130        // a vco is in *every* saved patch — so a missing `#[serde(default)]`
2131        // there does not cost one module, it fails the whole `SessionState`
2132        // deserialize and takes bank, observation log and lineage with it.
2133        // These two entries are the shapes that would have caught that:
2134        // roots with no `mod_depth` and no `modulation` key at all.
2135        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        // The vcos nested *inside* the three older entries must have defaulted
2168        // too — that is the shape a real save actually has.
2169        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        // And the whole thing restores into a live engine: every v1 patch
2179        // compiles, renders and vets under the v2 compiler, and the user's
2180        // vote is still in the log.
2181        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        // Node identities are the other thing this fixture is now proving: it
2204        // was written long before uids existed, so every node in it arrives
2205        // unset. The whole migration is that `#[serde(default)]` lets the save
2206        // load at all and the pool settles it on the way in — a returning user
2207        // gets working locks and layout without their save being rewritten.
2208        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    // ------------------------------------------------------------------
2219    // The render farm (crate::farm)
2220    // ------------------------------------------------------------------
2221
2222    /// A pool signature strong enough to catch any drift the farm could
2223    /// introduce: **id, term, and raw φ**.
2224    ///
2225    /// φ and not just the tree, deliberately. The tree alone proves the *draw
2226    /// stream* survived the move off-engine; it says nothing about whether the
2227    /// render did. φ is the only assertion that actually exercises
2228    /// `render.rs`'s `(term, spec) → bit-identical samples` contract across
2229    /// separate wasm instances, which is the claim the whole farm rests on.
2230    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    /// Fill a pool the way the farm does: issue up to `width` draws at a time,
2239    /// featurize them off-engine, let the results arrive **scrambled**, and
2240    /// absorb strictly in index order.
2241    ///
2242    /// `width == 0` issues one job at a time and absorbs it immediately, which
2243    /// is the serial fallback — the same code path the app takes when no farm
2244    /// worker ever reports ready.
2245    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        // Completed-but-unabsorbed results, deliberately kept in whatever
2256        // order the "workers" finished in.
2257        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            // Scramble completion order as a wider farm would: more workers,
2270            // more reordering. Absorption must not be able to tell.
2271            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; // drained: no work left to issue and none outstanding
2293            }
2294        }
2295        engine
2296    }
2297
2298    /// The judge's gate. Same `fill_seed`, farm widths {0,1,2,3,5,8}, one
2299    /// pool.
2300    ///
2301    /// This is the assertion that makes the farm's determinism a property of
2302    /// the code rather than of the argument in `farm.rs`: draws are named by
2303    /// index, absorbed in index order, and the fold at index *i* sees exactly
2304    /// the pool that indices `< i` built — so how many renders were in flight,
2305    /// and in what order they finished, cannot reach the result.
2306    #[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    /// The farm fold and the in-process fill are the same fold.
2318    ///
2319    /// `fill_pool` renders inside the engine; `farm_fill` renders outside it
2320    /// and hands the results back. Given one `fill_seed` they must agree
2321    /// exactly — otherwise "serial fallback" would mean "a different bank",
2322    /// and every user whose browser cannot spawn workers would be running a
2323    /// different product.
2324    #[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        // Chunking is invisible too: the draw cursor lives in the engine, not
2344        // in a loop variable, so `fill_step(2)` forty times is `fill_step(40)`.
2345        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    /// The wire is `f32`, and that has to be invisible.
2364    ///
2365    /// A farm result's audition crosses as `Float32Array` and is rebuilt on
2366    /// the far side. Since the pool's buffer is *only* ever consumed as f32
2367    /// (`render_of`, `edit_render`), a transported buffer must equal the one
2368    /// an in-process render would have kept — sample for sample, not
2369    /// approximately.
2370    #[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                        // Exactly what crosses the port: the samples, and
2401                        // nothing else. Rebuilt from the engine's own phrase.
2402                        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    /// A deferred restore is `import_state` with the renders moved out of it.
2431    ///
2432    /// Restore is the returning user's boot, and it is the path the farm helps
2433    /// most (today it is a full bank of serial renders behind a frozen bar).
2434    /// Moving that work off-engine must change nothing about what comes back:
2435    /// ids, terms, names, origins, φ_std, content keys, and the id allocator.
2436    #[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        // Off-engine, in bank order — which is what the engine worker does
2462        // with a wave of farm results.
2463        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        // The id allocator has to come back the same, or a post-restore insert
2483        // collides with a restored candidate on one path and not the other.
2484        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}