Files
CashuMints.space/shared/src/score.ts
T
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00

103 lines
4.2 KiB
TypeScript

/**
* 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 ?? '');
}