Skip to main content

auracle_taste/
lib.rs

1//! # auracle-taste
2//!
3//! The **user model**: a latent utility over patches, fit from human feedback,
4//! persisted across sessions.
5//!
6//! ```text
7//! u(x) = θ_z · φ(x)        z ~ per-session style latent (mixture of experts)
8//! ```
9//!
10//! One utility, three observation likelihoods in a single fugue program (the
11//! reference: *One utility, three likelihoods*): Bradley–Terry duels
12//! (primary), keep/kill against a
13//! per-session threshold latent τ, and ordinal star ratings with learned
14//! cutpoints. Inference is fugue's adaptive MH over the taste program;
15//! the [`observe::ObservationLog`] is the profile's source of truth and the
16//! posterior can always be re-fit from it.
17//!
18//! Ships at **K = 1** (a one-component mixture *is* Bayesian linear
19//! regression); the mixture machinery (per-session style sites) is present
20//! and unlocked by config.
21//!
22//! The M3 gate lives in this crate's tests: a [`synthetic::SyntheticUser`]
23//! with ground-truth θ* generates noisy feedback and the posterior must
24//! recover θ* and predict held-out choices — the taste core is falsifiable
25//! with no UI and no human.
26
27pub mod model;
28pub mod observe;
29pub mod standardize;
30pub mod synthetic;
31
32pub use model::{TasteConfig, TasteModel, TastePosterior, TasteSample, MAX_NORMAL_SD};
33pub use observe::{
34    Feedback, FitSet, Observation, ObservationLog, Provenance, PHI_SCHEMA, PHI_SCHEMA_STANDARDIZED,
35};
36pub use standardize::Standardizer;
37pub use synthetic::{IdealPointUser, MixtureSyntheticUser, SyntheticUser};
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42    use rand::rngs::StdRng;
43    use rand::{Rng, SeedableRng};
44    use synthetic::cosine;
45
46    const D: usize = 16;
47
48    fn random_phi<R: Rng>(rng: &mut R) -> Vec<f64> {
49        // Standardized feature space: unit normals.
50        (0..D)
51            .map(|_| {
52                let (u1, u2): (f64, f64) = (rng.gen(), rng.gen());
53                (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
54            })
55            .collect()
56    }
57
58    fn ground_truth() -> SyntheticUser {
59        // A sparse, interpretable taste: likes dims 0/3 strongly, dislikes 1/7.
60        let mut theta = vec![0.0; D];
61        theta[0] = 1.8;
62        theta[1] = -1.2;
63        theta[3] = 1.0;
64        theta[7] = -0.8;
65        theta[10] = 0.5;
66        SyntheticUser {
67            theta,
68            tau: 0.4,
69            cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
70        }
71    }
72
73    /// M3 gate 1: duels alone recover θ* (direction) and predict held-out
74    /// duels far above chance.
75    #[test]
76    fn duels_recover_theta() {
77        let mut rng = StdRng::seed_from_u64(11);
78        let user = ground_truth();
79
80        let mut log = ObservationLog::new();
81        for _ in 0..400 {
82            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
83            log.push(user.observe_duel(&mut rng, a, b, 0));
84        }
85
86        let model = TasteModel::new(TasteConfig::linear(D));
87        let posterior = model.fit(&mut rng, &FitSet::as_is(&log), 30_000, 10_000);
88
89        let theta_hat = posterior.theta_mean(0);
90        let cos = cosine(&theta_hat, &user.theta);
91        assert!(cos > 0.85, "theta recovery cosine {cos} too low");
92
93        // Held-out predictive accuracy: predict the *modal* outcome
94        // (deterministic argmax of true utility), which a perfect model gets
95        // ~100% of.
96        let mut correct = 0;
97        let n_test = 300;
98        for _ in 0..n_test {
99            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
100            let truth = user.utility(&a) > user.utility(&b);
101            let pred = posterior.prob_prefers(&a, &b) > 0.5;
102            if pred == truth {
103                correct += 1;
104            }
105        }
106        let acc = correct as f64 / n_test as f64;
107        assert!(acc > 0.8, "held-out duel accuracy {acc} too low");
108    }
109
110    /// A fused group adds exactly `K` sites and nothing else, and an unfused
111    /// config is untouched.
112    ///
113    /// The second half is the one that matters for a change like this: the
114    /// flat path is what every unit test, every synthetic user and every
115    /// existing saved posterior runs on, and it must be the same program node
116    /// for node. `mu` is empty without a group, so it is.
117    #[test]
118    fn fusing_costs_one_site_per_style_and_nothing_when_unused() {
119        let flat = TasteConfig::mixture(40, 5);
120        let flat_sites = model::SiteAddrs::new(&flat, 1).site_count();
121        assert_eq!(flat_sites, 40 * 5 + 1 + 5, "the documented 206");
122
123        // Both knobs are needed, which is itself the guard: naming a group
124        // with rho at its default 0 must stay the flat program.
125        let mut named_only = flat.clone();
126        named_only.fused = vec![vec![2, 5, 0]];
127        assert_eq!(
128            model::SiteAddrs::new(&named_only, 1).site_count(),
129            flat_sites,
130            "a named group at rho = 0 must add no sites — off means off"
131        );
132
133        let mut fused = named_only.clone();
134        fused.fused_rho = Some(0.25);
135        let fused_sites = model::SiteAddrs::new(&fused, 1).site_count();
136        assert_eq!(
137            fused_sites,
138            flat_sites + 5,
139            "one latent mean per style, and no other new site"
140        );
141    }
142
143    /// A fused prior over correlated coordinates recovers taste better than a
144    /// flat one when evidence is thin — which is the whole claim.
145    ///
146    /// The fixture is φ shaped like the real brightness cluster: coordinates
147    /// 0, 1 and 2 are one latent quantity plus small independent noise, and
148    /// the user weights all three. That is the situation a VIF of ~17 reports.
149    /// Both arms see the **same** duels from the same seed, so the comparison
150    /// is the prior and nothing else.
151    ///
152    /// Thin evidence is the point. With enough duels the likelihood swamps any
153    /// prior and both arms converge, so a test at 400 duels would pass whatever
154    /// the prior did; 40 is where a prior that says "these three move together"
155    /// can still be wrong or right.
156    #[test]
157    fn a_fused_prior_beats_a_flat_one_on_a_correlated_cluster() {
158        let mut rng = StdRng::seed_from_u64(0xB817);
159        let mut theta = vec![0.0; D];
160        theta[0] = 1.2;
161        theta[1] = 1.0;
162        theta[2] = 0.9;
163        theta[8] = -1.1;
164        let user = SyntheticUser {
165            theta,
166            tau: 0.4,
167            cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
168        };
169
170        // φ with a genuine brightness cluster: one shared factor, three noisy
171        // views of it.
172        let correlated = |rng: &mut StdRng| -> Vec<f64> {
173            let mut x = random_phi(rng);
174            let shared = x[0];
175            x[1] = 0.93 * shared + 0.37 * x[1];
176            x[2] = 0.90 * shared + 0.44 * x[2];
177            x
178        };
179
180        let mut log = ObservationLog::new();
181        for _ in 0..40 {
182            let (a, b) = (correlated(&mut rng), correlated(&mut rng));
183            log.push(user.observe_duel(&mut rng, a, b, 0));
184        }
185        let data = FitSet::as_is(&log);
186
187        let fit = |cfg: TasteConfig, seed: u64| {
188            let mut r = StdRng::seed_from_u64(seed);
189            let p = TasteModel::new(cfg).fit(&mut r, &data, 20_000, 6_000);
190            cosine(&p.theta_mean(0), &user.theta)
191        };
192
193        // Across several chain seeds, not one: a single pair proves nothing
194        // about a prior, and this codebase has already been bitten once by a
195        // statistic that was really about seed luck (see `RefineKeep`).
196        let mut wins = 0;
197        let (mut sum_flat, mut sum_fused) = (0.0, 0.0);
198        for seed in [7u64, 19, 23, 41, 57, 63, 71, 89, 97, 103, 111, 127] {
199            let flat = fit(TasteConfig::linear(D), seed);
200            let mut cfg = TasteConfig::linear(D);
201            cfg.fused = vec![vec![0, 1, 2]];
202            cfg.fused_rho = Some(0.25);
203            let fused = fit(cfg, seed);
204            println!(
205                "seed {seed}: flat {flat:.3}  fused {fused:.3}  ({:+.3})",
206                fused - flat
207            );
208            sum_flat += flat;
209            sum_fused += fused;
210            if fused > flat {
211                wins += 1;
212            }
213        }
214        let n = 12.0;
215        let (flat, fused) = (sum_flat / n, sum_fused / n);
216        println!("mean: flat {flat:.3}  fused {fused:.3}");
217        assert!(
218            fused > flat && wins >= 8,
219            "fusing the cluster did not help: flat {flat:.3}, fused {fused:.3}, {wins}/12 wins"
220        );
221    }
222
223    /// An imputed coordinate makes a keep/kill verdict *less certain*, and
224    /// leaves a duel alone.
225    ///
226    /// The asymmetry is the whole point. A duel carries the same absence on
227    /// both candidates, so the imputed term cancels in `u_a − u_b` and the
228    /// observation is silent about that axis — correct, and untouched here. A
229    /// keep/kill has nothing to cancel against: `u(x)` meets a threshold, and
230    /// a coordinate imputed at the mean enters that sum as though it had been
231    /// measured and found average. It was not measured at all, and the
232    /// likelihood now says so by pulling the log-odds toward zero.
233    #[test]
234    fn imputation_costs_confidence_on_keep_kill_but_not_on_duels() {
235        let mut theta = vec![0.0; D];
236        theta[0] = 1.5;
237        theta[1] = 1.5;
238        theta[2] = 0.8;
239        let s = TasteSample {
240            theta: vec![theta],
241            tau: vec![0.0],
242            cuts: vec![-2.0, -0.9, 0.0, 0.9, 2.0],
243        };
244
245        let mut x = vec![0.0; D];
246        x[2] = 1.0;
247        let keep = Feedback::KeepKill {
248            x: x.clone(),
249            kept: true,
250        };
251
252        // Coordinates 0 and 1 carry real weight; imputing them should cost
253        // confidence in this verdict.
254        let measured = s.loglik_with(&keep, 0, &[]);
255        let imputed = s.loglik_with(&keep, 0, &[0, 1]);
256        assert!(
257            imputed < measured,
258            "imputing two weighted axes did not reduce confidence:              measured {measured:.4}, imputed {imputed:.4}"
259        );
260        // Less certain means *closer to a coin flip*, not merely different.
261        let coin = 0.5f64.ln();
262        assert!(
263            (imputed - coin).abs() < (measured - coin).abs(),
264            "the correction moved the verdict away from 0.5 instead of toward it"
265        );
266
267        // Imputing an axis this listener does not care about costs nothing:
268        // its θ is zero, so it contributes no variance.
269        let mut theta_z = vec![0.0; D];
270        theta_z[2] = 0.8;
271        let s0 = TasteSample {
272            theta: vec![theta_z],
273            ..s.clone()
274        };
275        assert!(
276            (s0.loglik_with(&keep, 0, &[0, 1]) - s0.loglik_with(&keep, 0, &[])).abs() < 1e-12,
277            "an imputed axis with zero weight must be free"
278        );
279
280        // A duel is untouched: the absence cancels.
281        let duel = Feedback::Duel {
282            a: x.clone(),
283            b: vec![0.0; D],
284            chose_a: true,
285        };
286        assert!(
287            (s.loglik_with(&duel, 0, &[0, 1]) - s.loglik_with(&duel, 0, &[])).abs() < 1e-12,
288            "a duel must not be attenuated — the imputed term cancels in u_a − u_b"
289        );
290    }
291
292    /// M3 gate 2: all three modalities condition one posterior; recovery
293    /// still holds and the keep/kill threshold τ is located.
294    #[test]
295    fn mixed_modalities_recover() {
296        let mut rng = StdRng::seed_from_u64(22);
297        let user = ground_truth();
298
299        let mut log = ObservationLog::new();
300        for _ in 0..150 {
301            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
302            log.push(user.observe_duel(&mut rng, a, b, 0));
303        }
304        for _ in 0..150 {
305            let x = random_phi(&mut rng);
306            let kept = user.keep(&mut rng, &x);
307            log.push(Observation::new(Feedback::KeepKill { x, kept }, 0, &[]));
308        }
309        for _ in 0..150 {
310            let x = random_phi(&mut rng);
311            let rating = user.stars(&mut rng, &x);
312            log.push(Observation::new(Feedback::Stars { x, rating }, 0, &[]));
313        }
314
315        let model = TasteModel::new(TasteConfig::linear(D));
316        let posterior = model.fit(&mut rng, &FitSet::as_is(&log), 30_000, 10_000);
317
318        let cos = cosine(&posterior.theta_mean(0), &user.theta);
319        assert!(cos > 0.85, "mixed-modality recovery cosine {cos} too low");
320
321        // τ posterior mean near the truth (same scale as u).
322        let tau_mean: f64 = posterior.samples.iter().map(|s| s.tau[0]).sum::<f64>()
323            / posterior.samples.len() as f64;
324        assert!(
325            (tau_mean - user.tau).abs() < 0.6,
326            "tau posterior mean {tau_mean} far from truth {}",
327            user.tau
328        );
329    }
330
331    /// M3 gate 3: ranking a candidate pool by posterior-mean utility puts
332    /// genuinely good candidates on top (the exploit half of acquisition).
333    #[test]
334    fn posterior_ranks_a_pool() {
335        let mut rng = StdRng::seed_from_u64(33);
336        let user = ground_truth();
337
338        let mut log = ObservationLog::new();
339        for _ in 0..300 {
340            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
341            log.push(user.observe_duel(&mut rng, a, b, 0));
342        }
343        let model = TasteModel::new(TasteConfig::linear(D));
344        let posterior = model.fit(&mut rng, &FitSet::as_is(&log), 30_000, 10_000);
345
346        // Pool of 100; compare model's top-10 against true top-10.
347        let pool: Vec<Vec<f64>> = (0..100).map(|_| random_phi(&mut rng)).collect();
348        let mut by_model: Vec<usize> = (0..pool.len()).collect();
349        by_model.sort_by(|&i, &j| {
350            posterior
351                .utility(&pool[j], 0)
352                .0
353                .total_cmp(&posterior.utility(&pool[i], 0).0)
354        });
355        let mut by_truth: Vec<usize> = (0..pool.len()).collect();
356        by_truth.sort_by(|&i, &j| user.utility(&pool[j]).total_cmp(&user.utility(&pool[i])));
357
358        let top_model: std::collections::HashSet<usize> = by_model[..10].iter().copied().collect();
359        let overlap = by_truth[..10]
360            .iter()
361            .filter(|i| top_model.contains(i))
362            .count();
363        assert!(
364            overlap >= 6,
365            "only {overlap}/10 of the true best candidates in the model's top 10"
366        );
367    }
368
369    /// **The misspecification gate.** Every other user here is linear in the
370    /// same φ the model is linear in, so the model is correctly specified by
371    /// construction and the gates only ever measure estimation speed. This one
372    /// is an ideal-point listener: `u* = −Σ w(φ−c)²`, strictly concave, while
373    /// `max_k θ_k·φ` is a maximum of affine functions and therefore convex.
374    /// The model provably cannot represent this user at any K.
375    ///
376    /// What it *should* still do is rank most pairs, because over any region
377    /// not straddling the ideal point the true utility is locally monotone.
378    /// So the assertions are: still clearly better than chance (this can fail,
379    /// and would if inference broke), and measurably worse than the same
380    /// machinery on a well-specified user (this can also fail — if it did, the
381    /// harness would not be sensitive enough to detect misspecification at
382    /// all, which is the property being established).
383    #[test]
384    fn misspecified_user_is_learned_partially_and_detectably() {
385        let mut rng = StdRng::seed_from_u64(0x1DEA);
386        let mut center = vec![0.0; D];
387        let mut weights = vec![0.15; D];
388        // A specific sound: bright-ish, not too bright; quiet on dim 1.
389        center[0] = 0.8;
390        center[1] = -0.6;
391        center[3] = 0.4;
392        weights[0] = 0.9;
393        weights[1] = 0.7;
394        weights[3] = 0.5;
395        let curved = IdealPointUser { center, weights };
396        let linear = ground_truth();
397
398        // Held-out modal accuracy under each user, same budget and inference.
399        let accuracy = |rng: &mut StdRng, use_curved: bool| -> f64 {
400            let mut log = ObservationLog::new();
401            for _ in 0..300 {
402                let (a, b) = (random_phi(rng), random_phi(rng));
403                log.push(if use_curved {
404                    curved.observe_duel(rng, a, b, 0)
405                } else {
406                    linear.observe_duel(rng, a, b, 0)
407                });
408            }
409            let posterior = TasteModel::new(TasteConfig::mixture(D, 2)).fit(
410                rng,
411                &FitSet::as_is(&log),
412                20_000,
413                6_000,
414            );
415            let mut correct = 0;
416            let n_test = 400;
417            for _ in 0..n_test {
418                let (a, b) = (random_phi(rng), random_phi(rng));
419                let truth = if use_curved {
420                    curved.utility(&a) > curved.utility(&b)
421                } else {
422                    linear.utility(&a) > linear.utility(&b)
423                };
424                if (posterior.prob_prefers(&a, &b) > 0.5) == truth {
425                    correct += 1;
426                }
427            }
428            correct as f64 / n_test as f64
429        };
430
431        let acc_curved = accuracy(&mut rng, true);
432        let acc_linear = accuracy(&mut rng, false);
433        println!("misspecified acc {acc_curved:.3} vs well-specified {acc_linear:.3}");
434
435        assert!(
436            acc_curved > 0.60,
437            "a concave user should still be ranked well above chance, got {acc_curved}"
438        );
439        assert!(
440            acc_curved < acc_linear,
441            "the harness cannot tell a misspecified user ({acc_curved}) from a \
442             well-specified one ({acc_linear}) — it would not catch a real one"
443        );
444    }
445
446    /// Observation logs round-trip through JSON (the profile's source of
447    /// truth must survive persistence).
448    #[test]
449    fn log_roundtrips() {
450        let mut rng = StdRng::seed_from_u64(44);
451        let user = ground_truth();
452        let mut log = ObservationLog::new();
453        for s in 0..3 {
454            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
455            log.push(user.observe_duel(&mut rng, a, b, s));
456        }
457        let dir = std::env::temp_dir().join("auracle-taste-test");
458        std::fs::create_dir_all(&dir).unwrap();
459        let path = dir.join("log.json");
460        log.save(&path).unwrap();
461        let back = ObservationLog::load(&path).unwrap();
462        assert_eq!(back, log);
463        assert_eq!(back.n_sessions(), 3);
464    }
465
466    /// The standardizer normalizes to zero mean / unit variance and
467    /// round-trips dimension.
468    ///
469    /// The tolerances are still exact, and that is the point: `fit` gained a
470    /// runaway-column detector, not a routine trim, so on clean data it is the
471    /// plain moments to the last bit. If this test ever needs loosening, the
472    /// robustification has started charging the honest columns for the
473    /// dishonest ones.
474    #[test]
475    fn standardizer_standardizes() {
476        let mut rng = StdRng::seed_from_u64(55);
477        let rows: Vec<Vec<f64>> = (0..500)
478            .map(|_| vec![rng.gen::<f64>() * 100.0, 5.0, rng.gen::<f64>() - 3.0])
479            .collect();
480        let sz = Standardizer::fit(&rows);
481        assert_eq!(sz.dimension(), 3);
482        let transformed: Vec<Vec<f64>> = rows.iter().map(|r| sz.transform(r)).collect();
483        for dim in [0, 2] {
484            let col: Vec<f64> = transformed.iter().map(|r| r[dim]).collect();
485            let mean = col.iter().sum::<f64>() / col.len() as f64;
486            let var = col.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / col.len() as f64;
487            assert!(mean.abs() < 1e-9, "dim {dim} is not centred: {mean}");
488            assert!((var - 1.0).abs() < 1e-9, "dim {dim} scale drifted: {var}");
489        }
490        // Constant column: std floored, no NaN.
491        assert!(transformed.iter().all(|r| r[1].abs() < 1e-9));
492    }
493
494    /// A log written before raw-φ logging still loads, and is recognizable
495    /// as legacy — silently reading its standardized vectors as raw values
496    /// would corrupt the profile it was meant to preserve.
497    #[test]
498    fn legacy_logs_still_load() {
499        let json = r#"{"observations":[
500            {"Duel":{"a":[1.0,2.0],"b":[3.0,4.0],"chose_a":true,"session":0}},
501            {"KeepKill":{"x":[0.5,0.25],"kept":false,"session":1}},
502            {"Stars":{"x":[0.1,0.2],"rating":3,"session":1}}
503        ]}"#;
504        let log: ObservationLog = serde_json::from_str(json).unwrap();
505        assert_eq!(log.len(), 3);
506        assert_eq!(log.n_sessions(), 2);
507        assert!(
508            log.observations.iter().all(|o| !o.is_raw()),
509            "legacy observations must not claim to be raw"
510        );
511        // …and they contribute nothing to a standardizer fit over raw values.
512        assert!(log
513            .raw_rows(&[String::from("a"), String::from("b")])
514            .is_empty());
515    }
516
517    /// The point of raw-φ logging: the feature set can change and old votes
518    /// still land on the right axes. A renamed/reordered/extended feature set
519    /// must re-project by name, and a coordinate the vote predates is imputed
520    /// at the standardizer mean — which standardizes to exactly zero, i.e.
521    /// "this vote says nothing about that axis".
522    #[test]
523    fn observations_reproject_by_name() {
524        let names_then: Vec<String> = ["bright", "noisy"].iter().map(|s| s.to_string()).collect();
525        let mut log = ObservationLog::new();
526        log.push(Observation::new(
527            Feedback::Duel {
528                a: vec![10.0, 1.0],
529                b: vec![0.0, 3.0],
530                chose_a: true,
531            },
532            0,
533            &names_then,
534        ));
535        // The feature set later gains a coordinate and swaps the order.
536        let names_now: Vec<String> = ["noisy", "warm", "bright"]
537            .iter()
538            .map(|s| s.to_string())
539            .collect();
540        let sz = Standardizer {
541            mean: vec![2.0, 7.0, 5.0],
542            std: vec![1.0, 2.0, 5.0],
543        };
544        let fit = FitSet::build(&log, &names_now, &sz);
545        let Feedback::Duel { a, b, chose_a } = &fit.rows[0].0 else {
546            panic!("modality changed");
547        };
548        assert!(chose_a);
549        // noisy: (1−2)/1, warm: absent ⇒ 0, bright: (10−5)/5.
550        assert_eq!(a, &vec![-1.0, 0.0, 1.0]);
551        assert_eq!(b, &vec![1.0, 0.0, -1.0]);
552        assert_eq!(log.raw_rows(&names_then).len(), 2, "raw rows are fittable");
553    }
554
555    /// σ_θ must widen with K. `u = max_k u_k` is the max of K standard
556    /// normals under the prior, whose SD *falls* with K — so at fixed σ_θ,
557    /// growing the mixture would quietly shrink `Var(u_a − u_b)` and make the
558    /// model less able to express a strong preference than before.
559    #[test]
560    fn sigma_theta_compensates_the_k_schedule() {
561        let s1 = TasteConfig::linear(D).sigma_theta();
562        let mut prev = s1;
563        for k in 2..=5 {
564            let s = TasteConfig::mixture(D, k).sigma_theta();
565            assert!(s > prev, "sigma did not widen from K={} to K={k}", k - 1);
566            prev = s;
567        }
568        assert!(
569            (s1 - 1.0 / (D as f64).sqrt()).abs() < 1e-12,
570            "K=1 unchanged"
571        );
572        // Var(u_a − u_b) restored to its K=1 value, to within the table.
573        for k in 1..=5 {
574            let s = TasteConfig::mixture(D, k).sigma_theta();
575            let sd_u = s * (D as f64).sqrt() * MAX_NORMAL_SD[k - 1];
576            assert!((sd_u - 1.0).abs() < 1e-9, "K={k} utility SD {sd_u}");
577        }
578        // An explicit override still wins.
579        let mut cfg = TasteConfig::mixture(D, 5);
580        cfg.theta_prior_std = Some(0.3);
581        assert_eq!(cfg.sigma_theta(), 0.3);
582    }
583
584    /// Between full refits the posterior is updated by importance
585    /// reweighting. It must move toward the evidence, degrade *visibly*
586    /// (falling ESS) rather than silently, and survive resampling.
587    #[test]
588    fn importance_updates_track_new_evidence() {
589        let mut rng = StdRng::seed_from_u64(88);
590        let user = ground_truth();
591        let mut log = ObservationLog::new();
592        for _ in 0..40 {
593            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
594            log.push(user.observe_duel(&mut rng, a, b, 0));
595        }
596        let p = TasteModel::new(TasteConfig::linear(D)).fit(
597            &mut rng,
598            &FitSet::as_is(&log),
599            8_000,
600            3_000,
601        );
602        assert!(
603            (p.ess() - p.samples.len() as f64).abs() < 1e-6,
604            "fit is uniform"
605        );
606
607        // A decisive duel: A is far up θ*, B far down. Reweighting must raise
608        // the model's probability for that outcome.
609        let mut a = vec![0.0; D];
610        a[0] = 3.0;
611        let mut b = vec![0.0; D];
612        b[0] = -3.0;
613        let before = p.prob_prefers(&a, &b);
614        let after = p
615            .reweighted(
616                &Feedback::Duel {
617                    a: a.clone(),
618                    b: b.clone(),
619                    chose_a: true,
620                },
621                0,
622            )
623            .reweighted(
624                &Feedback::Duel {
625                    a: a.clone(),
626                    b: b.clone(),
627                    chose_a: true,
628                },
629                0,
630            );
631        assert!(
632            after.prob_prefers(&a, &b) > before,
633            "reweighting ignored the evidence: {before} → {}",
634            after.prob_prefers(&a, &b)
635        );
636        assert!(
637            after.ess() < p.ess(),
638            "ESS must show the cost of the update"
639        );
640        assert!((after.weights.iter().sum::<f64>() - 1.0).abs() < 1e-9);
641
642        let re = after.resampled();
643        assert_eq!(re.samples.len(), after.samples.len());
644        assert!(
645            (re.ess() - re.samples.len() as f64).abs() < 1e-6,
646            "resampling restores uniform weights"
647        );
648        // Resampling preserves the weighted summary it was drawn from.
649        assert!((re.prob_prefers(&a, &b) - after.prob_prefers(&a, &b)).abs() < 0.05);
650    }
651
652    /// K = 2 smoke: the mixture path runs end-to-end and returns finite
653    /// summaries, weights sum to one, and alignment is well-formed.
654    #[test]
655    fn k2_smoke() {
656        let mut rng = StdRng::seed_from_u64(66);
657        let user = ground_truth();
658        let mut log = ObservationLog::new();
659        for s in 0..2 {
660            for _ in 0..30 {
661                let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
662                log.push(user.observe_duel(&mut rng, a, b, s));
663            }
664        }
665        let posterior = TasteModel::new(TasteConfig::mixture(D, 2))
666            .fit(&mut rng, &FitSet::as_is(&log), 4_000, 2_000)
667            .aligned();
668        let phi = random_phi(&mut rng);
669        for style in 0..2 {
670            let (m, s) = posterior.utility(&phi, style);
671            assert!(m.is_finite() && s.is_finite());
672        }
673        let (m, s) = posterior.utility_mix(&phi);
674        assert!(m.is_finite() && s.is_finite());
675        let r = posterior.responsibilities(&phi);
676        assert!((r.iter().sum::<f64>() - 1.0).abs() < 1e-9);
677    }
678
679    /// **The M6 mixture gate.** A user whose true taste is bimodal — utility
680    /// = max over two orthogonal-ish component tastes — is a function no
681    /// single linear θ can represent. The K = 2 marginalized mixture must
682    /// (a) predict held-out duels better than K = 1, and (b) recover *both*
683    /// component directions after alignment.
684    #[test]
685    fn mixture_captures_bimodal_taste() {
686        let mut rng = StdRng::seed_from_u64(77);
687        // Mirrored dominant dimension: u* = max(θ_a·φ, θ_b·φ) is V-shaped in
688        // φ₀, which no single linear θ can track (its best move is to zero
689        // out φ₀ entirely).
690        let mut theta_a = vec![0.0; D];
691        theta_a[0] = 2.4;
692        theta_a[1] = 1.2;
693        theta_a[2] = 0.8;
694        let mut theta_b = vec![0.0; D];
695        theta_b[0] = -2.4;
696        theta_b[1] = 1.2;
697        theta_b[3] = 0.8;
698        let user = MixtureSyntheticUser {
699            thetas: vec![theta_a.clone(), theta_b.clone()],
700        };
701
702        let mut log = ObservationLog::new();
703        for _ in 0..350 {
704            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
705            log.push(user.observe_duel(&mut rng, a, b, 0));
706        }
707
708        let p1 = TasteModel::new(TasteConfig::linear(D)).fit(
709            &mut rng,
710            &FitSet::as_is(&log),
711            25_000,
712            8_000,
713        );
714        let p2 = TasteModel::new(TasteConfig::mixture(D, 2)).fit(
715            &mut rng,
716            &FitSet::as_is(&log),
717            45_000,
718            15_000,
719        );
720
721        // (a) held-out modal accuracy.
722        let mut correct = [0usize; 2];
723        let n_test = 400;
724        for _ in 0..n_test {
725            let (a, b) = (random_phi(&mut rng), random_phi(&mut rng));
726            let truth = user.utility(&a) > user.utility(&b);
727            if (p1.prob_prefers(&a, &b) > 0.5) == truth {
728                correct[0] += 1;
729            }
730            if (p2.prob_prefers(&a, &b) > 0.5) == truth {
731                correct[1] += 1;
732            }
733        }
734        let acc1 = correct[0] as f64 / n_test as f64;
735        let acc2 = correct[1] as f64 / n_test as f64;
736        assert!(
737            acc2 > acc1 + 0.02,
738            "mixture ({acc2}) does not beat linear ({acc1}) on a bimodal user"
739        );
740        assert!(acc2 > 0.75, "mixture accuracy {acc2} too low");
741
742        // (b) both true directions are recovered by some aligned style.
743        let aligned = p2.aligned();
744        let best_cos = |truth: &[f64]| -> f64 {
745            (0..2)
746                .map(|k| cosine(&aligned.theta_mean(k), truth))
747                .fold(f64::NEG_INFINITY, f64::max)
748        };
749        let (ca, cb) = (best_cos(&theta_a), best_cos(&theta_b));
750        assert!(
751            ca > 0.6 && cb > 0.6,
752            "style recovery too weak: cos_a={ca:.2} cos_b={cb:.2}"
753        );
754    }
755
756    /// A log written before provenance existed still loads, and every row in
757    /// it reads as the thing it was: a dealt duel. The observation log is one
758    /// IndexedDB blob with no schema version, so compatibility is by
759    /// construction or it is nothing — and the alternative to a default here
760    /// is a saved profile that fails to parse and takes a user's whole taste
761    /// history with it.
762    #[test]
763    fn a_log_written_before_provenance_still_loads() {
764        let old = r#"{"observations":[
765            {"feedback":{"Duel":{"a":[0.5],"b":[0.25],"chose_a":true}},
766             "session":0,"feature_names":["x"],"schema_version":2},
767            {"KeepKill":{"x":[0.1],"kept":true,"session":1}}
768        ]}"#;
769        let log: ObservationLog = serde_json::from_str(old).expect("an old log parses");
770        assert_eq!(log.len(), 2);
771        assert!(log
772            .observations
773            .iter()
774            .all(|o| o.provenance == Provenance::Duel));
775        assert_eq!(log.n_with(Provenance::Duel), 2);
776        assert_eq!(log.n_with(Provenance::SelfReport), 0);
777
778        // And the tag round-trips when it is not the default, while a default
779        // one stays off the wire — an old reader sees exactly what it saw.
780        let mut log = log;
781        log.push(Observation::tagged(
782            Feedback::KeepKill {
783                x: vec![0.7],
784                kept: false,
785            },
786            2,
787            &["x".to_string()],
788            Provenance::SelfReport,
789        ));
790        let json = serde_json::to_string(&log).unwrap();
791        assert!(json.contains("self_report"));
792        assert_eq!(
793            json.matches("provenance").count(),
794            1,
795            "the default was serialized"
796        );
797        let back: ObservationLog = serde_json::from_str(&json).unwrap();
798        assert_eq!(back, log);
799    }
800}