From c7849a76fd0223a576a67827801fa2e20b263d4c Mon Sep 17 00:00:00 2001 From: Kevin Schaper Date: Fri, 5 Jun 2026 13:52:41 -0700 Subject: [PATCH] norm: preserve canonical OBO prefix case in obo_handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `obo_handle` strips `OBO:` from CURIEs like `OBO:FBbt_00000001` and rebuilds them as `FBbt:00000001`. The final step was uppercasing the prefix portion with `.upper()`: new_id = f"{split_id[0].upper()}:{split_id[1]}" The accompanying comment ("Capitalize the prefix") describes the intent, but `.upper()` is the wrong operation: it preserves the case for OBO namespaces that are conventionally all-uppercase (HP, MONDO, UBERON, CL, ...) but breaks any mixed-case OBO prefix. In practice this caused every Drosophila and C. elegans gene-expression edge in monarch-kg to dangle, because: - phenio.json source has canonical IRIs like http://purl.obolibrary.org/obo/FBbt_NNNNNNNN. - KGX (which lacks an explicit prefix entry for FBbt / WBbt in its default JSON-LD context) falls back to writing them as `OBO:FBbt_NNNNNNNN`. - This function then `.upper()`-ed the prefix, producing `FBBT:` and `WBBT:` — neither of which is the canonical OBO Foundry CURIE for those namespaces (`FBbt:`, `WBbt:`). - Downstream consumers like the Alliance gene-to-expression ingest look up nodes by the canonical form, so all ~503k Drosophila + C. elegans gene-expression edges per monarch-kg release went to the dangling-edge pile. Drop the `.upper()` and preserve the case as it appears in the OBO PURL local-name segment. Behaviour is unchanged for all-uppercase OBO prefixes (HP/MONDO/UBERON/CL/...) — the `.upper()` was a no-op for those already — and now correct for mixed-case prefixes (FBbt/WBbt/ZFA/XAO/...). Add regression tests asserting both behaviours. Verified end-to-end on a synthetic input through `clean_and_normalize_graph`: before: OBO:FBbt_00000001 → FBBT:00000001 after: OBO:FBbt_00000001 → FBbt:00000001 HP / MONDO / UBERON / CL unchanged. --- tests/test_norm.py | 30 ++++++++++++++++++++++++++++++ universalizer/norm.py | 13 +++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/test_norm.py b/tests/test_norm.py index bb83fb0..11ae4ed 100644 --- a/tests/test_norm.py +++ b/tests/test_norm.py @@ -86,3 +86,33 @@ def test_normalize_and_remove_extra_edges(self): with open(self.chebi_graph_edges, "r") as infile: afterlen = len(infile.readlines()) self.assertLess(afterlen, beforelen, "Edges not removed?") + + +class TestObohandle(TestCase): + """Tests for `obo_handle`'s prefix-casing behaviour.""" + + def test_uppercase_prefix_unchanged(self): + """Pure-uppercase OBO prefixes survive obo_handle as-is. + + HP, MONDO, UBERON, CL, etc. were already uppercase, so the previous + `.upper()` was a no-op for them. Removing that call must not regress + the no-op. + """ + from universalizer.norm import obo_handle + self.assertEqual(obo_handle("OBO:HP_0000118"), "HP:0000118") + self.assertEqual(obo_handle("OBO:MONDO_0000001"), "MONDO:0000001") + self.assertEqual(obo_handle("OBO:UBERON_0000061"), "UBERON:0000061") + self.assertEqual(obo_handle("OBO:CL_0000000"), "CL:0000000") + + def test_mixed_case_prefix_preserved(self): + """Mixed-case OBO prefixes (FBbt, WBbt) keep their canonical case. + + Regression test for the bug where `obo_handle` `.upper()`-ed the + prefix, turning `OBO:FBbt_00000001` into `FBBT:00000001` instead of + the canonical `FBbt:00000001`. This silently dropped every + Drosophila and C. elegans gene-expression edge in downstream + consumers (e.g. monarch-kg's alliance_gene_to_expression). + """ + from universalizer.norm import obo_handle + self.assertEqual(obo_handle("OBO:FBbt_00000001"), "FBbt:00000001") + self.assertEqual(obo_handle("OBO:WBbt_0000001"), "WBbt:0000001") diff --git a/universalizer/norm.py b/universalizer/norm.py index 574ca3f..02ca6af 100644 --- a/universalizer/norm.py +++ b/universalizer/norm.py @@ -502,10 +502,15 @@ def obo_handle(old_id: str) -> str: # Replace the first underscore with colon new_id = new_id.replace("_", ":", 1) - # Capitalize the prefix, but not the rest - # of the ID - split_id = new_id.split(":", 1) - new_id = f"{split_id[0].upper()}:{split_id[1]}" + # Preserve the canonical case of the prefix as it appears in the + # source OBO PURL — `FBbt`, `WBbt` etc. stay mixed-case; pure + # uppercase prefixes (HP, MONDO, UBERON, CL, ...) are unchanged. + # Previously this `.upper()`-ed the whole prefix, which broke any + # mixed-case OBO namespace by emitting e.g. `FBBT:` instead of the + # canonical `FBbt:`. That caused every Drosophila and C. elegans + # gene-expression edge in monarch-kg to dangle (~503k edges per + # release on `alliance_gene_to_expression_edges`), because Alliance + # was looking up nodes by the canonical mixed-case CURIE. else: new_id = old_id