Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
96 lines
3.6 KiB
TypeScript
96 lines
3.6 KiB
TypeScript
// Selecting a *diverse* set of genres for the music landing page.
|
||
//
|
||
// The naive approach ("keep the genres with the most albums") tends to surface
|
||
// a cluster of near-synonyms — "Rock", "Hard Rock", "Classic Rock", "Pop Rock"
|
||
// — because big umbrella genres and their sub-genres are all populous. The
|
||
// result reads as one genre repeated, not a tour of the library.
|
||
//
|
||
// Instead we pick greedily for *spread*: start from the most populous genre,
|
||
// then repeatedly add whichever remaining genre is least similar to everything
|
||
// already chosen (a max-min / farthest-point selection). Similarity is word-
|
||
// token overlap (Jaccard), so "Hard Rock" stays close to "Rock" but far from
|
||
// "Jazz" or "Hip Hop". Count still acts as a gentle tie-breaker so we don't
|
||
// promote a one-album novelty genre over a healthy distinct one.
|
||
|
||
/** Anything with a name and a relative weight (album count) we can rank by. */
|
||
export interface DiversityCandidate {
|
||
name: string;
|
||
}
|
||
|
||
/**
|
||
* Sample up to `count` items at an even stride across `items`. Genre lists come
|
||
* back alphabetical, so taking the first N would only ever surface A-genres;
|
||
* striding spreads the sample A→Z. Always includes the first item.
|
||
*/
|
||
export function sampleAcross<T>(items: T[], count: number): T[] {
|
||
if (items.length <= count) return items.slice();
|
||
const stride = Math.max(1, Math.floor(items.length / count));
|
||
return items.filter((_, i) => i % stride === 0).slice(0, count);
|
||
}
|
||
|
||
/** Split a genre name into a set of lowercased word tokens. */
|
||
function tokenize(name: string): Set<string> {
|
||
return new Set(
|
||
name
|
||
.toLowerCase()
|
||
.split(/[^a-z0-9]+/)
|
||
.filter(Boolean),
|
||
);
|
||
}
|
||
|
||
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|, in [0, 1]. */
|
||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||
if (a.size === 0 && b.size === 0) return 1;
|
||
let intersection = 0;
|
||
for (const t of a) if (b.has(t)) intersection++;
|
||
const union = a.size + b.size - intersection;
|
||
return union === 0 ? 0 : intersection / union;
|
||
}
|
||
|
||
/**
|
||
* Pick up to `limit` genres that are textually distinct from one another,
|
||
* preferring more populous genres. Input order is treated as the count ranking
|
||
* (most albums first); ties in distance fall back to that order.
|
||
*/
|
||
export function selectDiverseGenres<T extends DiversityCandidate>(
|
||
candidates: T[],
|
||
limit: number,
|
||
): T[] {
|
||
if (candidates.length <= limit) return candidates.slice();
|
||
|
||
const tokens = candidates.map((c) => tokenize(c.name));
|
||
const chosen: number[] = [];
|
||
const remaining = new Set(candidates.map((_, i) => i));
|
||
|
||
// Seed with the most populous genre (candidates[0]).
|
||
chosen.push(0);
|
||
remaining.delete(0);
|
||
|
||
while (chosen.length < limit && remaining.size > 0) {
|
||
let best = -1;
|
||
// The best candidate is the one *least* similar to its most-similar chosen
|
||
// member. We track that max-similarity and minimise it: a genre is only
|
||
// "diverse" if it resembles *nothing* already chosen, so looking at the
|
||
// single nearest neighbour (as plain farthest-point does) isn't enough —
|
||
// "Hard Rock" must stay close to "Rock" even after unrelated "Jazz" is in.
|
||
let bestMaxSim = Infinity;
|
||
for (const i of remaining) {
|
||
let maxSim = 0;
|
||
for (const c of chosen) {
|
||
const sim = jaccard(tokens[i], tokens[c]);
|
||
if (sim > maxSim) maxSim = sim;
|
||
}
|
||
// Lower max-similarity wins; on a tie keep the earlier (more populous)
|
||
// one, guaranteed because `remaining` iterates in insertion order.
|
||
if (maxSim < bestMaxSim) {
|
||
bestMaxSim = maxSim;
|
||
best = i;
|
||
}
|
||
}
|
||
chosen.push(best);
|
||
remaining.delete(best);
|
||
}
|
||
|
||
return chosen.map((i) => candidates[i]);
|
||
}
|