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 | 187 | if (opts.remap) { |
| 188 | 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 | 198 | await db.execute(sql`CREATE TEMP TABLE IF NOT EXISTS _alias_map (normalized text PRIMARY KEY, drug_id varchar(32) NOT NULL)`); |
| 192 | 199 | await db.execute(sql`TRUNCATE _alias_map`); |
| 193 | 200 | await db.execute(sql` |
| 194 | 201 | INSERT INTO _alias_map (normalized, drug_id) |
| 195 | − SELECT normalized, min(drug_id) FROM drug_aliases WHERE length(normalized) >= 3 | |
| 196 | − GROUP BY normalized HAVING count(DISTINCT drug_id) = 1`); | |
| 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`); | |
| 202 | + WITH cand AS ( | |
| 203 | + SELECT a.normalized, a.drug_id, | |
| 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 | 226 | // Pass 1: exact normalized alias. |
| 200 | 227 | const p1 = await db.execute<{ n: string }>(sql` |
| 201 | 228 | |