From 478f06b52f672fa2a01b0f5c54a5f8c5df67c656 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:35:46 +0000 Subject: [PATCH 1/3] fix(medications): restore Dosulepin to the TCA class and repair the missed-member guard (#318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mechanical fixes. Neither changes a mapping that needed a clinical answer, and the sign-off block in docs/medication-interaction-lexicon-review.md is untouched — the sheet is still UNREVIEWED, which is the only thing that closes `#318`. **Dosulepin fired none of the 20 severe TCA rows.** The `tcas` selector listed slug `dothiepin`; the catalogue keys the drug as `dosulepin` — the same drug under its current INN. A search of data/medications-snapshot.json for "dothiepin" returns zero records, so the slug matched nothing and a TCA whose own record flags `Toxicity in OD: FATAL` was absent from every one of that term's CRITICAL/HIGH rows. This restores the author's evident intent rather than making a new clinical determination: they wrote `dothiepin`, and the catalogue already files the drug as `subclass: TCA`. Measured effect after regenerating the interaction index — 22 rows now name `dosulepin` as a counterparty, 20 of them CRITICAL/HIGH, up from 0 via this term. Aggregate resolution is unchanged (523 rows, 362 resolved, 161 unresolved, 423 with a catalogue target) because those rows already resolved through other TCAs, so this widens counterparties inside already-resolved rows rather than resolving new ones. Durable guard: the coverage test now fails on ANY selector slug or denySlug that resolves to no catalogue record. The pre-existing test only required a TERM to resolve to *some* drug, so `tcas` stayed green on five of its six slugs — which is exactly how this shipped. **The guard that should have caught it could not run.** `missedClassMembers()` skipped any surface stem shorter than four characters, disabling the check entirely for `tcas` and `arbs` (`ppis` was rescued by its long surface), and it read only `class`/`subclass`, never `tag`. The sheet's printed "Checks that ran and found nothing" line was therefore false for two terms. A printed clean result that could not have found anything is worse than no line at all, because it retires the question. Both closed: the floor is 3, the shortest stem any real surface produces, and the haystack includes `tag`. The sheet now raises the Celecoxib/Parecoxib coxib gap itself — 2 flagged, up from 1. Worth recording because the first attempt was wrong: the acronym fix originally matched short stems as whole tokens. Mutation testing showed that branch did no protective work — the leading `\b` already stops `arb` reaching inside `Carbapenem` — while it would newly MISS a subclass spelled `TCAs`, a regression in the dangerous direction. It is a plain prefix match, pinned by a pluralised-subclass test. Gate: `npm run verify:pr-local` completed 18 checks with "failed: (none)", including lint, typecheck, the full unit suite, build, check:medication-interactions and check:medication-lexicon-report. Focused suite 33 passed (was 30). Mutation-verified four ways: reverting the slug fails 2 tests, restoring the `< 4` floor fails 2, dropping `tag` from the haystack fails 1, and anchoring the stem tail fails 1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PZL59KVSBWURMKvfc9mbJr --- data/medication-interaction-index.json | 31 ++++- docs/medication-interaction-lexicon-review.md | 4 +- docs/medication-lexicon-review-worklist.md | 126 ++++++++++-------- .../db498cc1-c516-4141-9837-15fc9ef30684.json | 10 ++ scripts/build-medication-lexicon-report.ts | 32 ++++- src/lib/medication-interaction-lexicon.ts | 7 +- ...ation-interaction-lexicon-coverage.test.ts | 96 ++++++++++++- 7 files changed, 241 insertions(+), 65 deletions(-) create mode 100644 docs/outstanding-issues-inbox/db498cc1-c516-4141-9837-15fc9ef30684.json diff --git a/data/medication-interaction-index.json b/data/medication-interaction-index.json index cee4a6bc9e..57aa26aa5a 100644 --- a/data/medication-interaction-index.json +++ b/data/medication-interaction-index.json @@ -702,6 +702,7 @@ "clozapine", "codeine", "diazepam", + "dosulepin", "doxepin", "fentanyl", "flupentixol-decanoate", @@ -776,6 +777,7 @@ "chlorpromazine", "clomipramine", "clozapine", + "dosulepin", "doxepin", "flupentixol-decanoate", "haloperidol-decanoate", @@ -872,6 +874,7 @@ "clozapine", "codeine", "diazepam", + "dosulepin", "doxepin", "fentanyl", "flupentixol-decanoate", @@ -1252,6 +1255,7 @@ "citalopram", "clomipramine", "desvenlafaxine", + "dosulepin", "doxepin", "duloxetine", "escitalopram", @@ -2669,6 +2673,7 @@ "counterparties": [ "amitriptyline", "clomipramine", + "dosulepin", "doxepin", "imipramine", "nortriptyline", @@ -3610,6 +3615,7 @@ "clozapine", "codeine", "diazepam", + "dosulepin", "doxepin", "fentanyl", "flupentixol-decanoate", @@ -3717,6 +3723,7 @@ "clozapine", "codeine", "diazepam", + "dosulepin", "doxepin", "fentanyl", "flupentixol-decanoate", @@ -3927,6 +3934,7 @@ "clozapine", "cyclizine", "diphenhydramine", + "dosulepin", "doxepin", "fexofenadine", "flupentixol-decanoate", @@ -5781,6 +5789,7 @@ "chlorpromazine", "clomipramine", "clozapine", + "dosulepin", "doxepin", "flupentixol-decanoate", "haloperidol-decanoate", @@ -6753,6 +6762,7 @@ "atomoxetine", "clomipramine", "desvenlafaxine", + "dosulepin", "doxepin", "duloxetine", "imipramine", @@ -6817,6 +6827,7 @@ "chlorpromazine", "clomipramine", "clozapine", + "dosulepin", "doxepin", "flupentixol-decanoate", "haloperidol-decanoate", @@ -6865,6 +6876,7 @@ "clozapine", "cyclizine", "diphenhydramine", + "dosulepin", "doxepin", "fexofenadine", "hyoscine-butylbromide", @@ -6934,6 +6946,7 @@ "chlorpromazine", "clomipramine", "clozapine", + "dosulepin", "doxepin", "flupentixol-decanoate", "haloperidol-decanoate", @@ -7269,7 +7282,15 @@ "rowKey": "Pharmacokinetic", "rowIndex": 2, "severity": "high", - "counterparties": ["clomipramine", "doxepin", "fluoxetine", "imipramine", "nortriptyline", "paroxetine"], + "counterparties": [ + "clomipramine", + "dosulepin", + "doxepin", + "fluoxetine", + "imipramine", + "nortriptyline", + "paroxetine" + ], "termIds": ["cyp-inhibitors", "tcas"], "resolved": false, "note": "HIGH — **CYP2D6** inhibitors (Fluoxetine, Paroxetine) spike TCA blood levels into the toxic range." @@ -7490,6 +7511,7 @@ "amitriptyline", "clomipramine", "codeine", + "dosulepin", "doxepin", "imipramine", "metoprolol", @@ -7687,6 +7709,7 @@ "amitriptyline", "citalopram", "clomipramine", + "dosulepin", "doxepin", "escitalopram", "fluoxetine", @@ -8480,6 +8503,7 @@ "amitriptyline", "benzatropine", "clomipramine", + "dosulepin", "doxepin", "hyoscine-butylbromide", "hyoscine-hydrobromide", @@ -8611,6 +8635,7 @@ "amitriptyline", "citalopram", "clomipramine", + "dosulepin", "doxepin", "escitalopram", "fluoxetine", @@ -8786,6 +8811,7 @@ "clozapine", "cyclizine", "diphenhydramine", + "dosulepin", "doxepin", "fexofenadine", "flupentixol-decanoate", @@ -8938,6 +8964,7 @@ "clomipramine", "clonazepam", "diazepam", + "dosulepin", "doxepin", "imipramine", "lorazepam", @@ -9018,6 +9045,7 @@ "clozapine", "cyclizine", "diphenhydramine", + "dosulepin", "doxepin", "fexofenadine", "flupentixol-decanoate", @@ -9122,6 +9150,7 @@ "chlorpromazine", "clomipramine", "clozapine", + "dosulepin", "doxepin", "flupentixol-decanoate", "haloperidol-decanoate", diff --git a/docs/medication-interaction-lexicon-review.md b/docs/medication-interaction-lexicon-review.md index ff70571c26..11a9ea5915 100644 --- a/docs/medication-interaction-lexicon-review.md +++ b/docs/medication-interaction-lexicon-review.md @@ -30,7 +30,7 @@ CRITICAL or HIGH. Start at the top — the table is sorted by severe usage. | `opioids` | opioids, opioid, opioid analgesia, opiates, full agonists | 36 | 35 | **14** — Buprenorphine (SL/depot), Buprenorphine + naloxone, Buprenorphine patch, Codeine, Fentanyl, Hydromorphone (IR/IV), Loperamide, Methadone, Morphine (IR/IV), Morphine SR / MR, Oxycodone IR, Oxycodone SR / MR, Tapentadol SR, Tramadol IR | | `benzodiazepines` | benzodiazepines, benzodiazepine, benzos, benzo | 32 | 32 | **8** — Alprazolam, Clonazepam, Diazepam, Lorazepam, Midazolam, Nitrazepam, Oxazepam, Temazepam | | `beta-blockers` | beta-blockers, beta blockers, beta-blocker, beta blocker, non-selective beta-blockers | 23 | 22 | **7** — Atenolol, Bisoprolol, Carvedilol, Labetalol, Metoprolol, Propranolol, Sotalol | -| `tcas` | tcas, tca, tricyclics, tricyclic antidepressants, anticholinergic tcas | 22 | 20 | **5** — Amitriptyline, Clomipramine, Doxepin, Imipramine, Nortriptyline | +| `tcas` | tcas, tca, tricyclics, tricyclic antidepressants, anticholinergic tcas | 22 | 20 | **6** — Amitriptyline, Clomipramine, Dosulepin, Doxepin, Imipramine, Nortriptyline | | `acei` | acei, aceis, ace inhibitors, ace inhibitor | 20 | 20 | **1** — Perindopril | | `ssris` | ssris, ssri | 21 | 19 | **6** — Citalopram, Escitalopram, Fluoxetine, Fluvoxamine, Paroxetine, Sertraline | | `maois` | maois, maoi, monoamine oxidase inhibitors | 19 | 17 | **2** — Phenelzine, Tranylcypromine | @@ -123,12 +123,12 @@ interaction row or making an existing row machine-resolvable, with clinical revi ## Flagged for a closer look +- `nsaids` does **not** cover Celecoxib (COX-2 Inhibitor), Parecoxib (COX-2 Inhibitor (Injectable)), whose own catalogue class names the term. A drug left out of a class is a missed alert, not a false one. - The catalogue holds 2 records named **Warfarin** (`warfarin-vka`, `warfarin-anticoagulant`), and a lexicon class resolves to them. They carry **different** interaction rows (`warfarin-vka`: 3, `warfarin-anticoagulant`: 3; only 0 in common), so which record the clinician opens changes which warnings they see. Reconcile them in the catalogue. Checks that ran and found nothing: - **Accidental substring matches** — no class token matches a subclass only as a fragment of a longer word. (This is the check that caught `ARB` inside _Carbapenem_.) -- **Missed class members** — no catalogue drug whose own class or subclass names a term's phrase was left out of that term. Where a class resolves to a single drug, that is the catalogue holding one such drug, not a narrow selector. ## Sign-off diff --git a/docs/medication-lexicon-review-worklist.md b/docs/medication-lexicon-review-worklist.md index 14fe335424..c95f9747e5 100644 --- a/docs/medication-lexicon-review-worklist.md +++ b/docs/medication-lexicon-review-worklist.md @@ -4,14 +4,15 @@ `docs/medication-interaction-lexicon-review.md`, and it makes no clinical determination. Its only job is to make the top-down pass that `#318` asks for as short as it can honestly be. -Generated against `origin/main` at `8069188`. The review sheet was regenerated at that commit and -came back byte-identical to the committed copy, so the sheet is current: 28 catalogue terms, -1 flagged. +First written against `origin/main` at `8069188`; **updated 2026-08-17** after §2.1 and §4 were fixed. +The sheet now reads 28 catalogue terms, **2 flagged** — the coxib gap in §2.2 is raised +automatically where it previously was not. ## Read this first -**Two of the row's three known defects are already closed**, and one new mechanical defect was found -that the sheet's own guard could not see. +**Two of the row's three known defects were already closed** before this pass, one new mechanical +defect was found and has now been **fixed**, and the guard that should have caught it has been +repaired. | Defect as stated in `#318` | State now | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | @@ -19,9 +20,12 @@ that the sheet's own guard could not see. | Two divergent Warfarin records | **Open.** Still the single flagged item. §2.6 — highest consequence on this page. | | Lithium unreachable from eight HIGH rows | **Closed.** `lithium` resolves to Lithium carbonate (IR/SR) across 9 rows / 9 severe, and lithium is absent from the unreachable-drugs table. | -**New — a dead slug drops a TCA from 20 severe rows.** `§2.1`. This one is a broken reference, not a -clinical judgment call, and it is the reason `§4` matters: the sheet's "Checks that ran and found -nothing" line is **overstated for two terms**, `tcas` among them. +**New, and now fixed — a dead slug dropped a TCA from 20 severe rows.** `§2.1`. A broken reference +rather than a clinical judgment call, and the reason `§4` matters: the sheet's "Checks that ran and +found nothing" line was **overstated for two terms**, `tcas` among them. Both are repaired. + +**Nothing that needed your clinical answer was changed.** §2.2 to §2.6 are still open questions with +their mappings untouched, and the sign-off block is still empty. ## 1. The top ten terms by severe usage @@ -36,7 +40,7 @@ red-and-amber surface. | 3 | `benzodiazepines` | 32 / 32 | 8 — Alprazolam, Clonazepam, Diazepam, Lorazepam, Midazolam, Nitrazepam, Oxazepam, Temazepam | clean | | 4 | `beta-blockers` | 23 / 22 | 7 — Atenolol, Bisoprolol, Carvedilol, Labetalol, Metoprolol, Propranolol, Sotalol | clean | | 5 | `acei` | 20 / 20 | 1 — Perindopril | §2.5 | -| 6 | `tcas` | 22 / 20 | 5 — Amitriptyline, Clomipramine, Doxepin, Imipramine, Nortriptyline | **§2.1** | +| 6 | `tcas` | 22 / 20 | **6** — Amitriptyline, Clomipramine, **Dosulepin**, Doxepin, Imipramine, Nortriptyline | **§2.1** | | 7 | `ssris` | 21 / 19 | 6 — Citalopram, Escitalopram, Fluoxetine, Fluvoxamine, Paroxetine, Sertraline | clean | | 8 | `diuretics` | 18 / 17 | 6 — Amiloride, Eplerenone, Frusemide, Hydrochlorothiazide, Indapamide, Spironolactone | clean | | 9 | `maois` | 19 / 17 | 2 — Phenelzine, Tranylcypromine | §2.3 | @@ -50,28 +54,27 @@ the term already covers all of them, with the deliberate exclusions accounted fo The prose a clinician sees is always verbatim catalogue text. What is in question is only which drugs a phrase was taken to mean. -### 2.1 `tcas` carries a dead slug — Dosulepin is silently excluded from 20 severe rows - -**This is a defect, not a judgment call, and it needs no clinical opinion to confirm.** +### 2.1 `tcas` carried a dead slug — Dosulepin was excluded from 20 severe rows — **FIXED** -`src/lib/medication-interaction-lexicon.ts` selects TCAs by an explicit slug list: +**Resolved 2026-08-17.** The selector now reads `dosulepin` and the sheet shows `tcas` resolving to +**6** drugs including Dosulepin, so those 20 CRITICAL/HIGH rows now reach it. Nothing below is +outstanding; it is kept as the record of what the defect was. -``` -select: { slugs: ["amitriptyline", "nortriptyline", "imipramine", "clomipramine", "doxepin", "dothiepin"] } -``` +This was a defect rather than a judgment call: Dosulepin _is_ dothiepin under its current INN, the +catalogue already filed it as `subclass: TCA`, and the lexicon author wrote `dothiepin` — so +including it was the evident intent and only the spelling had drifted. The durable guard is +`tests/medication-interaction-lexicon-coverage.test.ts`, which now fails on **any** selector slug +that resolves to no catalogue record. The pre-existing test only required a term to resolve to _some_ +drug, so `tcas` stayed green on five of six slugs — which is exactly how this shipped. -Six slugs, but the catalogue record's slug is **`dosulepin`**, not `dothiepin`. A search of -`data/medications-snapshot.json` for `"dothiepin"` returns **zero** records — the slug matches -nothing. The drug is in the catalogue (`class: Antidepressant`, `subclass: TCA`, and its own -overview reads "Tricyclic Antidepressant (also known as Dothiepin)"), so the lexicon was written -against the older Australian/UK name while the catalogue uses the current INN. +What it was: `src/lib/medication-interaction-lexicon.ts` selected TCAs by explicit slug list, and one +entry read `dothiepin` where the catalogue record's slug is `dosulepin`. A search of +`data/medications-snapshot.json` for `"dothiepin"` returned **zero** records, so Dosulepin — whose own +record flags `Toxicity in OD: FATAL` and `Anticholinergic: HIGH` — fired none of the 20 severe `tcas` +rows. -Consequence: Dosulepin fires **none** of the 20 severe `tcas` rows. Its own catalogue record flags -`Toxicity in OD: FATAL` and `Anticholinergic: HIGH`. - -**Your call:** confirm Dosulepin should be a TCA for interaction purposes (I expect yes), and the -fix is a one-word slug correction plus a regenerate. I have not made it — `#318` says do not change -the lexicon. +Still your call, and unchanged by the fix: whether the _interaction rows themselves_ are right for +Dosulepin now that it reaches them. Restoring the mapping does not review the 20 rows it switched on. ### 2.2 `nsaids` excludes Celecoxib and Parecoxib — missed-alert direction, 38 severe rows @@ -89,12 +92,21 @@ may be right. Its renal, lithium-level, and ACE-I/ARB "triple whammy" interactio Should `nsaids` cover the coxibs for the non-bleeding rows? This may be a row-by-row answer rather than a blanket include. +**Now surfaced automatically.** Since the §4 fixes, the generated sheet raises this itself under +"Flagged for a closer look" — it no longer depends on someone reading the term table by hand. The +mapping is deliberately unchanged, because unlike §2.1 this one needs your clinical answer first. + ### 2.3 `maois` excludes Moclobemide — missed-alert direction, 17 severe rows Moclobemide **is** in the catalogue: `class: Antidepressant`, `subclass: RIMA`. Phenelzine and Tranylcypromine are `Irreversible MAOI`. The selector is `subclassIncludes: ["MAOI"]`, and the string `RIMA` does not contain `MAOI`, so Moclobemide sits outside all 17 severe rows. +**This one the sheet still cannot find on its own**, even after the §4 fixes: Moclobemide's `tag` is +also `RIMA`, so widening the haystack to include tags does not reach it. `RIMA` and `MAOI` are +synonyms in pharmacology and unrelated as strings, and no string check closes that gap — which is +precisely why the clinician pass in this document is not replaceable by a better guard. + **Your call:** moclobemide is a reversible, selective MAO-A inhibitor. Its tyramine/food risk is genuinely much lower than the irreversible agents, so excluding it from dietary rows is defensible. Its serotonin-syndrome risk with SSRIs, TCAs, tramadol or other serotonergics is not lower. Should @@ -145,44 +157,50 @@ antiplatelets, not anticoagulants — because the catalogue files all three unde **Your call:** three antiplatelets are in and one is out, on identical class metadata. Is that the intended clinical line, or should `anticoagulants` and `antiplatelets` be cleanly separated? -## 3. What I did not do - -- Did not modify `src/lib/medication-interaction-lexicon.ts` or any catalogue data. -- Did not fill in, or pre-fill, the sign-off block. That is the clinician action `#318` asks for. -- Did not treat `check:medication-lexicon-report` as review. Regeneration was a no-op, which proves - only that the sheet describes the current lexicon — not that any mapping is correct. `#318`'s stop - condition is unchanged. +## 3. What is still owed from you -## 4. The review instrument has two blind spots — worth fixing before the next reading pass +- **The sign-off block is still empty and the sheet is still UNREVIEWED.** Nothing in this document + or in the fixes below changes that; only a clinician filling it in closes `#318`. +- **§2.2 to §2.6 are unanswered clinical questions**, and their mappings are deliberately unchanged. +- `check:medication-lexicon-report` passing is still not review. It proves only that the sheet + describes the current lexicon, never that a mapping is correct. `#318`'s stop condition stands. -The sheet prints "Checks that ran and found nothing: **Missed class members** — no catalogue drug -whose own class or subclass names a term's phrase was left out of that term." For three terms that -sentence is **not true**, because the check could not run. +What changed on 2026-08-17: only §2.1 (a broken reference restoring evident intent) and §4 (the +instrument's own blind spots). No mapping that needed a clinical answer was touched. -`missedClassMembers()` in `scripts/build-medication-lexicon-report.ts` (line 442) skips any surface -whose singular stem is shorter than four characters: +## 4. The review instrument had two blind spots — **both FIXED** -``` -const stem = surface.toLowerCase().replace(/s$/, ""); -if (stem.length < 4) return false; -``` +**Resolved 2026-08-17.** This section is kept because the failure shape matters more than the fix: a +printed "checked, nothing found" that _could not have found anything_ is worse than no line at all, +because it retires the question. -That guard exists to stop short tokens generating false positives, but it silently disables the -whole check for a three-letter class acronym unless some _other_ surface on the same term is long -enough to carry it. I executed the guard's own logic against each term's real surfaces and -haystacks rather than reasoning about it: +The sheet used to print "**Missed class members** — no catalogue drug whose own class or subclass +names a term's phrase was left out of that term". For two terms that sentence was **not true**, +because the check could not run. `missedClassMembers()` skipped any surface whose singular stem was +shorter than four characters — which silently disabled it for a three-letter class acronym unless +some _other_ surface on the same term was long enough to carry it. Executing the guard's own logic +against each term's real surfaces, rather than reasoning about it: -| Term | Usable stems after the `< 4` filter | Verdict | Severe rows | Real miss? | +| Term | Usable stems under the old `< 4` filter | Verdict | Severe rows | Real miss? | | ------ | -------------------------------------------------------------- | --------------- | ----------- | ---------------------------- | | `tcas` | `tricyclic`, `tricyclic antidepressant`, `anticholinergic tca` | **cannot fire** | 20 | **Yes — Dosulepin (§2.1)** | | `arbs` | _none_ | **cannot fire** | 16 | No — catalogue holds one ARB | -| `ppis` | `proton pump inhibitor` | runs correctly | 8 | n/a | +| `ppis` | `proton pump inhibitor` | ran correctly | 8 | n/a | + +`ppis` was rescued by its long surface; `tcas` was not, because none of its three usable stems match +the haystack `antidepressant tca`, and `arbs` had no usable stem at all. + +The second blind spot: the check read only `class` and `subclass`, never `tag` — so Celecoxib and +Parecoxib, which carry `tag: "NSAID"`, were invisible to it (§2.2). -`ppis` is rescued by its long surface; `tcas` is not, because none of its three usable stems match -the haystack `antidepressant tca`, and `arbs` has no usable stem at all. +**Both are now closed.** The floor is 3, the shortest stem any real surface produces, and the +haystack includes `tag`. The sheet now raises the coxibs itself. -Second blind spot: the check reads only `class` and `subclass`, never `tag`. Celecoxib and Parecoxib -carry `tag: "NSAID"` (§2.2) and are invisible to it for that reason. +One design note worth recording, because the first attempt was wrong: the fix originally matched short +acronyms as whole tokens (`\btca\b`). Mutation testing showed that branch did no protective work — +the leading `\b` already stops `arb` reaching inside `Carbapenem` — while it would newly **miss** a +subclass spelled `TCAs`, a regression in the dangerous direction. It is a plain prefix match, pinned +by a test using a pluralised subclass. Neither fix is made here. Both are cheap, and doing them before the clinical pass would mean the "ran clean" lines can be trusted at face value. diff --git a/docs/outstanding-issues-inbox/db498cc1-c516-4141-9837-15fc9ef30684.json b/docs/outstanding-issues-inbox/db498cc1-c516-4141-9837-15fc9ef30684.json new file mode 100644 index 0000000000..4bd65c04e7 --- /dev/null +++ b/docs/outstanding-issues-inbox/db498cc1-c516-4141-9837-15fc9ef30684.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "db498cc1-c516-4141-9837-15fc9ef30684", + "createdOn": "2026-08-17", + "action": "update", + "payload": { + "id": "#318", + "detail": "docs/medication-interaction-lexicon-review.md is generated by npm run medications:lexicon-report and expands every lexicon term to the catalogue drugs it resolves to, with how many CRITICAL/HIGH rows depend on it, sorted by severe usage. It is marked UNREVIEWED and its sign-off table is unfilled, so every red and amber drug-drug interaction alert is currently an unvalidated mapping over source-backed text. The wording shown to a clinician is always verbatim catalogue prose; what is unreviewed is which drugs a phrase like 'NSAIDs' or 'CNS depressants' was taken to mean. Next: a clinician reads the term table top-down and fills in the sign-off block. Stop: do not treat check:medication-lexicon-report passing as review - that check only proves the sheet describes the current lexicon, not that the mappings are correct. WORKLIST PREPARED 2026-08-15 (PR #1991): docs/medication-lexicon-review-worklist.md gives the top ten terms by severe usage (236 of 390 severe firings, 61 percent) with resolved drug sets and six prioritised questions. TWO OF THE THREE DEFECTS THIS ROW CITES WERE ALREADY CLOSED: the ARB/Carbapenem substring match is fixed and guarded, and lithium is reachable (9 rows / 9 severe). The divergent Warfarin pair remains and is worse than stated - warfarin-vka and warfarin-anticoagulant carry 3 interaction rows each with ZERO in common, so which record is opened changes which warnings appear. TWO FIXES LANDED 2026-08-17, owner-approved, both mechanical rather than clinical. (1) DEAD SLUG: the tcas selector listed slug 'dothiepin' but the catalogue keys the drug as 'dosulepin' (same drug, current INN), so the slug matched zero records and Dosulepin - whose own record flags Toxicity in OD FATAL and Anticholinergic HIGH - fired none of the term's 20 CRITICAL/HIGH rows. Fixed; restoring the author's evident intent, corroborated by the catalogue already filing it subclass TCA. Measured effect after regenerating data/medication-interaction-index.json: 22 rows now name dosulepin as a counterparty, 20 of them CRITICAL/HIGH, up from 0 via this term; aggregate resolution is unchanged (523 rows, 362 resolved, 161 unresolved, 423 with a catalogue target) because those rows already resolved through other TCAs, so this widens counterparties inside already-resolved rows rather than resolving new ones. Durable guard added: the coverage test now fails on ANY selector slug or denySlug that resolves to no catalogue record. The pre-existing test only required a TERM to resolve to some drug, so tcas stayed green on five of its six slugs - that is exactly how this shipped. (2) THE REVIEW INSTRUMENT'S TWO BLIND SPOTS: missedClassMembers() in scripts/build-medication-lexicon-report.ts skipped any surface stem shorter than four characters, which made the check unable to fire at all for tcas and arbs (ppis was rescued by its long surface 'proton pump inhibitors'), and it read only class and subclass, never tag. So the sheet's printed 'Checks that ran and found nothing' line was false for two terms - a printed clean result that could not have found anything is worse than no line, because it retires the question. Both closed: the floor is now 3, the shortest stem any real surface produces, and the haystack includes tag. The sheet now raises the Celecoxib/Parecoxib coxib gap itself (2 flagged, up from 1). Design note recorded because the first attempt was wrong: the fix originally matched short acronyms as whole tokens, and mutation testing showed that branch did no protective work - the leading word boundary already stops 'arb' reaching inside 'Carbapenem' - while it would newly MISS a subclass spelled 'TCAs', a regression in the dangerous direction. It is a plain prefix match, pinned by a pluralised-subclass test. STILL OPEN AND STILL YOURS: the sign-off block is untouched and the sheet is still UNREVIEWED, which is the only thing that closes this row. Five clinical questions remain with their mappings deliberately unchanged - nsaids excluding Celecoxib/Parecoxib across 38 severe rows (now auto-flagged); maois excluding Moclobemide across 17 severe rows, which the sheet still CANNOT surface because Moclobemide's tag is also RIMA and RIMA/MAOI are synonyms in pharmacology but unrelated as strings; opioids including Loperamide across 35 severe rows in the false-alert direction; acei and arbs resolving to one drug each, which is catalogue coverage rather than a narrow selector (ramipril, lisinopril, irbesartan, telmisartan, valsartan are absent from the catalogue entirely); and anticoagulants including three antiplatelets while deliberately excluding Aspirin on identical class metadata. Also worth its own row: src/lib/medication-interaction-lexicon.ts alone classifies clinicalRisk FALSE under classifyPullRequestFiles, and only the generated data/medication-interaction-index.json makes a lexicon PR clinical-risk - so a lexicon edit that changes which drugs a CRITICAL phrase resolves to would skip the governance preflight if the index were not regenerated in the same PR." + } +} diff --git a/scripts/build-medication-lexicon-report.ts b/scripts/build-medication-lexicon-report.ts index 15a921a535..fd26c7cafd 100644 --- a/scripts/build-medication-lexicon-report.ts +++ b/scripts/build-medication-lexicon-report.ts @@ -212,9 +212,10 @@ async function main(): Promise { } if (missed.length === 0) { lines.push( - "- **Missed class members** — no catalogue drug whose own class or subclass names a term's phrase was" + - " left out of that term. Where a class resolves to a single drug, that is the catalogue holding one" + - " such drug, not a narrow selector.", + "- **Missed class members** — no catalogue drug whose own class, subclass or tag names a term's phrase" + + " was left out of that term. Three-letter class acronyms are matched as whole tokens, so `TCA` and" + + " `ARB` are covered rather than skipped. Where a class resolves to a single drug, that is the" + + " catalogue holding one such drug, not a narrow selector.", ); } lines.push(""); @@ -422,8 +423,27 @@ function substringDriven(term: LexiconTerm): boolean { * * A clean run is worth as much as a hit here: it is what turns "`arbs` resolves * to one drug — is the selector too narrow?" into "the catalogue holds one ARB". + * + * That cuts both ways, which is why this had two blind spots worth naming. A + * printed "checked, nothing found" that could not have found anything is worse + * than no line at all, because it retires the question. + * + * 1. The old `stem.length < 4` bail silently disabled the whole check for any + * three-letter class acronym. `tcas`, `arbs` and `ppis` are the three, and + * `tcas`/`arbs` have no surface long enough to carry them, so neither could + * ever report — which is how Dosulepin, subclass `TCA`, sat outside that term's + * 20 CRITICAL/HIGH rows unreported. The floor is now 3, the shortest stem any + * real surface produces. + * + * Deliberately still a PREFIX match, not a whole-token one. The leading `\b` + * already stops `arb` reaching inside `Carbapenem` — the trap `substringTraps` + * exists for — so anchoring the tail buys no safety, and it would newly MISS a + * subclass spelled `TCAs`, which is the dangerous direction. + * 2. The haystack read only `class` and `subclass`, never `tag`. Celecoxib and + * Parecoxib are `COX-2 Inhibitor` by subclass but `NSAID` by tag, so the + * catalogue does call them NSAIDs — in the one field this check ignored. */ -function missedClassMembers( +export function missedClassMembers( terms: readonly LexiconTerm[], expansions: Map, records: readonly MedicationRecord[], @@ -435,11 +455,11 @@ function missedClassMembers( const missed: string[] = []; for (const record of records) { if (selected.has(record.slug) || denied.has(record.slug)) continue; - const haystack = `${record.class ?? ""} ${record.subclass ?? ""}`.toLowerCase(); + const haystack = `${record.class ?? ""} ${record.subclass ?? ""} ${record.tag ?? ""}`.toLowerCase(); const hit = term.surfaces.some((surface) => { // Compare singular stems so "NSAIDs" matches a subclass reading "NSAID". const stem = surface.toLowerCase().replace(/s$/, ""); - if (stem.length < 4) return false; + if (stem.length < 3) return false; return new RegExp(`\\b${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(haystack); }); if (hit) missed.push(`${record.name} (${record.subclass ?? record.class})`); diff --git a/src/lib/medication-interaction-lexicon.ts b/src/lib/medication-interaction-lexicon.ts index 900e108e53..a8e70af878 100644 --- a/src/lib/medication-interaction-lexicon.ts +++ b/src/lib/medication-interaction-lexicon.ts @@ -54,7 +54,12 @@ const CATALOGUE_TERMS: LexiconTerm[] = [ id: "tcas", surfaces: ["tcas", "tca", "tricyclics", "tricyclic antidepressants", "anticholinergic tcas"], kind: "catalogue", - select: { slugs: ["amitriptyline", "nortriptyline", "imipramine", "clomipramine", "doxepin", "dothiepin"] }, + // `dosulepin`, not `dothiepin`: same drug, and the catalogue keys it on the + // current INN. The old spelling matched no record, so a TCA the catalogue + // marks FATAL in overdose fired none of this term's 20 CRITICAL/HIGH rows. + // tests/medication-interaction-lexicon-coverage.test.ts now fails on any slug + // that resolves to nothing, so a dead selector cannot ship again. + select: { slugs: ["amitriptyline", "nortriptyline", "imipramine", "clomipramine", "doxepin", "dosulepin"] }, }, { id: "antipsychotics", diff --git a/tests/medication-interaction-lexicon-coverage.test.ts b/tests/medication-interaction-lexicon-coverage.test.ts index e6bda94d49..db29b20b72 100644 --- a/tests/medication-interaction-lexicon-coverage.test.ts +++ b/tests/medication-interaction-lexicon-coverage.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from "vitest"; import { INTERACTION_LEXICON, selectCatalogueSlugs, type LexiconTerm } from "@/lib/medication-interaction-lexicon"; import { loadMedicationSnapshot } from "@/lib/medication-snapshot"; -import { substringTraps } from "../scripts/build-medication-lexicon-report"; +import { missedClassMembers, substringTraps } from "../scripts/build-medication-lexicon-report"; type IndexRow = { rowKey: string; @@ -181,6 +181,76 @@ describe("lexicon deny-lists (the traps this module exists for)", () => { expect(traps[0]).toContain("Carbapenem"); }); + describe("missed class members (the direction that produces a MISSED alert)", () => { + const synthetic = (records: Array>) => + records as unknown as Parameters[2]; + const term = (id: string, surfaces: string[]) => [{ id, kind: "catalogue" as const, surfaces, select: {} }]; + + it("finds a three-letter class acronym instead of skipping the check", () => { + // The `stem.length < 4` bail used to disable this check entirely for `tcas` + // and `arbs`, so the sheet printed "checked, nothing found" for terms it had + // never examined. That is how Dosulepin stayed invisible. + const missed = missedClassMembers( + term("tcas", ["tcas", "tca", "tricyclics"]), + new Map([["tcas", ["amitriptyline"]]]), + synthetic([{ slug: "dosulepin", name: "Dosulepin", class: "Antidepressant", subclass: "TCA", tag: "TCA" }]), + ); + expect(missed).toHaveLength(1); + expect(missed[0]).toContain("Dosulepin"); + }); + + it("still refuses to reach a short acronym inside a longer word", () => { + // The leading \b does this, not a tail anchor: `arb` must not find + // `Carbapenem`, which is the trap substringTraps exists for. + const missed = missedClassMembers( + term("arbs", ["arbs", "arb"]), + new Map([["arbs", ["candesartan"]]]), + synthetic([{ slug: "meropenem", name: "Meropenem", class: "Antibiotic", subclass: "Carbapenem", tag: "" }]), + ); + expect(missed).toEqual([]); + }); + + it("matches a pluralised subclass, so the acronym fix stays a prefix match", () => { + // Pins the choice against anchoring the tail: `\btca\b` would miss a + // subclass spelled "TCAs", and a missed member is the dangerous direction. + const missed = missedClassMembers( + term("tcas", ["tcas", "tca"]), + new Map([["tcas", ["amitriptyline"]]]), + synthetic([{ slug: "dosulepin", name: "Dosulepin", class: "Antidepressant", subclass: "TCAs", tag: "" }]), + ); + expect(missed).toHaveLength(1); + expect(missed[0]).toContain("Dosulepin"); + }); + + it("reads the tag, not only class and subclass", () => { + // Celecoxib is `COX-2 Inhibitor` by subclass but `NSAID` by tag, so the + // catalogue does call it an NSAID — in the one field this check ignored. + const missed = missedClassMembers( + term("nsaids", ["nsaids", "nsaid"]), + new Map([["nsaids", ["ibuprofen"]]]), + synthetic([ + { slug: "celecoxib", name: "Celecoxib", class: "Analgesia", subclass: "COX-2 Inhibitor", tag: "NSAID" }, + ]), + ); + expect(missed).toHaveLength(1); + expect(missed[0]).toContain("Celecoxib"); + }); + + it("does not report a drug the term already selects or deliberately denies", () => { + const records = synthetic([ + { slug: "dosulepin", name: "Dosulepin", class: "Antidepressant", subclass: "TCA", tag: "TCA" }, + ]); + expect(missedClassMembers(term("tcas", ["tcas"]), new Map([["tcas", ["dosulepin"]]]), records)).toEqual([]); + expect( + missedClassMembers( + [{ id: "tcas", kind: "catalogue", surfaces: ["tcas"], select: { denySlugs: ["dosulepin"] } }], + new Map(), + records, + ), + ).toEqual([]); + }); + }); + it("reaches lithium from the rows that name it", () => { // The catalogue record is "Lithium carbonate (IR/SR)", so the name-derived // surfaces never included the bare word every row actually uses. Lithium was @@ -387,6 +457,30 @@ describe("lexicon hygiene", () => { } }); + it("resolves every individual slug a selector names", () => { + // The guard the `tcas` dead slug needed. The check above passes as long as a + // term resolves to ANY drug, so `tcas` stayed green on five of its six slugs + // while `dothiepin` — the pre-INN spelling of `dosulepin` — matched nothing, + // and a TCA the catalogue marks FATAL in overdose fired none of that term's + // 20 CRITICAL/HIGH rows. A slug that resolves to nothing is always a bug: it + // is either a typo or a drug that left the catalogue. + const known = new Set(records.map((record) => record.slug)); + for (const item of INTERACTION_LEXICON) { + for (const slug of item.select?.slugs ?? []) { + expect(known.has(slug), `${item.id} selects slug "${slug}", which is not in the catalogue`).toBe(true); + } + for (const slug of item.select?.denySlugs ?? []) { + expect(known.has(slug), `${item.id} denies slug "${slug}", which is not in the catalogue`).toBe(true); + } + } + }); + + it("covers Dosulepin as a TCA", () => { + // Regression pin for the dead slug. Dosulepin is dothiepin under its current + // INN and the catalogue files it as subclass TCA, so the term must reach it. + expect(slugsFor("tcas")).toContain("dosulepin"); + }); + it("never lets a source medication appear as its own counterparty", () => { for (const [slug, entry] of Object.entries(index.bySlug)) { for (const row of entry.rows) { From 62e6fdedc0e174970963eff3710cec0fb144ac4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:37:05 +0000 Subject: [PATCH 2/3] docs(ledger): record the #318 lexicon fix handoff review Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PZL59KVSBWURMKvfc9mbJr --- ...1dbb74413feee9a1471c1303aad0af3e55e97664038a28937de.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/691b5961fbbe31dbb74413feee9a1471c1303aad0af3e55e97664038a28937de.record.md diff --git a/docs/branch-review-records/691b5961fbbe31dbb74413feee9a1471c1303aad0af3e55e97664038a28937de.record.md b/docs/branch-review-records/691b5961fbbe31dbb74413feee9a1471c1303aad0af3e55e97664038a28937de.record.md new file mode 100644 index 0000000000..82b28be177 --- /dev/null +++ b/docs/branch-review-records/691b5961fbbe31dbb74413feee9a1471c1303aad0af3e55e97664038a28937de.record.md @@ -0,0 +1 @@ +| 2026-08-17 | PR (branch claude/p1-318-lexicon-slug-and-guards, #318 follow-up) | 478f06b52f672fa2a01b0f5c54a5f8c5df67c656 | src/lib/medication-interaction-lexicon.ts (tcas slug), scripts/build-medication-lexicon-report.ts (missedClassMembers), tests/medication-interaction-lexicon-coverage.test.ts, regenerated data/medication-interaction-index.json + docs/medication-interaction-lexicon-review.md, docs/medication-lexicon-review-worklist.md, one #318 inbox request (db498cc1). clinicalRisk true. Sign-off block untouched. | Authored handoff, owner-approved scope (dead slug + guard blind spots only; no mapping needing a clinical answer was changed). DEAD SLUG: tcas selected 'dothiepin' where the catalogue keys the drug 'dosulepin' (same drug, current INN), so it matched zero records and Dosulepin - own record flags Toxicity in OD FATAL - fired none of the term's 20 CRITICAL/HIGH rows. Treated as restoring evident intent, not a new clinical determination: the author wrote dothiepin and the catalogue already filed it subclass TCA. Measured after regenerating the index: 22 rows now name dosulepin as counterparty, 20 CRITICAL/HIGH, up from 0; aggregate resolution unchanged (523/362/161/423) because those rows already resolved via other TCAs. Durable guard: coverage test now fails on any selector slug or denySlug resolving to no record - the pre-existing test only required a TERM to resolve to some drug, so tcas stayed green on five of six slugs. GUARD BLIND SPOTS: missedClassMembers skipped sub-4-char stems, so the check could not fire for tcas or arbs (ppis rescued by its long surface), and it never read tag. The sheet's printed 'checks ran clean' line was false for two terms. Floor now 3 and haystack includes tag; the sheet now raises the Celecoxib/Parecoxib gap itself (2 flagged, up from 1). First attempt was wrong and mutation testing caught it: whole-token matching for short acronyms did no protective work (the leading word boundary already blocks arb-in-Carbapenem) and would have missed a subclass spelled TCAs - kept as a prefix match, pinned by a pluralised-subclass test. STILL OPEN: sign-off block untouched, sheet still UNREVIEWED, and five clinical questions unanswered (coxibs, Moclobemide which the sheet structurally cannot surface because its tag is also RIMA, Loperamide, single-drug acei/arbs, antiplatelets in anticoagulants). Noted for a separate row: the lexicon source alone classifies clinicalRisk FALSE and only the generated index makes such a PR clinical-risk. | verify:pr-local 18 checks completed, failed: (none) - includes lint, typecheck, full unit suite, build, check:medication-interactions, check:medication-lexicon-report. Focused tests/medication-interaction-lexicon-coverage.test.ts 33 passed (was 30). check:production-readiness run for the clinical-risk scope: 2 PASS, 5 WARN, 2 FAIL, both FAILs the documented offline provider gap (absent NEXT_PUBLIC_SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY/OPENAI_API_KEY), not regressions from this diff. Mutation-verified four ways: slug revert fails 2 tests; restoring the <4 stem floor fails 2; dropping tag fails 1; anchoring the stem tail fails 1. verify:ui NOT run and NOT runnable here - Playwright chromium-1194 vs pinned 1234 (#255/#312) fails closed; no browser coverage claimed, none needed for this scope. | From d6d48b8af0861ddd0facd38e9046b2064b762da6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:17:38 +0000 Subject: [PATCH 3/3] chore(issues): cancel superseded #318 inbox request Two pending outstanding-issues update requests targeted #318: 46750cbf (2026-08-15, from PR #1991) and this PR's own db498cc1 (2026-08-17), which restates and extends the former with the fixes landed here. docs:check-links' mutationConflicts guard correctly refused to pick a winner between two pending mutations on the same row, so queue the explicit cancellation the guard's own error message asks for. --- .../370d002c-5a65-4c99-bf5b-4146c59d5dc8.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/outstanding-issues-inbox/370d002c-5a65-4c99-bf5b-4146c59d5dc8.json diff --git a/docs/outstanding-issues-inbox/370d002c-5a65-4c99-bf5b-4146c59d5dc8.json b/docs/outstanding-issues-inbox/370d002c-5a65-4c99-bf5b-4146c59d5dc8.json new file mode 100644 index 0000000000..3d67c62634 --- /dev/null +++ b/docs/outstanding-issues-inbox/370d002c-5a65-4c99-bf5b-4146c59d5dc8.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "370d002c-5a65-4c99-bf5b-4146c59d5dc8", + "createdOn": "2026-08-17", + "action": "cancel", + "payload": { + "requestId": "46750cbf-f4f4-4a00-ab93-bdac644afbde", + "reason": "Superseded by db498cc1-c516-4141-9837-15fc9ef30684 (PR #2015), which restates and extends this note with the 2026-08-17 landed fixes; keeping both pending update requests on #318 blocked docs:check-links (mutationConflicts requires an explicit cancellation decision when two pending mutations target the same row)." + } +}