SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.8 KB · 168 lines typescript
Raw Blame History
1import { and, eq, inArray, sql } from 'drizzle-orm';2import { normalizeLabel, uninvertMeshTerm, type MatchType } from '@cancerindex/shared';3import { type Database, cancerAliases, cancerCodes, cancerHierarchy, cancers } from '@cancerindex/database';4import { baseDiseaseLabel } from './qualifiers.js';56export interface CancerMatch {7  cancerId: string;8  matchType: MatchType;9  confidence: number;10  via: string; // human-readable explanation11}1213/**14 * Cancer label reconciliation (CLAUDE.md §69): identifiers → curated aliases → normalized strings.15 * Never uses embeddings or an LLM to merge. Returns null (→ unresolved queue) when unsure.16 */17export class CancerResolver {18  private aliasCache = new Map<string, { cancerId: string; aliasType: string }[]>();19  private codeCache = new Map<string, { cancerId: string; matchType: MatchType }>();2021  constructor(private readonly db: Database) {}2223  /** Preload alias + code lookups in memory for bulk reconciliation. */24  async warm(): Promise<void> {25    const rows = await this.db26      .select({ cancerId: cancerAliases.cancerId, normalized: cancerAliases.normalized, aliasType: cancerAliases.aliasType })27      .from(cancerAliases)28      .innerJoin(cancers, eq(cancers.id, cancerAliases.cancerId))29      .where(eq(cancers.status, 'active'));30    this.aliasCache.clear();31    for (const r of rows) {32      const arr = this.aliasCache.get(r.normalized) ?? [];33      arr.push({ cancerId: r.cancerId, aliasType: r.aliasType });34      this.aliasCache.set(r.normalized, arr);35    }36    // Codes recorded through an alias or a lineage decision are still identifiers of that entity; the37    // stored match type is carried through so callers can judge confidence (exact codes win on conflict).38    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']));39    this.codeCache.clear();40    const rank: Record<string, number> = { EXACT_IDENTIFIER: 5, CURATED_EXACT: 4, ONTOLOGY_EXACT: 3, ALIAS: 2, CURATED_BROADER: 1 };41    for (const c of codes) {42      const key = `${c.system}:${c.code}`;43      const cur = this.codeCache.get(key);44      if (!cur || (rank[c.matchType] ?? 0) > (rank[cur.matchType] ?? 0)) this.codeCache.set(key, { cancerId: c.cancerId, matchType: c.matchType as MatchType });45    }46    const edges = await this.db.select({ parentId: cancerHierarchy.parentId, childId: cancerHierarchy.childId }).from(cancerHierarchy);47    this.parents.clear();48    for (const e of edges) {49      const arr = this.parents.get(e.childId) ?? [];50      arr.push(e.parentId);51      this.parents.set(e.childId, arr);52    }53  }5455  private parents = new Map<string, string[]>();5657  /** True when `ancestor` is reachable from `id` by walking parent edges (any hierarchy type), bounded depth. */58  private isAncestor(ancestor: string, id: string): boolean {59    const seen = new Set<string>();60    let frontier = [id];61    for (let depth = 0; depth < 14 && frontier.length; depth++) {62      const next: string[] = [];63      for (const n of frontier) {64        for (const p of this.parents.get(n) ?? []) {65          if (p === ancestor) return true;66          if (!seen.has(p)) {67            seen.add(p);68            next.push(p);69          }70        }71      }72      frontier = next;73    }74    return false;75  }7677  /**78   * Ambiguous alias shared by several concepts: if one candidate is an ancestor of every other79   * candidate (e.g. "Breast Cancer" → Malignant Breast Neoplasm ⊃ Breast Carcinoma ⊃ Childhood80   * Breast Carcinoma), the broadest concept is the safe reading of a registry label.81   */82  private broadestOfLineage(ids: string[]): string | null {83    for (const cand of ids) if (ids.every((o) => o === cand || this.isAncestor(cand, o))) return cand;84    return null;85  }8687  get warmed(): boolean {88    return this.aliasCache.size > 0;89  }9091  /** Resolve by external identifier (NCIt code, DOID, OncoTree code, UMLS CUI, MeSH UI…). */92  byCode(system: string, code: string): CancerMatch | null {93    const hit = this.codeCache.get(`${system}:${code}`);94    if (!hit) return null;95    const confidence = hit.matchType === 'ALIAS' ? 0.9 : hit.matchType === 'CURATED_BROADER' ? 0.8 : 1;96    return { cancerId: hit.cancerId, matchType: hit.matchType, confidence, via: `${system}:${code}${hit.matchType === 'EXACT_IDENTIFIER' ? '' : ` (code recorded as ${hit.matchType})`}` };97  }9899  /** Resolve a free-text label. Ambiguous alias hits (≠ 1 distinct cancer) are returned as null. */100  byLabel(label: string, opts: { allowMeshInversion?: boolean } = {}): CancerMatch | null {101    const candidates = [label];102    if (opts.allowMeshInversion && label.includes(',')) candidates.push(uninvertMeshTerm(label));103    // Common registry phrasings: "Cancer of the X" → "X cancer"; "Carcinoma of X" → "X carcinoma"104    const m = /^(cancer|carcinoma|neoplasm|tumou?r|sarcoma|lymphoma|leukemia|adenocarcinoma|melanoma) of (the )?(.+)$/i.exec(label);105    if (m) candidates.push(`${m[3]} ${m[1]}`);106    // Qualified states ("Stage IV Pancreatic Cancer", "Metastatic Breast Cancer") → base disease (CLAUDE.md §217).107    const base = baseDiseaseLabel(label);108    const qualified = base !== label.trim() && base.length > 0;109    if (qualified) candidates.push(base);110    for (const c of candidates) {111      const isBase = qualified && c === base;112      const norm = normalizeLabel(c);113      if (!norm) continue;114      const hits = this.aliasCache.get(norm);115      if (!hits || hits.length === 0) continue;116      const distinct = [...new Set(hits.map((h) => h.cancerId))];117      if (distinct.length === 1) {118        const preferred = hits.some((h) => h.aliasType === 'preferred');119        if (isBase) return { cancerId: distinct[0]!, matchType: 'CURATED_BROADER', confidence: 0.78, via: `qualified state "${label}" → base disease "${c}"` };120        return { cancerId: distinct[0]!, matchType: preferred ? 'ONTOLOGY_EXACT' : 'ALIAS', confidence: preferred ? 0.98 : 0.9, via: `alias "${c}"` };121      }122      // Ambiguous: prefer the cancer where this string is the preferred name…123      const pref = hits.filter((h) => h.aliasType === 'preferred');124      const prefDistinct = [...new Set(pref.map((h) => h.cancerId))];125      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}"` : ''}` };126      // …then a curated display name (top-level set, CLAUDE.md §247)…127      const disp = [...new Set(hits.filter((h) => h.aliasType === 'display').map((h) => h.cancerId))];128      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}"` : ''}` };129      // …then the broadest concept when all candidates sit on one lineage.130      const broad = this.broadestOfLineage(distinct);131      if (broad) return { cancerId: broad, matchType: 'CURATED_BROADER', confidence: 0.8, via: `alias "${c}" shared by ${distinct.length} concepts on one lineage → broadest` };132      return null;133    }134    return null;135  }136137  /** Try identifiers first, then labels (CLAUDE.md §143, §221). */138  resolve(input: { codes?: Array<{ system: string; code: string }>; labels?: string[]; allowMeshInversion?: boolean }): CancerMatch | null {139    for (const c of input.codes ?? []) {140      const hit = this.byCode(c.system, c.code);141      if (hit) return hit;142    }143    for (const l of input.labels ?? []) {144      const hit = this.byLabel(l, { allowMeshInversion: input.allowMeshInversion });145      if (hit) return hit;146    }147    return null;148  }149150  /** Fuzzy suggestion for the curation queue only (never auto-accepted). */151  async suggest(label: string): Promise<{ cancerId: string; name: string; score: number } | null> {152    const norm = normalizeLabel(label);153    if (norm.length < 4) return null;154    const rows = await this.db.execute<{ cancer_id: string; canonical_name: string; score: number }>(sql`155      SELECT a.cancer_id, c.canonical_name, similarity(a.normalized, ${norm}) AS score156      FROM cancer_aliases a JOIN cancers c ON c.id = a.cancer_id157      WHERE c.status = 'active' AND a.normalized % ${norm}158      ORDER BY score DESC LIMIT 1`);159    const r = rows[0];160    return r && r.score >= 0.6 ? { cancerId: r.cancer_id, name: r.canonical_name, score: Number(r.score) } : null;161  }162}163164export async function loadCancerIdBySlug(db: Database, slug: string): Promise<string | null> {165  const [row] = await db.select({ id: cancers.id }).from(cancers).where(and(eq(cancers.slug, slug), eq(cancers.status, 'active'))).limit(1);166  return row?.id ?? null;167}168