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}