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}