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
67 changes: 67 additions & 0 deletions scripts/gtdb_ground.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,58 @@ def resolve_kg_microbe_dir(explicit: str | None) -> Path:
)


# GTDB appends an alphabetic suffix to every clade split off a genus/family that
# does NOT contain the nomenclatural type: `g__Enterococcus_B` is Enterococcus
# minus the lineage holding *E. faecalis*, which keeps the bare `g__Enterococcus`.
# So the suffix is GTDB's own published marker for "not the type-anchored clade".
_NON_TYPE_SUFFIX = re.compile(r"^(?P<base>[A-Z][A-Za-z0-9-]*)_(?P<suffix>[A-Z]+)$")


def non_type_clade(name: str) -> str | None:
"""The unsuffixed sibling of a GTDB name, when the name is a non-type clade.

Returns the base name (`Enterococcus` for `Enterococcus_B`), else None.

Used to *flag* rather than to *resolve* (#374). Grounding by majority-of-
genomes and GTDB's type-species rule disagree whenever a non-type clade is
more heavily sequenced. Verified against GTDB R226, that is one taxon of the
five where the two denominators differ — `Enterococcus` grounds to
`g__Enterococcus_B` while *E. faecalis* sits in `g__Enterococcus`. The other
four agree because the majority happens to be the type clade, which is luck
and not construction; nothing stops them flipping as sequencing depth shifts.

Deliberately not used to override the answer. Preferring the type clade
would change what every existing grounding means, on the evidence of a
single live case — and the majority answer is not obviously wrong, since a
user asking for "NCBI genus X" may well want the clade most of X's genomes
are in. Emitting both and letting a curator see the conflict costs nothing
and forecloses nothing.
"""
match = _NON_TYPE_SUFFIX.match(name or "")
return match.group("base") if match else None


def _non_type_warning(chosen: str, weights: dict) -> str:
"""Human-readable note for a grounding that is not the type-anchored clade.

Says whether the unsuffixed sibling was even a contender, because the two
cases call for different curator action: if it drew genomes here, this is a
close call worth a look; if it drew none, the NCBI name's genomes simply do
not sit in the type clade at all, which is a stronger statement.
"""
base = non_type_clade(chosen)
sibling = weights.get(base)
if sibling:
return (
f"{chosen} is not the type-anchored clade; GTDB reserves {base} for the "
f"lineage holding the nomenclatural type, which drew {int(sibling)} genomes here (#374)"
)
return (
f"{chosen} is not the type-anchored clade; GTDB reserves {base} for the "
f"lineage holding the nomenclatural type, which drew no genomes here (#374)"
)


def _curie(name: str, prefix: str) -> str:
return f"GTDB:{prefix}__" + name.replace(" ", "_")

Expand Down Expand Up @@ -584,6 +636,15 @@ def resolve_higher(
"is_reclassified": top != _clean_label(label),
"via": f"ncbi_rank_{prefix}",
"n_alt": len(weights),
# The majority answer is a non-type clade, so it disagrees with
# GTDB's own naming rule (#374). Reported, not resolved: the
# curator decides. Key omitted entirely when there is no
# conflict, so its presence always means something.
**(
{"non_type_clade_warning": _non_type_warning(top, weights)}
if non_type_clade(top)
else {}
),
}
# Same tie-break as `top` above: ties here were still row-ordered, so the
# AMBIGUOUS option list a curator reads was not reproducible (#382 review).
Expand Down Expand Up @@ -1913,6 +1974,12 @@ def main(argv: list[str] | None = None) -> int:
# record's *nitrite* oxidizer. The number alone did not say "look here".
near = " ⚠ NEAR-TIE" if _is_near_tie(g.get("majority_fraction")) else ""
print(f" majority : {g['majority_fraction']}{via}{of}{thin}{near}")
# Printed, not just stored (#374). The whole value of flagging rather
# than resolving is that a curator sees the conflict at the moment they
# ground the taxon; a key that only ever appears in emitted YAML would
# be found later or not at all.
if g.get("non_type_clade_warning"):
print(f" ⚠ NON-TYPE : {g['non_type_clade_warning']}")
if args.emit_yaml:
print(" --- gtdb_classification block ---")
for line in emit_block(g, mapping_source).splitlines():
Expand Down
156 changes: 156 additions & 0 deletions tests/test_gtdb_non_type_clade_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Grounding to a non-type clade is reported, not resolved (#374).

`gtdb_ground.py` grounds a higher-rank NCBI taxon to whichever GTDB taxon holds
the most genomes. GTDB names by **nomenclatural type**: the lineage containing
the type species keeps the unsuffixed name and the rest take alphabetic
suffixes. The two rules disagree whenever a non-type clade is more heavily
sequenced.

Verified against GTDB R226 (the mapping this repo reads), `Enterococcus` grounds
to `g__Enterococcus_B` at 0.598 while *E. faecalis* — the type species — sits in
`g__Enterococcus`, which drew 9904 of the 28462 genomes. The other four taxa
where the denominators differ agree only because the majority happens to be the
type clade, which is luck rather than construction.

**Reported, not resolved, by decision.** Preferring the type clade would change
what every existing grounding means on the evidence of one live case, and the
majority answer is not obviously wrong — someone asking for "NCBI genus X" may
well want the clade most of X's genomes are in. So the tool emits both and the
curator decides.

The suffix is not a heuristic: it is GTDB's own published marker for "this clade
does not contain the type". That is why the check can be a regex and still be
correct.
"""

from __future__ import annotations

import importlib.util
import pathlib

import pytest

REPO = pathlib.Path(__file__).parent.parent
SCRIPT = REPO / "scripts/gtdb_ground.py"


@pytest.fixture(scope="module")
def gtdb():
spec = importlib.util.spec_from_file_location("gtdb_ground_under_test", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


@pytest.mark.parametrize(
("name", "expected_base"),
[
# Every non-type clade actually present in the KB today.
("Enterococcus_B", "Enterococcus"),
("Cetobacterium_A", "Cetobacterium"),
("Methanobrevibacter_A", "Methanobrevibacter"),
("Bacillota_A", "Bacillota"),
("Clostridium_AM", "Clostridium"),
("Acidithiobacillus_A", "Acidithiobacillus"),
("Pseudomonas_E", "Pseudomonas"),
("Ruminococcus_B", "Ruminococcus"),
],
)
def test_a_suffixed_name_is_a_non_type_clade(gtdb, name, expected_base):
assert gtdb.non_type_clade(name) == expected_base


@pytest.mark.parametrize(
"name",
[
# The type-anchored names themselves.
"Enterococcus",
"Pseudomonas",
"Bacillus",
"Leptospirillum",
# A binomial is not a genus name; the species path does not get this
# warning, and passing one here must not produce a false positive.
"Ruminococcus_B gnavus",
"Clostridium_AM drakei",
# Genuine names that merely contain characters the pattern touches.
"Candidatus Nitrosocosmicus",
"CAG-267",
"",
],
)
def test_these_are_not_flagged(gtdb, name):
assert gtdb.non_type_clade(name) is None


def test_the_warning_names_the_type_clade_and_its_support(gtdb):
"""The message has to tell a curator what to compare against.

Numbers from the real Enterococcus case: the type clade is not a fringe
option, it drew 9904 genomes against the winner's 17011, which is exactly
the situation where a curator's judgement is worth having.
"""
warning = gtdb._non_type_warning(
"Enterococcus_B", {"Enterococcus_B": 17011, "Enterococcus": 9904}
)

assert "Enterococcus_B is not the type-anchored clade" in warning
assert "reserves Enterococcus for" in warning
assert "9904 genomes" in warning
assert "#374" in warning


def test_the_warning_distinguishes_a_type_clade_with_no_genomes(gtdb):
"""Different curator action, so a different sentence.

If the type clade drew nothing, the NCBI name's genomes simply do not sit in
it — a stronger statement than a close call, and it should not read as one.
"""
warning = gtdb._non_type_warning("Cetobacterium_A", {"Cetobacterium_A": 40})

assert "drew no genomes here" in warning
assert "0 genomes" not in warning, "a bare 0 reads like a close call that lost"


def _ground(name: str) -> str:
"""Run the real CLI against the real GTDB mapping."""
import subprocess

result = subprocess.run(
["uv", "run", "python", str(SCRIPT), "--name", name],
capture_output=True,
text=True,
cwd=REPO,
)
return result.stdout


@pytest.mark.integration
def test_enterococcus_warns_and_still_returns_the_majority_answer():
"""End-to-end on the one taxon in the KB where the rules actually disagree.

Flagging must not change the answer — that was the decision. If someone
later makes this prefer the type clade, the second assertion goes red and
they have to say so deliberately.
"""
out = _ground("Enterococcus")
if "GTDB taxon" not in out:
pytest.skip("local GTDB mapping unavailable")

assert "NON-TYPE" in out, "the one real conflict in the KB produced no warning"
assert "g__Enterococcus_B" in out, "the majority answer was silently overridden"


@pytest.mark.integration
@pytest.mark.parametrize("name", ["Pseudomonas", "Acetobacter", "Leptospirillum", "Bacillus"])
def test_the_four_that_agree_do_not_warn(name):
"""The other four taxa where the denominators differ.

They agree with the type rule only because the majority happens to be the
type clade. A warning here would be a false positive on 4 of 5 cases, which
would train a curator to ignore it.
"""
out = _ground(name)
if "GTDB taxon" not in out:
pytest.skip("local GTDB mapping unavailable")

assert "NON-TYPE" not in out, f"{name} agrees with the type rule but was flagged"
Loading