From 4e62db3da5f3f2c2d2545d3dd02195518ad37ce3 Mon Sep 17 00:00:00 2001 From: gaurav Date: Fri, 6 Nov 2026 21:00:47 -0500 Subject: [PATCH 1/2] Report Biolink classes with their real case in /get_semantic_types /get_semantic_types collects types from a terms aggregation on `type`. Its bucket keys are the indexed keyword values, which the index mapping lowercases ("biolink:smallmolecule"); /get_normalized_nodes reads `_source`, which keeps the case. #58 switched the ancestors to bmt's class_uri but still added the raw bucket key, so CI returned both "biolink:smallmolecule" and "biolink:SmallMolecule". Each key is now looked up with toolkit.get_element(), which matches case-insensitively, and reported as its class_uri; a key the model doesn't know is reported unchanged. No reindex is needed. The test's fake Elasticsearch returned "biolink:Disease" as the bucket key, which is why it missed this. It now returns the lowercased key real indices give, and the test also rejects case-insensitive duplicates. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/semantic_types.py | 8 +++++++- tests/test_openapi.py | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/nodenorm/handlers/semantic_types.py b/src/nodenorm/handlers/semantic_types.py index 79df56c..d7f00ae 100644 --- a/src/nodenorm/handlers/semantic_types.py +++ b/src/nodenorm/handlers/semantic_types.py @@ -28,10 +28,16 @@ async def get(self) -> dict: ) raise network_error from gen_exc + # The aggregation's bucket keys are the indexed keyword values of `type`, which the + # index mapping lowercases ("biolink:smallmolecule"), unlike the `_source` that + # /get_normalized_nodes reads. Don't add the keys directly: look each one up in the + # Biolink model (bmt matches case-insensitively) and report its `class_uri` + # ("biolink:SmallMolecule"). A key the model doesn't know is reported as is. semantic_types = set() for bucket in type_aggregation_result.body["aggregations"]["unique_types"]["buckets"]: biolink_type = bucket["key"] - semantic_types.add(biolink_type) + element = toolkit.get_element(biolink_type) + semantic_types.add(element["class_uri"] if element else biolink_type) for ancestor in toolkit.get_ancestors(biolink_type): semantic_types.add(toolkit.get_element(ancestor)["class_uri"]) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 131a2fa..3493e54 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -271,7 +271,8 @@ def __init__(self): async def search(self, **kwargs): self.calls.append(kwargs) - return SimpleNamespace(body={"aggregations": {"unique_types": {"buckets": [{"key": "biolink:Disease"}]}}}) + # Real indices lowercase the `type` keyword, so aggregation keys come back lowercased. + return SimpleNamespace(body={"aggregations": {"unique_types": {"buckets": [{"key": "biolink:disease"}]}}}) async def msearch(self, **kwargs): self.calls.append(kwargs) @@ -356,7 +357,8 @@ def test_status_matches_current_metadata_schema(self): class TestSemanticTypes(AsyncHTTPTestCase): - """Regression: the handler lower-cased every class ("biolink:namedthing").""" + """Regression: the handler lower-cased every class ("biolink:namedthing"), and then + reported the lowercased aggregation key next to its capitalised class_uri.""" def get_app(self) -> tornado.web.Application: app = tornado.web.Application([(r"/get_semantic_types", SemanticTypeHandler)]) @@ -374,6 +376,7 @@ def test_types_are_biolink_class_curies(self): types = body["semantic_types"]["types"] assert "biolink:Disease" in types and "biolink:NamedThing" in types assert all(re.fullmatch(r"biolink:[A-Z][A-Za-z]*", t) for t in types), types + assert len({t.lower() for t in types}) == len(types), types class UnreachableElasticsearch: From 5ea349a65cdb554eabb46340e3582916edc951bb Mon Sep 17 00:00:00 2001 From: gaurav Date: Sat, 7 Nov 2026 18:36:26 -0500 Subject: [PATCH 2/2] Note that bmt already memoizes the semantic-types lookups bmt's Toolkit wraps get_element() and get_ancestors() in lru_cache, so a cache in the handler would add nothing; say so where someone would think of adding one. Co-Authored-By: Claude Opus 5.5 --- src/nodenorm/handlers/semantic_types.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/nodenorm/handlers/semantic_types.py b/src/nodenorm/handlers/semantic_types.py index d7f00ae..c3bd974 100644 --- a/src/nodenorm/handlers/semantic_types.py +++ b/src/nodenorm/handlers/semantic_types.py @@ -33,6 +33,10 @@ async def get(self) -> dict: # /get_normalized_nodes reads. Don't add the keys directly: look each one up in the # Biolink model (bmt matches case-insensitively) and report its `class_uri` # ("biolink:SmallMolecule"). A key the model doesn't know is reported as is. + # + # These lookups don't need memoizing here: bmt's Toolkit wraps get_element() and + # get_ancestors() in functools.lru_cache (1024 entries, more than the model's + # classes), so after the first request the loop costs about a millisecond. semantic_types = set() for bucket in type_aggregation_result.body["aggregations"]["unique_types"]["buckets"]: biolink_type = bucket["key"]