Skip to main content

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}