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(¢ered, None);
263 let (ax2, var2, ok2) = leading_axis(¢ered, 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(¢ered, None);
357 let (ax2, _, ok2) = leading_axis(¢ered, 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(¢ered, None);
398 let (_, var2, _) = leading_axis(¢ered, Some(&ax1));
399 // Whatever it reports, it must not lie about the ordering.
400 assert!(var1 >= var2);
401 }
402}