diff --git a/kg_microbe/transform_utils/metatraits/metatraits.py b/kg_microbe/transform_utils/metatraits/metatraits.py index 7e04fd4e..6b5b9bf2 100644 --- a/kg_microbe/transform_utils/metatraits/metatraits.py +++ b/kg_microbe/transform_utils/metatraits/metatraits.py @@ -3599,6 +3599,15 @@ def run( if use_mp: _ensure_ncbitaxon_db_ready() + # Version-alignment guard runs regardless of MP mode (the mismatch it + # catches is independent of parallelism). No-ops when the DB isn't + # present yet — sequential runs fetch it lazily downstream. + local_db, _ = _ncbitaxon_db_paths() + if local_db.exists(): + from kg_microbe.utils.ontology_utils import assert_ncbitaxon_version_alignment + + assert_ncbitaxon_version_alignment(str(local_db)) + # Decide whether to use parallel or sequential processing try: if use_mp and len(input_files) > 1: diff --git a/kg_microbe/utils/ontology_utils.py b/kg_microbe/utils/ontology_utils.py index e9c000a6..5b17015b 100644 --- a/kg_microbe/utils/ontology_utils.py +++ b/kg_microbe/utils/ontology_utils.py @@ -53,6 +53,11 @@ def _obo_release_from_head(path: Path, nbytes: int = 2_000_000) -> Optional[str] except OSError: return None m = _RELEASE_RE.search(head) + if m: + return m.group(1) + # OWL versionIRI whose date isn't under a ``releases/`` path — e.g. NCBITaxon's + # ``versionIRI rdf:resource=".../ncbitaxon/2026-05-13/ncbitaxon.owl"``. + m = re.search(r"versionIRI[^>]{0,160}?(\d{4}-\d{2}-\d{2})", head) if m: return m.group(1) # OBO-JSON versionInfo, e.g. {"pred": ".../versionInfo", "val": "2026-05-19"} @@ -61,6 +66,25 @@ def _obo_release_from_head(path: Path, nbytes: int = 2_000_000) -> Optional[str] return m.group(1) if m else None +def _version_check_strict(env_var: str, strict: Optional[bool], default_strict: bool = True) -> bool: + """ + Resolve a version-gate's strictness: explicit arg wins, else the env var. + + ```` may be ``strict`` (raise) or ``warn``; unset falls back to + ``default_strict`` (which differs per gate — GO defaults strict because a + mismatch silently corrupts categories, NCBITaxon defaults warn because + owl/db release drift is common and low-risk). + """ + if strict is not None: + return strict + val = os.environ.get(env_var, "").strip().lower() + if val == "warn": + return False + if val == "strict": + return True + return default_strict + + def assert_go_version_alignment(strict: Optional[bool] = None) -> None: """ Guard that GO's aspect-map source (go.owl→go.db) matches the transform's (go.json). @@ -79,8 +103,7 @@ def assert_go_version_alignment(strict: Optional[bool] = None) -> None: """ from kg_microbe.transform_utils.constants import GO_SOURCE - if strict is None: - strict = os.environ.get("KG_GO_VERSION_CHECK", "strict").strip().lower() != "warn" + strict = _version_check_strict("KG_GO_VERSION_CHECK", strict) if not GO_SOURCE: return owl_release = _obo_release_from_head(Path(GO_SOURCE)) @@ -99,6 +122,72 @@ def assert_go_version_alignment(strict: Optional[bool] = None) -> None: print(f"WARNING: {msg}") +def _ncbitaxon_db_release(db_path: str) -> Optional[str]: + """ + Return the NCBITaxon release (YYYY-MM-DD) recorded in a SemSQL ``.db``. + + Reads ``owl:versionInfo`` from the ``statements`` table (OAK/SemanticSQL + stores the ontology's version there). Returns None on any read error or a + missing/unparsable stamp. + """ + try: + conn = sqlite3.connect(db_path) + try: + # Target the ontology node's versionInfo (subject carries "ncbitaxon") + # rather than any entity that happens to be annotated with one. + row = conn.execute( + "SELECT value FROM statements WHERE predicate = 'owl:versionInfo' " + "AND subject LIKE '%ncbitaxon%' AND value IS NOT NULL LIMIT 1" + ).fetchone() + finally: + conn.close() + except sqlite3.Error: + return None + if not row or not row[0]: + return None + m = re.search(r"(\d{4}-\d{2}-\d{2})", str(row[0])) + return m.group(1) if m else None + + +def assert_ncbitaxon_version_alignment(db_path: str, strict: Optional[bool] = None) -> None: + """ + Guard that the NCBITaxon lookup DB matches the transform's OWL release. + + The metatraits transform looks taxa up in ``ncbitaxon.db`` (an OAK-fetched + prebuilt SemSQL DB whose release is whatever OAK last downloaded) while its + nodes are emitted from ``ncbitaxon.owl``. If the two are different releases, + lookups can resolve against taxa that differ from those emitted. Compare the + ``owl:versionInfo`` in ``db_path`` with ``ncbitaxon.owl``'s versionIRI and, + on mismatch, warn (default) or raise. No-op when either stamp can't be read. + + Unlike the GO gate this **defaults to warn** — the OAK cache and the pinned + ``ncbitaxon.owl`` legitimately drift (OAK auto-refreshes to the latest), + and NCBITaxon labels/lineage are stable, so a mismatch is worth surfacing + loudly but not aborting. Set ``KG_NCBITAXON_VERSION_CHECK=strict`` (or pass + ``strict=True``) to fail loud instead. + """ + from kg_microbe.transform_utils.constants import NCBITAXON_SOURCE + + strict = _version_check_strict("KG_NCBITAXON_VERSION_CHECK", strict, default_strict=False) + if not NCBITAXON_SOURCE: + return + owl_release = _obo_release_from_head(Path(NCBITAXON_SOURCE)) + db_release = _ncbitaxon_db_release(db_path) + if owl_release and db_release and owl_release != db_release: + msg = ( + f"NCBITaxon source version mismatch: ncbitaxon.owl={owl_release} vs " + f"ncbitaxon.db={db_release}. The metatraits transform emits nodes from " + "ncbitaxon.owl but looks taxa up in ncbitaxon.db; a release gap can " + "resolve lookups against taxa the transform didn't emit. To realign, " + "refresh the OAK SemSQL DB to the pinned release " + "(rm ~/.data/oaklib/ncbitaxon.db; poetry run python -c " + "'from oaklib import get_adapter; get_adapter(\"sqlite:obo:ncbitaxon\")')." + ) + if strict: + raise RuntimeError(msg) + print(f"WARNING: {msg}") + + def _ensure_go_db(go_db_path: str) -> bool: """ Build the GO SemSQL DB from ``go.owl`` if missing/empty; return True if usable. diff --git a/tests/test_ncbitaxon_version_alignment.py b/tests/test_ncbitaxon_version_alignment.py new file mode 100644 index 00000000..2bfc29a3 --- /dev/null +++ b/tests/test_ncbitaxon_version_alignment.py @@ -0,0 +1,121 @@ +"""Tests for the NCBITaxon .owl/.db version-alignment gate (#604 fix 4).""" + +import sqlite3 +from pathlib import Path + +import pytest + +from kg_microbe.utils import ontology_utils as ou + +# NCBITaxon stamps its version as ``ncbitaxon/DATE/`` (no ``releases/`` segment). +_OWL = '' + + +def _write_owl(tmp_path: Path, release: str) -> Path: + p = tmp_path / "ncbitaxon.owl" + p.write_text(_OWL.format(d=release), encoding="utf-8") + return p + + +def _make_db(tmp_path: Path, release, name: str = "ncbitaxon.db") -> str: + """Write a minimal SemSQL-shaped sqlite with an owl:versionInfo row.""" + path = str(tmp_path / name) + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE statements (subject TEXT, predicate TEXT, object TEXT, value TEXT)") + if release is not None: + conn.execute( + "INSERT INTO statements (subject, predicate, object, value) " + "VALUES ('obo:ncbitaxon.owl', 'owl:versionInfo', NULL, ?)", + (release,), + ) + conn.commit() + conn.close() + return path + + +def test_owl_release_parsed_from_ncbitaxon_versioniri(tmp_path): + """The date is read from NCBITaxon's ``ncbitaxon/DATE/`` versionIRI (no releases/).""" + assert ou._obo_release_from_head(_write_owl(tmp_path, "2026-05-13")) == "2026-05-13" + + +def test_db_release_read_from_versioninfo(tmp_path): + """_ncbitaxon_db_release reads owl:versionInfo from the SemSQL statements table.""" + assert ou._ncbitaxon_db_release(_make_db(tmp_path, "2026-05-13")) == "2026-05-13" + + +def test_db_release_none_when_absent(tmp_path): + """A db with no versionInfo row (or an unreadable path) yields None.""" + assert ou._ncbitaxon_db_release(_make_db(tmp_path, None)) is None + assert ou._ncbitaxon_db_release(str(tmp_path / "missing.db")) is None + + +def test_aligned_does_not_raise(tmp_path, monkeypatch): + """Matching ncbitaxon.owl and ncbitaxon.db releases pass silently.""" + monkeypatch.setattr( + "kg_microbe.transform_utils.constants.NCBITAXON_SOURCE", _write_owl(tmp_path, "2026-05-13") + ) + db = _make_db(tmp_path, "2026-05-13") + ou.assert_ncbitaxon_version_alignment(db, strict=True) # must not raise + + +def test_default_is_warn_not_raise(tmp_path, monkeypatch, capsys): + """Unlike GO, the NCBITaxon gate defaults to warn (owl/db drift is expected).""" + monkeypatch.setattr( + "kg_microbe.transform_utils.constants.NCBITAXON_SOURCE", _write_owl(tmp_path, "2026-05-13") + ) + db = _make_db(tmp_path, "2026-01-01") + ou.assert_ncbitaxon_version_alignment(db) # strict=None → default warn → no raise + assert "NCBITaxon source version mismatch" in capsys.readouterr().out + + +def test_mismatch_raises_when_strict(tmp_path, monkeypatch): + """strict=True (or KG_NCBITAXON_VERSION_CHECK=strict) fails loudly.""" + monkeypatch.setattr( + "kg_microbe.transform_utils.constants.NCBITAXON_SOURCE", _write_owl(tmp_path, "2026-05-13") + ) + db = _make_db(tmp_path, "2026-01-01") + with pytest.raises(RuntimeError, match="NCBITaxon source version mismatch"): + ou.assert_ncbitaxon_version_alignment(db, strict=True) + + +def test_env_var_strict_upgrades_to_raise(tmp_path, monkeypatch): + """KG_NCBITAXON_VERSION_CHECK=strict upgrades the warn default to a raise.""" + monkeypatch.setattr( + "kg_microbe.transform_utils.constants.NCBITAXON_SOURCE", _write_owl(tmp_path, "2026-05-13") + ) + monkeypatch.setenv("KG_NCBITAXON_VERSION_CHECK", "strict") + db = _make_db(tmp_path, "2026-01-01") + with pytest.raises(RuntimeError, match="NCBITaxon source version mismatch"): + ou.assert_ncbitaxon_version_alignment(db) # strict=None → env strict → raise + + +def test_versioninfo_query_targets_ontology_subject(tmp_path): + """A decoy owl:versionInfo on a non-ontology subject is ignored (subject targeting).""" + path = str(tmp_path / "ncbitaxon.db") + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE statements (subject TEXT, predicate TEXT, object TEXT, value TEXT)") + # Decoy first (a random entity annotated with a version-shaped date), then the ontology row. + conn.execute( + "INSERT INTO statements VALUES ('SomeEntity:1', 'owl:versionInfo', NULL, '1999-12-31')" + ) + conn.execute( + "INSERT INTO statements VALUES ('obo:ncbitaxon.owl', 'owl:versionInfo', NULL, '2026-05-13')" + ) + conn.commit() + conn.close() + assert ou._ncbitaxon_db_release(path) == "2026-05-13" + + +def test_corrupt_db_yields_none(tmp_path): + """A non-sqlite / corrupt file is handled (sqlite3.Error → None), not raised.""" + corrupt = tmp_path / "ncbitaxon.db" + corrupt.write_text("this is not a sqlite database", encoding="utf-8") + assert ou._ncbitaxon_db_release(str(corrupt)) is None + + +def test_missing_stamp_is_a_noop(tmp_path, monkeypatch): + """When the db has no version stamp, the gate can't judge → no-op.""" + monkeypatch.setattr( + "kg_microbe.transform_utils.constants.NCBITAXON_SOURCE", _write_owl(tmp_path, "2026-05-13") + ) + ou.assert_ncbitaxon_version_alignment(_make_db(tmp_path, None), strict=True) # must not raise