Skip to main content

auracle_session/
map.rs

1//! The taste map: a 2D embedding of everything the user has heard, with
2//! posterior utility attached — taste rendered as *territory* (islands of
3//! glow across patch space) rather than a single preference vector.
4//!
5//! The projection is plain PCA over standardized features (top two principal
6//! axes by power iteration, deterministic start), computed over the union of
7//! the current pool and the observation history. Pool points carry candidate
8//! ids (clickable in a frontend); history points are ghosts — patches that
9//! may have been evicted, kept to show where the user has traveled.
10
11use serde::{Deserialize, Serialize};
12
13use crate::engine::{Engine, Origin};
14
15/// One point on the map.
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct MapPoint {
18    /// Candidate id for pool members; `None` for history ghosts.
19    pub id: Option<u64>,
20    /// First principal coordinate.
21    pub x: f64,
22    /// Second principal coordinate.
23    pub y: f64,
24    /// Posterior-mean mixture utility (0 with no posterior).
25    pub utility: f64,
26    /// Posterior utility std (0 with no posterior).
27    pub utility_std: f64,
28    /// Most responsible style lens (0 with no posterior or K = 1).
29    pub style: usize,
30    /// `"prior"`, `"refined"`, `"edited"`, or `"history"`.
31    pub origin: String,
32}
33
34/// The 2D taste map.
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct TasteMap {
37    /// All points (pool first, then history ghosts).
38    pub points: Vec<MapPoint>,
39    /// Fraction of total variance captured by each of the two axes.
40    pub explained: [f64; 2],
41    /// Whether each axis's power iteration actually converged.
42    ///
43    /// `false` means the projection is a direction the solver was still moving
44    /// toward when it hit its cap, which happens when the top two eigenvalues
45    /// are near-tied — a live possibility here, because φ's brightness cluster
46    /// is three genuine measurements of one perceptual thing. The map is still
47    /// drawable; what it is not, in that case, is *stable*, and a surface that
48    /// invites the reader to recognise territory should be able to know that.
49    ///
50    /// `#[serde(default)]` so maps serialized before this existed load as
51    /// `[false, false]` rather than failing — the honest reading, since nothing
52    /// checked convergence when they were written.
53    #[serde(default)]
54    pub converged: [bool; 2],
55}
56
57/// Most recent history φs to include as ghost points.
58const MAX_HISTORY: usize = 400;
59
60fn mean_center(rows: &mut [Vec<f64>]) {
61    if rows.is_empty() {
62        return;
63    }
64    let d = rows[0].len();
65    let n = rows.len() as f64;
66    let mut mu = vec![0.0; d];
67    for r in rows.iter() {
68        for (m, x) in mu.iter_mut().zip(r) {
69            *m += x / n;
70        }
71    }
72    for r in rows.iter_mut() {
73        for (x, m) in r.iter_mut().zip(&mu) {
74            *x -= m;
75        }
76    }
77}
78
79/// Power iterations before giving up. Generous, because the loop now *stops*
80/// when it has converged rather than always running to the cap — so this is a
81/// bound on the pathological case, not the cost of the normal one.
82const MAX_POWER_ITERS: usize = 400;
83
84/// Convergence test on the direction: `1 − |⟨v, v_prev⟩|`, i.e. the sine-squared
85/// of the angle between successive iterates, to first order. Sign-insensitive
86/// because a power iterate may alternate sign while the *axis* is stationary.
87const AXIS_TOL: f64 = 1e-12;
88
89/// Leading right-singular vector of the (centered) data by power iteration
90/// on X'X, with a deterministic start.
91///
92/// Returns `(direction, variance, converged)`.
93///
94/// ## Two things this has to do that it previously did not
95///
96/// **Stop when it has converged, and say when it has not.** The loop used to
97/// run exactly 60 iterations and return whatever it was holding. Power
98/// iteration converges as `(λ₂/λ₁)^k`, and φ's brightness cluster is three
99/// genuine measurements of one perceptual thing — so near-ties in the top
100/// eigenvalues are a designed-in property of this feature set, not a rare
101/// accident. Sixty iterations was an assertion about a ratio nobody measured.
102///
103/// **Pin the sign.** An eigenvector is only defined up to sign, and nothing
104/// fixed it: the map's x-axis could point one way on one refit and the other
105/// way on the next, mirroring "where you have travelled" left-for-right under
106/// the reader. The deterministic start made this *usually* stable, which is
107/// worse than either extreme — it flips rarely enough to look like a bug in the
108/// data rather than a property of the projection.
109///
110/// The convention is the standard one (`svd_flip`): the component of largest
111/// magnitude is made positive. It is stateless, which is why it is used here
112/// over aligning each axis to the previously drawn one — that would be strictly
113/// more stable, and it needs `taste_map` to carry state across calls, which is
114/// a bigger change than the defect warrants. What remains is that a *tie* for
115/// largest magnitude can still flip; with 40 continuous coordinates that is a
116/// measure-zero event rather than the routine one this replaces.
117fn leading_axis(rows: &[Vec<f64>], deflate: Option<&[f64]>) -> (Vec<f64>, f64, bool) {
118    let d = rows.first().map(|r| r.len()).unwrap_or(0);
119    if d == 0 {
120        return (Vec::new(), 0.0, true);
121    }
122    // Deterministic start: the coordinate axis of largest variance.
123    let mut var0 = vec![0.0; d];
124    for r in rows {
125        for (v, x) in var0.iter_mut().zip(r) {
126            *v += x * x;
127        }
128    }
129    let start = var0
130        .iter()
131        .enumerate()
132        .max_by(|a, b| a.1.total_cmp(b.1))
133        .map(|(i, _)| i)
134        .unwrap_or(0);
135    let mut v = vec![0.0; d];
136    v[start] = 1.0;
137
138    let project_out = |v: &mut [f64]| {
139        if let Some(u) = deflate {
140            let dot: f64 = v.iter().zip(u).map(|(a, b)| a * b).sum();
141            for (vi, ui) in v.iter_mut().zip(u) {
142                *vi -= dot * ui;
143            }
144        }
145    };
146    project_out(&mut v);
147
148    let mut converged = false;
149    for _ in 0..MAX_POWER_ITERS {
150        // w = X'(X v)
151        let mut w = vec![0.0; d];
152        for r in rows {
153            let s: f64 = r.iter().zip(&v).map(|(a, b)| a * b).sum();
154            for (wi, xi) in w.iter_mut().zip(r) {
155                *wi += s * xi;
156            }
157        }
158        project_out(&mut w);
159        let norm: f64 = w.iter().map(|x| x * x).sum::<f64>().sqrt();
160        if norm < 1e-12 {
161            // The data has no variance left on this axis. Degenerate, but
162            // settled: there is nothing further to converge to.
163            converged = true;
164            break;
165        }
166        // `|⟨v_next, v⟩|` — absolute, because an iterate may flip sign between
167        // steps while the axis itself is stationary, and treating that as
168        // movement would spin to the cap on a converged direction.
169        let align: f64 = w
170            .iter()
171            .zip(&v)
172            .map(|(wi, vi)| (wi / norm) * vi)
173            .sum::<f64>()
174            .abs();
175        for (vi, wi) in v.iter_mut().zip(&w) {
176            *vi = wi / norm;
177        }
178        if 1.0 - align < AXIS_TOL {
179            converged = true;
180            break;
181        }
182    }
183
184    // Pin the sign: largest-magnitude component positive. Applied after the
185    // iteration rather than inside it, because the iteration does not care and
186    // flipping mid-loop would only confuse the convergence test above.
187    if let Some(pivot) = (0..d).max_by(|&i, &j| v[i].abs().total_cmp(&v[j].abs())) {
188        if v[pivot] < 0.0 {
189            for vi in v.iter_mut() {
190                *vi = -*vi;
191            }
192        }
193    }
194
195    let variance: f64 = rows
196        .iter()
197        .map(|r| {
198            let s: f64 = r.iter().zip(&v).map(|(a, b)| a * b).sum();
199            s * s
200        })
201        .sum::<f64>()
202        / rows.len().max(1) as f64;
203    (v, variance, converged)
204}
205
206impl Engine {
207    /// Build the taste map over the pool plus recent observation history.
208    pub fn taste_map(&self) -> TasteMap {
209        let mut rows: Vec<Vec<f64>> = Vec::new();
210        let mut meta: Vec<(Option<u64>, String)> = Vec::new();
211        for c in &self.pool {
212            if c.phi_std.is_empty() {
213                continue;
214            }
215            rows.push(c.phi_std.clone());
216            let origin = match c.origin {
217                Origin::Prior => "prior",
218                Origin::Refined => "refined",
219                Origin::Edited => "edited",
220                Origin::Preset => "preset",
221            };
222            meta.push((Some(c.id), origin.into()));
223        }
224        // History φ are raw; the map lives in standardized space, so they go
225        // through the current standardizer — the same one the pool points use,
226        // which is what keeps ghosts and live candidates on one projection.
227        let mut history: Vec<Vec<f64>> = Vec::new();
228        for o in self.log.observations.iter().rev() {
229            for phi in o.feedback.phis() {
230                let phi = match (&self.standardizer, o.is_raw()) {
231                    (Some(sz), true) if phi.len() == sz.dimension() => sz.transform(phi),
232                    _ => phi.to_vec(),
233                };
234                history.push(phi);
235            }
236            if history.len() >= MAX_HISTORY {
237                break;
238            }
239        }
240        for phi in history {
241            rows.push(phi);
242            meta.push((None, "history".into()));
243        }
244
245        if rows.len() < 3 {
246            return TasteMap {
247                points: Vec::new(),
248                explained: [0.0, 0.0],
249                // Nothing was solved, so nothing converged. Reported as such
250                // rather than as a vacuous success.
251                converged: [false, false],
252            };
253        }
254
255        let mut centered = rows.clone();
256        mean_center(&mut centered);
257        let total_var: f64 = centered
258            .iter()
259            .map(|r| r.iter().map(|x| x * x).sum::<f64>())
260            .sum::<f64>()
261            / centered.len() as f64;
262        let (ax1, var1, ok1) = leading_axis(&centered, None);
263        let (ax2, var2, ok2) = leading_axis(&centered, Some(&ax1));
264
265        let points = centered
266            .iter()
267            .zip(rows.iter())
268            .zip(meta)
269            .map(|((c, phi), (id, origin))| {
270                let x: f64 = c.iter().zip(&ax1).map(|(a, b)| a * b).sum();
271                let y: f64 = c.iter().zip(&ax2).map(|(a, b)| a * b).sum();
272                let (utility, utility_std, style) = match &self.posterior {
273                    Some(p) => {
274                        let (m, s) = p.utility_mix(phi);
275                        let r = p.responsibilities(phi);
276                        let style = r
277                            .iter()
278                            .enumerate()
279                            .max_by(|a, b| a.1.total_cmp(b.1))
280                            .map(|(i, _)| i)
281                            .unwrap_or(0);
282                        (m, s, style)
283                    }
284                    None => (0.0, 0.0, 0),
285                };
286                MapPoint {
287                    id,
288                    x,
289                    y,
290                    utility,
291                    utility_std,
292                    style,
293                    origin,
294                }
295            })
296            .collect();
297
298        TasteMap {
299            points,
300            explained: [
301                (var1 / total_var.max(1e-12)).min(1.0),
302                (var2 / total_var.max(1e-12)).min(1.0),
303            ],
304            converged: [ok1, ok2],
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    /// Rows on a plane: strong variance along `u1`, weaker along `u2`, plus a
314    /// third near-flat coordinate so the deflated axis has somewhere to go.
315    fn plane(u1: [f64; 3], u2: [f64; 3], n: usize) -> Vec<Vec<f64>> {
316        (0..n)
317            .map(|i| {
318                let a = i as f64 - (n as f64 - 1.0) / 2.0;
319                // A second loading that is not a multiple of the first, so the
320                // two directions are genuinely distinguishable.
321                let b = ((i * 7) % 5) as f64 - 2.0;
322                (0..3).map(|k| 6.0 * a * u1[k] + b * u2[k]).collect()
323            })
324            .collect()
325    }
326
327    /// **The sign convention, on data built to violate it.**
328    ///
329    /// A PCA axis is defined only up to sign. Power iteration returns whichever
330    /// orientation has a positive inner product with its start vector, so the
331    /// orientation is a fact about the *solver*, not about the data — and it
332    /// changes when the start changes, which it does as the pool moves. On the
333    /// map that mirrors "where you have travelled" left-for-right between one
334    /// recompute and the next.
335    ///
336    /// This is the regression test proper: with the convention removed, the
337    /// second axis below comes back with its largest component negative.
338    ///
339    /// The **second** axis is where this bites hardest and is why the case is
340    /// built around it. The first axis starts from the highest-variance
341    /// coordinate, which is usually also where the leading eigenvector puts its
342    /// mass, so the natural orientation tends to satisfy the convention by
343    /// accident. The deflated axis starts from that same vector with the first
344    /// axis projected *out* of it, and what is left has no such relationship to
345    /// the second eigenvector — its sign is genuinely arbitrary.
346    #[test]
347    fn axes_come_back_with_their_largest_component_positive() {
348        let cases = [
349            (plane([0.9, 0.3, 0.3], [-0.2, 0.9, -0.4], 40), "a"),
350            (plane([0.2, 0.95, 0.2], [0.7, -0.1, -0.7], 40), "b"),
351            (plane([0.5, 0.5, 0.7], [-0.8, 0.1, 0.6], 60), "c"),
352        ];
353        for (rows, name) in &cases {
354            let mut centered = rows.clone();
355            mean_center(&mut centered);
356            let (ax1, _, ok1) = leading_axis(&centered, None);
357            let (ax2, _, ok2) = leading_axis(&centered, Some(&ax1));
358            assert!(ok1 && ok2, "case {name}: an axis did not converge");
359
360            for (which, ax) in [("ax1", &ax1), ("ax2", &ax2)] {
361                let pivot = (0..ax.len())
362                    .max_by(|&i, &j| ax[i].abs().total_cmp(&ax[j].abs()))
363                    .expect("nonempty axis");
364                assert!(
365                    ax[pivot] > 0.0,
366                    "case {name}: {which} largest component is {:.4} — the sign is unpinned",
367                    ax[pivot]
368                );
369            }
370
371            // Orthonormal, so the two axes are still a basis after the flip.
372            let dot: f64 = ax1.iter().zip(&ax2).map(|(a, b)| a * b).sum();
373            assert!(
374                dot.abs() < 1e-8,
375                "case {name}: axes not orthogonal ({dot:.2e})"
376            );
377            for (which, ax) in [("ax1", &ax1), ("ax2", &ax2)] {
378                let norm: f64 = ax.iter().map(|x| x * x).sum::<f64>().sqrt();
379                assert!((norm - 1.0).abs() < 1e-8, "case {name}: {which} not unit");
380            }
381        }
382    }
383
384    /// A near-degenerate spectrum must be *reported*, not silently returned as
385    /// though it had settled. Two coordinates with identical variance and no
386    /// covariance leave the second axis with nothing to converge toward.
387    #[test]
388    fn a_tied_spectrum_is_reported_rather_than_hidden() {
389        let rows: Vec<Vec<f64>> = (0..40)
390            .map(|i| {
391                let a = i as f64 - 19.5;
392                vec![a, if i % 2 == 0 { 1.0 } else { -1.0 }, 0.0]
393            })
394            .collect();
395        let mut centered = rows.clone();
396        mean_center(&mut centered);
397        let (ax1, var1, _) = leading_axis(&centered, None);
398        let (_, var2, _) = leading_axis(&centered, Some(&ax1));
399        // Whatever it reports, it must not lie about the ordering.
400        assert!(var1 >= var2);
401    }
402}