Skip to main content

auracle_taste/
model.rs

1//! The taste model as a fugue program, and its MCMC posterior.
2//!
3//! ```text
4//! θ_k  ~ Normal(0, σ_θ)      per style k, per feature      addr theta<k>#i
5//! τ_s  ~ Normal(0, 1)        per session s (keep/kill bar) addr tau#s
6//! cuts : c_1 = −2 + 1.5·raw₀;  c_j = c_{j−1} + exp(−0.5 + 0.7·raw_j)
7//!                                                          addr cut#j
8//! u(x) = max_k θ_k · φ(x)
9//! ```
10//!
11//! **Mixture semantics (K > 1):** taste is a **max of linear experts** — a
12//! candidate is as good as its best style thinks it is. This is what lets
13//! one user's taste span several islands (dark drones *and* bright plucks):
14//! each island gets its own linear lens, and every judgment — including a
15//! duel *across* islands — compares candidates on the shared scale
16//! `u(x) = max_k u_k(x)`. (A per-observation latent-lens mixture cannot do
17//! this: it forces both duel items through the same lens, so cross-island
18//! comparisons are unrepresentable. The max-utility form was adopted after a
19//! synthetic bimodal user exposed exactly that failure.) There are no
20//! discrete latent sites, and at K = 1 the model reduces exactly to the
21//! plain linear taste.
22//!
23//! One `factor` carries the total log-likelihood: Bradley–Terry for duels,
24//! `σ(u − τ_s)` for keep/kill, cumulative-logit ordinal for stars. Inference
25//! is fugue's adaptive single-site MH — every site is `F64`, so the generic
26//! chain applies unchanged.
27//!
28//! Default `σ_θ = 1/(√d · s_K)`, making the prior utility of a standardized
29//! candidate roughly unit-variance — likelihood scales stay sane at any
30//! feature count *and* at any K. The `s_K` factor is the correction the
31//! max-of-experts form forces on us: with ‖φ‖² ≈ d each `u_k` is marginally
32//! N(0,1) under the prior, so `u = max_k u_k` is the max of K iid standard
33//! normals, whose SD *falls* with K (1.000, 0.826, 0.748, 0.701, 0.669). The
34//! mean shift cancels in duels and is absorbed by `τ`/`cuts` elsewhere; the
35//! variance shrinkage does not. Left uncorrected, `Var(u_a − u_b)` drops from
36//! 2.0 at K=1 to 0.90 at K=5, so growing K mid-session would quietly make the
37//! model *less* able to express a strong preference — the opposite of what
38//! adding capacity is supposed to do.
39//!
40//! Mixture posteriors are permutation-symmetric in the style labels (label
41//! switching); call [`TastePosterior::aligned`] before per-style summaries.
42//!
43//! Posterior draws carry **importance weights**. A full MCMC fit costs
44//! seconds, which is far too slow to run after every vote, so between fits the
45//! session layer folds each new observation in by sequential importance
46//! sampling ([`TastePosterior::reweighted`]): `w_s ← w_s · p(y | θ_s)`. That
47//! is exact — the weighted draws target the updated posterior — and it costs
48//! O(S). It degrades gracefully rather than silently: effective sample size
49//! ([`TastePosterior::ess`]) falls as the weights concentrate, and that is the
50//! signal to pay for a real refit.
51
52use fugue::runtime::handler::run;
53use fugue::runtime::interpreters::PriorHandler;
54use fugue::{
55    adaptive_mcmc_chain_thinned, addr, factor, sample, Address, Model, ModelExt, Normal, Trace,
56};
57use rand::Rng;
58use serde::{Deserialize, Serialize};
59use std::sync::Arc;
60
61use crate::observe::{Feedback, FitSet};
62
63/// SD of the maximum of K iid standard normals, K = 1..=5. See the module doc.
64pub const MAX_NORMAL_SD: [f64; 5] = [1.000, 0.826, 0.748, 0.701, 0.669];
65
66/// Posterior draws retained from a fit, after thinning.
67///
68/// The chain is thinned because single-site draws are heavily autocorrelated —
69/// 500 spread over the whole chain carry far more information than 500
70/// consecutive ones — and because every retained draw is a `TasteSample` the
71/// posterior holds for the rest of the session.
72pub const KEEP: usize = 500;
73
74/// Model configuration.
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76pub struct TasteConfig {
77    /// Feature dimension (after standardization).
78    pub n_features: usize,
79    /// Number of style components (mixture of linear experts).
80    pub k_styles: usize,
81    /// Number of star categories (ratings `0..n_stars`).
82    pub n_stars: usize,
83    /// Prior std of each θ coordinate. `None` → `1/√n_features`.
84    pub theta_prior_std: Option<f64>,
85    /// Recency half-life in observations: an observation `h` places back in
86    /// the log weighs `0.5^(h / half_life)` in the likelihood, so old taste
87    /// fades as new evidence arrives. `None` → no forgetting.
88    #[serde(default)]
89    pub recency_half_life: Option<f64>,
90    /// Groups of φ coordinates that measure **one perceptual thing**, and so
91    /// share a latent per-style mean instead of being independent draws.
92    ///
93    /// Empty by default, which is the flat prior this model has always had.
94    /// The caller supplies indices because only it knows the feature *names*;
95    /// see `SessionConfig` for the brightness group it fills in.
96    ///
97    /// ## Why a fused prior rather than dropping a column
98    ///
99    /// `rolloff_mean`, `zcr_mean` and `centroid_mean` are three genuine
100    /// measurements of brightness, and over 1200 prior draws they carry VIFs
101    /// of ~16.9 / ~9.7 / ~5.9 — `rolloff_mean` is the worst-conditioned
102    /// coordinate in φ. Dropping any of them discards real signal: they
103    /// disagree about *which* brightness (spectral tilt, high-frequency
104    /// energy, and waveform sign changes are not the same statistic), and a
105    /// listener who prefers one shading over another is expressing something
106    /// the survivors cannot represent alone.
107    ///
108    /// A shared mean says what is actually true: these coordinates are
109    /// correlated *a priori*, so evidence about one is partial evidence about
110    /// the others. The model can still separate them when the data insist —
111    /// [`Self::sigma_within`] is what buys that freedom — but with few duels
112    /// it pools them instead of splitting an ill-conditioned ridge three ways
113    /// at random, which is the failure mode a high VIF names.
114    #[serde(default)]
115    pub fused: Vec<Vec<usize>>,
116    /// How strongly a fused group's coordinates are correlated *a priori*,
117    /// in `[0, 1)`. **`None` → 0.0, i.e. off** — see [`Self::fused_rho`] for
118    /// the measurement that switched it off.
119    ///
120    /// Parameterized as a correlation rather than as an inner SD so that the
121    /// **marginal** prior on each coordinate is unchanged: with
122    /// `σ_μ = σ_θ√ρ` and `σ_within = σ_θ√(1−ρ)`, every θ still has prior
123    /// variance `σ_θ²` and only the *covariance* between group members moves.
124    /// At `ρ = 0` the program is exactly the flat one.
125    ///
126    /// That distinction is not cosmetic. An earlier version of this used
127    /// `σ_within = σ_θ/2` with `σ_μ = σ_θ`, which quietly inflated the
128    /// marginal variance to `1.25 σ_θ²` — so it changed the prior's *scale*
129    /// as well as its correlation, and any measurement of "does fusing help"
130    /// was really measuring two changes at once.
131    #[serde(default)]
132    pub fused_rho: Option<f64>,
133}
134
135impl TasteConfig {
136    /// A K=1 config for the given feature dimension.
137    pub fn linear(n_features: usize) -> Self {
138        Self {
139            n_features,
140            k_styles: 1,
141            n_stars: 6,
142            theta_prior_std: None,
143            recency_half_life: None,
144            fused: Vec::new(),
145            fused_rho: None,
146        }
147    }
148
149    /// A K-style mixture config for the given feature dimension.
150    pub fn mixture(n_features: usize, k_styles: usize) -> Self {
151        Self {
152            k_styles: k_styles.max(1),
153            ..Self::linear(n_features)
154        }
155    }
156
157    /// Prior correlation within a fused group, clamped to `[0, 0.99]`.
158    ///
159    /// ## Why this ships at zero
160    ///
161    /// The machinery is implemented, correct and **switched off**, the way
162    /// [`RefineKeep::Best`](../../auracle_session/enum.RefineKeep.html) and
163    /// `Acquisition::Thompson` are kept after losing. Two gates were run and
164    /// they **disagreed**, which is the finding.
165    ///
166    /// Swept against the always-on closed-loop gate — five seeds, a real
167    /// posterior fit against a synthetic listener — fusing *helps*:
168    ///
169    /// ```text
170    /// rho    mean posterior/truth r
171    /// 0.00   0.657   (the flat prior, reproduced exactly)
172    /// 0.25   0.702   <- best
173    /// 0.50   0.644
174    /// 0.75   fails the per-seed floor (seed 0x2 at 0.437)
175    /// ```
176    ///
177    /// Measured on the **climb** at rho = 0.25 — 48 paired seeds, the gate that
178    /// asks what the pool is actually worth to the listener — it *hurts*, and
179    /// not marginally:
180    ///
181    /// ```text
182    /// paired (fused − flat)   10% trimmed  −0.579 ± 0.188   (−3.09 se)
183    ///                         median       −0.726
184    ///                         sign         16 better / 32 worse, p = 0.029
185    ///                         climbed      41/48 → 38/48
186    /// ```
187    ///
188    /// **Both are true, and the reason is that they measure different things.**
189    /// The closed-loop gate scores θ *recovery*, where pooling an
190    /// ill-conditioned ridge is a real regularizer. The climb scores the true
191    /// utility of the pool the search delivers, and there the pooling is a
192    /// bias: this listener's taste puts 2.0 on `centroid_mean` and exactly 0
193    /// on the other two, so shrinking them together drags the one coefficient
194    /// that matters toward two that do not, and the search aims worse.
195    ///
196    /// That is the general warning, and it is worth more than the feature. A
197    /// VIF says the three brightness coordinates move together **across
198    /// patches** — a fact about φ. Fusing their coefficients asserts that a
199    /// listener's **preferences** about them move together — a fact about
200    /// people, which does not follow from the first and was not measured.
201    ///
202    /// Re-open this if the listener model ever gains a reason to believe
203    /// preferences follow φ's correlation structure; the sweep and both gates
204    /// are here to re-run.
205    pub fn fused_rho(&self) -> f64 {
206        self.fused_rho.unwrap_or(0.0).clamp(0.0, 0.99)
207    }
208
209    /// SD of a fused group's latent mean: `σ_θ√ρ`.
210    pub fn sigma_group(&self) -> f64 {
211        self.sigma_theta() * self.fused_rho().sqrt()
212    }
213
214    /// SD of a fused coordinate about its group mean: `σ_θ√(1−ρ)`. Together
215    /// with [`Self::sigma_group`] this keeps the marginal at `σ_θ`.
216    pub fn sigma_within(&self) -> f64 {
217        self.sigma_theta() * (1.0 - self.fused_rho()).sqrt()
218    }
219
220    /// The groups actually in force. Empty when `ρ = 0`, so **ρ = 0 is the
221    /// flat prior node for node** — no latent means, no extra sites, the same
222    /// program this model has always run. A guard rather than an accident: a
223    /// zero-SD `Normal` is not a distribution, and "turn the feature off"
224    /// should not depend on remembering to also clear `fused`.
225    pub fn effective_fused(&self) -> &[Vec<usize>] {
226        if self.fused_rho() <= 0.0 {
227            &[]
228        } else {
229            &self.fused
230        }
231    }
232
233    /// Which fused group each coordinate belongs to, or `None` for the
234    /// coordinates that keep the flat prior. Built once per fit.
235    fn group_of(&self) -> Vec<Option<usize>> {
236        let mut out = vec![None; self.n_features];
237        for (g, members) in self.effective_fused().iter().enumerate() {
238            for &i in members {
239                if i < self.n_features {
240                    out[i] = Some(g);
241                }
242            }
243        }
244        out
245    }
246
247    /// Prior SD of one θ coordinate, corrected for the max-of-K utility so
248    /// that `Var(u_a − u_b)` is invariant to K (module doc).
249    pub fn sigma_theta(&self) -> f64 {
250        let k = self.k_styles.clamp(1, MAX_NORMAL_SD.len());
251        let s_k = MAX_NORMAL_SD[k - 1];
252        self.theta_prior_std
253            .unwrap_or(1.0 / ((self.n_features as f64).sqrt() * s_k))
254    }
255}
256
257/// One posterior draw of every latent.
258#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
259pub struct TasteSample {
260    /// Per-style weight vectors `[k][d]`.
261    pub theta: Vec<Vec<f64>>,
262    /// Per-session keep/kill thresholds.
263    pub tau: Vec<f64>,
264    /// Ordered star cutpoints (`n_stars − 1` of them).
265    pub cuts: Vec<f64>,
266}
267
268impl TasteSample {
269    /// Utility of a standardized candidate under one style lens.
270    pub fn utility(&self, phi: &[f64], style: usize) -> f64 {
271        dot(&self.theta[style], phi)
272    }
273
274    /// Mixture utility `u(φ) = max_k u_k(φ)`: a candidate is as good as its
275    /// best style thinks it is. Reduces to `u_0` at K = 1. This is the one
276    /// utility every likelihood and ranking uses.
277    pub fn utility_mix(&self, phi: &[f64]) -> f64 {
278        self.theta
279            .iter()
280            .map(|t| dot(t, phi))
281            .fold(f64::NEG_INFINITY, f64::max)
282    }
283
284    /// Probability this sample assigns to "a beats b".
285    pub fn prob_prefers(&self, a: &[f64], b: &[f64]) -> f64 {
286        sigmoid(self.utility_mix(a) - self.utility_mix(b))
287    }
288
289    /// Which style lens is this candidate's best (its island).
290    pub fn best_style(&self, phi: &[f64]) -> usize {
291        (0..self.theta.len())
292            .max_by(|&i, &j| dot(&self.theta[i], phi).total_cmp(&dot(&self.theta[j], phi)))
293            .unwrap_or(0)
294    }
295
296    /// Log-likelihood this draw assigns to one standardized observation.
297    pub fn loglik(&self, feedback: &Feedback, session: usize) -> f64 {
298        obs_loglik(feedback, session, self)
299    }
300
301    /// [`Self::loglik`], told which coordinates of the observation were
302    /// **imputed** rather than measured — see [`FitSet::absent`].
303    pub fn loglik_with(&self, feedback: &Feedback, session: usize, absent: &[usize]) -> f64 {
304        obs_loglik_with(feedback, session, self, absent)
305    }
306}
307
308fn dot(a: &[f64], b: &[f64]) -> f64 {
309    a.iter().zip(b).map(|(x, y)| x * y).sum()
310}
311
312/// Numerically stable `log σ(x)`.
313fn log_sigmoid(x: f64) -> f64 {
314    // -softplus(-x) with softplus(t) = max(t,0) + ln(1 + e^{-|t|}).
315    -((-x).max(0.0) + (-(-x).abs()).exp().ln_1p())
316}
317
318fn sigmoid(x: f64) -> f64 {
319    1.0 / (1.0 + (-x).exp())
320}
321
322/// Log-likelihood of one standardized observation under the max-of-experts
323/// utility.
324fn obs_loglik(o: &Feedback, session: usize, s: &TasteSample) -> f64 {
325    obs_loglik_with(o, session, s, &[])
326}
327
328/// `1/√(1 + λ² σ²)` — the logistic analogue of marginalizing a Gaussian
329/// through a probit link, with `λ² = π/8`.
330///
331/// A comparison whose utility is uncertain by `σ` should not be scored as if
332/// it were known: `E[σ(u + ε)] ≈ σ(u / √(1 + πσ²/8))` for `ε ~ N(0, σ²)`. The
333/// effect is to pull the log-odds toward zero — the model still learns from
334/// the observation, it just stops claiming to be certain about a comparison
335/// that is partly guesswork.
336fn attenuate(var: f64) -> f64 {
337    1.0 / (1.0 + std::f64::consts::PI * var / 8.0).sqrt()
338}
339
340/// Variance the **imputed** coordinates contribute to `u(x)` under this draw.
341///
342/// A coordinate imputed at the standardized mean is not known to be zero; it
343/// is unknown, and its prior is the unit normal the standardizer defines. So
344/// its contribution `θ_i · x_i` has variance `θ_i²`, read off the expert that
345/// actually scores this candidate.
346fn imputed_var(s: &TasteSample, x: &[f64], absent: &[usize]) -> f64 {
347    if absent.is_empty() {
348        return 0.0;
349    }
350    let k = s.best_style(x);
351    absent
352        .iter()
353        .filter_map(|&i| s.theta[k].get(i))
354        .map(|t| t * t)
355        .sum()
356}
357
358fn obs_loglik_with(o: &Feedback, session: usize, s: &TasteSample, absent: &[usize]) -> f64 {
359    match o {
360        Feedback::Duel { a, b, chose_a } => {
361            // Nothing to correct: both candidates carry the same absence, so
362            // the imputed terms cancel in the difference and the observation
363            // is silent about those axes rather than wrong about them.
364            let d = s.utility_mix(a) - s.utility_mix(b);
365            log_sigmoid(if *chose_a { d } else { -d })
366        }
367        Feedback::KeepKill { x, kept } => {
368            // A session with no τ site (reweighting against a posterior fit
369            // before that session existed) contributes no threshold evidence.
370            let Some(tau) = s.tau.get(session) else {
371                return 0.0;
372            };
373            // Here there is no second candidate to cancel against, so the
374            // imputed coordinates enter the comparison as if they were
375            // measured at the mean. They were not measured at all.
376            let d = (s.utility_mix(x) - tau) * attenuate(imputed_var(s, x, absent));
377            log_sigmoid(if *kept { d } else { -d })
378        }
379        Feedback::Stars { x, rating } => {
380            // Same correction as keep/kill, and for the same reason: an
381            // ordinal rating is a comparison of `u` against fixed cutpoints
382            // with nothing to cancel the imputation against.
383            let u = s.utility_mix(x) * attenuate(imputed_var(s, x, absent));
384            let k = *rating as usize;
385            let n_cats = s.cuts.len() + 1;
386            let k = k.min(n_cats - 1);
387            // Cumulative logit: P(y=k) = σ(c_{k+1}−u) − σ(c_k−u),
388            // with c_0 = −∞ and c_{n} = +∞.
389            let upper = if k == n_cats - 1 {
390                1.0
391            } else {
392                sigmoid(s.cuts[k] - u)
393            };
394            let lower = if k == 0 {
395                0.0
396            } else {
397                sigmoid(s.cuts[k - 1] - u)
398            };
399            (upper - lower).max(1e-12).ln()
400        }
401    }
402}
403
404/// The MCMC site addresses of one taste program, built once.
405///
406/// Single-site MH re-executes the whole program on **every step**, so every
407/// `sample()` node — `d·K + S + (n_stars − 1) + K·G` of them, **206** as
408/// shipped (K = 5, d = 40, one session, and no fused group, since the fused
409/// prior defaults to off; a group would add K) — is reconstructed
410/// 26 000 times per fit. Building each address inline
411/// (`addr!(format!("theta{k}"), i)`) therefore cost a `format!` into a
412/// `String`, a re-allocation into `Arc<str>` and a SipHash of that string,
413/// *per site per step*: ~3.7 M allocations per mature fit, and measurably the
414/// bulk of the fit's wall time (see `examples/fit_bench.rs` — the fit is
415/// `steps × sites`-shaped, and the likelihood is ~20 % of it even at
416/// n_obs = 100).
417///
418/// The addresses are a pure function of `(k_styles, n_features, n_stars,
419/// n_sessions)`, none of which move during a fit, so they are built once and
420/// [`Address`] is cloned into each node — an `Arc` refcount bump plus a copy
421/// of the cached hash, no allocation and no hashing.
422///
423/// The strings are produced by the *same* `addr!` invocations as before
424/// (`theta<k>#i`, `tau#s`, `cut#j`), so traces, serialized posteriors and any
425/// warm-start path see byte-identical addresses.
426#[derive(Clone, Debug)]
427pub struct SiteAddrs {
428    /// θ sites, flattened `k * n_features + i`.
429    theta: Vec<Address>,
430    /// τ sites, one per session.
431    tau: Vec<Address>,
432    /// Cutpoint raw sites, `n_stars − 1` of them.
433    cut: Vec<Address>,
434    /// Latent group means, flattened `k * n_groups + g` — one per fused
435    /// group per style. Empty when nothing is fused, which is what keeps the
436    /// site count and the addresses byte-identical for a flat config.
437    mu: Vec<Address>,
438}
439
440impl SiteAddrs {
441    /// Total `sample()` nodes in the program — what single-site MH divides its
442    /// step budget across, and the number the fit's cost is linear in.
443    ///
444    /// `d·K + S + (n_stars − 1) + K·G`, where `G` is the number of fused
445    /// groups. At K = 5, d = 40, S = 1 and one brightness group that is
446    /// 200 + 1 + 5 + 5 = **211**; without the group it is the 206 the module
447    /// doc quotes.
448    pub fn site_count(&self) -> usize {
449        self.theta.len() + self.tau.len() + self.cut.len() + self.mu.len()
450    }
451
452    /// Build the address table for `cfg` over a log spanning `n_sessions`.
453    pub fn new(cfg: &TasteConfig, n_sessions: usize) -> Self {
454        Self {
455            theta: (0..cfg.k_styles)
456                .flat_map(|k| (0..cfg.n_features).map(move |i| addr!(format!("theta{k}"), i)))
457                .collect(),
458            tau: (0..n_sessions).map(|s| addr!("tau", s)).collect(),
459            cut: (0..cfg.n_stars.saturating_sub(1))
460                .map(|j| addr!("cut", j))
461                .collect(),
462            mu: (0..cfg.k_styles)
463                .flat_map(|k| {
464                    (0..cfg.effective_fused().len()).map(move |g| addr!(format!("mu{k}"), g))
465                })
466                .collect(),
467        }
468    }
469}
470
471/// The taste model: prior over latents + observation-log likelihood.
472#[derive(Clone, Debug)]
473pub struct TasteModel {
474    /// Configuration.
475    pub cfg: TasteConfig,
476}
477
478impl TasteModel {
479    /// Build with the given config.
480    pub fn new(cfg: TasteConfig) -> Self {
481        Self { cfg }
482    }
483
484    /// The fugue program. Returns the decoded [`TasteSample`]; the
485    /// observation likelihood enters as a single `factor`.
486    ///
487    /// Builds a fresh [`SiteAddrs`] each call, so it is the right entry point
488    /// for one-shot uses ([`Self::prior_sample`]). Inference paths that
489    /// rebuild the program per step must hoist the table out of the loop and
490    /// call [`Self::model_at`] — that is what [`Self::fit`] does.
491    pub fn model(&self, data: &FitSet) -> Model<TasteSample> {
492        let addrs = Arc::new(SiteAddrs::new(&self.cfg, data.n_sessions().max(1)));
493        self.model_at(data, &addrs)
494    }
495
496    /// The fugue program over a precomputed address table.
497    ///
498    /// `addrs` must have been built by [`SiteAddrs::new`] from this model's
499    /// config and this `data`'s session count; it is cheap to clone and is
500    /// intended to be built once per fit and shared across every MH step.
501    ///
502    /// The observation list and the address table both ride in
503    /// [`Arc`]: the model is reconstructed every MH step, and
504    /// this keeps that reconstruction O(1) in the log size and
505    /// allocation-free in the address count.
506    pub fn model_at(&self, data: &FitSet, addrs: &Arc<SiteAddrs>) -> Model<TasteSample> {
507        let cfg = self.cfg.clone();
508        let obs = Arc::new(data.rows.clone());
509        let absent = Arc::new(data.absent.clone());
510        // Per-observation likelihood weights: newest = 1, halving every
511        // `recency_half_life` observations back.
512        let n_obs = data.rows.len();
513        let weights = Arc::new(match cfg.recency_half_life {
514            Some(hl) if hl > 0.0 => (0..n_obs)
515                .map(|i| 0.5f64.powf((n_obs - 1 - i) as f64 / hl))
516                .collect(),
517            _ => vec![1.0; n_obs],
518        });
519        let sigma = cfg.sigma_theta();
520        let sigma_within = cfg.sigma_within();
521        let sigma_group = cfg.sigma_group();
522        let group_of = Arc::new(cfg.group_of());
523        let n_groups = cfg.effective_fused().len();
524
525        // μ: one latent mean per fused group per style, sampled *before* θ so
526        // the members of a group can be drawn around it. With nothing fused
527        // this list is empty and the program below is the flat one, node for
528        // node.
529        let mu_models: Vec<Model<f64>> = addrs
530            .mu
531            .iter()
532            .map(|a| {
533                sample(
534                    a.clone(),
535                    Normal::new(0.0, sigma_group).expect("valid group mean prior"),
536                )
537            })
538            .collect();
539
540        let (d, k_styles) = (cfg.n_features, cfg.k_styles);
541        let addrs_outer = addrs.clone();
542        fugue::sequence_vec(mu_models).bind(move |mu| {
543            let addrs = addrs_outer.clone();
544            let group_of = group_of.clone();
545            // θ: k_styles × n_features Normal sites. A coordinate in a fused
546            // group is drawn about that group's latent mean rather than about
547            // zero — which is the whole of the change, and why the group mean had
548            // to be sampled first.
549            let theta_models: Vec<Model<f64>> = addrs
550                .theta
551                .iter()
552                .enumerate()
553                .map(|(idx, a)| {
554                    let (k, i) = (idx / d, idx % d);
555                    let (mean, sd) = match group_of[i] {
556                        Some(g) => (mu[k * n_groups + g], sigma_within),
557                        None => (0.0, sigma),
558                    };
559                    sample(a.clone(), Normal::new(mean, sd).expect("valid theta prior"))
560                })
561                .collect();
562
563            let addrs = addrs.clone();
564            fugue::sequence_vec(theta_models).bind(move |theta_flat| {
565                // τ: one Normal site per session.
566                let tau_models: Vec<Model<f64>> = addrs
567                    .tau
568                    .iter()
569                    .map(|a| sample(a.clone(), Normal::new(0.0, 1.0).expect("valid tau prior")))
570                    .collect();
571                let obs = obs.clone();
572                let weights = weights.clone();
573                fugue::sequence_vec(tau_models).bind(move |tau| {
574                    // Cutpoint raws: n_stars − 1 Normal sites (ordered by
575                    // transform).
576                    let cut_models: Vec<Model<f64>> = addrs
577                        .cut
578                        .iter()
579                        .map(|a| sample(a.clone(), Normal::new(0.0, 1.0).expect("valid cut prior")))
580                        .collect();
581                    let obs = obs.clone();
582                    let weights = weights.clone();
583                    fugue::sequence_vec(cut_models).bind(move |cut_raw| {
584                        let theta: Vec<Vec<f64>> = (0..k_styles)
585                            .map(|ki| theta_flat[ki * d..(ki + 1) * d].to_vec())
586                            .collect();
587                        // Ordered cutpoints from raw sites.
588                        let mut cuts = Vec::with_capacity(cut_raw.len());
589                        let mut c = f64::NAN;
590                        for (j, r) in cut_raw.iter().enumerate() {
591                            c = if j == 0 {
592                                -2.0 + 1.5 * r
593                            } else {
594                                c + (-0.5 + 0.7 * r).exp()
595                            };
596                            cuts.push(c);
597                        }
598                        let s = TasteSample { theta, tau, cuts };
599                        let ll: f64 = obs
600                            .iter()
601                            .zip(weights.iter())
602                            .enumerate()
603                            .map(|(i, ((o, session), w))| {
604                                let absent = absent.get(i).map(Vec::as_slice).unwrap_or(&[]);
605                                w * obs_loglik_with(o, *session, &s, absent)
606                            })
607                            .sum();
608                        factor(ll).map(move |_| s)
609                    })
610                })
611            })
612        })
613    }
614
615    /// Fit the posterior by adaptive single-site MH.
616    ///
617    /// `n_samples` post-warmup draws are kept (thinned to at most 500 for
618    /// summary storage). Each MH step moves one site, so budget steps ≈
619    /// `sites × desired effective sweeps`.
620    ///
621    /// # The chain is thinned at the driver, not after it
622    ///
623    /// 97 % of the chain is discarded, and it is discarded *as it is produced*.
624    /// That used to happen one line after the whole chain was built:
625    /// `adaptive_mcmc_chain` materialized every step — a `(TasteSample, Trace)`
626    /// per iteration pushed into a `Vec` returned by value — and only then did
627    /// `step_by(stride)` keep every 20th. At K = 5 that is ~10 000 `Trace`
628    /// clones of 206 `BTreeMap` entries held live at once to keep 500, scaling
629    /// with `n_samples`: a plausible mobile-Safari OOM rather than mere waste
630    /// on a 32-bit heap.
631    ///
632    /// It could not be fixed here — the retention was inside fugue's chain
633    /// driver, and the pieces needed to reimplement that driver with identical
634    /// RNG consumption (`single_site_mh_step`, `propose_and_score`,
635    /// `SingleSiteProposalHandler`) are private or `pub(crate)`. So it was
636    /// fixed *there*: `adaptive_mcmc_chain_thinned` (fugue-ppl 0.2.2) takes a
637    /// stride and pushes only on `i % thin == 0`.
638    ///
639    /// **The draws are bit-identical to what the old code returned.** `thin`
640    /// gates the push and nothing else: every transition still runs, so the RNG
641    /// is consumed in the same order and quantity, and `0, stride, 2·stride, …`
642    /// is exactly what `step_by(stride)` kept. `fit_bench`'s per-fit checksum
643    /// is the auracle-side witness; fugue's own
644    /// `thinning_retains_exactly_the_draws_step_by_would` is the upstream one.
645    ///
646    /// Measured, `fit_bench 10000 3000` under `/usr/bin/time -l`:
647    ///
648    /// | | peak RSS | mature-fit checksum |
649    /// |---|---|---|
650    /// | before | 303.1 MB | `07d204764b58c88b` |
651    /// | after | **18.2 MB** | `07d204764b58c88b` |
652    ///
653    /// **16.7× less peak memory for the same draws** — the checksum is the
654    /// point of that table, not a footnote to it. What stays resident is the
655    /// 500 draws the posterior actually keeps, so the peak no longer scales
656    /// with `mcmc_samples` at all: the budget is free to be chosen on the
657    /// recovery tables (`SessionConfig::mcmc_samples`) rather than against a
658    /// memory ceiling.
659    pub fn fit<R: Rng>(
660        &self,
661        rng: &mut R,
662        data: &FitSet,
663        n_samples: usize,
664        n_warmup: usize,
665    ) -> TastePosterior {
666        // Hoisted out of the step loop: the address table is identical for
667        // every one of the `n_samples + n_warmup` reconstructions.
668        let addrs = Arc::new(SiteAddrs::new(&self.cfg, data.n_sessions().max(1)));
669        let model_fn = || self.model_at(data, &addrs);
670        // The stride is known before the chain runs, because the driver pushes
671        // exactly `n_samples` draws — so asking it to retain only every
672        // `stride`-th is the same subsequence `step_by` produced, without ever
673        // holding the other 95% live. See `KEEP`.
674        let stride = (n_samples / KEEP).max(1);
675        let samples: Vec<TasteSample> =
676            adaptive_mcmc_chain_thinned(rng, model_fn, n_samples, n_warmup, stride)
677                .into_iter()
678                .map(|(s, _): (TasteSample, Trace)| s)
679                .collect();
680        TastePosterior {
681            cfg: self.cfg.clone(),
682            weights: vec![1.0 / samples.len().max(1) as f64; samples.len()],
683            samples,
684        }
685    }
686
687    /// Draw one prior sample (useful for prior-predictive checks).
688    pub fn prior_sample<R: Rng>(&self, rng: &mut R, data: &FitSet) -> TasteSample {
689        let (s, _) = run(
690            PriorHandler {
691                rng,
692                trace: Trace::default(),
693            },
694            self.model(data),
695        );
696        s
697    }
698}
699
700/// A fitted posterior: thinned MCMC draws, their importance weights, and
701/// summaries. Weights are uniform straight out of a fit and concentrate as
702/// [`TastePosterior::reweighted`] folds in observations between fits.
703#[derive(Clone, Debug, Serialize, Deserialize)]
704pub struct TastePosterior {
705    /// The config this posterior was fit under.
706    pub cfg: TasteConfig,
707    /// Thinned posterior draws.
708    pub samples: Vec<TasteSample>,
709    /// Normalized importance weights, parallel to `samples`. Empty means
710    /// uniform (and is what older persisted posteriors deserialize to).
711    #[serde(default)]
712    pub weights: Vec<f64>,
713}
714
715/// All permutations of `0..k` (k! of them; k is small).
716fn permutations(k: usize) -> Vec<Vec<usize>> {
717    if k <= 1 {
718        return vec![(0..k).collect()];
719    }
720    let mut out = Vec::new();
721    for p in permutations(k - 1) {
722        for slot in 0..k {
723            let mut q = p.clone();
724            q.insert(slot, k - 1);
725            out.push(q);
726        }
727    }
728    out
729}
730
731fn cosine(a: &[f64], b: &[f64]) -> f64 {
732    let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
733    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
734    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
735    dot / (na * nb + 1e-12)
736}
737
738impl TastePosterior {
739    /// Number of style components.
740    pub fn k_styles(&self) -> usize {
741        self.cfg.k_styles.max(1)
742    }
743
744    /// Importance weight of draw `i` (uniform when no weights are stored).
745    pub fn weight(&self, i: usize) -> f64 {
746        match self.weights.get(i) {
747            Some(w) => *w,
748            None => 1.0 / self.samples.len().max(1) as f64,
749        }
750    }
751
752    /// Effective sample size of the weighted draws, `1 / Σ wₛ²`. Equals the
753    /// draw count for uniform weights and collapses toward 1 as the weights
754    /// concentrate — the trigger for paying for a full MCMC refit.
755    pub fn ess(&self) -> f64 {
756        let n = self.samples.len();
757        if n == 0 {
758            return 0.0;
759        }
760        let sq: f64 = (0..n).map(|i| self.weight(i) * self.weight(i)).sum();
761        if sq <= 0.0 {
762            0.0
763        } else {
764            1.0 / sq
765        }
766    }
767
768    /// Systematic resampling: draw the weighted set back to a uniformly
769    /// weighted one of the same size, deterministically.
770    ///
771    /// Importance weights degenerate — after enough updates almost all the
772    /// mass sits on one draw, and a "posterior" of one point tells the
773    /// acquisition function that it is certain when it is merely exhausted.
774    /// Resampling trades that for duplicate draws, which is the honest cost:
775    /// the sample is impoverished but still spans the posterior's support, and
776    /// [`Self::ess`] on the fresh uniform weights no longer *claims* more
777    /// information than is there. It is a stopgap between full refits, not a
778    /// substitute for one; `Engine::needs_refit` is still the thing to watch.
779    ///
780    /// Deterministic (systematic, offset ½N) rather than multinomial, because
781    /// every other stochastic step in this engine is seeded and reproducible
782    /// and this one has no reason not to be.
783    pub fn resampled(&self) -> TastePosterior {
784        let n = self.samples.len();
785        if n == 0 {
786            return self.clone();
787        }
788        let step = 1.0 / n as f64;
789        let mut u = 0.5 * step;
790        let mut cum = 0.0;
791        let mut src = 0usize;
792        let mut out = Vec::with_capacity(n);
793        for _ in 0..n {
794            while src + 1 < n && cum + self.weight(src) < u {
795                cum += self.weight(src);
796                src += 1;
797            }
798            out.push(self.samples[src].clone());
799            u += step;
800        }
801        TastePosterior {
802            cfg: self.cfg.clone(),
803            samples: out,
804            weights: vec![step; n],
805        }
806    }
807
808    /// Fold one new standardized observation into the weights by sequential
809    /// importance sampling: `w_s ← w_s · p(y | θ_s)`, renormalized.
810    ///
811    /// This is what makes each duel respond to the one before it. A full
812    /// refit costs seconds of MCMC and cannot run per-vote; without this the
813    /// acquisition function reads a frozen posterior and re-asks the same
814    /// question until the next refit.
815    pub fn reweighted(&self, feedback: &Feedback, session: usize) -> TastePosterior {
816        let n = self.samples.len();
817        if n == 0 {
818            return self.clone();
819        }
820        let ll: Vec<f64> = self
821            .samples
822            .iter()
823            .map(|s| obs_loglik(feedback, session, s))
824            .collect();
825        // Shift by the max before exponentiating: log-likelihoods here are
826        // bounded above by 0, but the same guard keeps mixed modalities safe.
827        let m = ll.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
828        let mut w: Vec<f64> = (0..n).map(|i| self.weight(i) * (ll[i] - m).exp()).collect();
829        let sum: f64 = w.iter().sum();
830        if sum > 0.0 && sum.is_finite() {
831            for wi in &mut w {
832                *wi /= sum;
833            }
834        } else {
835            w = vec![1.0 / n as f64; n];
836        }
837        TastePosterior {
838            cfg: self.cfg.clone(),
839            samples: self.samples.clone(),
840            weights: w,
841        }
842    }
843
844    /// Resolve label switching: relabel each sample's styles to best match a
845    /// reference (the last sample, then one refinement pass against the
846    /// aligned mean), by total θ cosine similarity. Per-style summaries
847    /// ([`Self::theta_mean`] etc.) are only meaningful on an aligned
848    /// posterior. No-op at K = 1. K is assumed small (≤ 5): alignment is
849    /// exhaustive over permutations.
850    pub fn aligned(&self) -> TastePosterior {
851        let k = self.k_styles();
852        if k == 1 || self.samples.is_empty() {
853            return self.clone();
854        }
855        let perms = permutations(k);
856        let relabel = |s: &TasteSample, reference: &[Vec<f64>]| -> TasteSample {
857            let best = perms
858                .iter()
859                .max_by(|p, q| {
860                    let score = |perm: &[usize]| -> f64 {
861                        (0..k)
862                            .map(|i| cosine(&s.theta[perm[i]], &reference[i]))
863                            .sum()
864                    };
865                    score(p).total_cmp(&score(q))
866                })
867                .expect("nonempty perms");
868            TasteSample {
869                theta: best.iter().map(|&i| s.theta[i].clone()).collect(),
870                tau: s.tau.clone(),
871                cuts: s.cuts.clone(),
872            }
873        };
874        // Pass 1: align to the last sample.
875        let reference = self.samples.last().expect("nonempty").theta.clone();
876        let pass1: Vec<TasteSample> = self
877            .samples
878            .iter()
879            .map(|s| relabel(s, &reference))
880            .collect();
881        // Pass 2: align to the pass-1 mean.
882        //
883        // **Importance-weighted**, like every other summary on this type. The
884        // draws stop being equally probable as soon as `reweighted` has folded
885        // votes in between fits — that is what the weights are for — so an
886        // unweighted reference mean aligns the labels against a posterior
887        // nobody holds. It leans on draws the evidence has already discounted,
888        // and leans hardest exactly when the weights have concentrated, which
889        // is when the per-style summaries are most worth reading.
890        let d = self.cfg.n_features;
891        let mut mean = vec![vec![0.0; d]; k];
892        for (i, s) in pass1.iter().enumerate() {
893            let w = self.weight(i);
894            for (mk, tk) in mean.iter_mut().zip(&s.theta) {
895                for (m, t) in mk.iter_mut().zip(tk) {
896                    *m += w * t;
897                }
898            }
899        }
900        TastePosterior {
901            cfg: self.cfg.clone(),
902            samples: pass1.iter().map(|s| relabel(s, &mean)).collect(),
903            weights: self.weights.clone(),
904        }
905    }
906
907    /// Posterior mean of θ for a style (align first at K > 1).
908    pub fn theta_mean(&self, style: usize) -> Vec<f64> {
909        let d = self.cfg.n_features;
910        let mut m = vec![0.0; d];
911        for (i, s) in self.samples.iter().enumerate() {
912            let w = self.weight(i);
913            for (mi, ti) in m.iter_mut().zip(&s.theta[style]) {
914                *mi += w * ti;
915            }
916        }
917        m
918    }
919
920    /// Per-dimension posterior std of θ for a style (credible-interval
921    /// widths for taste instrumentation; align first at K > 1).
922    pub fn theta_std(&self, style: usize) -> Vec<f64> {
923        let d = self.cfg.n_features;
924        let mean = self.theta_mean(style);
925        let mut var = vec![0.0; d];
926        for (i, s) in self.samples.iter().enumerate() {
927            let w = self.weight(i);
928            for ((v, t), m) in var.iter_mut().zip(&s.theta[style]).zip(&mean) {
929                *v += w * (t - m) * (t - m);
930            }
931        }
932        var.into_iter().map(f64::sqrt).collect()
933    }
934
935    /// Share of the given candidates claimed by each style: for each φ, the
936    /// posterior probability that style k is its best lens, averaged over
937    /// candidates. A style with ≈0 share is inactive — the user's taste has
938    /// fewer islands than K. Align first at K > 1.
939    pub fn style_share(&self, phis: &[Vec<f64>]) -> Vec<f64> {
940        let k = self.k_styles();
941        let mut m = vec![0.0; k];
942        if phis.is_empty() {
943            return m;
944        }
945        for phi in phis {
946            for (mi, ri) in m.iter_mut().zip(self.responsibilities(phi)) {
947                *mi += ri / phis.len() as f64;
948            }
949        }
950        m
951    }
952
953    /// Posterior mean and std of the per-style utility `u_k(φ)`.
954    pub fn utility(&self, phi: &[f64], style: usize) -> (f64, f64) {
955        self.summarize(|s| s.utility(phi, style))
956    }
957
958    /// Posterior mean and std of the mixture utility (the ranking score).
959    pub fn utility_mix(&self, phi: &[f64]) -> (f64, f64) {
960        self.summarize(|s| s.utility_mix(phi))
961    }
962
963    /// Style responsibilities of a candidate: the posterior probability that
964    /// each style is its best lens (align first at K > 1).
965    pub fn responsibilities(&self, phi: &[f64]) -> Vec<f64> {
966        let k = self.k_styles();
967        let mut m = vec![0.0; k];
968        for (i, s) in self.samples.iter().enumerate() {
969            m[s.best_style(phi)] += self.weight(i);
970        }
971        m
972    }
973
974    fn summarize(&self, f: impl Fn(&TasteSample) -> f64) -> (f64, f64) {
975        let us: Vec<f64> = self.samples.iter().map(f).collect();
976        let mean: f64 = us.iter().enumerate().map(|(i, u)| self.weight(i) * u).sum();
977        let var: f64 = us
978            .iter()
979            .enumerate()
980            .map(|(i, u)| self.weight(i) * (u - mean) * (u - mean))
981            .sum();
982        (mean, var.sqrt())
983    }
984
985    /// Posterior probability that candidate `a` beats candidate `b` in a duel
986    /// (marginalizing θ, weights, and the per-observation lens).
987    pub fn prob_prefers(&self, a: &[f64], b: &[f64]) -> f64 {
988        self.samples
989            .iter()
990            .enumerate()
991            .map(|(i, s)| self.weight(i) * s.prob_prefers(a, b))
992            .sum()
993    }
994
995    /// Serialize to a JSON file (posterior snapshot; the log remains the
996    /// source of truth).
997    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
998        std::fs::write(path, serde_json::to_string(self)?)
999    }
1000
1001    /// Load from a JSON file.
1002    pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
1003        Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
1004    }
1005}