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}