auracle_session/migrate.rs
1//! Migrating profiles written before raw-φ logging.
2//!
3//! A schema-1 log stored *standardized* φ under a 30-coordinate feature set,
4//! with no names. That is recoverable, because the profile persisted the
5//! standardizer alongside it: `raw = z·σ + μ` inverts the transform exactly,
6//! and the schema-1 coordinate order is known and fixed ([`SCHEMA1_NAMES`]).
7//!
8//! What is *not* a pure re-labelling is that two of the current coordinates
9//! changed units. Those conversions are applied here rather than being
10//! papered over, because a value silently carried across a unit change is
11//! worse than a dropped one — it is evidence pointing the wrong way:
12//!
13//! - `centroid_mean`, `rolloff_mean`, `zcr_mean` moved from a linear-Hz
14//! fraction of Nyquist to the octave axis. Exact: recover the frequency,
15//! re-map it.
16//! - `centroid_std` was the spread of a linear-Hz quantity and is now the
17//! spread of a log one. There is no exact inverse for a spread, so it goes
18//! through the delta method — the local derivative of the axis map at the
19//! observation's own centroid. First-order, and honest about it.
20//! - `crest`, `tail_ratio`, `attack_s` are now logged. Exact.
21//! - `size` was dropped from φ entirely (it was exactly collinear with the
22//! module counts). Dropped here too.
23//! - `n_mix`, `n_fold` and `n_chorus` no longer exist as φ coordinates: the
24//! first left for the same collinearity reason as `size`, and the other two
25//! were folded into the `n_drive` / `n_mod_fx` families. A schema-1 vote
26//! carries no value for a family coordinate — it was never measured — so
27//! they are imputed at the mean like any other absent coordinate, rather
28//! than being re-derived from a count that answered a different question.
29//!
30//! A φ coordinate that is *renamed* rather than dropped is a third case, and
31//! the one that fails silently if nobody handles it. Both the schema-1 table
32//! and every raw-φ observation already on disk store their coordinate names,
33//! and [`FitSet::build`](auracle_taste::FitSet::build) matches on those names
34//! — so renaming `n_delay` to `n_time` in wave 2A would have quietly imputed
35//! that column at the mean for every vote ever cast, which reads as "this user
36//! has no opinion about delays" rather than as a rename.
37//!
38//! [`RENAMES`] carries the value across instead. That is exact, not a
39//! convenience: `n_time` counts delays *and* granulators, and no observation
40//! predating this wave can contain a granulator, so the old `n_delay` count
41//! **is** the new coordinate's value for every row being migrated.
42//!
43//! Anything the migration cannot place is left at the new standardizer's mean
44//! by [`FitSet::build`](auracle_taste::FitSet::build), which standardizes to
45//! zero: "this vote says nothing about that axis".
46
47use auracle_taste::{ObservationLog, Standardizer, PHI_SCHEMA};
48
49/// φ coordinate names as of schema 1, in vector order.
50pub const SCHEMA1_NAMES: [&str; 30] = [
51 "centroid_mean",
52 "centroid_std",
53 "rolloff_mean",
54 "flatness_mean",
55 "flux_mean",
56 "zcr_mean",
57 "rms_mean",
58 "rms_std",
59 "crest",
60 "attack_s",
61 "tail_ratio",
62 "bass_fraction",
63 "n_vco",
64 "n_supersaw",
65 "n_noise",
66 "n_mix",
67 "n_filter",
68 "n_fold",
69 "n_delay",
70 "n_chorus",
71 "n_reverb",
72 "n_lfo",
73 "n_env",
74 "n_rand",
75 "depth",
76 "size",
77 "mod_density",
78 "amp_attack",
79 "amp_sustain",
80 "amp_release",
81];
82
83/// φ coordinates that were renamed, as `(old name, current name)`.
84///
85/// A rename is not a drop: the stored value is still the right value for the
86/// new coordinate, so a log written under the old name must be read under the
87/// new one rather than imputed away. See the module doc for why each entry is
88/// exact rather than approximate.
89pub const RENAMES: [(&str, &str); 1] = [
90 // Wave 2A: the column counts delays and granulators, so a name that says
91 // "delay" would be a lie. No pre-2A patch can hold a granulator, so the
92 // old count is the new count.
93 ("n_delay", "n_time"),
94];
95
96/// The current name for a possibly-renamed φ coordinate.
97fn renamed(name: &str) -> &str {
98 RENAMES
99 .iter()
100 .find(|(old, _)| *old == name)
101 .map_or(name, |(_, new)| *new)
102}
103
104/// The audio φ names as of the **v1 stimulus** (no stimulus tag), in order.
105const V1_AUDIO_NAMES: [&str; 12] = [
106 "centroid_mean",
107 "centroid_std",
108 "rolloff_mean",
109 "flatness_mean",
110 "flux_mean",
111 "zcr_mean",
112 "rms_mean",
113 "rms_std",
114 "crest",
115 "attack_s",
116 "tail_ratio",
117 "bass_fraction",
118];
119
120/// φ names as of the v1 stimulus: the 12 un-tagged audio coordinates plus the
121/// current (stimulus-independent) structural set.
122///
123/// Schema-1 values were measured under the v1 phrase, so migration must land
124/// them **here** — never on the current stimulus-tagged audio names, which
125/// would launder old-stimulus evidence into coordinates it was never
126/// commensurable with. `FitSet::build` then carries the structural
127/// coordinates forward by name and imputes the current audio coordinates at
128/// "no evidence", which is the honest reading of a vote about a stimulus
129/// that no longer exists.
130pub fn v1_names() -> Vec<String> {
131 use auracle_features::StructFeatures;
132 V1_AUDIO_NAMES
133 .iter()
134 .chain(StructFeatures::NAMES.iter())
135 .map(|s| s.to_string())
136 .collect()
137}
138
139/// Convert one schema-1 raw vector onto `target` — the *v1-stimulus* φ names
140/// ([`v1_names`]), in their order.
141///
142/// Projecting onto the live feature set rather than "schema 1 minus whatever
143/// we dropped" is what keeps a migrated vote first-class: `raw_rows` matches
144/// on the exact name list, so a vote carrying a stale ordering would still
145/// fit the model (`FitSet::build` maps by name) but would be silently skipped
146/// when the standardizer is fit. Coordinates that did not exist in schema 1
147/// come back `None` and are left for `FitSet::build` to impute at the mean.
148fn convert(raw: &[f64], nyquist: f64, target: &[String]) -> Vec<f64> {
149 use auracle_features::audio::log_axis;
150 let get = |name: &str| {
151 SCHEMA1_NAMES
152 .iter()
153 .position(|n| renamed(n) == name)
154 .and_then(|i| raw.get(i).copied())
155 };
156 let centroid_hz = get("centroid_mean").unwrap_or(0.0) * nyquist;
157
158 target
159 .iter()
160 .map(|name| {
161 let Some(v) = get(name) else {
162 return 0.0;
163 };
164 match name.as_str() {
165 "centroid_mean" | "rolloff_mean" => log_axis(v * nyquist, nyquist),
166 // zcr was a fraction of sample *pairs*; two crossings a cycle.
167 "zcr_mean" => log_axis(v * nyquist, nyquist),
168 // Delta method: dv_log = dv_lin · d(log_axis)/df at the
169 // observation's own centroid.
170 "centroid_std" => {
171 let span = (nyquist / 20.0).log2();
172 let f = centroid_hz.max(20.0);
173 v * nyquist / (f * std::f64::consts::LN_2 * span)
174 }
175 "crest" => v.max(1e-6).ln(),
176 // Floors, not just offsets: a schema-1 vector reconstructed
177 // from a standardizer can land slightly negative on a
178 // non-negative quantity, and NaN in a migrated profile is a
179 // silently dead log.
180 "attack_s" => (v + 0.005).max(1e-6).ln(),
181 "tail_ratio" => (v + 1e-3).max(1e-6).ln(),
182 _ => v,
183 }
184 })
185 .collect()
186}
187
188/// Rewrite a schema-1 log into the current schema, in place.
189///
190/// `sz` must be the standardizer the log was written under (the one the
191/// profile carries) and `names` the φ names of the stimulus the log was
192/// *recorded* under — [`v1_names`] for every schema-1 log, since raw-φ
193/// logging and the v2 stimulus both postdate schema 1. Returns how many
194/// observations were migrated;
195/// observations already in the current schema are left alone, and the whole
196/// thing is a no-op if the standardizer's dimension doesn't match schema 1 (in
197/// which case we genuinely cannot recover the raw values, and pretending
198/// otherwise would corrupt the profile).
199pub fn migrate_log(
200 log: &mut ObservationLog,
201 sz: &Standardizer,
202 names: &[String],
203 nyquist: f64,
204) -> usize {
205 if sz.dimension() != SCHEMA1_NAMES.len() {
206 return 0;
207 }
208
209 let mut migrated = 0;
210 for o in &mut log.observations {
211 if o.is_raw() {
212 continue;
213 }
214 // Only re-label what was actually converted. Stamping the new names
215 // and schema onto a vector we could not convert is a *crash*, not a
216 // cosmetic slip: the observation then claims to be raw φ of the
217 // current width, `raw_rows` hands it to `Standardizer::fit`, and the
218 // ragged-row assertion there takes the whole app down on load. A vote
219 // we cannot interpret must stay marked as one we cannot interpret.
220 if o.feedback
221 .phis()
222 .iter()
223 .any(|z| z.len() != SCHEMA1_NAMES.len())
224 {
225 continue;
226 }
227 o.feedback = o
228 .feedback
229 .map_phi(|z| convert(&sz.inverse(z), nyquist, names));
230 o.feature_names = names.to_vec();
231 o.schema_version = PHI_SCHEMA;
232 migrated += 1;
233 }
234 migrated
235}
236
237/// True when the log holds anything written before raw-φ logging.
238pub fn needs_migration(log: &ObservationLog) -> bool {
239 log.observations.iter().any(|o| !o.is_raw())
240}
241
242/// Stamp current-schema observations that carry no names with `names` — the
243/// synthetic-user and headless paths log raw φ without them, and a named log
244/// is what makes the next feature-set change survivable.
245pub fn stamp_names(log: &mut ObservationLog, names: &[String]) {
246 for o in &mut log.observations {
247 if o.feature_names.is_empty() && o.is_raw() {
248 o.feature_names = names.to_vec();
249 }
250 }
251}
252
253/// Which coordinates of a raw-φ row have a bound this can enforce.
254///
255/// Only [`StructFeatures::UNIT_NAMES`] do: the module counts are unbounded
256/// above and the audio descriptors live on axes whose range depends on the
257/// stimulus, so for those the only defensible check is finiteness.
258fn unit_mask(names: &[String]) -> Vec<bool> {
259 use auracle_features::StructFeatures;
260 names
261 .iter()
262 .map(|n| StructFeatures::UNIT_NAMES.contains(&n.as_str()))
263 .collect()
264}
265
266/// Clamp one raw-φ row's unit-bounded coordinates in place, against a mask
267/// from [`unit_mask`]. Returns how many cells moved.
268fn clamp_row(row: &mut [f64], unit: &[bool]) -> usize {
269 let mut hits = 0;
270 for (x, is_unit) in row.iter_mut().zip(unit) {
271 if *is_unit && !(0.0..=1.0).contains(x) {
272 *x = x.clamp(0.0, 1.0);
273 hits += 1;
274 }
275 }
276 hits
277}
278
279/// The same repair for the implicit-event stream's stored `phi_before` /
280/// `phi_after` pairs — the *fourth* carrier of raw φ in a saved session, after
281/// the pool, the log and the HELD tray, and the one that is easiest to forget
282/// because nothing reads it yet.
283///
284/// That is exactly why it has to be repaired: the stream exists so a later
285/// model can be fitted on hand edits, and a corpus that is quietly wrong on the
286/// day it is first used is worse than one that is missing.
287///
288/// Matched **positionally** against the live φ names, which the log's repair
289/// refuses to do — the difference is that an event carries no names of its own,
290/// so position is the only interpretation it has, and the length guard is what
291/// makes that safe.
292///
293/// A row of a different width is **left exactly alone**, and that is a decision
294/// rather than a gap. φ has been 12 + n_struct, 13 + n_struct and now 15 + 25
295/// columns wide; a 38-wide row is either 13 audio over today's 25 structural
296/// coordinates or 15 audio over the 23 that predate wave 3, and nothing in the
297/// row says which. Aligning it to the live names would clamp *a* coordinate —
298/// just not necessarily the one that is out of range. So a stale row keeps its
299/// bad cell, which costs nothing (no consumer reads this stream yet, and every
300/// consumer that ever does will have to reconcile widths before it can) and is
301/// the only reading that cannot invent evidence.
302pub fn repair_phi_pair(before: &mut [f64], after: &mut [f64], names: &[String]) -> usize {
303 let unit = unit_mask(names);
304 let mut hits = 0;
305 for row in [before, after] {
306 if row.len() == names.len() {
307 hits += clamp_row(row, &unit);
308 }
309 }
310 hits
311}
312
313/// Pull out-of-domain cells in a stored log back inside their coordinate's
314/// range, and drop the rows that cannot be repaired. Returns
315/// `(cells clamped, observations dropped)`.
316///
317/// The other half of the sentinel fix, and the half that is not optional. A
318/// gate that stops the *next* bad row does nothing about the ones already on
319/// disk: six cells of exactly `1e30` sat in the raw φ of fifty stored
320/// observations, and every fit after this load would have re-read them, re-fit
321/// the standardizer on them and re-poisoned the posterior — the fault would
322/// have looked fixed while the profile stayed broken.
323///
324/// **Repaired by name, never positionally.** Every observation carries its own
325/// coordinate names ([`ObservationLog`]'s whole design), and only the seven in
326/// [`StructFeatures::UNIT_NAMES`] have a bound this can enforce: the counts are
327/// unbounded above and the audio descriptors live on axes whose range depends
328/// on the stimulus, so for those the only defensible check is finiteness. A row
329/// with a non-finite cell anywhere is dropped rather than patched, because
330/// there is no value to clamp it to that is not an invention.
331///
332/// Idempotent, and a no-op on a clean log — which every log written after this
333/// ships will be.
334pub fn repair_log(log: &mut ObservationLog) -> (usize, usize) {
335 let mut clamped = 0usize;
336 let before = log.observations.len();
337 log.observations.retain(|o| {
338 o.feedback
339 .phis()
340 .iter()
341 .all(|v| v.iter().all(|x| x.is_finite()))
342 });
343 let dropped = before - log.observations.len();
344 for o in &mut log.observations {
345 // Positional repair on an unnamed log would be a guess about which
346 // coordinate is which, and a wrong guess clamps a legitimate count.
347 if o.feature_names.is_empty() {
348 continue;
349 }
350 let unit = unit_mask(&o.feature_names);
351 // Counted outside the closure: `map_phi` takes an `Fn`, and a `Cell`
352 // is a smaller thing to explain than a second walk that has to agree
353 // with the first about what "out of range" means.
354 let hits = std::cell::Cell::new(0usize);
355 o.feedback = o.feedback.map_phi(|v| {
356 v.iter()
357 .enumerate()
358 .map(|(i, x)| {
359 if unit.get(i).copied().unwrap_or(false) && !(0.0..=1.0).contains(x) {
360 hits.set(hits.get() + 1);
361 x.clamp(0.0, 1.0)
362 } else {
363 *x
364 }
365 })
366 .collect()
367 });
368 clamped += hits.get();
369 }
370 (clamped, dropped)
371}
372
373/// Rewrite [`RENAMES`]'d coordinate names in a log's stored name lists.
374///
375/// Cheap, idempotent, and the difference between a renamed coordinate keeping
376/// its evidence and losing it: `FitSet::build` matches an observation's stored
377/// names against the live feature set, so a name that moved takes every vote
378/// about it along unless someone rewrites it here.
379pub fn apply_renames(log: &mut ObservationLog) -> usize {
380 let mut touched = 0;
381 for o in &mut log.observations {
382 let mut hit = false;
383 for name in &mut o.feature_names {
384 if let Some((_, new)) = RENAMES.iter().find(|(old, _)| old == name) {
385 *name = (*new).to_string();
386 hit = true;
387 }
388 }
389 touched += usize::from(hit);
390 }
391 touched
392}
393
394#[cfg(test)]
395mod repair_tests {
396 use super::*;
397 use auracle_features::{Features, StructFeatures};
398 use auracle_taste::{Feedback, Observation};
399
400 fn names() -> Vec<String> {
401 Features::phi_names()
402 .into_iter()
403 .map(|s| s.to_string())
404 .collect()
405 }
406
407 fn row(names: &[String], set: &[(&str, f64)]) -> Vec<f64> {
408 let mut v = vec![0.5; names.len()];
409 for (n, x) in set {
410 let i = names.iter().position(|m| m == n).expect("known coordinate");
411 v[i] = *x;
412 }
413 v
414 }
415
416 /// Six cells of exactly `1e30`, which is what the shipped profile held.
417 /// Repair pulls them back inside the coordinate's range and leaves the
418 /// vote standing — the player's preference is still their preference; only
419 /// one number in it was never a measurement.
420 #[test]
421 fn repair_clamps_the_sentinel_and_keeps_the_vote() {
422 let n = names();
423 let mut log = ObservationLog::default();
424 for _ in 0..3 {
425 log.observations.push(Observation::new(
426 Feedback::Duel {
427 a: row(&n, &[("amp_sustain", 1e30), ("n_vco", 4.0)]),
428 b: row(&n, &[("amp_sustain", 1e30)]),
429 chose_a: true,
430 },
431 0,
432 &n,
433 ));
434 }
435 let (clamped, dropped) = repair_log(&mut log);
436 assert_eq!((clamped, dropped), (6, 0));
437 assert_eq!(log.observations.len(), 3);
438
439 let i = n.iter().position(|m| m == "amp_sustain").unwrap();
440 let j = n.iter().position(|m| m == "n_vco").unwrap();
441 for o in &log.observations {
442 for phi in o.feedback.phis() {
443 assert_eq!(phi[i], 1.0, "the unit coordinate was not repaired");
444 }
445 // An unbounded coordinate is left exactly alone: four oscillators
446 // is a patch, not corruption, and a repair that clamped counts
447 // would be inventing evidence.
448 assert_eq!(o.feedback.phis()[0][j], 4.0);
449 }
450 // Idempotent — a second load must not find anything to do.
451 assert_eq!(repair_log(&mut log), (0, 0));
452 }
453
454 /// A NaN cannot be clamped to anything that is not an invention, so the
455 /// whole observation goes. One vote is a smaller loss than a posterior of
456 /// NaNs.
457 #[test]
458 fn a_non_finite_vote_is_dropped() {
459 let n = names();
460 let mut log = ObservationLog::default();
461 log.observations.push(Observation::new(
462 Feedback::KeepKill {
463 x: row(&n, &[("mod_depth_mean", f64::NAN)]),
464 kept: true,
465 },
466 0,
467 &n,
468 ));
469 log.observations.push(Observation::new(
470 Feedback::KeepKill {
471 x: row(&n, &[]),
472 kept: false,
473 },
474 0,
475 &n,
476 ));
477 assert_eq!(repair_log(&mut log), (0, 1));
478 assert_eq!(log.observations.len(), 1);
479 }
480
481 /// The implicit stream's φ pairs are repaired positionally, and only when
482 /// the width matches the live feature set — a row from a different φ is a
483 /// row this cannot interpret, and guessing at it is how a "repair" invents
484 /// evidence.
485 #[test]
486 fn implicit_event_phi_is_repaired_only_at_the_right_width() {
487 let n = names();
488 let mut before = row(&n, &[("amp_sustain", 1e30)]);
489 let mut after = row(&n, &[("amp_sustain", 0.4), ("n_vco", 3.0)]);
490 assert_eq!(repair_phi_pair(&mut before, &mut after, &n), 1);
491 let i = n.iter().position(|m| m == "amp_sustain").unwrap();
492 assert_eq!(before[i], 1.0);
493 assert_eq!(after[i], 0.4);
494
495 // A stale-width row is left exactly as it was found.
496 let mut stale = vec![1e30; 3];
497 let mut empty: Vec<f64> = Vec::new();
498 assert_eq!(repair_phi_pair(&mut stale, &mut empty, &n), 0);
499 assert_eq!(stale, vec![1e30; 3]);
500 }
501
502 /// Every name the repair enforces a bound on has to still be a φ
503 /// coordinate. Rename one and this fails here rather than by quietly
504 /// enforcing nothing.
505 #[test]
506 fn every_unit_name_is_a_live_coordinate() {
507 let n = names();
508 for name in StructFeatures::UNIT_NAMES {
509 assert!(
510 n.iter().any(|m| m == name),
511 "{name} is no longer in φ — the domain repair is a no-op for it"
512 );
513 }
514 }
515}