Skip to main content

auracle_wasm/
lib.rs

1//! # auracle-wasm
2//!
3//! Thin `wasm-bindgen` bindings over [`auracle_session::Engine`] for the web
4//! app. Designed to run inside a **Web Worker**: all methods here can take
5//! seconds (rendering, MCMC); the main thread only plays transferred audio
6//! buffers and draws instrumentation.
7//!
8//! Everything crossing the boundary is either JSON (structures) or a
9//! `Float32Array` (audio). Candidates are addressed by **stable id** — pool
10//! positions shift on eviction, ids never do. The engine is deterministic
11//! given the seed.
12//!
13//! The **workbench** is the interactive-panel surface: `edit_begin(id)`
14//! clones a candidate's tree; `edit_param` writes one knob (a trace-address
15//! edit) and re-renders; `edit_commit` inserts the result as a new candidate
16//! (optionally logging an "edited beats original" duel);
17//! `refine_from(id, locks)` evolves everything *except* the locked
18//! addresses.
19
20mod live;
21pub use live::LivePoly;
22
23use std::sync::Arc;
24
25use auracle_features::{featurize_memo, Audition, CachedFeatures, Features, PhraseSpec};
26use auracle_grammar::{
27    apply_struct_op, describe, presets, set_param, validate_tree, ParamValue, PatchGrammarPrior,
28    PatchTree, StructOp,
29};
30use auracle_session::{
31    BankEntry, EditOutcome, Engine, Origin, PreFeaturized, Profile, RenderPolicy, SessionConfig,
32    SessionState,
33};
34use rand::rngs::StdRng;
35use rand::SeedableRng;
36use serde::Serialize;
37use wasm_bindgen::prelude::*;
38
39/// One row of the ranked-pool summary.
40///
41/// `name` is the display name — the user's if they gave one, otherwise a
42/// **musical** name read off the measured features (`Bright Pluck`,
43/// `Fat Sub`), disambiguated across the pool. `signature` is the topology
44/// (`ssaw·lp·ladr`), kept as separate metadata: it describes the circuit, not
45/// the sound, and it collides constantly, so it belongs under the name rather
46/// than in place of it.
47#[derive(Serialize)]
48struct RankedRow {
49    id: u64,
50    mean: f64,
51    std: f64,
52    origin: &'static str,
53    name: String,
54    named: bool,
55    signature: String,
56    sexpr: String,
57    pinned: bool,
58}
59
60/// One θ coordinate of one style.
61#[derive(Serialize)]
62struct ThetaRow {
63    name: String,
64    mean: f64,
65    std: f64,
66}
67
68/// One style lens of the taste posterior.
69#[derive(Serialize)]
70struct StyleRow {
71    /// User-given name ("" = unnamed).
72    name: String,
73    /// Fraction of the pool this lens claims (its island's share).
74    share: f64,
75    /// Feature weights of this lens.
76    theta: Vec<ThetaRow>,
77    /// Pool ids this lens scores highest (its exemplar patches).
78    exemplars: Vec<u64>,
79}
80
81/// Engine status snapshot for the UI.
82#[derive(Serialize)]
83struct Status {
84    pool: usize,
85    pool_target: usize,
86    observations: usize,
87    session: usize,
88    has_posterior: bool,
89    generation: usize,
90    k_styles: usize,
91    /// Effective sample size of the posterior draws after the importance
92    /// updates folded in since the last full fit (0 before the first fit).
93    ess: f64,
94    /// True when those weights have degenerated enough that a full MCMC
95    /// refit is worth its seconds — a better refit trigger than a fixed
96    /// vote count.
97    needs_refit: bool,
98}
99
100fn origin_str(o: Origin) -> &'static str {
101    match o {
102        Origin::Prior => "prior",
103        Origin::Refined => "refined",
104        Origin::Edited => "edited",
105        Origin::Preset => "preset",
106    }
107}
108
109/// The buffer form WebAudio wants. Cloned rather than moved because the
110/// engine and the workbench both keep the authoritative copy — every consumer
111/// of an audition on this boundary hands it straight to a `Float32Array`.
112fn pcm(a: &Audition) -> Vec<f32> {
113    a.samples.clone()
114}
115
116// ----------------------------------------------------------------------
117// The render farm's stateless surface
118// ----------------------------------------------------------------------
119
120/// One farm result: a render + vet + featurize that happened with **no
121/// [`Engine`] anywhere in sight**.
122///
123/// This is the whole farm-worker contract. A farm worker holds a wasm instance
124/// and nothing else — no pool, no RNG, no session — so any worker is
125/// interchangeable with any other and with the engine itself. `samples` is
126/// moved out on the first read so the buffer can be transferred rather than
127/// copied.
128#[wasm_bindgen]
129pub struct RenderJob {
130    ok: bool,
131    cached: String,
132    samples: Vec<f32>,
133}
134
135#[wasm_bindgen]
136impl RenderJob {
137    /// Whether the term rendered and passed vetting. A `false` here is a
138    /// **normal outcome** — a quarantined draw — not an error to report.
139    #[wasm_bindgen(getter)]
140    pub fn ok(&self) -> bool {
141        self.ok
142    }
143
144    /// Serialized `auracle_features::CachedFeatures`: the content key, the
145    /// raw φ and vet report, the note onsets and the render length. `""` when
146    /// `!ok`.
147    #[wasm_bindgen(getter)]
148    pub fn cached(&self) -> String {
149        self.cached.clone()
150    }
151
152    /// The normalized audition as `f32`, emptying the job.
153    ///
154    /// `f32` is not a precision compromise: a stored render is only ever
155    /// consumed through this boundary (`render_of`, `edit_render`), and the
156    /// engine measures φ on the f64 render inside the farm worker, before this
157    /// conversion. See `auracle_features::Audition`'s one-way-door note.
158    pub fn take_samples(&mut self) -> Vec<f32> {
159        std::mem::take(&mut self.samples)
160    }
161
162    /// Number of samples still held (0 after [`RenderJob::take_samples`]).
163    #[wasm_bindgen(getter)]
164    pub fn n_samples(&self) -> usize {
165        self.samples.len()
166    }
167}
168
169/// Render, vet and featurize one term under `phrase_json` — the farm worker's
170/// entire job, and a pure function of its two arguments.
171///
172/// The phrase travels with the handshake rather than being reconstructed from
173/// a default, so a farm worker can never measure φ under a stimulus the pool
174/// was not measured under. A vet failure returns `ok:false` rather than
175/// throwing: a quarantined draw is a normal outcome, and the engine consumes
176/// its index either way.
177///
178/// `want_audio` decides whether the ~565 KB buffer comes back at all. The
179/// engine's own fill asks for φ only (its `RenderPolicy::Lazy` pool keeps no
180/// audio at admission), so the flag exists to let the caller pay for audio
181/// exactly where it will be heard — the first few patches, which are the ones
182/// the user auditions while the rest of the bank lands.
183/// The persistent render cache's namespace for `phrase_json`, or `""` if the
184/// phrase does not parse.
185///
186/// Two rows may only be compared, stored or served under the same namespace:
187/// it pins both the stimulus and [`auracle_features::RENDER_EPOCH`], the
188/// featurizer's own generation. A build whose φ differs from the one that wrote
189/// a row therefore cannot read it — the namespace simply does not match, so
190/// there is no stale-row path to get wrong.
191///
192/// The store this keys is `namespace → key → CachedFeatures`. Dropping a
193/// namespace is how a cache is invalidated, and it is the *only* correct
194/// granularity: φ moving invalidates everything measured under the old φ.
195#[wasm_bindgen]
196pub fn cache_namespace(phrase_json: &str) -> String {
197    serde_json::from_str::<PhraseSpec>(phrase_json)
198        .map(|spec| auracle_features::cache_namespace(&spec))
199        .unwrap_or_default()
200}
201
202/// The content address of `(tree_json, phrase_json)` **without rendering it** —
203/// what a caller asks the persistent cache about before paying for a render.
204///
205/// Returns `""` if either argument fails to parse, which the caller should
206/// treat as a miss rather than an error: the render path validates its own
207/// inputs and is the one place allowed to reject them.
208#[wasm_bindgen]
209pub fn farm_key(tree_json: &str, phrase_json: &str) -> String {
210    let (Ok(tree), Ok(spec)) = (
211        serde_json::from_str::<PatchTree>(tree_json),
212        serde_json::from_str::<PhraseSpec>(phrase_json),
213    ) else {
214        return String::new();
215    };
216    auracle_features::render_key(&tree, &spec)
217}
218
219#[wasm_bindgen]
220pub fn farm_render(tree_json: &str, phrase_json: &str, want_audio: bool) -> RenderJob {
221    let rejected = || RenderJob {
222        ok: false,
223        cached: String::new(),
224        samples: Vec::new(),
225    };
226    let (Ok(tree), Ok(spec)) = (
227        serde_json::from_str::<PatchTree>(tree_json),
228        serde_json::from_str::<PhraseSpec>(phrase_json),
229    ) else {
230        return rejected();
231    };
232    let Ok(pre) = PreFeaturized::render(tree, &spec, want_audio) else {
233        return rejected();
234    };
235    let Ok(cached) = serde_json::to_string(&pre.cached) else {
236        return rejected();
237    };
238    let samples = pre
239        .audition
240        .map(|a| Arc::try_unwrap(a).unwrap_or_else(|a| (*a).clone()).samples)
241        .unwrap_or_default();
242    RenderJob {
243        ok: true,
244        cached,
245        samples,
246    }
247}
248
249/// The session engine, wasm-side.
250#[wasm_bindgen]
251pub struct WasmEngine {
252    engine: Engine,
253    rng: StdRng,
254    bench_tree: Option<PatchTree>,
255    bench_render: Option<Arc<Audition>>,
256    bench_original: Option<u64>,
257    bench_vet_ok: bool,
258    bench_gain_db: f64,
259    /// Raw φ of the tree on the bench, and of the tree that was on it before
260    /// the last featurize.
261    ///
262    /// The current one is what the live utility readout is computed from: the
263    /// bench re-featurizes on every edit regardless, so `θ · φ_std` costs a
264    /// dot product and the alternative — a number describing the patch you
265    /// loaded rather than the one under your hands — is not cheaper, only
266    /// wrong. The previous one exists for the implicit stream: a revert is a
267    /// transition, and a transition logged with only one side of it says
268    /// nothing about the direction the player moved.
269    bench_phi: Option<Vec<f64>>,
270    bench_phi_prev: Option<Vec<f64>>,
271    /// Bank entries of a deferred restore, awaiting off-engine featurization.
272    /// Held here rather than shipped to JS so the orchestrator addresses them
273    /// by index — the same statelessness the pool fill gets from its draw
274    /// stream.
275    pending_bank: Vec<BankEntry>,
276}
277
278/// The workbench's audition buffer after a featurize.
279///
280/// The bench is the one surface that *always* needs audio — the user is
281/// looking at a scope of the edit they just made — so a memo hit whose buffer
282/// has aged out is re-derived rather than left blank. `render_playback` is
283/// bit-identical to what `featurize` normalized, so the scope and the sound
284/// are the same artifact either way.
285fn bench_audio(
286    tree: &PatchTree,
287    phrase: &PhraseSpec,
288    features: &Features,
289    fresh: Option<Arc<Audition>>,
290) -> Option<Arc<Audition>> {
291    fresh.or_else(|| {
292        auracle_features::render_playback(tree, phrase, features.gain_db)
293            .ok()
294            .map(Arc::new)
295    })
296}
297
298/// LUFS makeup as a linear gain, clamped to ±12 dB so near-silent patches
299/// don't get cranked into the noise floor.
300fn makeup_linear(gain_db: f64) -> f64 {
301    10f64.powf(gain_db.clamp(-12.0, 12.0) / 20.0)
302}
303
304#[wasm_bindgen]
305impl WasmEngine {
306    /// Create an engine with the default grammar and session config.
307    #[wasm_bindgen(constructor)]
308    pub fn new(seed: u64, pool_size: usize) -> WasmEngine {
309        console_error_panic_hook::set_once();
310        let cfg = SessionConfig {
311            pool_size,
312            // The browser is the one place audition memory is scarce and the
313            // one place audio is actually played. Lazy is the answer to both:
314            // a full eager pool is tens of megabytes of buffers the user will
315            // mostly never hear, while the dozen that matter (the duel pair,
316            // the bench subject, whatever was just auditioned) stay resident.
317            render_policy: RenderPolicy::Lazy,
318            audio_cache: 12,
319            // The MCMC budget is no longer overridden here. This used to run
320            // 20 000/6 000 as a "slightly lighter chain than the native
321            // default" of 30 000/10 000; the default is now 10 000/3 000
322            // (chosen from a measured recovery-vs-budget curve — see
323            // `SessionConfig::mcmc_samples`), so an override would make the
324            // browser, the one place a fit blocks a human, the *heaviest*
325            // chain in the tree.
326            ..Default::default()
327        };
328        let mut engine = Engine::new(PatchGrammarPrior::default(), cfg);
329        engine.begin_session();
330        WasmEngine {
331            engine,
332            rng: StdRng::seed_from_u64(seed),
333            bench_tree: None,
334            bench_render: None,
335            bench_original: None,
336            bench_vet_ok: false,
337            bench_gain_db: 0.0,
338            bench_phi: None,
339            bench_phi_prev: None,
340            pending_bank: Vec::new(),
341        }
342    }
343
344    /// Loudness-makeup linear gain for live playback of candidate `id`
345    /// (evens patches out to the audition target). 1.0 for unknown ids.
346    pub fn makeup_of(&self, id: u32) -> f64 {
347        self.engine
348            .find(id as u64)
349            .map(|i| makeup_linear(self.engine.pool[i].features.gain_db))
350            .unwrap_or(1.0)
351    }
352
353    /// Loudness-makeup linear gain for the current workbench tree.
354    pub fn edit_makeup(&self) -> f64 {
355        makeup_linear(self.bench_gain_db)
356    }
357
358    /// Add up to `max_new` vetted candidates. Returns how many were added,
359    /// so the worker can post fill progress between calls.
360    ///
361    /// The serial path, and the fallback whenever no farm is available. It
362    /// folds the same indexed draw stream `fill_draw`/`fill_absorb` fold, so
363    /// the pool it builds is the pool the farm builds.
364    pub fn fill_step(&mut self, max_new: usize) -> usize {
365        self.engine.fill_pool_step(&mut self.rng, max_new)
366    }
367
368    // ------------------------------------------------------------------
369    // Render farm (see `auracle_session::farm`)
370    // ------------------------------------------------------------------
371
372    /// The audition stimulus as JSON, for the farm handshake.
373    ///
374    /// Shipped rather than assumed: a farm worker that defaulted its own
375    /// `PhraseSpec` would measure φ under a different stimulus the moment the
376    /// engine's phrase ever becomes configurable, and the drift would be
377    /// silent because every individual render would still be internally
378    /// consistent.
379    pub fn phrase_json(&self) -> String {
380        serde_json::to_string(&self.engine.cfg.phrase).unwrap_or_default()
381    }
382
383    /// Next index of the pool draw stream the engine will fold in.
384    pub fn fill_cursor(&self) -> u32 {
385        self.engine.draw_cursor() as u32
386    }
387
388    /// Hand out up to `n` unrendered draws as JSON
389    /// `[{"i":7,"tree":{…},"dup":false}]`, possibly shorter than `n` or empty.
390    ///
391    /// Empty means "nothing to issue *right now*" — the pool has as much work
392    /// outstanding as it can use, or the draw budget is spent. It is a stop
393    /// signal only in combination with nothing outstanding; see
394    /// `Engine::fill_draw`.
395    pub fn fill_draw(&mut self, n: usize) -> String {
396        self.engine.ensure_fill_seed(&mut self.rng);
397        serde_json::to_string(&self.engine.fill_draw(n)).unwrap_or_else(|_| "[]".into())
398    }
399
400    /// The term at `index` of the draw stream, as JSON (`""` before the stream
401    /// starts).
402    ///
403    /// The re-issue path: a farm worker that dies or hangs loses nothing but
404    /// its render, because the job it was doing is fully named by its index.
405    /// No tree JSON has to be retained anywhere to recover it.
406    pub fn draw_json(&self, index: u32) -> String {
407        self.engine
408            .draw_at(index as u64)
409            .and_then(|t| serde_json::to_string(&t).ok())
410            .unwrap_or_default()
411    }
412
413    /// Fold one farm result into the pool, in index order.
414    ///
415    /// `cached_json == ""` (or samples whose length disagrees with the render
416    /// the farm reported) means the draw did not survive: the index is
417    /// consumed and 0 returned, exactly as a vet failure burns an attempt in
418    /// the serial loop. Returns the new candidate id otherwise, or 0 for a
419    /// duplicate or a full pool.
420    pub fn fill_absorb(&mut self, index: u32, cached_json: &str, samples: &[f32]) -> u32 {
421        let i = index as u64;
422        let pre = self
423            .engine
424            .draw_at(i)
425            .and_then(|tree| self.pre_featurized(tree, cached_json, samples));
426        self.engine.absorb_prior(i, pre).unwrap_or(0) as u32
427    }
428
429    /// Restore a session but leave the bank un-rendered: returns JSON
430    /// `[{"i":0,"tree":{…}}]` in bank order. Every entry must come back
431    /// through [`WasmEngine::bank_absorb`], after which
432    /// [`WasmEngine::restore_finish`] closes the restore.
433    pub fn import_session_deferred(&mut self, json: &str) -> String {
434        let Ok(state) = serde_json::from_str::<SessionState>(json) else {
435            return "[]".into();
436        };
437        self.pending_bank = self.engine.import_state_deferred(state);
438        self.engine.begin_session();
439        let jobs: Vec<serde_json::Value> = self
440            .pending_bank
441            .iter()
442            .enumerate()
443            .map(|(i, e)| serde_json::json!({ "i": i, "tree": e.tree }))
444            .collect();
445        serde_json::to_string(&jobs).unwrap_or_else(|_| "[]".into())
446    }
447
448    /// The term of pending bank entry `index`, as JSON (`""` if unknown) —
449    /// the restore path's re-issue hook.
450    pub fn bank_draw_json(&self, index: usize) -> String {
451        self.pending_bank
452            .get(index)
453            .and_then(|e| serde_json::to_string(&e.tree).ok())
454            .unwrap_or_default()
455    }
456
457    /// Reinstate one restored bank entry from an off-engine featurization.
458    /// Returns false for an unknown index or a result that did not survive —
459    /// a bank entry that no longer vets is dropped, exactly as the serial
460    /// restore drops it.
461    pub fn bank_absorb(&mut self, index: usize, cached_json: &str, samples: &[f32]) -> bool {
462        let Some(entry) = self.pending_bank.get(index).cloned() else {
463            return false;
464        };
465        let Some(pre) = self.pre_featurized(entry.tree.clone(), cached_json, samples) else {
466            return false;
467        };
468        self.engine.absorb_bank_entry(entry, pre);
469        true
470    }
471
472    /// Featurize and reinstate pending bank entry `index` **in this worker**.
473    ///
474    /// The deferred restore's serial completion: whatever the farm did not
475    /// finish is finished here, so a restore never depends on the farm having
476    /// survived. Same work, same order, same result — it just blocks.
477    pub fn bank_render(&mut self, index: usize) -> bool {
478        let Some(entry) = self.pending_bank.get(index).cloned() else {
479            return false;
480        };
481        let want_audio = self.engine.cfg.render_policy == RenderPolicy::Eager;
482        let Ok((cached, audition)) = featurize_memo(
483            &entry.tree,
484            &self.engine.cfg.phrase,
485            self.engine.memo(),
486            want_audio,
487        ) else {
488            return false;
489        };
490        let pre = PreFeaturized {
491            tree: entry.tree.clone(),
492            cached,
493            audition,
494        };
495        self.engine.absorb_bank_entry(entry, pre);
496        true
497    }
498
499    /// Close a deferred restore (standardizer + φ resolution). Returns the
500    /// number of bank entries that landed.
501    pub fn restore_finish(&mut self) -> usize {
502        self.pending_bank = Vec::new();
503        self.engine.finish_restore()
504    }
505
506    /// Make the pool duel-able **now**, mid-fill: standardize every member,
507    /// fitting a standardizer over whatever has been drawn so far if none
508    /// exists yet. Cheap — no renders, just mean/variance over φ.
509    ///
510    /// This is what lets a progressive boot hand the user a duel after ~8
511    /// candidates instead of after all 40: `next_duel` refuses any candidate
512    /// with an empty `phi_std`, and without this the engine only standardizes
513    /// when the pool first *reaches* its target.
514    pub fn standardize_now(&mut self) {
515        self.engine.standardize_now();
516    }
517
518    /// Re-fit the standardizer once the fill completes, over the full pool
519    /// rather than the first few draws. No-op if a posterior already exists —
520    /// moving the scale under live θ would rescale every utility on screen.
521    pub fn restandardize_if_untaught(&mut self) {
522        self.engine.restandardize_if_untaught();
523    }
524
525    /// Engine status as JSON.
526    pub fn status(&self) -> String {
527        serde_json::to_string(&Status {
528            pool: self.engine.pool.len(),
529            pool_target: self.engine.cfg.pool_size,
530            observations: self.engine.log.len(),
531            session: self.engine.session,
532            has_posterior: self.engine.posterior.is_some(),
533            generation: self.engine.generation,
534            k_styles: self.engine.cfg.k_styles,
535            ess: self.engine.posterior_ess().unwrap_or(0.0),
536            needs_refit: self.engine.needs_refit(),
537        })
538        .unwrap()
539    }
540
541    /// Choose the next duel: JSON `[idA, idB]`, or `null` if the pool is
542    /// small. See [`WasmEngine::next_duel_ex`] for the annotated form.
543    pub fn next_duel(&mut self) -> String {
544        let pair = self
545            .engine
546            .next_duel(&mut self.rng)
547            .map(|(a, b)| [self.engine.pool[a].id, self.engine.pool[b].id]);
548        serde_json::to_string(&pair).unwrap()
549    }
550
551    /// Choose the next duel, with the reasoning attached — `null` if the pool
552    /// is too small:
553    ///
554    /// ```json
555    /// {"a":12,"b":31,"info_gain":0.41,"random_check":false,"method":"bald"}
556    /// ```
557    ///
558    /// `a`/`b` are candidate **ids**. `info_gain` is expected information
559    /// about θ in nats (max `ln 2 ≈ 0.693`). `method` is `"bald"`,
560    /// `"check"` (a uniformly-random calibration probe — worth labelling in
561    /// the UI, since the model is deliberately not choosing it) or
562    /// `"random"` (no posterior yet).
563    pub fn next_duel_ex(&mut self) -> String {
564        #[derive(Serialize)]
565        struct Row {
566            a: u64,
567            b: u64,
568            info_gain: f64,
569            random_check: bool,
570            method: &'static str,
571        }
572        match self.engine.next_duel_full(&mut self.rng) {
573            Some(d) => serde_json::to_string(&Row {
574                a: self.engine.pool[d.a].id,
575                b: self.engine.pool[d.b].id,
576                info_gain: d.info_gain,
577                random_check: d.random_check,
578                method: d.method,
579            })
580            .unwrap(),
581            None => "null".into(),
582        }
583    }
584
585    /// The audition buffer of candidate `id` (mono, ±1.0), for WebAudio.
586    ///
587    /// **`&mut self`, and it can take a render.** Under the lazy policy this
588    /// engine boots with, the buffer is materialized here on first request
589    /// rather than retained from the fill. An **empty** return means the term
590    /// no longer renders (a restored bank can outlive the DSP that made it) —
591    /// callers must treat it as a failure and stop waiting, not as "not yet".
592    pub fn render_of(&mut self, id: u32) -> Vec<f32> {
593        self.engine
594            .render_of(id as u64)
595            .map(|a| pcm(&a))
596            .unwrap_or_default()
597    }
598
599    /// Materialize `id`'s audition buffer without returning it.
600    ///
601    /// The deal path calls this for both sides the moment a pair is chosen,
602    /// so the lazy render happens while the user is still reading the cards
603    /// rather than after they press ▶. Cheap and idempotent once resident.
604    pub fn prefetch_render(&mut self, id: u32) -> bool {
605        self.engine.render_of(id as u64).is_some()
606    }
607
608    /// Featurization-memo counters as JSON
609    /// (`{hits, misses, features, audio, audio_bytes}`) — how much rendering
610    /// the memo is deleting, and how much audio is resident.
611    pub fn memo_stats(&self) -> String {
612        let s = self.engine.memo().stats();
613        serde_json::to_string(&serde_json::json!({
614            "hits": s.hits,
615            "misses": s.misses,
616            "features": s.features,
617            "audio": s.audio,
618            "audio_bytes": s.audio_bytes,
619        }))
620        .unwrap()
621    }
622
623    /// The render sample rate.
624    pub fn sample_rate(&self) -> f64 {
625        self.engine.cfg.phrase.sample_rate
626    }
627
628    /// Patch term of candidate `id`, as an s-expression.
629    pub fn sexpr_of(&self, id: u32) -> String {
630        let id = id as u64;
631        self.engine
632            .find(id)
633            .map(|i| self.engine.pool[i].tree.to_sexpr())
634            .unwrap_or_default()
635    }
636
637    /// The patch tree of candidate `id` as JSON — the payload the live
638    /// instrument (`LivePoly` in the AudioWorklet) compiles and plays.
639    pub fn tree_json_of(&self, id: u32) -> String {
640        let id = id as u64;
641        match self.engine.find(id) {
642            Some(i) => serde_json::to_string(&self.engine.pool[i].tree).unwrap(),
643            None => "null".into(),
644        }
645    }
646
647    /// The workbench tree as JSON (`null` if the bench is empty), for live
648    /// playing of in-progress edits.
649    pub fn edit_tree_json(&self) -> String {
650        match &self.bench_tree {
651            Some(t) => serde_json::to_string(t).unwrap(),
652            None => "null".into(),
653        }
654    }
655
656    /// Rack description (modules, knobs with live trace addresses, wires) of
657    /// candidate `id`, as JSON. `null` for an unknown id.
658    pub fn describe_of(&self, id: u32) -> String {
659        let id = id as u64;
660        match self.engine.find(id) {
661            Some(i) => serde_json::to_string(&describe(&self.engine.pool[i].tree)).unwrap(),
662            None => "null".into(),
663        }
664    }
665
666    /// Record a duel outcome between candidate ids.
667    pub fn record_duel(&mut self, a: u32, b: u32, chose_a: bool) {
668        let (a, b) = (a as u64, b as u64);
669        if let (Some(i), Some(j)) = (self.engine.find(a), self.engine.find(b)) {
670            self.engine.record_duel(i, j, chose_a);
671        }
672    }
673
674    /// Record a keep/kill decision on a candidate id.
675    pub fn record_keep(&mut self, id: u32, kept: bool) {
676        let id = id as u64;
677        if let Some(i) = self.engine.find(id) {
678            self.engine.record_keep(i, kept);
679        }
680    }
681
682    /// Record a star rating on a candidate id.
683    pub fn record_stars(&mut self, id: u32, rating: u8) {
684        let id = id as u64;
685        if let Some(i) = self.engine.find(id) {
686            self.engine.record_stars(i, rating);
687        }
688    }
689
690    /// Re-fit the taste posterior from the log (seconds of MCMC — worker!).
691    pub fn fit(&mut self) {
692        self.engine.fit_posterior(&mut self.rng);
693    }
694
695    /// One round of taste-guided refinement (renders — worker!).
696    pub fn refine(&mut self) {
697        self.engine.refine(&mut self.rng);
698    }
699
700    /// Open a generation; returns the parent ids to refine from as a JSON
701    /// array, or `[]` if there is no taste to refine toward yet (in which case
702    /// no generation is opened).
703    ///
704    /// Paired with [`WasmEngine::refine_seed`] so the caller can drive a
705    /// generation one seed at a time and show progress. A generation is tens
706    /// of seconds of render-bound work; as a single call it looks like a hang.
707    pub fn refine_begin(&mut self) -> String {
708        serde_json::to_string(&self.engine.refine_begin()).unwrap_or_else(|_| "[]".into())
709    }
710
711    /// Refine one seed of the open generation. Returns the child id, or 0 if
712    /// the walk was rejected or landed on a patch already in the pool.
713    pub fn refine_seed(&mut self, parent_id: u32) -> u32 {
714        self.engine
715            .refine_seed(&mut self.rng, parent_id as u64)
716            .unwrap_or(0) as u32
717    }
718
719    /// Locked refinement from candidate `id`: evolve everything except the
720    /// locked addresses (`locked_json` = JSON array of `key#site` strings).
721    /// Returns the new child id, or 0 if no move was accepted.
722    pub fn refine_from(&mut self, id: u32, locked_json: &str) -> u32 {
723        let id = id as u64;
724        let locked: Vec<String> = serde_json::from_str(locked_json).unwrap_or_default();
725        self.engine
726            .refine_from(&mut self.rng, id, &locked)
727            .unwrap_or(0) as u32
728    }
729
730    /// Ranked pool as JSON
731    /// (`[{id, mean, std, origin, name, named, signature, sexpr}]`).
732    pub fn ranked(&self) -> String {
733        let names = self.engine.display_names();
734        let rows: Vec<RankedRow> = self
735            .engine
736            .ranked()
737            .into_iter()
738            .map(|(idx, mean, std)| {
739                let c = &self.engine.pool[idx];
740                RankedRow {
741                    id: c.id,
742                    mean,
743                    std,
744                    origin: origin_str(c.origin),
745                    name: names
746                        .get(&c.id)
747                        .cloned()
748                        .unwrap_or_else(|| c.tree.signature()),
749                    named: c.name.is_some(),
750                    signature: c.tree.signature(),
751                    sexpr: c.tree.to_sexpr(),
752                    pinned: c.pinned,
753                }
754            })
755            .collect();
756        serde_json::to_string(&rows).unwrap()
757    }
758
759    /// Display name of one candidate (user-given, else musical).
760    pub fn name_of(&self, id: u32) -> String {
761        self.engine
762            .display_names()
763            .get(&(id as u64))
764            .cloned()
765            .unwrap_or_default()
766    }
767
768    /// **Why this patch scores what it does**, as JSON, or `null` before the
769    /// first fit / for an unknown id:
770    ///
771    /// ```json
772    /// {"id":12,"style":1,"style_name":"Dark Drones",
773    ///  "utility":0.84,"utility_std":0.31,
774    ///  "mix_utility":0.91,"responsibility":0.86,
775    ///  "contributions":[{"name":"centroid_mean","theta":0.42,
776    ///                    "phi_std":1.01,"contribution":0.42}, …]}
777    /// ```
778    ///
779    /// Contributions are sorted by descending |contribution| and sum exactly
780    /// to `utility` — utility is linear within a lens, so this is an exact
781    /// decomposition rather than a surrogate approximation.
782    ///
783    /// **Draw `mix_utility` as the score.** It is the value `ranked()` sorts
784    /// the bank by; `utility` is the lens-conditional quantity the
785    /// contributions explain, and it is always ≤ `mix_utility`. Rendering
786    /// `utility` beside a row ranked by `mix_utility` shows a number that
787    /// disagrees with its own list. `responsibility` says how much that
788    /// distinction matters for this patch: near 1 the two coincide, well
789    /// below 1 the patch sits between styles.
790    pub fn explain(&self, id: u32) -> String {
791        match self.engine.explain(id as u64) {
792            Some(e) => serde_json::to_string(&e).unwrap(),
793            None => "null".into(),
794        }
795    }
796
797    /// Prequential calibration as JSON — a **proper** score, replacing the
798    /// running hit rate (which is not one, and which the acquisition function
799    /// pins near 50 % by design):
800    ///
801    /// ```json
802    /// {"n":42,"brier":0.19,"log_loss":0.58,"skill":0.24,
803    ///  "bins":[{"lo":0.0,"hi":0.2,"n":7,"predicted":0.11,"observed":0.14}, …],
804    ///  "check_n":4,"check_skill":0.18,"check_log_loss":0.61,"hit_rate":0.55}
805    /// ```
806    ///
807    /// `skill` is `1 − Brier/0.25`: 0 means no better than a coin flip, 1
808    /// means perfect and certain. `log_loss` is in nats (`ln 2 ≈ 0.693` is
809    /// the coin-flip baseline). `bins` is the reliability diagram over
810    /// `P(A wins)`. `check_*` restricts the score to the uniformly-random
811    /// check duels, which is the only selection-bias-free number here.
812    pub fn calibration(&self) -> String {
813        serde_json::to_string(&self.engine.calibration()).unwrap()
814    }
815
816    /// The 2D taste map (pool + history ghosts) as JSON, or `null` when
817    /// there is too little to project.
818    pub fn taste_map(&self) -> String {
819        let map = self.engine.taste_map();
820        if map.points.is_empty() {
821            "null".into()
822        } else {
823            serde_json::to_string(&map).unwrap()
824        }
825    }
826
827    /// Style lenses of the aligned posterior as JSON
828    /// (`[{share, theta: [{name, mean, std}], exemplars: [ids]}]`), or
829    /// `null` before the first fit. Inactive lenses have share ≈ 0.
830    pub fn styles(&self) -> String {
831        let Some(p) = &self.engine.posterior else {
832            return "null".into();
833        };
834        let names = Features::phi_names();
835        let pool_phis: Vec<Vec<f64>> = self
836            .engine
837            .pool
838            .iter()
839            .filter(|c| !c.phi_std.is_empty())
840            .map(|c| c.phi_std.clone())
841            .collect();
842        let shares = p.style_share(&pool_phis);
843        let rows: Vec<StyleRow> = (0..p.k_styles())
844            .map(|k| {
845                let means = p.theta_mean(k);
846                let stds = p.theta_std(k);
847                let theta = names
848                    .iter()
849                    .zip(means)
850                    .zip(stds)
851                    .map(|((name, mean), std)| ThetaRow {
852                        name: name.to_string(),
853                        mean,
854                        std,
855                    })
856                    .collect();
857                let mut scored: Vec<(u64, f64)> = self
858                    .engine
859                    .pool
860                    .iter()
861                    .filter(|c| !c.phi_std.is_empty())
862                    .map(|c| (c.id, p.utility(&c.phi_std, k).0))
863                    .collect();
864                scored.sort_by(|a, b| b.1.total_cmp(&a.1));
865                StyleRow {
866                    name: self.engine.style_names.get(k).cloned().unwrap_or_default(),
867                    share: shares.get(k).copied().unwrap_or(0.0),
868                    theta,
869                    exemplars: scored.iter().take(3).map(|&(id, _)| id).collect(),
870                }
871            })
872            .collect();
873        serde_json::to_string(&rows).unwrap()
874    }
875
876    /// The standardizer's per-coordinate scale, as `{"n_filter":1.42,…}`
877    /// (`{}` before one is fitted).
878    ///
879    /// θ lives in standardized space and everything that has ever been shown
880    /// to the user lived there with it, which was fine while the only claim
881    /// being made was "this coefficient is positive". Pricing a placement is a
882    /// different claim: adding one filter is a **raw** unit step in
883    /// `n_filter`, and turning that into a utility needs the divisor that
884    /// carried it into z-scores. Without it the client can render θ and cannot
885    /// render what θ is worth.
886    ///
887    /// Names rather than a bare vector, because the client already keys every
888    /// module to its coordinate *by name* (`MODULES[k].phi`) and an index
889    /// agreement across the wasm boundary is a silent-drift bug waiting for
890    /// the next φ column to land. Mean is deliberately absent: a **delta** is
891    /// invariant to it, so shipping it would only invite someone to use it.
892    pub fn phi_scale(&self) -> String {
893        let Some(sz) = &self.engine.standardizer else {
894            return "{}".into();
895        };
896        let map: std::collections::BTreeMap<&'static str, f64> = Features::phi_names()
897            .into_iter()
898            .zip(sz.std.iter().copied())
899            .collect();
900        serde_json::to_string(&map).unwrap_or_else(|_| "{}".into())
901    }
902
903    /// The lineage log (evolution/edit events, oldest first) as JSON.
904    pub fn lineage(&self) -> String {
905        serde_json::to_string(&self.engine.lineage).unwrap()
906    }
907
908    /// Name (or rename; empty clears) a candidate.
909    pub fn set_name(&mut self, id: u32, name: &str) {
910        self.engine.set_name(id as u64, name);
911    }
912
913    /// Pin or unpin a patch against eviction. Returns `false` when the id is
914    /// gone or the pin budget is full — the caller must say which, because a
915    /// pin control that silently does nothing is the exact failure this whole
916    /// mechanism exists to end.
917    pub fn set_pinned(&mut self, id: u32, pinned: bool) -> bool {
918        self.engine.set_pinned(id as u64, pinned)
919    }
920
921    /// How many patches are pinned, and the ceiling, as `[count, cap]`.
922    pub fn pin_budget(&self) -> Vec<u32> {
923        vec![
924            self.engine.pinned_count() as u32,
925            self.engine.pin_cap() as u32,
926        ]
927    }
928
929    /// Name an aligned style index.
930    pub fn set_style_name(&mut self, k: usize, name: &str) {
931        self.engine.set_style_name(k, name);
932    }
933
934    /// Log an implicit preference event (promote, play counts, …).
935    pub fn log_event(&mut self, kind: &str, id: u32, value: f64) {
936        self.engine.log_event(kind, id as u64, value);
937    }
938
939    /// Model's predicted probability that `a` beats `b` (−1 before the
940    /// first fit / unknown ids).
941    pub fn duel_pred(&self, a: u32, b: u32) -> f64 {
942        match (self.engine.find(a as u64), self.engine.find(b as u64)) {
943            (Some(i), Some(j)) => self.engine.predict_duel(i, j).unwrap_or(-1.0),
944            _ => -1.0,
945        }
946    }
947
948    /// The aligned style index that best explains candidate `id`
949    /// (−1 before the first fit / unknown id).
950    pub fn best_style_of(&self, id: u32) -> i32 {
951        let (Some(i), Some(p)) = (self.engine.find(id as u64), &self.engine.posterior) else {
952            return -1;
953        };
954        let phi = &self.engine.pool[i].phi_std;
955        if phi.is_empty() {
956            return -1;
957        }
958        let r = p.responsibilities(phi);
959        r.iter()
960            .enumerate()
961            .max_by(|(_, x), (_, y)| x.total_cmp(y))
962            .map(|(k, _)| k as i32)
963            .unwrap_or(-1)
964    }
965
966    /// The built-in preset bank as JSON
967    /// (`[{index, name, category, blurb, sig}]`).
968    ///
969    /// `category` is what the browser groups by and what the warm start
970    /// samples across — with the library past two dozen, an unstratified
971    /// sample of nine would keep landing in one corner of the space, which is
972    /// the same cold-start bias the warm start exists to remove.
973    pub fn preset_list(&self) -> String {
974        #[derive(Serialize)]
975        struct Row {
976            index: usize,
977            name: &'static str,
978            category: &'static str,
979            blurb: &'static str,
980            sig: String,
981        }
982        let rows: Vec<Row> = auracle_grammar::preset_bank()
983            .into_iter()
984            .enumerate()
985            .map(|(index, p)| Row {
986                index,
987                name: p.name,
988                category: p.category,
989                blurb: p.blurb,
990                sig: p.tree.signature(),
991            })
992            .collect();
993        serde_json::to_string(&rows).unwrap()
994    }
995
996    /// Load preset `index` into the bank; returns its id (existing id if the
997    /// identical patch is already there), or 0 on failure.
998    pub fn load_preset(&mut self, index: usize) -> u32 {
999        let all = presets();
1000        let Some((name, tree)) = all.into_iter().nth(index) else {
1001            return 0;
1002        };
1003        self.engine.insert_preset(tree, name).unwrap_or(0) as u32
1004    }
1005
1006    // ------------------------------------------------------------------
1007    // Workbench (the interactive panel)
1008    // ------------------------------------------------------------------
1009
1010    /// Import a shared patch (tree JSON + optional name) into the bank.
1011    /// Returns the new id, or 0 (bad JSON / duplicate / vet failure).
1012    pub fn import_patch(&mut self, tree_json: &str, name: &str) -> u32 {
1013        let Ok(mut tree) = serde_json::from_str::<PatchTree>(tree_json) else {
1014            return 0;
1015        };
1016        // A shared file is untrusted input by definition, and the pictures
1017        // already in circulation carry whatever the build that wrote them had
1018        // on the bench — including `1e30`. Repair on the way in, so an imported
1019        // patch cannot reintroduce a fault the session has just been mended of.
1020        tree.clamp_domains();
1021        match self.engine.commit_edit(None, tree, EditOutcome::Untold) {
1022            Some(id) => {
1023                self.engine.set_name(id, name);
1024                id as u32
1025            }
1026            None => 0,
1027        }
1028    }
1029
1030    /// Load candidate `id` onto the workbench. Returns false for unknown id.
1031    pub fn edit_begin(&mut self, id: u32) -> bool {
1032        let id = id as u64;
1033        match self.engine.find(id) {
1034            Some(i) => {
1035                self.bench_tree = Some(self.engine.pool[i].tree.clone());
1036                self.bench_gain_db = self.engine.pool[i].features.gain_db;
1037                // Materializes the buffer if the lazy pool had let it go: the
1038                // panel shows a scope the moment it opens, so the bench must
1039                // never start empty for a candidate that renders fine.
1040                self.bench_render = self.engine.render_of(id);
1041                self.bench_original = Some(id);
1042                // A different patch entirely: nothing about the last one's φ
1043                // is a "before" for anything this one does.
1044                self.bench_phi = Some(self.engine.pool[i].features.phi());
1045                self.bench_phi_prev = None;
1046                // A pool member vetted when it was admitted, but a bank
1047                // restored across a DSP change can hold a term that no longer
1048                // renders — and `bench_vet_ok` is what gates commit *and*
1049                // playback. Take it from whether a buffer actually exists,
1050                // not from the fact that this id is in the pool.
1051                self.bench_vet_ok = self.bench_render.is_some();
1052                true
1053            }
1054            None => false,
1055        }
1056    }
1057
1058    /// Write one knob on the workbench tree (`value` is the normalized
1059    /// continuous value, or the index when `is_index`), then re-render and
1060    /// re-vet. Returns false if the edit was rejected (structural site,
1061    /// unknown address, no workbench).
1062    pub fn edit_param(&mut self, addr: &str, value: f64, is_index: bool) -> bool {
1063        let Some(tree) = &self.bench_tree else {
1064            return false;
1065        };
1066        let v = if is_index {
1067            ParamValue::Index(value.max(0.0) as usize)
1068        } else {
1069            ParamValue::Continuous(value)
1070        };
1071        let (phrase, memo) = (self.phrase(), self.engine.memo().clone());
1072        match set_param(tree, addr, v) {
1073            Ok(edited) => {
1074                match featurize_memo(&edited, &phrase, &memo, true) {
1075                    Ok((cf, audio)) => {
1076                        self.bench_gain_db = cf.features.gain_db;
1077                        self.bench_render = bench_audio(&edited, &phrase, &cf.features, audio);
1078                        self.bench_vet_ok = true;
1079                        self.set_bench_phi(Some(cf.features.phi()));
1080                    }
1081                    Err(_) => {
1082                        // Keep the edit (the user asked for it) but flag it:
1083                        // the buffer is withheld, never played unvetted.
1084                        self.bench_render = None;
1085                        self.bench_vet_ok = false;
1086                        self.set_bench_phi(None);
1087                    }
1088                }
1089                self.bench_tree = Some(edited);
1090                true
1091            }
1092            Err(_) => false,
1093        }
1094    }
1095
1096    /// Adopt a structural edit (replace/insert/delete/set_mod/swap_mix, as
1097    /// JSON — see `auracle_grammar::StructOp`) **without** re-rendering.
1098    /// Returns an empty string on success or the rejection reason.
1099    ///
1100    /// Split out of [`Self::edit_structure`] because the render is the entire
1101    /// cost. The live worklet can swap a new tree in ~23 ms; the featurizer
1102    /// takes the better part of a second, and the only thing that ever put it
1103    /// between a player's gesture and the sound was that the two lived in one
1104    /// call. A caller that splits gets to speak to the audio thread first and
1105    /// featurize after — but it owes a following [`Self::edit_revet`], because
1106    /// until then `edit_render`/`edit_vet_ok` describe the tree *before* this
1107    /// edit.
1108    pub fn edit_structure_apply(&mut self, op_json: &str) -> String {
1109        let Some(tree) = &self.bench_tree else {
1110            return "no patch on the bench".into();
1111        };
1112        let op: StructOp = match serde_json::from_str(op_json) {
1113            Ok(op) => op,
1114            Err(e) => return format!("bad op: {e}"),
1115        };
1116        match apply_struct_op(tree, &op) {
1117            Ok(edited) => {
1118                self.bench_tree = Some(edited);
1119                String::new()
1120            }
1121            Err(e) => e.to_string(),
1122        }
1123    }
1124
1125    /// Adopt a whole replacement workbench tree (undo/redo restore, and every
1126    /// client-side rewrite the graph editor commits) **without** re-rendering.
1127    /// Returns an empty string on success or the rejection reason.
1128    ///
1129    /// The ceiling check is the load-bearing line. This route does not go
1130    /// through `apply_struct_op`, so until `validate_tree` existed it was a
1131    /// hole straight through MAX_SIZE / MAX_DEPTH / MAX_MOD_DEPTH — and it is
1132    /// exactly the route a move or a reconnect uses. A patch built past those
1133    /// ceilings is not just big: it has ~zero mass under the prior, sits
1134    /// outside the range the standardizer was fitted on, and gets mutated back
1135    /// inside them by the next refinement, so the player's structure
1136    /// disappears on the next evolve with nothing ever having said no.
1137    pub fn edit_set_tree_apply(&mut self, tree_json: &str) -> String {
1138        if self.bench_tree.is_none() {
1139            return "no patch on the bench".into();
1140        }
1141        let mut tree: PatchTree = match serde_json::from_str(tree_json) {
1142            Ok(t) => t,
1143            Err(e) => return format!("bad tree: {e}"),
1144        };
1145        // Domains are repaired, ceilings are refused, and the split is the same
1146        // one `finish()` makes: a knob outside its range has one obviously
1147        // right answer and a 40-node patch does not. It matters here because a
1148        // rewrite is computed from the tree already on the bench — so if that
1149        // tree came out of a session written before this gate, refusing would
1150        // mean the player cannot edit their way out of the corruption, only
1151        // look at it.
1152        tree.clamp_domains();
1153        if let Err(e) = validate_tree(&tree) {
1154            return e;
1155        }
1156        // The panel builds this tree itself, so it is also the one route by
1157        // which a node can arrive with no identity (a module the editor just
1158        // made) or with someone else's (a duplicated subtree brings its
1159        // original's uids along in the copy). Settling assigns the first and
1160        // breaks the second, and it is idempotent for every node that merely
1161        // moved — which is the whole point: a reconnect must not reissue
1162        // identities, or the locks and positions riding on them die on a
1163        // gesture that changed nothing but a wire.
1164        tree.ensure_uids();
1165        self.bench_tree = Some(tree);
1166        String::new()
1167    }
1168
1169    /// Re-render and re-vet whatever tree is currently on the bench.
1170    ///
1171    /// The expensive half of an edit, callable on its own so the cheap half
1172    /// can be delivered to the ear first. Idempotent.
1173    pub fn edit_revet(&mut self) {
1174        let Some(tree) = self.bench_tree.clone() else {
1175            return;
1176        };
1177        let (phrase, memo) = (self.phrase(), self.engine.memo().clone());
1178        match featurize_memo(&tree, &phrase, &memo, true) {
1179            Ok((cf, audio)) => {
1180                self.bench_gain_db = cf.features.gain_db;
1181                self.bench_render = bench_audio(&tree, &phrase, &cf.features, audio);
1182                self.bench_vet_ok = true;
1183                self.set_bench_phi(Some(cf.features.phi()));
1184            }
1185            Err(_) => {
1186                self.bench_render = None;
1187                self.bench_vet_ok = false;
1188                self.set_bench_phi(None);
1189            }
1190        }
1191    }
1192
1193    /// Advance the bench's φ, keeping the one it displaces.
1194    ///
1195    /// Not a plain assignment: `edit_revet` is documented as idempotent and
1196    /// callers rely on that, so a second revet of the same tree must not
1197    /// shuffle the *actual* previous φ out of reach — a revert logged after
1198    /// one would carry `phi_before == phi_after` and read as a no-op edit.
1199    fn set_bench_phi(&mut self, phi: Option<Vec<f64>>) {
1200        if phi != self.bench_phi {
1201            self.bench_phi_prev = self.bench_phi.take();
1202        }
1203        self.bench_phi = phi;
1204    }
1205
1206    /// Apply a structural edit and re-render in one call — apply + revet, for
1207    /// callers with nothing to do in between.
1208    pub fn edit_structure(&mut self, op_json: &str) -> String {
1209        let err = self.edit_structure_apply(op_json);
1210        if err.is_empty() {
1211            self.edit_revet();
1212        }
1213        err
1214    }
1215
1216    /// Replace the whole workbench tree and re-render in one call.
1217    pub fn edit_set_tree(&mut self, tree_json: &str) -> String {
1218        let err = self.edit_set_tree_apply(tree_json);
1219        if err.is_empty() {
1220            self.edit_revet();
1221        }
1222        err
1223    }
1224
1225    /// The workbench audition buffer (empty when the current edit failed
1226    /// vetting — the gate's rule is that no unvetted patch ever plays).
1227    pub fn edit_render(&self) -> Vec<f32> {
1228        self.bench_render.as_deref().map(pcm).unwrap_or_default()
1229    }
1230
1231    /// Whether the current workbench state passed vetting.
1232    pub fn edit_vet_ok(&self) -> bool {
1233        self.bench_vet_ok
1234    }
1235
1236    /// Render the **first `seconds`** of the bench tree with `op` applied,
1237    /// without the bench ever having held that tree.
1238    ///
1239    /// Hearing a module before you place it is the whole point, and the one
1240    /// thing it must not cost is the patch you already have. Every other route
1241    /// to a rendered edit goes through `bench_tree` — apply, revet, and now the
1242    /// player's patch *is* the proposal, recoverable only by an undo they did
1243    /// not ask for. So this clones, applies to the clone, and drops it:
1244    /// `bench_tree`, `bench_render`, `bench_phi` and `bench_vet_ok` are all
1245    /// untouched, which is what lets a hover be free of consequence.
1246    ///
1247    /// It takes a whole [`StructOp`] rather than a key and a fragment because
1248    /// the placement it is previewing is a `StructOp` — the same JSON, from the
1249    /// same call site. A preview built from a re-derived splice would be a
1250    /// second implementation of insertion semantics, and the day the two
1251    /// disagreed the app would be lying about a sound.
1252    ///
1253    /// The render is the **full phrase**, truncated after the fact. Two
1254    /// reasons, and the second is the one that makes previewing usable at all:
1255    /// a shorter phrase is a different stimulus, so its φ would not be the φ
1256    /// this patch is scored under and its loudness normalization would not
1257    /// match the bench's; and the full phrase is the memo's key, so re-hovering
1258    /// a socket — which is what hovering *is* — costs a hash lookup instead of
1259    /// a render. An empty return means "nothing to play": no bench, a rejected
1260    /// op, or a term that failed vetting. Never a silent zero-filled buffer.
1261    pub fn preview_op(&mut self, op_json: &str, seconds: f64) -> Vec<f32> {
1262        let Some(tree) = &self.bench_tree else {
1263            return Vec::new();
1264        };
1265        let Ok(op) = serde_json::from_str::<StructOp>(op_json) else {
1266            return Vec::new();
1267        };
1268        let Ok(edited) = apply_struct_op(tree, &op) else {
1269            return Vec::new();
1270        };
1271        // The same ceiling gate `edit_set_tree_apply` runs. A preview is not a
1272        // commit, but auditioning a patch the grammar would refuse teaches the
1273        // player a move that will be taken away from them later.
1274        if validate_tree(&edited).is_err() {
1275            return Vec::new();
1276        }
1277        let (phrase, memo) = (self.phrase(), self.engine.memo().clone());
1278        let Ok((cf, audio)) = featurize_memo(&edited, &phrase, &memo, true) else {
1279            return Vec::new();
1280        };
1281        let Some(a) = bench_audio(&edited, &phrase, &cf.features, audio) else {
1282            return Vec::new();
1283        };
1284        let n = ((seconds.max(0.1) * a.sample_rate) as usize).min(a.samples.len());
1285        let mut out = a.samples[..n].to_vec();
1286        // A phrase cut at an arbitrary sample is a step discontinuity, which is
1287        // a click — and a click at the end of every audition is the loudest
1288        // thing in the preview. 12 ms of cosine is below the threshold where a
1289        // release sounds shortened and well above the one where an edge is
1290        // audible.
1291        let fade = ((0.012 * a.sample_rate) as usize).min(out.len());
1292        for i in 0..fade {
1293            let t = i as f32 / fade as f32;
1294            let k = out.len() - fade + i;
1295            out[k] *= 0.5 * (1.0 + (std::f32::consts::PI * t).cos());
1296        }
1297        out
1298    }
1299
1300    /// Rack description of the workbench tree as JSON (`null` if empty).
1301    pub fn edit_describe(&self) -> String {
1302        match &self.bench_tree {
1303            Some(t) => serde_json::to_string(&describe(t)).unwrap(),
1304            None => "null".into(),
1305        }
1306    }
1307
1308    /// Commit the workbench tree as a new candidate. Returns the new
1309    /// candidate id, or 0 (duplicate / unvetted / empty bench).
1310    ///
1311    /// `outcome` is what the player reported about the edit against the
1312    /// original, as a wire string:
1313    ///
1314    /// - `"none"` — they said nothing. Lineage only.
1315    /// - `"heard_edited"` / `"heard_original"` — they heard both and picked.
1316    ///   **`"heard_original"` is the point of this API**: an edit that lost is
1317    ///   the more informative half of the comparison and had no way to be
1318    ///   said before ([`EditOutcome`]).
1319    /// - `"self_edited"` — the express "my edit is better" checkbox, tagged
1320    ///   [`Provenance::SelfReport`] so calibration can score an assertion
1321    ///   against a heard comparison instead of averaging them.
1322    ///
1323    /// An unknown string is `"none"`: a typo must not silently become a vote.
1324    pub fn edit_commit(&mut self, outcome: &str) -> u32 {
1325        let outcome = match outcome {
1326            "heard_edited" => EditOutcome::Heard { edited_won: true },
1327            "heard_original" => EditOutcome::Heard { edited_won: false },
1328            "self_edited" => EditOutcome::SelfReported,
1329            _ => EditOutcome::Untold,
1330        };
1331        let (Some(tree), true) = (self.bench_tree.clone(), self.bench_vet_ok) else {
1332            return 0;
1333        };
1334        self.engine
1335            .commit_edit(self.bench_original, tree, outcome)
1336            .unwrap_or(0) as u32
1337    }
1338
1339    /// The pool id the bench was opened from (0 for none) — what a commit
1340    /// duel plays against.
1341    pub fn edit_original_id(&self) -> u32 {
1342        self.bench_original.unwrap_or(0) as u32
1343    }
1344
1345    /// Has the bench actually diverged from the patch it was opened from?
1346    ///
1347    /// The gate on dealing a real duel at commit. The panel's own `dirty` flag
1348    /// answers "did the player touch anything", which is not the same
1349    /// question: turn a knob and turn it back, or undo to the start, and there
1350    /// is nothing to compare. Asking two patches that are the same patch which
1351    /// one is better is a question with no answer, and an answer to it is a
1352    /// row of noise in the log.
1353    ///
1354    /// `PatchTree`'s equality is content equality — `Uid`'s `PartialEq` is
1355    /// unconditionally true by construction (see `term.rs`), so a reconnect
1356    /// that reissued nothing and a rewrite that minted fresh identities
1357    /// compare the same way, which is what "the same patch" has to mean here.
1358    pub fn edit_differs_from_original(&self) -> bool {
1359        let (Some(tree), Some(oid)) = (&self.bench_tree, self.bench_original) else {
1360            return false;
1361        };
1362        match self.engine.find(oid) {
1363            Some(i) => self.engine.pool[i].tree != *tree,
1364            None => false,
1365        }
1366    }
1367
1368    /// What the model currently thinks of the tree on the bench:
1369    /// `{"ok":bool,"u":f64,"sd":f64,"lens":string}`.
1370    ///
1371    /// `u` is the **mixture** utility — the same quantity the bank is ranked
1372    /// by, so the number above the rack and the number beside the patch in the
1373    /// bank are the same claim. `ok:false` means there is nothing honest to
1374    /// show yet (no posterior, no standardizer, or a bench that failed
1375    /// vetting), and the panel says so rather than drawing a zero.
1376    pub fn edit_utility(&self) -> String {
1377        let ex = self
1378            .bench_phi
1379            .as_ref()
1380            .and_then(|phi| self.engine.explain_phi(phi));
1381        match ex {
1382            Some(ex) => format!(
1383                r#"{{"ok":true,"u":{},"sd":{},"lens":{}}}"#,
1384                ex.mix_utility,
1385                ex.utility_std,
1386                serde_json::to_string(&if ex.style_name.is_empty() {
1387                    format!("style {}", ex.style + 1)
1388                } else {
1389                    ex.style_name.clone()
1390                })
1391                .unwrap()
1392            ),
1393            None => r#"{"ok":false}"#.into(),
1394        }
1395    }
1396
1397    /// The exact per-feature decomposition of that number (`null` before the
1398    /// first fit). Same shape as [`Self::explain`], for the bench.
1399    pub fn edit_explain(&self) -> String {
1400        match self
1401            .bench_phi
1402            .as_ref()
1403            .and_then(|phi| self.engine.explain_phi(phi))
1404        {
1405            Some(ex) => serde_json::to_string(&ex).unwrap(),
1406            None => "null".into(),
1407        }
1408    }
1409
1410    /// Append one row of the editor's implicit stream (WS-8 §3): a revert, a
1411    /// commit, an evolve-from, a structural op, a link-drag query.
1412    ///
1413    /// `detail` is opaque JSON. `with_phi` attaches the bench's φ on both
1414    /// sides of the event, which only a transition (a revert) has — everything
1415    /// else passes false and stores the strings it knows.
1416    ///
1417    /// None of this enters the likelihood, and that is deliberate: a revert is
1418    /// confounded with plain curiosity, and the fit the app shows a number
1419    /// from is not the place to smuggle in an unvalidated signal. It is logged
1420    /// because it cannot be logged retroactively.
1421    pub fn log_edit_event(
1422        &mut self,
1423        kind: &str,
1424        id: u32,
1425        value: f64,
1426        detail: &str,
1427        with_phi: bool,
1428    ) {
1429        let (before, after) = if with_phi {
1430            (
1431                self.bench_phi_prev.clone().unwrap_or_default(),
1432                self.bench_phi.clone().unwrap_or_default(),
1433            )
1434        } else {
1435            (Vec::new(), Vec::new())
1436        };
1437        self.engine
1438            .log_event_detail(kind, id as u64, value, detail, before, after);
1439    }
1440
1441    /// Clear the workbench.
1442    pub fn edit_cancel(&mut self) {
1443        self.bench_tree = None;
1444        self.bench_render = None;
1445        self.bench_original = None;
1446        self.bench_vet_ok = false;
1447        self.bench_phi = None;
1448        self.bench_phi_prev = None;
1449    }
1450
1451    fn phrase(&self) -> PhraseSpec {
1452        self.engine.cfg.phrase.clone()
1453    }
1454
1455    /// Reconstitute a farm result. `None` is the "did not survive" answer that
1456    /// every absorb site treats as a vet failure.
1457    ///
1458    /// Two gates, both from DESIGN §2.1. The content key is re-derived from
1459    /// the tree the *engine* chose for this index and compared against the key
1460    /// the farm reported: a mis-routed reply — a duplicated or reordered worker
1461    /// message that files φ(A) under index B — is otherwise indistinguishable
1462    /// from a good result, and admitting it writes another patch's raw φ into
1463    /// the observation log, `export_profile` and the standardizer's reference
1464    /// population. That is durable corruption; an FNV-128 over the canonical
1465    /// tree is microseconds against a ~500 ms render.
1466    ///
1467    /// The samples-length check is the same argument one level down: a buffer
1468    /// whose length disagrees with the render the farm itself reported is a
1469    /// buffer belonging to some *other* patch, and admitting it would put audio
1470    /// into the pool whose vet report is a lie about it. Refusing either gate
1471    /// costs one draw; accepting costs the gate.
1472    fn pre_featurized(
1473        &self,
1474        tree: PatchTree,
1475        cached_json: &str,
1476        samples: &[f32],
1477    ) -> Option<PreFeaturized> {
1478        if cached_json.is_empty() {
1479            return None;
1480        }
1481        let cached: CachedFeatures = serde_json::from_str(cached_json).ok()?;
1482        if cached.key != auracle_features::render_key(&tree, &self.engine.cfg.phrase) {
1483            return None;
1484        }
1485        let audition = if samples.is_empty() {
1486            None
1487        } else {
1488            if samples.len() != cached.n_samples {
1489                return None;
1490            }
1491            Some(Arc::new(Audition {
1492                samples: samples.to_vec(),
1493                sample_rate: self.engine.cfg.phrase.sample_rate,
1494            }))
1495        };
1496        Some(PreFeaturized {
1497            tree,
1498            cached,
1499            audition,
1500        })
1501    }
1502
1503    // ------------------------------------------------------------------
1504    // Persistence
1505    // ------------------------------------------------------------------
1506
1507    /// Export the full session (profile + bank trees/names/origins +
1508    /// lineage) as JSON, for autosave.
1509    pub fn export_session(&self) -> String {
1510        serde_json::to_string(&self.engine.export_state()).unwrap()
1511    }
1512
1513    /// Restore a saved session (replacing pool, log, lineage). Returns the
1514    /// number of bank entries restored, 0 on parse failure. Re-featurizes
1515    /// every tree — seconds of work; call from the worker.
1516    pub fn import_session(&mut self, json: &str) -> usize {
1517        match serde_json::from_str::<SessionState>(json) {
1518            Ok(state) => {
1519                let n = self.engine.import_state(state);
1520                self.engine.begin_session();
1521                n
1522            }
1523            Err(_) => 0,
1524        }
1525    }
1526
1527    /// What the last restore had to mend, as JSON
1528    /// `{"terms":n,"cells":n,"dropped":n}` — saved patches whose knobs were
1529    /// outside their range, observation-log cells clamped back inside it, and
1530    /// votes dropped because a coordinate was not a number.
1531    ///
1532    /// All three are 0 for any session written by a build that carries the
1533    /// domain gate. Non-zero means the profile *was* being fitted on values
1534    /// that were not measurements, and the player is entitled to be told so
1535    /// rather than have it quietly corrected under them.
1536    pub fn repair_report(&self) -> String {
1537        let (terms, cells, dropped) = self.engine.repair_report();
1538        format!(r#"{{"terms":{terms},"cells":{cells},"dropped":{dropped}}}"#)
1539    }
1540
1541    /// Export the portable profile (observation log + its standardizer — θ
1542    /// is only meaningful relative to the standardizer, so they travel
1543    /// together) as JSON.
1544    pub fn export_profile(&self) -> String {
1545        serde_json::to_string(&self.engine.export_profile()).unwrap()
1546    }
1547
1548    /// Import a profile, replacing the log, adopting its standardizer, and
1549    /// starting a new session on top. Returns false on parse failure.
1550    pub fn import_profile(&mut self, json: &str) -> bool {
1551        match serde_json::from_str::<Profile>(json) {
1552            Ok(profile) => {
1553                self.engine.import_profile(profile);
1554                self.engine.begin_session();
1555                true
1556            }
1557            Err(_) => false,
1558        }
1559    }
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564    use super::*;
1565
1566    /// The structural-edit vocabulary is a **wire format**: `main.js` builds
1567    /// these payloads by hand and posts them at `apply_struct_op`, and the
1568    /// same strings are what `describe` reports as a module's `kind`, so the
1569    /// palette, the faceplate and the edit all key off one spelling. A serde
1570    /// rename drifting from the rack description would be invisible in Rust
1571    /// and would break exactly one button in the browser.
1572    #[test]
1573    fn the_structural_edit_vocabulary_keeps_its_spellings() {
1574        use auracle_grammar::{ModKind, NodeKind};
1575        for (kind, want) in [
1576            (NodeKind::Vco, "vco"),
1577            (NodeKind::Supersaw, "supersaw"),
1578            (NodeKind::Noise, "noise"),
1579            (NodeKind::Wavetable, "wavetable"),
1580            (NodeKind::Pluck, "pluck"),
1581            (NodeKind::Mix, "mix"),
1582            (NodeKind::Filter, "filter"),
1583            (NodeKind::Fold, "fold"),
1584            (NodeKind::Delay, "delay"),
1585            (NodeKind::Chorus, "chorus"),
1586            (NodeKind::Reverb, "reverb"),
1587            (NodeKind::Distortion, "distortion"),
1588            (NodeKind::Bitcrush, "bitcrush"),
1589            (NodeKind::Phaser, "phaser"),
1590            // Not `ring_mod`: `describe` reports `ringmod`, and one module
1591            // must not have two names.
1592            (NodeKind::RingMod, "ringmod"),
1593            (NodeKind::Formant, "formant"),
1594            (NodeKind::Flanger, "flanger"),
1595            (NodeKind::Tremolo, "tremolo"),
1596            (NodeKind::Vibrato, "vibrato"),
1597            (NodeKind::Eq, "eq"),
1598            (NodeKind::Granular, "granular"),
1599            (NodeKind::Shift, "shift"),
1600            (NodeKind::Comp, "comp"),
1601            (NodeKind::Duck, "duck"),
1602            (NodeKind::Gate, "gate"),
1603            (NodeKind::Vocoder, "vocoder"),
1604        ] {
1605            assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{want}\""));
1606        }
1607        for (kind, want) in [
1608            (ModKind::None, "none"),
1609            (ModKind::Lfo, "lfo"),
1610            (ModKind::Env, "env"),
1611            (ModKind::Rand, "rand"),
1612            (ModKind::Follow, "follow"),
1613            // Wave 2C. Each of these is also a `RackModule::kind` — the
1614            // shapers report `ModOp::label`/`PairOp::label`, which are the
1615            // same eleven strings, so the palette button and the module it
1616            // produces agree exactly as they do for the audio kinds.
1617            (ModKind::Euclid, "euclid"),
1618            (ModKind::Quantize, "quantize"),
1619            (ModKind::Slew, "slew"),
1620            (ModKind::Rectify, "rectify"),
1621            (ModKind::Hold, "hold"),
1622            (ModKind::Min, "min"),
1623            (ModKind::Max, "max"),
1624            (ModKind::And, "and"),
1625            (ModKind::Or, "or"),
1626            (ModKind::Xor, "xor"),
1627            (ModKind::Switch, "switch"),
1628        ] {
1629            assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{want}\""));
1630        }
1631        // Every buildable kind is also a kind the rack description names, so
1632        // the palette button and the module it produces agree.
1633        for kind in [
1634            NodeKind::Wavetable,
1635            NodeKind::Pluck,
1636            NodeKind::Distortion,
1637            NodeKind::Bitcrush,
1638            NodeKind::Phaser,
1639            NodeKind::RingMod,
1640            NodeKind::Formant,
1641            NodeKind::Flanger,
1642            NodeKind::Tremolo,
1643            NodeKind::Vibrato,
1644            NodeKind::Eq,
1645            NodeKind::Granular,
1646            NodeKind::Shift,
1647            NodeKind::Comp,
1648            NodeKind::Duck,
1649            NodeKind::Gate,
1650            NodeKind::Vocoder,
1651        ] {
1652            let tree = auracle_grammar::apply_struct_op(
1653                &auracle_grammar::presets()[0].1,
1654                &auracle_grammar::StructOp::Replace {
1655                    key: "node".into(),
1656                    kind,
1657                },
1658            )
1659            .expect("replace at the root always applies");
1660            let rack = auracle_grammar::describe(&tree);
1661            let spelled = serde_json::to_string(&kind).unwrap();
1662            assert!(
1663                rack.modules
1664                    .iter()
1665                    .any(|m| format!("\"{}\"", m.kind) == spelled),
1666                "no module named {spelled} in the rack it built"
1667            );
1668        }
1669    }
1670
1671    /// Drive one pool fill entirely through the farm boundary: the exact JSON
1672    /// shapes, index types and byte buffers `farm.js` and `worker.js` move.
1673    fn farm_fill(engine: &mut WasmEngine, want_audio: bool) {
1674        let phrase = engine.phrase_json();
1675        loop {
1676            let wave: Vec<serde_json::Value> =
1677                serde_json::from_str(&engine.fill_draw(4)).expect("fill_draw JSON");
1678            if wave.is_empty() {
1679                break;
1680            }
1681            // Deliberately absorbed in issue order after rendering the whole
1682            // wave — the reordering a real farm introduces lives between these
1683            // two loops.
1684            let mut results = Vec::new();
1685            for job in &wave {
1686                let index = job["i"].as_u64().expect("draw index") as u32;
1687                let tree = serde_json::to_string(&job["tree"]).expect("tree JSON");
1688                if job["dup"].as_bool().unwrap_or(false) {
1689                    results.push((index, String::new(), Vec::new()));
1690                    continue;
1691                }
1692                let mut r = farm_render(&tree, &phrase, want_audio);
1693                if !r.ok() {
1694                    results.push((index, String::new(), Vec::new()));
1695                    continue;
1696                }
1697                results.push((index, r.cached(), r.take_samples()));
1698            }
1699            for (index, cached, samples) in results {
1700                engine.fill_absorb(index, &cached, &samples);
1701            }
1702            let st: serde_json::Value =
1703                serde_json::from_str(&engine.status()).expect("status JSON");
1704            if st["pool"].as_u64() >= st["pool_target"].as_u64() {
1705                break;
1706            }
1707        }
1708    }
1709
1710    /// A saved session with its node identities stripped.
1711    ///
1712    /// Two engines that built the same patches by different routes are the
1713    /// same session, and identities are the one thing that legitimately differs
1714    /// between them: uids come from a process-global mint, so the second engine
1715    /// in a test has simply counted further. Comparing exports is comparing
1716    /// *content*, and content is what this strips to. (The identities
1717    /// themselves are pinned by the grammar and session suites.)
1718    fn session_content(engine: &WasmEngine) -> String {
1719        let mut state: auracle_session::SessionState =
1720            serde_json::from_str(&engine.export_session()).expect("a session round-trips");
1721        for entry in &mut state.bank {
1722            entry.tree.clear_uids();
1723        }
1724        serde_json::to_string(&state).expect("a session serializes")
1725    }
1726
1727    /// The whole point, at the boundary the browser actually crosses: a pool
1728    /// filled through `fill_draw` → `farm_render` → `fill_absorb` is the pool
1729    /// `fill_step` builds. If these ever disagree, a user whose browser cannot
1730    /// spawn a worker is running a different instrument.
1731    #[test]
1732    fn the_farm_boundary_builds_the_serial_pool() {
1733        let mut serial = WasmEngine::new(0xBEEF, 6);
1734        while serial.fill_step(2) > 0 {}
1735        let mut farmed = WasmEngine::new(0xBEEF, 6);
1736        farm_fill(&mut farmed, false);
1737        assert_eq!(
1738            serde_json::from_str::<serde_json::Value>(&serial.status()).unwrap()["pool"],
1739            serde_json::from_str::<serde_json::Value>(&farmed.status()).unwrap()["pool"],
1740        );
1741        assert_eq!(
1742            session_content(&serial),
1743            session_content(&farmed),
1744            "the farm boundary built a different session than the serial fill"
1745        );
1746    }
1747
1748    /// Audio may ride along, and when it does it must be the render φ was
1749    /// measured on. Asking for it must not move the pool either — it is a
1750    /// transport option, not a featurization one.
1751    #[test]
1752    fn transported_audio_neither_moves_nor_misses_the_pool() {
1753        let mut dry = WasmEngine::new(0x1234, 4);
1754        farm_fill(&mut dry, false);
1755        let mut wet = WasmEngine::new(0x1234, 4);
1756        farm_fill(&mut wet, true);
1757        assert_eq!(
1758            session_content(&dry),
1759            session_content(&wet),
1760            "asking the farm for audio changed the pool"
1761        );
1762        // The absorbed buffer is what `render_of` hands WebAudio, and it must
1763        // match a fresh in-process render of the same term.
1764        let ranked: Vec<serde_json::Value> = serde_json::from_str(&wet.ranked()).unwrap();
1765        let id = ranked[0]["id"].as_u64().expect("ranked id") as u32;
1766        let from_farm = wet.render_of(id);
1767        assert!(
1768            !from_farm.is_empty(),
1769            "absorbed audio never reached the pool"
1770        );
1771        let mut cold = WasmEngine::new(0x1234, 4);
1772        farm_fill(&mut cold, false);
1773        assert_eq!(
1774            from_farm,
1775            cold.render_of(id),
1776            "a transported audition drifted from the render it names"
1777        );
1778    }
1779
1780    /// A result that does not survive transport is a *vet failure*, not an
1781    /// admission: the draw's index is consumed and nothing enters the pool.
1782    /// Admitting audio whose length disagrees with its own vet report would be
1783    /// exactly the DESIGN §2.1 bypass the gate exists to prevent.
1784    #[test]
1785    fn a_corrupted_farm_result_burns_its_draw_and_admits_nothing() {
1786        let mut engine = WasmEngine::new(0x9999, 8);
1787        let phrase = engine.phrase_json();
1788        let wave: Vec<serde_json::Value> = serde_json::from_str(&engine.fill_draw(1)).unwrap();
1789        let index = wave[0]["i"].as_u64().unwrap() as u32;
1790        let tree = serde_json::to_string(&wave[0]["tree"]).unwrap();
1791        let mut r = farm_render(&tree, &phrase, true);
1792        assert!(r.ok(), "reference draw must render");
1793        let mut samples = r.take_samples();
1794        samples.truncate(samples.len() - 1);
1795
1796        assert_eq!(engine.fill_cursor(), index);
1797        assert_eq!(
1798            engine.fill_absorb(index, &r.cached(), &samples),
1799            0,
1800            "a length-mismatched buffer was admitted"
1801        );
1802        assert_eq!(engine.fill_cursor(), index + 1, "the draw was not consumed");
1803        let st: serde_json::Value = serde_json::from_str(&engine.status()).unwrap();
1804        assert_eq!(st["pool"], 0, "a refused result still reached the pool");
1805
1806        // An empty result (the farm's own vet failure) behaves identically.
1807        let next: Vec<serde_json::Value> = serde_json::from_str(&engine.fill_draw(1)).unwrap();
1808        let i2 = next[0]["i"].as_u64().unwrap() as u32;
1809        assert_eq!(engine.fill_absorb(i2, "", &[]), 0);
1810        assert_eq!(engine.fill_cursor(), i2 + 1);
1811    }
1812
1813    /// Absorption is in index order, and out-of-order results are refused
1814    /// rather than folded in — the invariant the whole width-equivalence
1815    /// argument rests on. A reorder buffer that silently accepted them would
1816    /// build a pool no other width reproduces.
1817    #[test]
1818    fn out_of_order_absorption_is_refused() {
1819        let mut engine = WasmEngine::new(0x77, 8);
1820        let phrase = engine.phrase_json();
1821        let wave: Vec<serde_json::Value> = serde_json::from_str(&engine.fill_draw(3)).unwrap();
1822        assert!(wave.len() >= 2, "need two draws to reorder");
1823        let cursor = engine.fill_cursor();
1824        let later = wave[1]["i"].as_u64().unwrap() as u32;
1825        let tree = serde_json::to_string(&wave[1]["tree"]).unwrap();
1826        let mut r = farm_render(&tree, &phrase, false);
1827        let samples = r.take_samples();
1828        assert_eq!(
1829            engine.fill_absorb(later, &r.cached(), &samples),
1830            0,
1831            "a result that jumped the queue was absorbed"
1832        );
1833        assert_eq!(
1834            engine.fill_cursor(),
1835            cursor,
1836            "the cursor moved out of order"
1837        );
1838    }
1839
1840    /// A deferred restore rebuilds the session the serial restore rebuilds,
1841    /// through the same index-addressed boundary the pool fill uses.
1842    #[test]
1843    fn deferred_restore_matches_the_serial_restore() {
1844        let mut origin = WasmEngine::new(0x5A5A, 5);
1845        while origin.fill_step(2) > 0 {}
1846        let saved = origin.export_session();
1847
1848        let mut serial = WasmEngine::new(1, 5);
1849        let n_serial = serial.import_session(&saved);
1850        assert!(n_serial >= 3, "bank too small to test");
1851
1852        let mut deferred = WasmEngine::new(1, 5);
1853        let phrase = deferred.phrase_json();
1854        let jobs: Vec<serde_json::Value> =
1855            serde_json::from_str(&deferred.import_session_deferred(&saved)).unwrap();
1856        assert_eq!(jobs.len(), n_serial);
1857        for job in &jobs {
1858            let index = job["i"].as_u64().unwrap() as usize;
1859            let tree = serde_json::to_string(&job["tree"]).unwrap();
1860            let mut r = farm_render(&tree, &phrase, false);
1861            assert!(deferred.bank_absorb(index, &r.cached(), &r.take_samples()));
1862        }
1863        assert_eq!(deferred.restore_finish(), n_serial);
1864        assert_eq!(
1865            serial.export_session(),
1866            deferred.export_session(),
1867            "the deferred restore rebuilt a different session"
1868        );
1869    }
1870
1871    /// Re-issue is stateless: the term at a draw index is recoverable from the
1872    /// engine alone, so a farm worker that dies mid-job costs its render and
1873    /// nothing else. Nobody has to have kept the tree JSON.
1874    #[test]
1875    fn a_lost_job_is_recoverable_from_its_index_alone() {
1876        let mut engine = WasmEngine::new(0x1D, 8);
1877        let wave: Vec<serde_json::Value> = serde_json::from_str(&engine.fill_draw(2)).unwrap();
1878        for job in &wave {
1879            let index = job["i"].as_u64().unwrap() as u32;
1880            let reissued: serde_json::Value =
1881                serde_json::from_str(&engine.draw_json(index)).expect("re-issued tree JSON");
1882            assert_eq!(
1883                reissued, job["tree"],
1884                "draw {index} could not be re-derived from its index"
1885            );
1886        }
1887        // And it stays true after the pool has moved underneath it: the stream
1888        // is indexed, not advanced.
1889        let far = engine.draw_json(37);
1890        while engine.fill_step(2) > 0 {}
1891        assert_eq!(engine.draw_json(37), far, "the draw stream advanced");
1892    }
1893
1894    /// The commit duel's gate. `wb.dirty` in the panel means "the player
1895    /// touched something", which is a different question from "is there
1896    /// anything to compare": turn a knob and turn it back, or undo to where
1897    /// you started, and dealing a duel would be asking which of two identical
1898    /// patches is better — a question whose answer is a row of noise in the
1899    /// preference log.
1900    #[test]
1901    fn a_bench_edited_back_to_where_it_started_has_no_duel_to_deal() {
1902        let mut engine = WasmEngine::new(0xD0E1, 6);
1903        while engine.fill_step(3) > 0 {}
1904        let id = serde_json::from_str::<Vec<serde_json::Value>>(&engine.ranked()).unwrap()[0]["id"]
1905            .as_u64()
1906            .unwrap() as u32;
1907        assert!(engine.edit_begin(id));
1908        assert_eq!(engine.edit_original_id(), id);
1909        assert!(
1910            !engine.edit_differs_from_original(),
1911            "a freshly benched patch is the patch it came from"
1912        );
1913
1914        let before = engine.edit_tree_json();
1915        assert!(engine.edit_param("amp#attack", 0.42, false));
1916        assert!(engine.edit_differs_from_original(), "a knob moved");
1917        // …and back, through the same route undo takes.
1918        assert_eq!(engine.edit_set_tree(&before), "");
1919        assert!(
1920            !engine.edit_differs_from_original(),
1921            "returning to the original tree still read as an edit"
1922        );
1923    }
1924
1925    /// The readout above the rack describes the tree under the player's
1926    /// hands, on every edit — the WHY line's failure was that it described the
1927    /// patch that was *loaded*, silently, through any number of edits. Both
1928    /// surfaces have to move with the bench and agree with each other, and
1929    /// both have to say "nothing to show" rather than draw a zero when there
1930    /// is no posterior to ask.
1931    #[test]
1932    fn the_bench_readout_follows_the_bench() {
1933        let mut engine = WasmEngine::new(0x0B1E, 6);
1934        while engine.fill_step(3) > 0 {}
1935        let id = serde_json::from_str::<Vec<serde_json::Value>>(&engine.ranked()).unwrap()[0]["id"]
1936            .as_u64()
1937            .unwrap() as u32;
1938        assert!(engine.edit_begin(id));
1939
1940        // Untaught: no posterior, so no honest number exists.
1941        let u: serde_json::Value = serde_json::from_str(&engine.edit_utility()).unwrap();
1942        assert_eq!(u["ok"], false, "a number was drawn with nothing behind it");
1943        assert_eq!(engine.edit_explain(), "null");
1944
1945        // Teach it something, then the same two calls have to answer.
1946        let ranked: Vec<serde_json::Value> = serde_json::from_str(&engine.ranked()).unwrap();
1947        let (a, b) = (
1948            ranked[0]["id"].as_u64().unwrap() as u32,
1949            ranked[1]["id"].as_u64().unwrap() as u32,
1950        );
1951        engine.record_duel(a, b, true);
1952        engine.fit();
1953        let u0: serde_json::Value = serde_json::from_str(&engine.edit_utility()).unwrap();
1954        assert_eq!(u0["ok"], true);
1955        let ex0: serde_json::Value = serde_json::from_str(&engine.edit_explain()).unwrap();
1956        let sum: f64 = ex0["contributions"]
1957            .as_array()
1958            .unwrap()
1959            .iter()
1960            .map(|c| c["contribution"].as_f64().unwrap())
1961            .sum();
1962        assert!(
1963            (sum - ex0["utility"].as_f64().unwrap()).abs() < 1e-9,
1964            "the decomposition is exact within a lens, or it is not a decomposition"
1965        );
1966
1967        // An edit big enough to move φ has to move the number with it.
1968        assert_eq!(
1969            engine.edit_structure(r#"{"op":"insert","key":"node","kind":"distortion"}"#),
1970            ""
1971        );
1972        let u1: serde_json::Value = serde_json::from_str(&engine.edit_utility()).unwrap();
1973        assert_eq!(u1["ok"], true);
1974        assert_ne!(
1975            u0["u"], u1["u"],
1976            "the readout kept describing the patch that was edited away"
1977        );
1978    }
1979
1980    /// The implicit stream: a revert has to arrive with φ on *both* sides of
1981    /// it, because a transition logged from one side says nothing about the
1982    /// direction the player moved — and direction is the entire signal.
1983    #[test]
1984    fn a_logged_revert_carries_both_sides_of_the_edit() {
1985        let mut engine = WasmEngine::new(0x2E7, 6);
1986        while engine.fill_step(3) > 0 {}
1987        let id = serde_json::from_str::<Vec<serde_json::Value>>(&engine.ranked()).unwrap()[0]["id"]
1988            .as_u64()
1989            .unwrap() as u32;
1990        assert!(engine.edit_begin(id));
1991        let before = engine.edit_tree_json();
1992        assert_eq!(
1993            engine.edit_structure(r#"{"op":"insert","key":"node","kind":"distortion"}"#),
1994            ""
1995        );
1996        assert_eq!(engine.edit_set_tree(&before), ""); // ⌘Z
1997        engine.log_edit_event(
1998            "revert",
1999            id,
2000            3400.0,
2001            r#"{"op":"insert","kind":"distortion"}"#,
2002            true,
2003        );
2004
2005        let state: auracle_session::SessionState =
2006            serde_json::from_str(&engine.export_session()).unwrap();
2007        let ev = state.events.last().expect("the revert was logged");
2008        assert_eq!(ev.kind, "revert");
2009        assert_eq!(ev.value, 3400.0);
2010        assert!(!ev.phi_before.is_empty() && !ev.phi_after.is_empty());
2011        assert_ne!(
2012            ev.phi_before, ev.phi_after,
2013            "a revert whose two sides are equal reverted nothing"
2014        );
2015        assert!(ev.detail.contains("distortion"));
2016        // And it stays out of the likelihood, which is the whole premise of
2017        // logging it this early.
2018        assert_eq!(state.profile.log.len(), 0);
2019    }
2020
2021    /// The one property the whole pre-placement audition rests on: you can
2022    /// hear the proposal without owning it. If the bench moved, a hover would
2023    /// be an edit, and the player would be undoing sounds they only looked at.
2024    #[test]
2025    fn a_preview_renders_the_proposal_and_leaves_the_bench_alone() {
2026        let mut engine = WasmEngine::new(0x9A1, 6);
2027        while engine.fill_step(3) > 0 {}
2028        let id = serde_json::from_str::<Vec<serde_json::Value>>(&engine.ranked()).unwrap()[0]["id"]
2029            .as_u64()
2030            .unwrap() as u32;
2031        assert!(engine.edit_begin(id));
2032        let before_tree = engine.edit_tree_json();
2033        let before_render = engine.edit_render();
2034        let before_desc = engine.edit_describe();
2035
2036        let pcm = engine.preview_op(r#"{"op":"insert","key":"node","kind":"distortion"}"#, 1.6);
2037        assert!(!pcm.is_empty(), "the spliced patch should have rendered");
2038        // Truncated, not the whole phrase: the phrase is ~5 s and the audition
2039        // is a glance.
2040        let want = (1.6 * engine.sample_rate()) as usize;
2041        assert_eq!(pcm.len(), want);
2042        assert!(
2043            pcm.iter().any(|s| s.abs() > 1e-4),
2044            "a preview of a real patch is not silence"
2045        );
2046        // The tail is faded, so the cut cannot click.
2047        assert!(pcm[pcm.len() - 1].abs() < 1e-6);
2048
2049        assert_eq!(engine.edit_tree_json(), before_tree);
2050        assert_eq!(engine.edit_render(), before_render);
2051        assert_eq!(engine.edit_describe(), before_desc);
2052        assert!(engine.edit_vet_ok());
2053        // Nor did it move the belief readout — a hover must not restate what
2054        // the model thinks of a patch the player never adopted.
2055        assert!(!engine.edit_differs_from_original());
2056    }
2057
2058    /// An op the grammar refuses and an op past the ceilings both come back as
2059    /// "nothing to play", never as a buffer of zeros that would audition as a
2060    /// patch that had gone silent.
2061    #[test]
2062    fn an_unplayable_preview_is_empty_rather_than_silent() {
2063        let mut engine = WasmEngine::new(0x9A2, 6);
2064        while engine.fill_step(3) > 0 {}
2065        let id = serde_json::from_str::<Vec<serde_json::Value>>(&engine.ranked()).unwrap()[0]["id"]
2066            .as_u64()
2067            .unwrap() as u32;
2068
2069        // No bench at all.
2070        assert!(engine
2071            .preview_op(r#"{"op":"insert","key":"node","kind":"distortion"}"#, 1.6)
2072            .is_empty());
2073
2074        assert!(engine.edit_begin(id));
2075        // A key that is not in the tree.
2076        assert!(engine
2077            .preview_op(r#"{"op":"insert","key":"node/9/9/9","kind":"fold"}"#, 1.6)
2078            .is_empty());
2079        // Not a `StructOp` at all.
2080        assert!(engine.preview_op(r#"{"op":"teleport"}"#, 1.6).is_empty());
2081        // And a source where a processor belongs — the grammar's own refusal.
2082        assert!(engine
2083            .preview_op(r#"{"op":"insert","key":"node","kind":"vco"}"#, 1.6)
2084            .is_empty());
2085    }
2086
2087    /// The scale is what turns θ into a price. Shipping it keyed by name (and
2088    /// only after a standardizer exists) is what keeps the client from
2089    /// inventing one.
2090    #[test]
2091    fn the_phi_scale_ships_by_name_once_it_exists() {
2092        let mut engine = WasmEngine::new(0x9A3, 6);
2093        assert_eq!(engine.phi_scale(), "{}", "no standardizer, no scale");
2094        while engine.fill_step(3) > 0 {}
2095        engine.standardize_now();
2096        let map: std::collections::BTreeMap<String, f64> =
2097            serde_json::from_str(&engine.phi_scale()).unwrap();
2098        assert_eq!(map.len(), Features::phi_names().len());
2099        for name in Features::phi_names() {
2100            let s = *map.get(name).expect("every φ coordinate is priced");
2101            assert!(s > 0.0, "{name} scaled by a non-positive divisor");
2102        }
2103        // The one the sockets are priced through most often.
2104        assert!(map.contains_key("n_filter"));
2105    }
2106}