From 4babe579be91b3c1c38670b1f69d9e859de9b653 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 14:45:23 +0200 Subject: [PATCH 1/7] Implement initial SSSOM export Closes #123 --- README.md | 4 +++ ontoaligner/utils/__init__.py | 0 ontoaligner/utils/sssom.py | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 ontoaligner/utils/__init__.py create mode 100644 ontoaligner/utils/sssom.py diff --git a/README.md b/README.md index e92a633..5280b4b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Build the same RAG-based alignment yourself for full control over every stage: ```python from ontoaligner.ontology import GenericOMDataset from ontoaligner.utils import metrics, xmlify +from ontoaligner.utils.sssom import write_sssom from ontoaligner.aligner import MistralLLMBERTRetrieverRAG from ontoaligner.encoder import ConceptParentRAGEncoder from ontoaligner.postprocess import rag_hybrid_postprocessor @@ -129,6 +130,9 @@ print("Hybrid Matching Evaluation Report:", evaluation) # Step 7: Convert matchings to XML format and save the XML representation xml_str = xmlify.xml_alignment_generator(matchings=hybrid_matchings) open("matchings.xml", "w", encoding="utf-8").write(xml_str) + +# Step 8: write SSSOM +write_sssom(hybrid_matchings) ``` ### Advanced `AlignerPipeline` diff --git a/ontoaligner/utils/__init__.py b/ontoaligner/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py new file mode 100644 index 0000000..9f83c8e --- /dev/null +++ b/ontoaligner/utils/sssom.py @@ -0,0 +1,68 @@ +"""Utilities for writing SSSOM.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TextIO + +import sssom_pydantic +from sssom_pydantic import SemanticMapping, MappingSet +import bioregistry +from curies import vocabulary as v +import curies + + +__all__ = [ + "write_sssom", +] + + +def write_sssom( + matchings: list[dict], + path: str | Path | TextIO, + *, + converter: curies.Converter | None = None, + metadata: MappingSet | None = None, + **kwargs: Any, +) -> None: + if converter is None: + converter = bioregistry.get_default_converter() + mappings = to_semantic_mappings(matchings, converter) + sssom_pydantic.write( + mappings, path, converter=converter, metadata=metadata, **kwargs + ) + + +LOOKUPS = { + "=": v.exact_match, +} + + +def to_semantic_mappings( + matchings: list[dict], converter: curies.Converter +) -> list[SemanticMapping]: + mappings = [] + for matching in matchings: + try: + mapping = _to_semantic_mapping(matching, converter) + except Exception: + continue + else: + if mapping is not None: + mappings.append(mapping) + return mappings + + +def _to_semantic_mapping( + matching: dict, converter: curies.Converter +) -> SemanticMapping | None: + sub = converter.parse_uri(matching["source"]) + obj = converter.parse_uri(matching["target"]) + if sub is None or obj is None: + return None + return SemanticMapping( + subject=sub, + predicate=LOOKUPS[matching.get("relation", "=")], + object=obj, + confidence=matching.get("score"), + ) From cb070f1d81447c29d7e90544f03ace7cdc376cf2 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 14:46:06 +0200 Subject: [PATCH 2/7] Update sssom.py --- ontoaligner/utils/sssom.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py index 9f83c8e..d99af09 100644 --- a/ontoaligner/utils/sssom.py +++ b/ontoaligner/utils/sssom.py @@ -5,12 +5,11 @@ from pathlib import Path from typing import TextIO -import sssom_pydantic -from sssom_pydantic import SemanticMapping, MappingSet import bioregistry -from curies import vocabulary as v import curies - +import sssom_pydantic +from curies import vocabulary as v +from sssom_pydantic import MappingSet, SemanticMapping __all__ = [ "write_sssom", From c15b3f92f469a178d14fe0ed476db79665be2450 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 14:48:36 +0200 Subject: [PATCH 3/7] Update sssom.py --- ontoaligner/utils/sssom.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py index d99af09..db4f772 100644 --- a/ontoaligner/utils/sssom.py +++ b/ontoaligner/utils/sssom.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import TextIO +from typing import Any, TextIO import bioregistry import curies @@ -63,5 +63,6 @@ def _to_semantic_mapping( subject=sub, predicate=LOOKUPS[matching.get("relation", "=")], object=obj, + justification=v.unspecified_matching_process, confidence=matching.get("score"), ) From 053c09311a17bee16fd8ad2d9caae6d7c83311a8 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 14:56:15 +0200 Subject: [PATCH 4/7] Update docs --- README.md | 4 ++-- ontoaligner/utils/sssom.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5280b4b..1e0aefa 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,8 @@ print("Hybrid Matching Evaluation Report:", evaluation) xml_str = xmlify.xml_alignment_generator(matchings=hybrid_matchings) open("matchings.xml", "w", encoding="utf-8").write(xml_str) -# Step 8: write SSSOM -write_sssom(hybrid_matchings) +# Step 8: implicitly convert to SSSOM and write to disk +write_sssom(hybrid_matchings, "matchings.sssom.tsv") ``` ### Advanced `AlignerPipeline` diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py index db4f772..4f2f0e6 100644 --- a/ontoaligner/utils/sssom.py +++ b/ontoaligner/utils/sssom.py @@ -38,8 +38,10 @@ def write_sssom( def to_semantic_mappings( - matchings: list[dict], converter: curies.Converter + matchings: list[dict], converter: curies.Converter | None = None, ) -> list[SemanticMapping]: + if converter is None: + converter = bioregistry.get_default_converter() mappings = [] for matching in matchings: try: From fd8fac4df019fc97c92becb85fc3dbfd01ba501c Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 15:04:00 +0200 Subject: [PATCH 5/7] Update sssom.py --- ontoaligner/utils/sssom.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py index 4f2f0e6..8279eb2 100644 --- a/ontoaligner/utils/sssom.py +++ b/ontoaligner/utils/sssom.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime from pathlib import Path from typing import Any, TextIO @@ -12,6 +13,7 @@ from sssom_pydantic import MappingSet, SemanticMapping __all__ = [ + "to_semantic_mappings", "write_sssom", ] @@ -24,6 +26,15 @@ def write_sssom( metadata: MappingSet | None = None, **kwargs: Any, ) -> None: + """Convert matchings to SSSOM objects and write a TSV to disk. + + :param matchings: OntoAligner matchings + :param path: The path or file to write to. + :param converter: + :param metadata: + :param kwargs: + :return: + """ if converter is None: converter = bioregistry.get_default_converter() mappings = to_semantic_mappings(matchings, converter) @@ -38,7 +49,8 @@ def write_sssom( def to_semantic_mappings( - matchings: list[dict], converter: curies.Converter | None = None, + matchings: list[dict], + converter: curies.Converter | None = None, ) -> list[SemanticMapping]: if converter is None: converter = bioregistry.get_default_converter() @@ -46,7 +58,7 @@ def to_semantic_mappings( for matching in matchings: try: mapping = _to_semantic_mapping(matching, converter) - except Exception: + except Exception: # noqa: BLE001 continue else: if mapping is not None: @@ -65,6 +77,12 @@ def _to_semantic_mapping( subject=sub, predicate=LOOKUPS[matching.get("relation", "=")], object=obj, + # depending on the aligner, this might be one of several + # other things from the SEMAPV namespace justification=v.unspecified_matching_process, confidence=matching.get("score"), + mapping_date=datetime.date.today(), # noqa: DTZ011 + # TODO in follow-up, add additional fields such as: + # mapping tool, license, etc. from + # https://mapping-commons.github.io/sssom/dev/ ) From 09ac435feaa9b680a45005e0155c41b8e2685919 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 15:12:46 +0200 Subject: [PATCH 6/7] Add tests --- setup.py | 3 ++ tests/data/ensemble_alignments.json | 57 +++++++++++++++++++++++++++++ tests/test_sssom.py | 35 ++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/data/ensemble_alignments.json create mode 100644 tests/test_sssom.py diff --git a/setup.py b/setup.py index 598d063..09d6412 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,9 @@ "scipy==1.15.3", "multiprocess", "cohere", + "sssom-pydantic", + "bioregistry", + "curies", ], classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/data/ensemble_alignments.json b/tests/data/ensemble_alignments.json new file mode 100644 index 0000000..2260149 --- /dev/null +++ b/tests/data/ensemble_alignments.json @@ -0,0 +1,57 @@ +[ + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_10", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_13", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_4", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_1", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_44", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_15", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_53", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_16", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_6", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_3", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_7", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_4", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_82", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_86", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_9", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_24", + "score": 5.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_49", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_20", + "score": 4.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_51", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_19", + "score": 3.0 + }, + { + "source": "https://kos.lifewatch.eu/thesauri/fishtraits/c_52", + "target": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/c_18", + "score": 3.0 + } +] \ No newline at end of file diff --git a/tests/test_sssom.py b/tests/test_sssom.py new file mode 100644 index 0000000..87e24bf --- /dev/null +++ b/tests/test_sssom.py @@ -0,0 +1,35 @@ +"""Tests for SSSOM export.""" + +import unittest +from pathlib import Path +import json +from ontoaligner.utils.sssom import to_semantic_mappings +from curies import Converter, NamableReference +from sssom_pydantic import SemanticMapping +from sssom_pydantic.testing import assert_semantic_mapping_equal +import datetime + +HERE = Path(__file__).parent.resolve() +TEST_DATA = HERE.parent.joinpath("data", "ensemble_alignments.json") + + +class TestSSSOM(unittest.TestCase): + """A test case for SSSOM export.""" + + def test_sssom(self) -> None: + today = datetime.date.today() + with TEST_DATA.open() as file: + matchings = json.load(file) + converter = Converter.from_prefix_map( + { + "fishtraits": "https://kos.lifewatch.eu/thesauri/fishtraits/", + "zooplanktontraits": "https://kos.lifewatch.eu/thesauri/zooplanktontraits/", + } + ) + expected = SemanticMapping.exact( + "fishtraits:c_10", + "zooplanktontraits:c_13", + mapping_date=today, + ) + actual = to_semantic_mappings(matchings, converter=converter) + assert_semantic_mapping_equal(self, expected, actual[0]) From 24c2d6707e0a13b2885f01f668c1ff7cd93172f7 Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Wed, 26 Aug 2026 15:12:53 +0200 Subject: [PATCH 7/7] Defend against bad confidence scores --- ontoaligner/utils/sssom.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ontoaligner/utils/sssom.py b/ontoaligner/utils/sssom.py index 8279eb2..6f19837 100644 --- a/ontoaligner/utils/sssom.py +++ b/ontoaligner/utils/sssom.py @@ -67,12 +67,18 @@ def to_semantic_mappings( def _to_semantic_mapping( - matching: dict, converter: curies.Converter + matching: dict[str, Any], converter: curies.Converter ) -> SemanticMapping | None: sub = converter.parse_uri(matching["source"]) obj = converter.parse_uri(matching["target"]) if sub is None or obj is None: return None + + if confidence := matching.get("score"): + confidence = float(confidence) + if confidence < 0 or confidence > 1: + confidence = None + return SemanticMapping( subject=sub, predicate=LOOKUPS[matching.get("relation", "=")], @@ -80,7 +86,7 @@ def _to_semantic_mapping( # depending on the aligner, this might be one of several # other things from the SEMAPV namespace justification=v.unspecified_matching_process, - confidence=matching.get("score"), + confidence=confidence, mapping_date=datetime.date.today(), # noqa: DTZ011 # TODO in follow-up, add additional fields such as: # mapping tool, license, etc. from