@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
const aOff = a.status === 'offline' ? 1 : 0;
|
||||
const bOff = b.status === 'offline' ? 1 : 0;
|
||||
if (aOff !== bOff) return aOff - bOff;
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.review_count - a.review_count;
|
||||
}
|
||||
Reference in New Issue
Block a user