/** * Bayesian weighted rating, replacing the old mean-only sort (see NOTES.md). * * score = (v / (v + m)) * R + (m / (v + m)) * C * * so 4 reviews of 5.0 does not outrank 39 reviews of 4.6. * * IMPORTANT, and a deviation from BACKEND.md worth reading before changing anything: * BACKEND.md defines C as "global mean rating across all reviews". On this network that * mean is about 4.7, because nearly every Cashu review is five stars. Shrinking toward a * prior that high cannot reorder two means that are both above it, it only compresses * them, so with C = 4.7 the spec's own example still fails: * * 39 reviews @ 4.6 -> 4.611 4 reviews @ 5.0 -> 4.833 1 review @ 5.0 -> 4.750 * * A single five star review would outrank 39 considered ones. With a neutral prior the * formula does what the spec says it should: * * 39 reviews @ 4.6 -> 4.418 4 reviews @ 5.0 -> 3.889 1 review @ 5.0 -> 3.333 * * So the default prior mean is NEUTRAL_PRIOR_MEAN, not the observed global mean. Set * SCORE_PRIOR_MEAN=global to get the literal BACKEND.md behaviour back. */ /** Prior weight: pretend every mint starts with 5 reviews at the prior mean. */ export const PRIOR_WEIGHT = 5; /** Midpoint of the 1..5 scale. An unreviewed mint is neither good nor bad. */ export const NEUTRAL_PRIOR_MEAN = 3; /** Offline mints keep their listing but sink. */ export const OFFLINE_PENALTY = 0.5; /** No reviews in this long counts as stale. */ export const STALE_AFTER_S = 180 * 24 * 60 * 60; export const STALE_PENALTY = 0.9; export interface ScoreInput { review_count: number; rating_avg: number | null; status: string; last_review_at: number | null; } /** * @param priorMean C, the rating an unreviewed mint is assumed to have * @param now unix seconds, injected so the value is testable and stable within a request */ export function bayesianScore(m: ScoreInput, priorMean: number, now: number): number { const v = m.review_count; const R = m.rating_avg ?? priorMean; let score = (v / (v + PRIOR_WEIGHT)) * R + (PRIOR_WEIGHT / (v + PRIOR_WEIGHT)) * priorMean; if (m.status === 'offline') score *= OFFLINE_PENALTY; if (m.last_review_at === null || now - m.last_review_at > STALE_AFTER_S) score *= STALE_PENALTY; return Math.round(score * 1000) / 1000; } /** * Three tiers, and the middle one exists for federations. * * `announced` is a Fedimint status: nothing has ever confirmed the thing is running, so * it cannot sit with the confirmed-online rows — but it is not evidence of being down * either, so it must not sink to the bottom with the rows a check actually failed on. * Not knowing belongs between knowing and knowing otherwise. * * A Cashu mint is never `announced`, so this is exactly the two-tier order it always * had for them. */ function healthRank(status: string): number { if (status === 'offline') return 2; if (status === 'announced') return 1; return 0; } /** * Default sort: every online mint before every offline one, then by score descending. * Offline mints must stay findable (people need to reach them to review them), they * just never appear above a live mint. */ export function compareMints< T extends { status: string; score: number; review_count: number; host?: string }, >(a: T, b: T): number { const aRank = healthRank(a.status); const bRank = healthRank(b.status); if (aRank !== bRank) return aRank - bRank; if (b.score !== a.score) return b.score - a.score; if (b.review_count !== a.review_count) return b.review_count - a.review_count; /* * A final tiebreak on the slug, so two indistinguishable rows still have an order. * * Without it the winner is whatever the database happened to return first, which is a * different answer on SQLite and on Postgres — the same data served in two orders, and * a nightly rebuild that reshuffles rows for no reason. It went unnoticed while ties * were rare (two unreviewed mints); federations made it the common case, since every * one that nobody has reviewed scores exactly the prior. * * Optional in the type because the ranking checks compare bare score objects that have * no slug, and there is nothing to tiebreak in a two-element fixture. */ return (a.host ?? '').localeCompare(b.host ?? ''); }