One utility, three likelihoods
Every feedback mode conditions the same latent . They differ only in how an answer connects to it.
All three enter as a single factor carrying the total weighted
log-likelihood, so from fugue’s point of view the model has one observation
node regardless of how many kinds of feedback the log contains.
Pairwise duels: Bradley–Terry
Feedback::Duel { a, b, chose_a } => {
let d = s.utility_mix(a) - s.utility_mix(b);
log_sigmoid(if *chose_a { d } else { -d })
}
The primary signal: best statistical properties, lowest cognitive load. It identifies up to an additive constant, which is exactly the right amount of information: a preference relation does not have an origin, and pretending otherwise is what makes absolute ratings drift.
Note that here is the mixture utility, so a duel across two islands compares the drone’s best style’s rating with the pluck’s best style’s rating. That this is well-formed is the whole reason for the max form.
log_sigmoid is computed stably as with
. The naive
underflows for moderately confident predictions, which is
exactly where a fitted model spends its time.
Keep / kill: a thresholded Bernoulli
is a per-session latent, one per session in the log.
The app emits one side of it: a bank row’s cut calls
record_keep(id, false) once its 7 s undo window closes (UNDO_WINDOW_MS,
defined in toasts.js and used in main.js’s cutRow).
Nothing emits a keep; the triage surfaces that would are unbuilt.
“Feeling picky today” is therefore modeled rather than treated as noise. A session where the player cuts almost everything is read as a strict session (a high ) rather than as a change in their taste. Without the per-session threshold, a strict day and a generous day would average into a meaningless global bar, and both days’ data would be degraded by the other’s.
One implementation subtlety: reweighting an old observation against a posterior fitted before that session existed finds no site, and contributes zero rather than guessing. No threshold site means no threshold evidence.
Star ratings: a cumulative logit
with 0-based cutpoints, (so the first term is 0) and (so the last is 1). Six categories by default, hence five cutpoints.
The code computes it in log space:
match (k == 0, k == n_cats - 1) {
(true, true) => 0.0,
(true, false) => log_sigmoid(a * (s.cuts[k] - u)),
(false, true) => log_sigmoid(-a * (s.cuts[k - 1] - u)),
(false, false) => {
log_sigmoid(a * (s.cuts[k] - u))
+ log_sigmoid(-a * (s.cuts[k - 1] - u))
+ log1mexp(a * (s.cuts[k] - s.cuts[k - 1]))
}
}
The factor is 1 unless some of the observation’s coordinates were imputed (below). A rating between the lowest and the highest rests on an identity: with above , , so
Three terms of one sign, none of them a difference of near-equal numbers, so
the log-probability is exact to a few rounding errors wherever sits.
log1mexp is the standard accurate
(Mächler 2012): ln_1p(-exp(-d)) above
and ln(-expm1(-d)) below it, so even a category squeezed nearly
shut scores its width. Far below the lower cutpoint the log-probability tends
to , and far above the upper
one to plus the same constant
(a_middle_rating_far_outside_its_cutpoints_scores_its_exact_tail in
auracle-taste pins both tails against a series, to , out to a
utility past the cutpoint).
Subtracting two near-equal sigmoids used to bottom out at a floor of . The first log-space form, , lifted the floor but not the tail: once passed about 37 the ratio rounded to 1 and the result was where the true value is finite, so a strong enough rating could move the weights less than a weaker one (#227; reweighting now).
This treats ★★★ as “between two cutpoints” rather than as the number 3, which is the point. A rating is an ordinal judgment, and modeling it as a real number asserts that the gap between 1 and 2 stars equals the gap between 4 and 5, which no rater believes.
Because the cutpoints are fitted, the model absorbs scale drift: a player who becomes harsher moves the cutpoints, not . Without that, a change in rating habit would be indistinguishable from a change in taste.
Enforcing the ordering
Cutpoints must be increasing. Rather than constrain the sampler, the model samples unconstrained normals and transforms:
The exponential increments are positive by construction, so ordering holds for every draw. No rejection, no constrained kernel, and the generic single-site MH applies unchanged.
The constants place the prior sensibly: near (so a 0-star rating means “well below average”), and increments with a median of so the five cutpoints span a few units of utility.
Imputed coordinates
An observation logged under an older feature set can lack some of the current coordinates, and persistence imputes them at the mean. A duel needs no correction: both sides carry the same absence, so the imputed terms cancel in . Keep/kill and stars compare one with a fixed threshold, so there the model scales the difference by
where is the variance the imputed coordinates add to under the draw: over the imputed , for the style that scores the candidate. It is the logistic analogue of marginalizing a Gaussian through a probit link. The observation still teaches, and stops claiming certainty about a comparison that is partly guessed. With nothing imputed, .
Edit-beats-original
Not a fourth likelihood, but a duel with a provenance tag. Keeping a
hand edit as new with pick the edit ticked records Duel { a: edited, b: original, chose_a: true }.
The tag is what makes the claim auditable. Provenance distinguishes:
Duel | A dealt pair the player listened to |
HeardEdit | An edit committed through a heard comparison |
SelfReport | An edit committed by ticking the box |
PerformOffer | A PERFORM offer heard against the sound being played, then taken (offer wins) or passed by asking for another (the played sound wins) |
These make the same claim in the log, and there is no reason to believe they are equally reliable. Calibration scores them separately, which is the only way to find out rather than assume.
Recency weighting
Every observation’s log-likelihood is scaled before summing:
so the newest observation has weight 1 and one back has weight .
The default is recency_half_life = Some(150.0) (in engine.rs); None
disables forgetting entirely.
Taste is allowed to change, and a model weighting a pick from three sessions ago equally with one from a minute ago would fight the player when it did. The cost is stated plainly: this is not a proper Bayesian posterior over a stationary parameter, it is a tempered/discounted likelihood, chosen because stationarity is the wrong assumption about a person.
Implicit signals are logged, not modeled
Play counts, opening a pair’s sound in PATCH, and the editor’s stream of edits
and reverts are logged as ImplicitEvents (Engine::log_event). None of
them enters the likelihood. Listen time and hover duration are not recorded
at all.
They are cheap to collect and easy to misread: a long listen can mean fascination or confusion, and the two have opposite signs; a revert can be curiosity. This version prefers less data that means what it says, and keeps the log so a later fit can be validated against the explicit picks.
Site count, and what it costs
The model has
sample sites. With and 6 star categories, that is : **49
- ** at and 225 + at . (A fused prior over correlated coordinates, built and off by default, would add sites per fused group.)
Single-site MH moves one site per step, over a fixed budget of steps, and each
step’s likelihood scores every observation through every lens. Two
consequences, both measured by auracle-taste/examples/fit_bench.rs:
- The fit is many times slower at the cap than at the first fit.
- The step budget is fixed, so a mature fit gets proportionally fewer sweeps per site than an early one. Growing makes the fit both slower and statistically thinner.
That is a real tension in the design. It used to be sharper: fugue’s chain
driver re-executed the whole program on every step, which first made the
address table worth hoisting out of the
step loop (building addresses inline cost a format!, a re-allocation, and a
SipHash per site per step, which measured as the bulk of a mature fit’s
wall time), and then made a kernel that never
rebuilds the program worth writing.