/** Pure part of entity search (§294, §312): tier → match label and the final ordering. */ export type SearchType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'publication' | 'source'; export type SearchMatch = 'exact' | 'alias' | 'prefix' | 'trigram' | 'identifier'; export interface RankedHit { type: SearchType; title: string; /** 0 exact · 1 alias/identifier · 2 prefix · 3 trigram */ tier: number; /** pg_trgm similarity (0..1), higher is better */ score: number; } const IDENTIFIER_TYPES: ReadonlySet = new Set(['gene', 'trial', 'publication', 'variant']); /** Tier 1 means "matched an identifier" for code-like entities and "matched an alias" for the rest. */ export function matchFromTier(tier: number, type: SearchType): SearchMatch { if (tier <= 0) return 'exact'; if (tier === 1) return IDENTIFIER_TYPES.has(type) ? 'identifier' : 'alias'; if (tier === 2) return 'prefix'; return 'trigram'; } /** Ordering: exact > alias/identifier > prefix > trigram, then similarity desc, then title A→Z. */ export function compareHits(a: RankedHit, b: RankedHit): number { if (a.tier !== b.tier) return a.tier - b.tier; if (a.score !== b.score) return b.score - a.score; return a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }); } export function sortHits(hits: T[]): T[] { return [...hits].sort(compareHits); }