packages/core/src/rarity.ts
Rarity scores, ranks and tiers. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.
packages/core/src/rarity.ts135 lines
1// Rarity tiers. Every NFT in a paired collection is scored by how rare its traits are (statistical rarity, the2// same idea the big marketplaces use), ranked, and placed in a tier. A tier's multiplier is how many shares of the3// holder rewards that NFT gets. Deterministic: the same trait data always gives the same ranks, so anyone can4// recompute the published list.5 6export type Tier = 'flare' | 'blaze' | 'glow' | 'ray';7 8export interface TierInfo {9 tier: Tier;10 label: string;11 /** the top X% of the collection by rank (1 = 100%) */12 topPct: number;13 /** reward shares per NFT */14 multiplier: number;15}16 17/** Best first. The last tier holds everything the others don't. */18export const TIERS: readonly TierInfo[] = [19 { tier: 'flare', label: 'Flare', topPct: 0.01, multiplier: 5 },20 { tier: 'blaze', label: 'Blaze', topPct: 0.05, multiplier: 3 },21 { tier: 'glow', label: 'Glow', topPct: 0.2, multiplier: 2 },22 { tier: 'ray', label: 'Ray', topPct: 1, multiplier: 1 },23] as const;24 25export const TIER_BY_NAME: Record<Tier, TierInfo> = Object.fromEntries(TIERS.map((t) => [t.tier, t])) as Record<Tier, TierInfo>;26 27export const tierMultiplier = (t: Tier): number => TIER_BY_NAME[t].multiplier;28 29export interface Trait {30 trait_type: string;31 value: string | number | boolean | null;32}33 34export interface NftForRarity {35 /** the NFT's address (mint or asset id) */36 id: string;37 attributes: readonly Trait[];38}39 40export interface RankedNft {41 id: string;42 score: number;43 /** 1 = rarest */44 rank: number;45 tier: Tier;46 multiplier: number;47}48 49export interface RarityResult {50 items: RankedNft[];51 /** fewer than half the NFTs had traits: every NFT gets the same share */52 flat: boolean;53 /** trait types that counted toward the score */54 traitTypes: string[];55 /** Σ multipliers: the collection's total reward shares */56 totalWeight: number;57}58 59const NONE = '\u0000none';60const norm = (s: string) => s.trim().toLowerCase();61 62/** Trait types that identify rather than describe (an edition number, a serial): skipped when almost every value is unique. */63const UNIQUE_SKIP_RATIO = 0.9;64 65export function rankRarity(nfts: readonly NftForRarity[]): RarityResult {66 const n = nfts.length;67 if (n === 0) return { items: [], flat: true, traitTypes: [], totalWeight: 0 };68 69 const withTraits = nfts.filter((x) => x.attributes.some((a) => a.value !== null && String(a.value).trim() !== '')).length;70 const flat = withTraits * 2 < n;71 72 // value per (item, trait type); a missing trait counts as its own value ("None")73 const types = new Map<string, string>(); // normalized → first display name74 const values: Array<Map<string, string>> = nfts.map((x) => {75 const m = new Map<string, string>();76 for (const a of x.attributes) {77 if (!a.trait_type || a.value === null || String(a.value).trim() === '') continue;78 const t = norm(a.trait_type);79 if (!types.has(t)) types.set(t, a.trait_type.trim());80 m.set(t, norm(String(a.value)));81 }82 return m;83 });84 85 const counted: string[] = [];86 const freq = new Map<string, Map<string, number>>();87 for (const t of [...types.keys()].sort()) {88 const f = new Map<string, number>();89 for (const m of values) {90 const v = m.get(t) ?? NONE;91 f.set(v, (f.get(v) ?? 0) + 1);92 }93 if (f.size <= 1) continue; // every NFT has the same value: says nothing about rarity94 if (f.size >= n * UNIQUE_SKIP_RATIO && n >= 20) continue; // a serial number, not a trait95 freq.set(t, f);96 counted.push(types.get(t)!);97 }98 // trait count is a trait too99 const countFreq = new Map<number, number>();100 for (const m of values) countFreq.set(m.size, (countFreq.get(m.size) ?? 0) + 1);101 102 const scored = nfts.map((x, i) => {103 if (flat) return { id: x.id, score: 0 };104 const m = values[i]!;105 let score = 0;106 for (const [t, f] of freq) score += n / f.get(m.get(t) ?? NONE)!;107 if (countFreq.size > 1) score += n / countFreq.get(m.size)!;108 return { id: x.id, score };109 });110 // rarest first; ties broken by address so the order is total and reproducible111 scored.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));112 113 const cut = (pct: number) => Math.floor(n * pct);114 const flareCut = Math.max(1, cut(TIERS[0]!.topPct));115 const blazeCut = Math.max(flareCut, cut(TIERS[1]!.topPct));116 const glowCut = Math.max(blazeCut, cut(TIERS[2]!.topPct));117 118 let totalWeight = 0;119 const items: RankedNft[] = scored.map((s, i) => {120 const rank = i + 1;121 const tier: Tier = flat ? 'ray' : rank <= flareCut ? 'flare' : rank <= blazeCut ? 'blaze' : rank <= glowCut ? 'glow' : 'ray';122 const multiplier = tierMultiplier(tier);123 totalWeight += multiplier;124 return { id: s.id, score: Math.round(s.score * 1000) / 1000, rank, tier, multiplier };125 });126 return { items, flat, traitTypes: counted, totalWeight };127}128 129/** How many NFTs land in each tier for a collection of `n` (docs and previews). */130export function tierCounts(n: number): Record<Tier, number> {131 const flare = Math.max(1, Math.floor(n * 0.01));132 const blaze = Math.max(flare, Math.floor(n * 0.05)) - flare;133 const glow = Math.max(flare + blaze, Math.floor(n * 0.2)) - flare - blaze;134 return { flare, blaze, glow, ray: Math.max(0, n - flare - blaze - glow) };135}