Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions kg_microbe/transform_utils/metatraits/metatraits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
93 changes: 91 additions & 2 deletions kg_microbe/utils/ontology_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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.

``<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).
Expand All @@ -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))
Expand All @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions tests/test_ncbitaxon_version_alignment.py
Original file line number Diff line number Diff line change
@@ -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 = '<owl versionIRI rdf:resource="http://purl.obolibrary.org/obo/ncbitaxon/{d}/ncbitaxon.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
Loading