Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
103 lines
4.2 KiB
TypeScript
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 ?? '');
|
|
}
|