Drug reconciliation: deterministic tie-break for aliases shared by salt-form duplicates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 changed file +31 −4
modified
packages/connectors/src/connectors/clinicaltrials/drugs.ts
+31 −4
@@ -187,14 +187,41 @@ export async function reconcileInterventionDrugs(db: Database, opts: { remap?: b | |||||
| 187 | if (opts.remap) { | 187 | if (opts.remap) { | ||
| 188 | await db.execute(sql`UPDATE trial_interventions SET drug_id = NULL, match_type = 'UNRESOLVED' WHERE intervention_type IN ('DRUG','BIOLOGICAL') AND match_type IN ('ALIAS','PROBABILISTIC')`); | 188 | await db.execute(sql`UPDATE trial_interventions SET drug_id = NULL, match_type = 'UNRESOLVED' WHERE intervention_type IN ('DRUG','BIOLOGICAL') AND match_type IN ('ALIAS','PROBABILISTIC')`); | ||
| 189 | } | 189 | } | ||
| 190 | − // Unambiguous alias map: normalized → drug_id (aliases shared by several drugs are excluded). | 190 | + // Alias map: normalized → drug_id. An alias shared by several drugs is accepted only when one | ||
| 191 | + // candidate is strictly preferred by a deterministic rule (SPEC §97 duplicate resolution): | ||||
| 192 | + // 0 the alias is the drug's own generic name (alias_type generic AND normalized(drugs.name) = alias) | ||||
| 193 | + // 1 the alias is a brand of the drug | ||||
| 194 | + // 2 the drug's name carries no salt/ester token (base molecule rather than "X Hydrochloride") | ||||
| 195 | + // 3 anything else | ||||
| 196 | + // Two candidates at the same best priority → ambiguous → skipped (never guessed). Salt-form | ||||
| 197 | + // duplicates ("Erlotinib" vs "Erlotinib Hydrochloride") are also reported for the merge queue. | ||||
| 191 | await db.execute(sql`CREATE TEMP TABLE IF NOT EXISTS _alias_map (normalized text PRIMARY KEY, drug_id varchar(32) NOT NULL)`); | 198 | await db.execute(sql`CREATE TEMP TABLE IF NOT EXISTS _alias_map (normalized text PRIMARY KEY, drug_id varchar(32) NOT NULL)`); | ||
| 192 | await db.execute(sql`TRUNCATE _alias_map`); | 199 | await db.execute(sql`TRUNCATE _alias_map`); | ||
| 193 | await db.execute(sql` | 200 | await db.execute(sql` | ||
| 194 | INSERT INTO _alias_map (normalized, drug_id) | 201 | INSERT INTO _alias_map (normalized, drug_id) | ||
| 195 | − SELECT normalized, min(drug_id) FROM drug_aliases WHERE length(normalized) >= 3 | 202 | + WITH cand AS ( | ||
| 196 | − GROUP BY normalized HAVING count(DISTINCT drug_id) = 1`); | 203 | + SELECT a.normalized, a.drug_id, | ||
| 197 | − const [amb] = await db.execute<{ n: string }>(sql`SELECT count(*)::text AS n FROM (SELECT normalized FROM drug_aliases GROUP BY normalized HAVING count(DISTINCT drug_id) > 1) x`); | 204 | + min(CASE | ||
| 205 | + WHEN a.alias_type = 'generic' AND lower(regexp_replace(d.name, '[^A-Za-z0-9]+', ' ', 'g')) = a.normalized THEN 0 | ||||
| 206 | + WHEN a.alias_type = 'brand' THEN 1 | ||||
| 207 | + WHEN NOT (lower(d.name) ~ ${sql.raw(`'\\m(${SALT_TOKENS.join('|')})\\M'`)}) THEN 2 | ||||
| 208 | + ELSE 3 END) AS prio | ||||
| 209 | + FROM drug_aliases a JOIN drugs d ON d.id = a.drug_id | ||||
| 210 | + WHERE length(a.normalized) >= 3 | ||||
| 211 | + GROUP BY a.normalized, a.drug_id | ||||
| 212 | + ), | ||||
| 213 | + best AS ( | ||||
| 214 | + SELECT normalized, min(prio) AS prio FROM cand GROUP BY normalized | ||||
| 215 | + ), | ||||
| 216 | + pick AS ( | ||||
| 217 | + SELECT c.normalized, c.drug_id, count(*) OVER (PARTITION BY c.normalized) AS n_best | ||||
| 218 | + FROM cand c JOIN best b ON b.normalized = c.normalized AND b.prio = c.prio | ||||
| 219 | + ) | ||||
| 220 | + SELECT normalized, drug_id FROM pick WHERE n_best = 1`); | ||||
| 221 | + const [amb] = await db.execute<{ n: string }>(sql` | ||||
| 222 | + SELECT count(*)::text AS n FROM ( | ||||
| 223 | + SELECT a.normalized FROM drug_aliases a WHERE length(a.normalized) >= 3 GROUP BY a.normalized HAVING count(DISTINCT a.drug_id) > 1 | ||||
| 224 | + EXCEPT SELECT normalized FROM _alias_map) x`); | ||||
| 198 | 225 | ||||
| 199 | // Pass 1: exact normalized alias. | 226 | // Pass 1: exact normalized alias. | ||
| 200 | const p1 = await db.execute<{ n: string }>(sql` | 227 | const p1 = await db.execute<{ n: string }>(sql` | ||
| 201 | 228 | ||||