Resolver: byCode carries recorded match types (alias/lineage codes); MeSH/ChEMBL docs
2 changed files +33 −7
modified
docs/connectors/chembl.md
+19 −2
@@ -60,9 +60,26 @@ Search pages (osimertinib, imatinib, trastuzumab, empty, page with a malformed m | |||||
| 60 | 60 | ||||
| 61 | ## Observed run (cancerindex_c, 2026-09-08, `--max-minutes 15`, 661 CIViC drugs) | 61 | ## Observed run (cancerindex_c, 2026-09-08, `--max-minutes 15`, 661 CIViC drugs) | ||
| 62 | 62 | ||||
| 63 | −See the table below — filled from the run log of `ING-CHEMBL-20260908-*`. | 63 | +The EBI API was unstable during the day (HTTP 500 / hung connections for ~40 min; 16 of 2,622 requests failed after retries): the first 15-minute run processed only 14 drugs (each hung request costs the SDK's 60 s timeout × retries). Once the API recovered the resumable cursor was used to cover the whole list in segments (10 runs, 62 min wall-clock, ~1.3 s per drug when healthy — a healthy full pass of 661 drugs takes ≈ 12 min). | ||
| 64 | 64 | ||||
| 65 | −OBSERVED_RUN_TABLE | 65 | +| | | | ||
| 66 | +|---|---| | ||||
| 67 | +| Drugs considered | 661 (every drug processed at least once across the segments); 66 skipped as non-molecule names (regimens, combinations, procedures) | | ||||
| 68 | +| Matched (exact name) | **401 drugs enriched with a ChEMBL id** (≈ 91 % by `pref_name`, 9 % by synonym); 190 names unresolved → `unresolved_labels` (`entity_kind drug`, with the top hits) | | ||||
| 69 | +| `kind` | small_molecule 342, monoclonal_antibody 36, adc 10, other 7, gene_therapy 2, cell_therapy 1 (3 kept null — ChEMBL `Unknown`) | | ||||
| 70 | +| `development_status` | approved 223, phase_3 73, phase_2 67, phase_1 9 (29 without a ChEMBL phase) | | ||||
| 71 | +| `mechanism` / `target_gene_ids` | 339 drugs with mechanism text; 314 drugs with ≥ 1 target gene | | ||||
| 72 | +| `drug_aliases` | 2,830 — development_code 1,192, synonym 847, brand 741, generic 50 | | ||||
| 73 | +| drug **TARGETS** gene | **1,171 edges** (314 drugs → 349 genes), `evidence_level` = ChEMBL action type | | ||||
| 74 | +| drug **INVESTIGATED_FOR** cancer | **6,545 edges** (361 drugs → 218 cancers), `evidence_level` = max phase for the indication | | ||||
| 75 | +| Indication headings not resolved | 99 distinct MeSH headings queued as unresolved cancers (e.g. Prostatic Neoplasms, Castration-Resistant [D064129] 41×, Neoplasm Metastasis, Adenocarcinoma, Gliosarcoma, Lymphoma Large B-Cell Diffuse, Glioma, Neuroblastoma) — descriptors the `mesh` connector could not map (genuine gaps or, on this development database, headings ambiguous because OncoTree minted duplicates before NCIt ran); non-cancer indications (rheumatoid arthritis, malaria, pulmonary hypertension…) are counted, not queued | | ||||
| 76 | +| Samples | **Osimertinib** → CHEMBL3353410, small_molecule, approved, mechanism "Epidermal growth factor receptor erbB1 inhibitor", TARGETS EGFR (INHIBITOR), aliases Tagrisso (brand), AZD-9291 / AZD9291 (development_code), INVESTIGATED_FOR Lung Non-Small Cell Carcinoma (4), Lung Neoplasm (3), Lung Adenocarcinoma (2), Glioblastoma (2)… — **Imatinib** → CHEMBL941, mechanism "Tyrosine-protein kinase ABL inhibitor; Platelet-derived growth factor receptor beta inhibitor; Stem cell growth factor receptor inhibitor; Bcr/Abl fusion protein inhibitor", TARGETS ABL1, KIT, PDGFRB, BCR, INVESTIGATED_FOR Gastrointestinal Stromal Tumor (4), Chronic Myeloid Leukemia BCR-ABL1 Positive (4), Acute Lymphoblastic Leukemia (4), Dermatofibrosarcoma Protuberans (4)… — **Trastuzumab** → CHEMBL1201585, monoclonal_antibody, approved, "Receptor protein-tyrosine kinase erbB-2 inhibitor", TARGETS ERBB2, INVESTIGATED_FOR Breast Neoplasm (4), Gastric Neoplasm (4), Esophageal Neoplasm (3)…; Trastuzumab Deruxtecan → adc, TARGETS ERBB2 + TOP1 | | ||||
| 77 | + | ||||
| 78 | +Idempotency: re-processing a drug reports the source record `unchanged`, reuses its provenance row and refreshes edges in place. Note for the integrator: CIViC created two `drugs` rows named "Trastuzumab Deruxtecan"; both received the same ChEMBL id (a merge candidate). | ||||
| 79 | + | ||||
| 80 | +### Resolver note | ||||
| 81 | + | ||||
| 82 | +`CancerResolver.byCode()` only caches codes with match type EXACT_IDENTIFIER / CURATED_EXACT / ONTOLOGY_EXACT. The `mesh` connector also writes deliberate `ALIAS` (entry-term) and `CURATED_BROADER` codes (206 of 428), so the connector loads `cancer_codes system 'mesh'` itself (all match types, ambiguous UIs dropped) and uses the resolver as the first lookup. Widening the resolver's filter to `ALIAS` would make `byCode('mesh', …)` complete for every consumer (PubMed currently falls back to `byLabel` on the `mesh_heading` alias, so its edges are unaffected). | ||||
| 66 | 83 | ||||
| 67 | ## Limitations / notes | 84 | ## Limitations / notes | ||
| 68 | 85 | ||||
modified
packages/ontology/src/reconcile.ts
+14 −5
@@ -16,7 +16,7 @@ export interface CancerMatch { | |||||
| 16 | */ | 16 | */ | ||
| 17 | export class CancerResolver { | 17 | export class CancerResolver { | ||
| 18 | private aliasCache = new Map<string, { cancerId: string; aliasType: string }[]>(); | 18 | private aliasCache = new Map<string, { cancerId: string; aliasType: string }[]>(); | ||
| 19 | − private codeCache = new Map<string, string>(); | 19 | + private codeCache = new Map<string, { cancerId: string; matchType: MatchType }>(); | ||
| 20 | 20 | ||||
| 21 | constructor(private readonly db: Database) {} | 21 | constructor(private readonly db: Database) {} | ||
| 22 | 22 | ||||
@@ -33,9 +33,16 @@ export class CancerResolver { | |||||
| 33 | arr.push({ cancerId: r.cancerId, aliasType: r.aliasType }); | 33 | arr.push({ cancerId: r.cancerId, aliasType: r.aliasType }); | ||
| 34 | this.aliasCache.set(r.normalized, arr); | 34 | this.aliasCache.set(r.normalized, arr); | ||
| 35 | } | 35 | } | ||
| 36 | − const codes = await this.db.select({ cancerId: cancerCodes.cancerId, system: cancerCodes.system, code: cancerCodes.code }).from(cancerCodes).where(inArray(cancerCodes.matchType, ['EXACT_IDENTIFIER', 'CURATED_EXACT', 'ONTOLOGY_EXACT'])); | 36 | + // Codes recorded through an alias or a lineage decision are still identifiers of that entity; the | ||
| 37 | + // 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'])); | ||||
| 37 | this.codeCache.clear(); | 39 | this.codeCache.clear(); | ||
| 38 | − for (const c of codes) this.codeCache.set(`${c.system}:${c.code}`, c.cancerId); | 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 | + } | ||||
| 39 | const edges = await this.db.select({ parentId: cancerHierarchy.parentId, childId: cancerHierarchy.childId }).from(cancerHierarchy); | 46 | const edges = await this.db.select({ parentId: cancerHierarchy.parentId, childId: cancerHierarchy.childId }).from(cancerHierarchy); | ||
| 40 | this.parents.clear(); | 47 | this.parents.clear(); | ||
| 41 | for (const e of edges) { | 48 | for (const e of edges) { | ||
@@ -83,8 +90,10 @@ export class CancerResolver { | |||||
| 83 | 90 | ||||
| 84 | /** Resolve by external identifier (NCIt code, DOID, OncoTree code, UMLS CUI, MeSH UI…). */ | 91 | /** Resolve by external identifier (NCIt code, DOID, OncoTree code, UMLS CUI, MeSH UI…). */ | ||
| 85 | byCode(system: string, code: string): CancerMatch | null { | 92 | byCode(system: string, code: string): CancerMatch | null { | ||
| 86 | − const id = this.codeCache.get(`${system}:${code}`); | 93 | + const hit = this.codeCache.get(`${system}:${code}`); | ||
| 87 | − return id ? { cancerId: id, matchType: 'EXACT_IDENTIFIER', confidence: 1, via: `${system}:${code}` } : null; | 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})`}` }; | ||||
| 88 | } | 97 | } | ||
| 89 | 98 | ||||
| 90 | /** Resolve a free-text label. Ambiguous alias hits (≠ 1 distinct cancer) are returned as null. */ | 99 | /** Resolve a free-text label. Ambiguous alias hits (≠ 1 distinct cancer) are returned as null. */ | ||
| 91 | 100 | ||||