Skip to main content

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}