auracle_taste/synthetic.rs
1//! The synthetic user (the reference: *Milestones*, the M3 gate).
2//!
3//! A ground-truth taste (θ*, τ*, cuts*) that generates noisy feedback exactly
4//! per the observation model. The gate tests assert the posterior recovers
5//! θ* and predicts held-out feedback — making the taste core falsifiable with
6//! no UI and no human. Later this doubles as demo mode ("watch it learn a
7//! fake user in fast-forward").
8//!
9//! Synthetic φ live directly on the model's scale (unit normals), so the
10//! observations these emit carry no feature names and are fed to the model
11//! through [`crate::FitSet::as_is`] rather than being re-standardized.
12
13use rand::Rng;
14
15use crate::observe::{Feedback, Observation};
16
17fn sigmoid(x: f64) -> f64 {
18 1.0 / (1.0 + (-x).exp())
19}
20
21/// A simulated user with fixed ground-truth taste.
22#[derive(Clone, Debug)]
23pub struct SyntheticUser {
24 /// Ground-truth weight vector.
25 pub theta: Vec<f64>,
26 /// Ground-truth keep/kill threshold.
27 pub tau: f64,
28 /// Ground-truth ordered star cutpoints.
29 pub cuts: Vec<f64>,
30}
31
32impl SyntheticUser {
33 /// True utility of a (standardized) candidate.
34 pub fn utility(&self, phi: &[f64]) -> f64 {
35 self.theta.iter().zip(phi).map(|(t, x)| t * x).sum()
36 }
37
38 /// Sample a duel outcome (true = chose A), Bradley–Terry noise.
39 pub fn duel<R: Rng>(&self, rng: &mut R, a: &[f64], b: &[f64]) -> bool {
40 rng.gen_bool(sigmoid(self.utility(a) - self.utility(b)).clamp(1e-9, 1.0 - 1e-9))
41 }
42
43 /// Sample a keep/kill decision.
44 pub fn keep<R: Rng>(&self, rng: &mut R, x: &[f64]) -> bool {
45 rng.gen_bool(sigmoid(self.utility(x) - self.tau).clamp(1e-9, 1.0 - 1e-9))
46 }
47
48 /// Sample a star rating (cumulative-logit ordinal).
49 pub fn stars<R: Rng>(&self, rng: &mut R, x: &[f64]) -> u8 {
50 let u = self.utility(x);
51 let r: f64 = rng.gen();
52 let mut cum_prev = 0.0;
53 for (k, c) in self.cuts.iter().enumerate() {
54 let cum = sigmoid(c - u);
55 if r < cum {
56 return k as u8;
57 }
58 cum_prev = cum;
59 }
60 let _ = cum_prev;
61 self.cuts.len() as u8
62 }
63
64 /// Generate a full duel observation on the given pair.
65 pub fn observe_duel<R: Rng>(
66 &self,
67 rng: &mut R,
68 a: Vec<f64>,
69 b: Vec<f64>,
70 session: usize,
71 ) -> Observation {
72 let chose_a = self.duel(rng, &a, &b);
73 Observation::new(Feedback::Duel { a, b, chose_a }, session, &[])
74 }
75}
76
77/// A simulated user whose taste has several islands: true utility is the
78/// **max** over component tastes ("I love a great drone OR a great pluck").
79/// A single linear θ provably cannot represent this — it is the ground truth
80/// for the K > 1 mixture gate.
81#[derive(Clone, Debug)]
82pub struct MixtureSyntheticUser {
83 /// Component ground-truth weight vectors.
84 pub thetas: Vec<Vec<f64>>,
85}
86
87impl MixtureSyntheticUser {
88 /// True utility: best component's score.
89 pub fn utility(&self, phi: &[f64]) -> f64 {
90 self.thetas
91 .iter()
92 .map(|t| t.iter().zip(phi).map(|(a, b)| a * b).sum::<f64>())
93 .fold(f64::NEG_INFINITY, f64::max)
94 }
95
96 /// Sample a duel outcome (true = chose A), Bradley–Terry noise on the
97 /// max-utility.
98 pub fn duel<R: Rng>(&self, rng: &mut R, a: &[f64], b: &[f64]) -> bool {
99 rng.gen_bool(sigmoid(self.utility(a) - self.utility(b)).clamp(1e-9, 1.0 - 1e-9))
100 }
101
102 /// Generate a full duel observation on the given pair.
103 pub fn observe_duel<R: Rng>(
104 &self,
105 rng: &mut R,
106 a: Vec<f64>,
107 b: Vec<f64>,
108 session: usize,
109 ) -> Observation {
110 let chose_a = self.duel(rng, &a, &b);
111 Observation::new(Feedback::Duel { a, b, chose_a }, session, &[])
112 }
113}
114
115/// A simulated user with an **ideal point**: there is a sound they are
116/// looking for, and both too little and too much of any quality is worse.
117///
118/// ```text
119/// u*(φ) = −Σ w_i (φ_i − c_i)²
120/// ```
121///
122/// This exists because every other user in this module is linear in the same
123/// φ the model is linear in, which makes the model *well specified by
124/// construction*. Under that setup "can it learn taste" collapses to
125/// "how fast does a correctly-specified linear model estimate its
126/// coefficients", and no amount of passing it says anything about a real
127/// listener. A gate that cannot fail is not a gate.
128///
129/// The misspecification here is not a matter of degree, it is structural.
130/// `u = max_k θ_k · φ` is a maximum of affine functions, and a maximum of
131/// affine functions is **convex**, always. The utility above is strictly
132/// **concave** (negative-definite quadratic). So no K, however large, brings
133/// the model closer to this user in the way extra experts help elsewhere —
134/// adding lenses can only build a better convex function. The model can still
135/// track the local gradient and rank most pairs, and that is the useful thing
136/// to measure; what it cannot do is be right everywhere at once, and a
137/// harness that never notices the difference is not measuring anything.
138#[derive(Clone, Debug)]
139pub struct IdealPointUser {
140 /// The sound being looked for, in standardized feature space.
141 pub center: Vec<f64>,
142 /// How sharply each coordinate is judged.
143 pub weights: Vec<f64>,
144}
145
146impl IdealPointUser {
147 /// True utility: how close this candidate is to the ideal, penalized per
148 /// coordinate.
149 pub fn utility(&self, phi: &[f64]) -> f64 {
150 -self
151 .weights
152 .iter()
153 .zip(&self.center)
154 .zip(phi)
155 .map(|((w, c), x)| w * (x - c) * (x - c))
156 .sum::<f64>()
157 }
158
159 /// Sample a duel outcome (true = chose A), Bradley–Terry noise.
160 pub fn duel<R: Rng>(&self, rng: &mut R, a: &[f64], b: &[f64]) -> bool {
161 rng.gen_bool(sigmoid(self.utility(a) - self.utility(b)).clamp(1e-9, 1.0 - 1e-9))
162 }
163
164 /// Generate a full duel observation on the given pair.
165 pub fn observe_duel<R: Rng>(
166 &self,
167 rng: &mut R,
168 a: Vec<f64>,
169 b: Vec<f64>,
170 session: usize,
171 ) -> Observation {
172 let chose_a = self.duel(rng, &a, &b);
173 Observation::new(Feedback::Duel { a, b, chose_a }, session, &[])
174 }
175}
176
177/// Cosine similarity between two vectors (θ-recovery metric).
178pub fn cosine(a: &[f64], b: &[f64]) -> f64 {
179 let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
180 let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
181 let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
182 dot / (na * nb + 1e-12)
183}