import { and, eq, inArray, sql } from 'drizzle-orm'; import { normalizeLabel, uninvertMeshTerm, type MatchType } from '@cancerindex/shared'; import { type Database, cancerAliases, cancerCodes, cancerHierarchy, cancers } from '@cancerindex/database'; import { baseDiseaseLabel } from './qualifiers.js'; export interface CancerMatch { cancerId: string; matchType: MatchType; confidence: number; via: string; // human-readable explanation } /** * Cancer label reconciliation (CLAUDE.md §69): identifiers → curated aliases → normalized strings. * Never uses embeddings or an LLM to merge. Returns null (→ unresolved queue) when unsure. */ export class CancerResolver { private aliasCache = new Map(); private codeCache = new Map(); constructor(private readonly db: Database) {} /** Preload alias + code lookups in memory for bulk reconciliation. */ async warm(): Promise { const rows = await this.db .select({ cancerId: cancerAliases.cancerId, normalized: cancerAliases.normalized, aliasType: cancerAliases.aliasType }) .from(cancerAliases) .innerJoin(cancers, eq(cancers.id, cancerAliases.cancerId)) .where(eq(cancers.status, 'active')); this.aliasCache.clear(); for (const r of rows) { const arr = this.aliasCache.get(r.normalized) ?? []; arr.push({ cancerId: r.cancerId, aliasType: r.aliasType }); this.aliasCache.set(r.normalized, arr); } // Codes recorded through an alias or a lineage decision are still identifiers of that entity; the // stored match type is carried through so callers can judge confidence (exact codes win on conflict). const codes = await this.db.select({ cancerId: cancerCodes.cancerId, system: cancerCodes.system, code: cancerCodes.code, matchType: cancerCodes.matchType }).from(cancerCodes).where(inArray(cancerCodes.matchType, ['EXACT_IDENTIFIER', 'CURATED_EXACT', 'ONTOLOGY_EXACT', 'ALIAS', 'CURATED_BROADER'])); this.codeCache.clear(); const rank: Record = { EXACT_IDENTIFIER: 5, CURATED_EXACT: 4, ONTOLOGY_EXACT: 3, ALIAS: 2, CURATED_BROADER: 1 }; for (const c of codes) { const key = `${c.system}:${c.code}`; const cur = this.codeCache.get(key); if (!cur || (rank[c.matchType] ?? 0) > (rank[cur.matchType] ?? 0)) this.codeCache.set(key, { cancerId: c.cancerId, matchType: c.matchType as MatchType }); } const edges = await this.db.select({ parentId: cancerHierarchy.parentId, childId: cancerHierarchy.childId }).from(cancerHierarchy); this.parents.clear(); for (const e of edges) { const arr = this.parents.get(e.childId) ?? []; arr.push(e.parentId); this.parents.set(e.childId, arr); } } private parents = new Map(); /** True when `ancestor` is reachable from `id` by walking parent edges (any hierarchy type), bounded depth. */ private isAncestor(ancestor: string, id: string): boolean { const seen = new Set(); let frontier = [id]; for (let depth = 0; depth < 14 && frontier.length; depth++) { const next: string[] = []; for (const n of frontier) { for (const p of this.parents.get(n) ?? []) { if (p === ancestor) return true; if (!seen.has(p)) { seen.add(p); next.push(p); } } } frontier = next; } return false; } /** * Ambiguous alias shared by several concepts: if one candidate is an ancestor of every other * candidate (e.g. "Breast Cancer" → Malignant Breast Neoplasm ⊃ Breast Carcinoma ⊃ Childhood * Breast Carcinoma), the broadest concept is the safe reading of a registry label. */ private broadestOfLineage(ids: string[]): string | null { for (const cand of ids) if (ids.every((o) => o === cand || this.isAncestor(cand, o))) return cand; return null; } get warmed(): boolean { return this.aliasCache.size > 0; } /** Resolve by external identifier (NCIt code, DOID, OncoTree code, UMLS CUI, MeSH UI…). */ byCode(system: string, code: string): CancerMatch | null { const hit = this.codeCache.get(`${system}:${code}`); if (!hit) return null; const confidence = hit.matchType === 'ALIAS' ? 0.9 : hit.matchType === 'CURATED_BROADER' ? 0.8 : 1; return { cancerId: hit.cancerId, matchType: hit.matchType, confidence, via: `${system}:${code}${hit.matchType === 'EXACT_IDENTIFIER' ? '' : ` (code recorded as ${hit.matchType})`}` }; } /** Resolve a free-text label. Ambiguous alias hits (≠ 1 distinct cancer) are returned as null. */ byLabel(label: string, opts: { allowMeshInversion?: boolean } = {}): CancerMatch | null { const candidates = [label]; if (opts.allowMeshInversion && label.includes(',')) candidates.push(uninvertMeshTerm(label)); // Common registry phrasings: "Cancer of the X" → "X cancer"; "Carcinoma of X" → "X carcinoma" const m = /^(cancer|carcinoma|neoplasm|tumou?r|sarcoma|lymphoma|leukemia|adenocarcinoma|melanoma) of (the )?(.+)$/i.exec(label); if (m) candidates.push(`${m[3]} ${m[1]}`); // Qualified states ("Stage IV Pancreatic Cancer", "Metastatic Breast Cancer") → base disease (CLAUDE.md §217). const base = baseDiseaseLabel(label); const qualified = base !== label.trim() && base.length > 0; if (qualified) candidates.push(base); for (const c of candidates) { const isBase = qualified && c === base; const norm = normalizeLabel(c); if (!norm) continue; const hits = this.aliasCache.get(norm); if (!hits || hits.length === 0) continue; const distinct = [...new Set(hits.map((h) => h.cancerId))]; if (distinct.length === 1) { const preferred = hits.some((h) => h.aliasType === 'preferred'); if (isBase) return { cancerId: distinct[0]!, matchType: 'CURATED_BROADER', confidence: 0.78, via: `qualified state "${label}" → base disease "${c}"` }; return { cancerId: distinct[0]!, matchType: preferred ? 'ONTOLOGY_EXACT' : 'ALIAS', confidence: preferred ? 0.98 : 0.9, via: `alias "${c}"` }; } // Ambiguous: prefer the cancer where this string is the preferred name… const pref = hits.filter((h) => h.aliasType === 'preferred'); const prefDistinct = [...new Set(pref.map((h) => h.cancerId))]; if (prefDistinct.length === 1) return { cancerId: prefDistinct[0]!, matchType: isBase ? 'CURATED_BROADER' : 'ONTOLOGY_EXACT', confidence: isBase ? 0.78 : 0.9, via: `preferred name "${c}" (alias also used by ${distinct.length - 1} other concept(s))${isBase ? ` via base of "${label}"` : ''}` }; // …then a curated display name (top-level set, CLAUDE.md §247)… const disp = [...new Set(hits.filter((h) => h.aliasType === 'display').map((h) => h.cancerId))]; if (disp.length === 1) return { cancerId: disp[0]!, matchType: isBase ? 'CURATED_BROADER' : 'CURATED_EXACT', confidence: isBase ? 0.78 : 0.92, via: `curated display name "${c}"${isBase ? ` via base of "${label}"` : ''}` }; // …then the broadest concept when all candidates sit on one lineage. const broad = this.broadestOfLineage(distinct); if (broad) return { cancerId: broad, matchType: 'CURATED_BROADER', confidence: 0.8, via: `alias "${c}" shared by ${distinct.length} concepts on one lineage → broadest` }; return null; } return null; } /** Try identifiers first, then labels (CLAUDE.md §143, §221). */ resolve(input: { codes?: Array<{ system: string; code: string }>; labels?: string[]; allowMeshInversion?: boolean }): CancerMatch | null { for (const c of input.codes ?? []) { const hit = this.byCode(c.system, c.code); if (hit) return hit; } for (const l of input.labels ?? []) { const hit = this.byLabel(l, { allowMeshInversion: input.allowMeshInversion }); if (hit) return hit; } return null; } /** Fuzzy suggestion for the curation queue only (never auto-accepted). */ async suggest(label: string): Promise<{ cancerId: string; name: string; score: number } | null> { const norm = normalizeLabel(label); if (norm.length < 4) return null; const rows = await this.db.execute<{ cancer_id: string; canonical_name: string; score: number }>(sql` SELECT a.cancer_id, c.canonical_name, similarity(a.normalized, ${norm}) AS score FROM cancer_aliases a JOIN cancers c ON c.id = a.cancer_id WHERE c.status = 'active' AND a.normalized % ${norm} ORDER BY score DESC LIMIT 1`); const r = rows[0]; return r && r.score >= 0.6 ? { cancerId: r.cancer_id, name: r.canonical_name, score: Number(r.score) } : null; } } export async function loadCancerIdBySlug(db: Database, slug: string): Promise { const [row] = await db.select({ id: cancers.id }).from(cancers).where(and(eq(cancers.slug, slug), eq(cancers.status, 'active'))).limit(1); return row?.id ?? null; }