/** * Pure ranking primitives (CLAUDE.md §155: deterministic given fixed inputs). */ export interface RankInput { id: string; value: number; /** Optional confidence label carried through to the output. */ confidence?: 'HIGH' | 'MEDIUM' | 'LOW' | 'INSUFFICIENT_DATA'; inputs?: Record; } export interface RankOutput extends RankInput { rank: number; percentile: number; // 0..100, 100 = most extreme in the ranking direction eligible: number; } /** * Rank entities. `higherIsWorse === null` means "higher is first" (descending) by convention. * Ties share the same rank (competition ranking: 1,2,2,4). Percentile = share of eligible entities * ranked at or below this entity in the ranking direction. */ export function rankEntities(items: RankInput[], opts: { descending?: boolean } = {}): RankOutput[] { const desc = opts.descending ?? true; const valid = items.filter((i) => Number.isFinite(i.value)); const sorted = [...valid].sort((a, b) => (desc ? b.value - a.value : a.value - b.value) || a.id.localeCompare(b.id)); const n = sorted.length; const out: RankOutput[] = []; let rank = 0; for (let i = 0; i < n; i++) { const cur = sorted[i]!; if (i === 0 || cur.value !== sorted[i - 1]!.value) rank = i + 1; out.push({ ...cur, rank, eligible: n, percentile: n === 1 ? 100 : Math.round(((n - rank) / (n - 1)) * 1000) / 10 }); } return out; } /** Percentile of each entity for a metric (0..100, higher value → higher percentile). */ export function percentiles(items: RankInput[]): Map { const ranked = rankEntities(items, { descending: true }); return new Map(ranked.map((r) => [r.id, r.percentile])); } /** Stable hash of ranking inputs so a snapshot is reproducible/auditable. */ export async function inputsHash(items: RankInput[]): Promise { const { createHash } = await import('node:crypto'); const h = createHash('sha256'); for (const i of [...items].sort((a, b) => a.id.localeCompare(b.id))) h.update(`${i.id}=${i.value};`); return h.digest('hex').slice(0, 24); }