first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+72
View File
@@ -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;
}