// 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(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 { 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, b: Set): 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( 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]); }