Skip to content

Activate glom's MONDO close-match guard, with concord-reader symmetry tests#888

Open
gaurav wants to merge 13 commits into
mainfrom
fix-mondo-close-guard
Open

Activate glom's MONDO close-match guard, with concord-reader symmetry tests#888
gaurav wants to merge 13 commits into
mainfrom
fix-mondo-close-guard

Conversation

@gaurav

@gaurav gaurav commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

What

Isolates the MONDO close-match guard fix from #883 onto a clean, main-based branch so it can be reviewed on its own. #883 also bundled the babel-clique-diff tool (already merged via #885) and the add-mpo (#790) work; neither is included here.

The bug

compute_cliques_for_impact_report() (which build_compendium() calls to do the real production build) read the MONDO_close concord with close_mondos[x[0]].add(x[1]), keying each MONDO subject to column 2 — the predicate, oio:closeMatch — instead of column 3, the close-match object CURIE.

Full write-up of the bug and its history: #912.

glom()'s close= guard (src/babel_utils.py) rejects a merge only when a MONDO CURIE's recorded close-match object also turns up in the same newly-merged set. Because the recorded value was the literal predicate string (which never appears in any clique), the guard never fired — a silent no-op on every build. Close-but-not-exact matches were free to collapse into exact MONDO cliques.

How long has this been broken?

git blame traces close_mondos[x[0]].add(x[1]) to commit 93a6c898 ("disease/phenotype", 2021-05-25) — the very same commit that introduced the close-match guard. Its parent had no close-match logic at all, and MONDO_close was already a 3-column concord (subject predicate object) written by ubergraph.build_sets() from that commit. So this was never a regression from a later format change: the reader keyed on the predicate (x[1]) from day one, while the regular-concord loop in the same function already keyed on subject + object (stuff[0]/stuff[2]).

The guard has therefore been a silent no-op for its entire ~4-year existence — no disease build has ever actually enforced it, and close-but-not-exact matches have been collapsing into exact MONDO cliques the whole time.

Why it went unnoticed / how to prevent the next one

  • It failed open, not loud: a mis-keyed guard silently permits merges rather than crashing, so nothing in the build ever complained.
  • The two column conventions lived side by side in one function (x[1] for the close reader vs stuff[0]/stuff[2] for every other concord) — a symmetry a reviewer or a linter can't easily see, and no test exercised the guard end-to-end.
  • Prevention baked into this PR: both disease concord readers are now single named helpers — load_concord_pairs() (regular concords) and load_close_mondos() (MONDO_close) — each with a docstring pinning the column semantics, and a set of concord-reader column-symmetry tests asserts the shared convention across all of them (see the Tests section). Any future concord reader should likewise route through a shared, tested parse rather than re-deriving column indices inline.

The fix

Extract the parse into load_close_mondos() and key on x[2], matching the (stuff[0], stuff[2]) parsing every other concord in compute_cliques_for_impact_report already uses. Also skip blank lines and raise RuntimeError on any row that is not exactly 3 columns, so x[2] is always safe and a malformed file fails loudly.

To remove the asymmetry at its root, the regular-concord reader that lived inline in compute_cliques_for_impact_report is also pulled out into a named load_concord_pairs() helper, so both disease concord readers now share one documented "subject + object, skip the predicate" parse (this also fixes a pre-existing malformed RuntimeError in that inline loop that passed a tuple of args instead of a message).

No change to src/babel_utils.py — the guard already worked; the bug was in what was fed to it.

Impact (SME review)

Activating the guard is a deliberate change to disease clique merging: close-but-not-exact matches no longer merge into exact MONDO cliques.

A build-vs-build clique diff isolating exactly this change (both sides built from identical cached disease intermediates, only src/createcompendia/diseasephenotype.py differs) is committed under docs/sources/MONDO/mondo-close-match-guard/:

  • 1,218 members dropped from Disease.txt, every one a MEDDRA identifier — the close-match MEDDRA terms that were reaching MONDO disease cliques transitively through the UMLS concord, now pulled back out.
  • 1,228 before-cliques changed (of 365,466); 114 members regrouped within Disease.txt, 72 moved to PhenotypicFeature.txt; net clique count +20.
  • PhenotypicFeature.txt: +13 cliques (14 new HP-led cliques formed from groups shed by disease cliques, 1 pre-existing absorbed); 0 dropped members. The diff tool emits only 1 row here because these are brand-new cliques with no before-counterpart.

The 1,218 figure matches the ~1,219 MEDDRA drop from the earlier combined analysis in #883.

Cliques that actually split — needs SME review

MEDDRA is barely used downstream, so the 1,218 dropped MEDDRA identifiers are low-stakes. The changes worth an SME's attention are the 37 cliques that structurally split, moving a real (non-MEDDRA) identifier out of a MONDO clique. The full list with members is in docs/sources/MONDO/mondo-close-match-guard/README.md and split-cliques.csv. Three kinds: 19 eject a piece into a brand-new Disease.txt clique, 4 re-attach it to a different existing MONDO clique, and 14 shed a group that re-types into a brand-new PhenotypicFeature.txt clique (these are the +13 phenotype additions the diff tool doesn't emit as rows, since it iterates before-cliques).

Mechanism: when the guard fires, glom() drops the entire pairwise concord link, not just the offending MEDDRA term. So a legitimate UMLS/DOID exact-equivalent that happens to ride in the same group as a close-matched MEDDRA is ejected as collateral damage. That is why many splits pair a MONDO term with an identically-labelled UMLS/DOID concept — a strong regression signal:

  • ⚠ likely regressions (near-identical labels), e.g. MONDO:0000889 "haemophilus meningitis" splitting off UMLS:C0276028 "Haemophilus influenzae meningitis"; the two multiple endocrine neoplasia DOID splits (MONDO:0007540DOID:10017, MONDO:0017169DOID:3125); toxic oil syndrome, argyria, pyomyositis, secondary polycythemia, macular corneal dystrophy, Spasmus nutans each splitting off their identically-named UMLS concept.
  • Likely improvements, e.g. MONDO:0009297 "familial renal glucosuria" correctly no longer subsuming the broader UMLS:C0017980 "Glycosuria, Renal".

The 4 re-attach cases land the ejected group in a different, already-existing MONDO clique. That happens because MONDO is a unique_prefix (one MONDO per clique): when a group is linked to two MONDO terms at once, only one wins, and blocking the winning path hands the group to the runner-up. Example: MONDO:0016702 "oligoastrocytoma" → MONDO:0003268 "mixed glioma" — a UMLS:C0280793 group (incl. NCIT:C129323 "Oligoastrocytoma, NOS") linked to both; the guard blocks the oligoastrocytoma path, so it lands under mixed glioma instead. These re-attachments can also be regressions (here an oligoastrocytoma NCIT term now sits under mixed glioma). See the README for the full trace.

The 14 phenotype cases eject a group carrying an HP term and no MONDO, so it re-types as biolink:PhenotypicFeature. Here a near-identical MONDO↔HP label does not imply a regression — Babel deliberately keeps disease and phenotype in separate compendia, so pulling HP:0001543 "Gastroschisis" out of MONDO:0009264 "gastroschisis" is arguably correct typing; the SME question is whether the UMLS/SNOMEDCT that moved with the HP belong on the phenotype side.

split-cliques.csv carries a first-pass sme_assessment for the 23 disease splits: the 2 confirmed cases, the 8 identical-label splits marked suspected regression, and the remaining 13 left blank as genuine SME calls. The 14 phenotype splits are left blank.

Tests

tests/createcompendia/test_diseasephenotype.py adds two groups of tests:

  • MONDO close-match guardload_close_mondos keys on the object column, skips blanks, and rejects malformed rows; glom's close= guard blocks a close-match MEDDRA term from collapsing into the exact MONDO clique end-to-end; and a companion test documents the original bug (a predicate-keyed close dict leaves the guard a no-op).

  • Concord-reader column symmetry — parametrized over every disease concord reader (load_concord_pairs and load_close_mondos), asserting each surfaces the subject (column 1) and object (column 3), never the predicate (column 2), and raises RuntimeError on a non-3-column row. This pins the shared column convention whose violation silently disabled the guard, so a new reader (or a regression of an old one) that reads the predicate column fails loudly. Adding a future reader to the CONCORD_READERS list automatically holds it to the same invariant.

  • uv run pytest tests/createcompendia/test_diseasephenotype.py -q — passes

  • uv run pytest -m unit -q — 251 passed

  • uv run ruff check / ruff format --check — clean

🤖 Generated with Claude Code

gaurav and others added 2 commits July 2, 2026 01:46
build_compendium() read the MONDO_close concord with close_mondos[x[0]].add(x[1]),
keying each MONDO subject to column 2 (the predicate, "oio:closeMatch") instead of
column 3 (the close-match object). No clique ever contains the literal predicate
string, so glom()'s `close=` guard never matched and has been a silent no-op on every
build, letting close (but not exact) matches collapse into exact MONDO cliques.

Extract the parse into load_close_mondos(), key on x[2] (matching the (stuff[0],
stuff[2]) parsing every other concord in build_compendium already uses), skip blank
lines, and raise RuntimeError on any row that is not exactly 3 columns.

Activating the guard is a deliberate, SME-reviewable change to disease clique merging:
close-but-not-exact matches (notably ~1,219 MEDDRA codes on an add-mpo-base build; the
exact count on a pure-main build may differ) no longer merge into exact MONDO cliques.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover load_close_mondos (keys on the object column, skips blanks, rejects malformed
rows) and glom's close= guard end-to-end: a close-match MEDDRA term must not collapse
into the exact MONDO clique. A companion test documents the original bug -- a
predicate-keyed close dict leaves the guard a no-op, so the merge goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gaurav and others added 3 commits July 2, 2026 01:52
git blame traces close_mondos[x[0]].add(x[1]) to commit 93a6c89 (2021-05-25,
"disease/phenotype") -- the very commit that introduced glom's MONDO close-match
guard. The parent commit had no close logic at all, and MONDO_close was already a
3-column concord then, yet the reader keyed on the predicate (x[1]) while the
regular-concord loop in the same function correctly keyed on subject+object
(stuff[0]/stuff[2]). So the guard has been a silent no-op for its entire ~4-year
existence, and no disease build has ever actually enforced it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…parse

The regular-concord reader lived inline in build_compendium and keyed on
subject+object (stuff[0]/stuff[2]); the close-match reader was a separate parse that
keyed on the predicate (x[1]) -- the asymmetry that silently disabled glom's close=
guard. Pull the regular reader into a named, documented load_concord_pairs() helper
alongside load_close_mondos() so the shared "subject + object, skip the predicate"
convention lives in one testable place per reader.

Also fixes the malformed RuntimeError in the old inline loop, which passed a tuple of
positional args instead of a formatted message, now an f-string matching
load_close_mondos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parametrized tests assert every disease concord reader (load_concord_pairs and
load_close_mondos) surfaces the subject (column 1) and object (column 3) and never the
predicate (column 2), and that each raises RuntimeError on a non-3-column row. This
pins the shared column convention whose violation disabled the close-match guard, so a
new reader (or a regression of an old one) that reads the predicate column fails
loudly. Also covers load_concord_pairs dropping bad_pairs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gaurav gaurav changed the title Activate glom's MONDO close-match guard (key on object, not predicate) Activate glom's MONDO close-match guard, with concord-reader symmetry tests Jul 2, 2026
gaurav added a commit that referenced this pull request Jul 2, 2026
The NOTE in compute_cliques_for_impact_report and disjointness.md's "Related
work" pointed the deferred close-match-guard fix at the old combined PR #883.
That PR was split: the babel-clique-diff tool is now #885 and the close-match
guard activation (keying glom's close= map on the object, x[2], rather than the
predicate, x[1]) is now #888 (fix-mondo-close-guard). Point both references at
the concrete PRs so they don't go stale. No behavior change -- this branch still
preserves the predicate-keyed no-op that #888 will fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gaurav
gaurav requested a review from Copilot July 10, 2026 06:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes how the disease/phenotype pipeline reads the MONDO_close concord so that glom()’s MONDO close-match guard is actually enforced (it was previously a silent no-op due to keying on the predicate column instead of the object column). It also extracts concord parsing into named helpers and adds regression + symmetry tests to prevent future column-index drift.

Changes:

  • Fix MONDO_close parsing by keying MONDO subjects to close-match objects (column 3), with blank-line skipping and strict 3-column validation.
  • Extract regular concord parsing into load_concord_pairs() with strict 3-column validation and bad-pair filtering.
  • Add unit tests covering the close-match guard end-to-end and parametrized “reader column symmetry” invariants across concord readers.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/createcompendia/diseasephenotype.py Adds load_close_mondos() / load_concord_pairs() helpers and wires them into build_compendium() so the MONDO close-match guard can trigger correctly.
tests/createcompendia/test_diseasephenotype.py Adds regression tests proving the guard blocks close-match merges and enforces subject/object column symmetry across concord readers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

gaurav and others added 2 commits July 10, 2026 02:38
PR #886 (MP ingestion) refactored build_compendium() into
compute_cliques_for_impact_report() and, in doing so, deliberately
carried the MONDO close-match bug forward with a NOTE deferring the fix
to this PR. Resolve by keeping all of #886's structure and applying this
PR's fix inside it:

- Keep compute_cliques_for_impact_report()'s excluded_sources handling,
  MONDO_close basename discovery, DISEASE_UNIQUE_PREFIXES,
  OVERUSE_FILTERED_CONCORDS (now including MP), the post-glom
  HP/MP split, and the logger calls.
- Route its two inline parse loops through this branch's
  load_close_mondos() and load_concord_pairs() helpers, so the
  close-match reader keys on the object (column 3), not the predicate.
- Drop the NOTE deferring the fix; this is that follow-up.
- Preserve #886's None-guard: an absent MONDO_close (impact-report CLI,
  or --source MONDO) still yields an empty close map rather than
  opening a missing file.

Both sides' tests are retained; the test file gains 164 lines and loses
none. Reword the now-stale "Related work" bullet in
docs/sources/MP/disjointness.md, which described this fix as still
deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full narrative of the mis-keyed close-match reader lives in
load_close_mondos; load_concord_pairs now points there and cites the
symmetry tests instead of re-telling it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gaurav and others added 6 commits July 10, 2026 12:11
Points readers at the full write-up of the four-year silent no-op in
glom's MONDO close-match guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build-vs-build diff isolating the close-match guard activation (#888,
issue #912): both compendia built from identical cached disease
intermediates, differing only in diseasephenotype.py keying close_mondos
on the object (x[2]) vs the predicate (x[1]).

Result: 1,218 MEDDRA identifiers drop from Disease.txt (the close-match
terms that reached MONDO cliques via the UMLS concord), matching the
~1,219 figure from #883. PhenotypicFeature.txt is essentially unaffected.

Indexes the new docs/sources/MONDO entry in docs/sources/README.md and
CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 1,218 dropped identifiers are all MEDDRA (low-stakes downstream). The
changes that need an SME's eye are the cliques that actually split, moving
a real non-MEDDRA identifier out of a MONDO clique.

Because glom() drops the whole pairwise concord link when the close-match
guard fires, a legitimate UMLS/DOID exact-equivalent riding in the same
group as a close-matched MEDDRA is ejected as collateral damage -- hence
the many splits pairing a MONDO term with an identically-labelled UMLS/DOID
concept (likely regressions), alongside genuine improvements. Lists all 23
in the README and a compact split-cliques.csv with an sme_assessment column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Marks the two user-confirmed cases (MONDO:0000889 regression,
MONDO:0009297 improvement) and the eight identical-label splits as
suspected regressions; the remaining 13 (a distinct close concept was
separated) stay blank for a genuine SME call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the mechanism (guard blocks the winning MONDO merge; MONDO being a
unique_prefix hands the ejected group to a sibling MONDO clique it also
had a concord path to) with the oligoastrocytoma -> mixed glioma trace,
so an SME understands why a real identifier can land under a different
MONDO term.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The guard also splits 14 disease cliques into brand-new PhenotypicFeature
cliques: the ejected group carries an HP term and no MONDO, so it re-types
as biolink:PhenotypicFeature (these are the +13 phenotype additions the
clique-diff tool doesn't surface as rows, since it iterates before-cliques).

Adds them to split-cliques.csv (now 37 rows: 23 disease + 14 phenotype)
and documents them, noting that a near-identical MONDO/HP label here does
NOT imply a regression -- disease and phenotype are meant to live in
separate compendia, so the separation is often correct typing; the SME
question is whether the moved UMLS/SNOMEDCT belong on the phenotype side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants