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
15 changes: 10 additions & 5 deletions funannotate2/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
)
from .log import finishLogging, startLogging, system_info
from .utilities import (
busco_lineage_from_taxonomy,
checkfile,
choose_best_busco_species,
create_directories,
create_tmpdir,
ensure_busco_lineage,
Expand Down Expand Up @@ -903,10 +903,15 @@ def sort_gff_line(line):
logger.warning(
"Unable to access JGI taxonomy lookup, reverting to taxonomy from training data"
)
taxonomy = params["taxonomy"]
busco_tax = choose_best_busco_species(
{"superkingdom": taxonomy.get("superkingdom"), "kingdom": taxonomy.get("kingdom")}
)
taxonomy = params.get("taxonomy")
busco_tax = busco_lineage_from_taxonomy(taxonomy, default=None)
if busco_tax is None:
busco_tax = "fungi"
logger.warning(
f"Could not resolve a BUSCO lineage for species '{args.species}' "
f"(taxonomy unavailable or unrecognized); defaulting to '{busco_tax}'. "
"BUSCO completeness scoring will be unreliable for a non-fungal genome."
)
busco_model_path = ensure_busco_lineage(busco_tax, logger)

# now we can loop through the abinitio predictions and run busco for completion
Expand Down
47 changes: 47 additions & 0 deletions funannotate2/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,53 @@ def choose_best_busco_species(query_tax):
return best_taxonomy(query_tax, busco_taxonomy, exact=True)


def busco_lineage_from_taxonomy(taxonomy, default="fungi"):
"""
Resolve a valid BUSCO lineage key from a taxonomy dict, deterministically.

`predict` gets taxonomy from `lookup_taxonomy` (which returns False when the
JGI lookup fails) and falls back to the training-data taxonomy (which may
also be False for a novel organism). Passing such a value straight into
`choose_best_busco_species` either crashed (`.get()` on a bool) or, for a
non-empty but unmatched taxonomy, returned a random lineage via
`best_taxonomy`'s tie-break. This helper guards both cases and always returns
a lineage that exists in `busco_taxonomy`.

Parameters:
- taxonomy (dict or None): a taxonomy dict, or a falsy value (False/None) when unavailable.
- default (str): lineage to fall back to; must be a valid busco_taxonomy key.

Returns:
- str: a lineage key guaranteed to be present in busco_taxonomy.
"""
if not isinstance(taxonomy, dict) or not taxonomy:
return default
query = {
"superkingdom": taxonomy.get("superkingdom"),
"kingdom": taxonomy.get("kingdom"),
}

# best_taxonomy() falls back to a random tie-break when nothing matches, and
# that random pick is always itself a valid busco_taxonomy key -- so a plain
# "result not in busco_taxonomy" check can never catch it. Only trust the
# matcher when the query actually overlaps the reference on superkingdom or
# kingdom; otherwise return the default deterministically.
def _overlaps(level):
value = query.get(level)
if not isinstance(value, str):
return False
value = value.lower()
return any(
isinstance(ref.get(level), str) and ref[level].lower() == value
for ref in busco_taxonomy.values()
)

if not (_overlaps("superkingdom") or _overlaps("kingdom")):
return default
lineage = choose_best_busco_species(query)
return lineage if lineage in busco_taxonomy else default


def best_taxonomy(query, reference, exact=False):
"""
Find the best matching taxonomy in a reference dictionary based on a query taxonomy.
Expand Down
118 changes: 117 additions & 1 deletion tests/unit/test_utilities_taxonomy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
from unittest.mock import patch

import funannotate2.utilities
from funannotate2.utilities import choose_best_busco_species
from funannotate2.utilities import (
busco_lineage_from_taxonomy,
choose_best_busco_species,
)
from funannotate2.config import busco_taxonomy


Expand Down Expand Up @@ -351,3 +354,116 @@ def test_returns_valid_busco_key_always(self):
assert result in busco_taxonomy, (
f"Result '{result}' should be a valid key in busco_taxonomy"
)


class TestBuscoLineageFromTaxonomy:
"""Tests for busco_lineage_from_taxonomy.

Regression coverage for issue #93 (crash on missing taxonomy) plus the
latent random-lineage path: a non-empty taxonomy that matches nothing in
busco_taxonomy must resolve to the default deterministically, not to a
random tie-broken lineage.
"""

def test_false_returns_default_without_crashing(self):
# Issue #93: predict() fed a `False` taxonomy straight into `.get()`,
# raising `AttributeError: 'bool' object has no attribute 'get'`.
assert busco_lineage_from_taxonomy(False) == "fungi"

def test_none_returns_default(self):
assert busco_lineage_from_taxonomy(None) == "fungi"

def test_empty_dict_returns_default(self):
assert busco_lineage_from_taxonomy({}) == "fungi"

def test_real_match_returns_valid_lineage(self):
# Exact, deterministic match -- not merely "some valid key", which would
# also pass if _overlaps() regressed and everything silently defaulted.
result = busco_lineage_from_taxonomy(
{"superkingdom": "Eukaryota", "kingdom": "Fungi"}
)
assert result == "fungi"

def test_partial_but_matching_dict_returns_valid_lineage(self):
# superkingdom-only match resolves to the broad "eukaryota" lineage.
result = busco_lineage_from_taxonomy({"superkingdom": "Eukaryota"})
assert result == "eukaryota"

def test_non_matching_dict_is_deterministic_default(self):
# A non-empty dict whose values match nothing would otherwise fall
# through best_taxonomy to random.choice(); the helper must not.
results = {
busco_lineage_from_taxonomy(
{"superkingdom": "Bacteria", "kingdom": "Nonexistent"}
)
for _ in range(50)
}
assert results == {"fungi"}

def test_all_none_is_deterministic_default(self):
results = {
busco_lineage_from_taxonomy({"superkingdom": None, "kingdom": None})
for _ in range(50)
}
assert results == {"fungi"}

def test_result_is_always_a_valid_lineage(self):
inputs = [
False,
None,
{},
{"superkingdom": "Eukaryota", "kingdom": "Fungi"},
{"superkingdom": "Eukaryota"},
{"superkingdom": "Bacteria", "kingdom": "Nonexistent"},
]
for tax in inputs:
assert busco_lineage_from_taxonomy(tax) in busco_taxonomy

def test_default_none_signals_unresolved(self):
# predict() passes default=None to detect when it must warn and fall back.
assert busco_lineage_from_taxonomy(False, default=None) is None
assert (
busco_lineage_from_taxonomy(
{"superkingdom": "Bacteria", "kingdom": "Nonexistent"}, default=None
)
is None
)
# a genuine match still returns a real lineage, not the sentinel
assert (
busco_lineage_from_taxonomy(
{"superkingdom": "Eukaryota", "kingdom": "Fungi"}, default=None
)
in busco_taxonomy
)

def test_default_is_overridable(self):
assert (
busco_lineage_from_taxonomy(False, default="eukaryota") == "eukaryota"
)

def test_every_kingdom_resolves_deterministically(self):
# The determinism guarantee currently rests on the busco_taxonomy data
# shape, not on structure. Sweep every real kingdom with no superkingdom
# (the shape most likely to reach best_taxonomy's random tie-break) and
# assert a single stable result across many runs, so a future reference
# edit that reintroduces a random pick fails here instead of shipping.
kingdoms = {
v.get("kingdom") for v in busco_taxonomy.values() if v.get("kingdom")
}
for kingdom in kingdoms:
results = {
busco_lineage_from_taxonomy({"superkingdom": None, "kingdom": kingdom})
for _ in range(25)
}
assert len(results) == 1, f"non-deterministic lineage for {kingdom}: {results}"
assert results.pop() in busco_taxonomy

def test_non_string_values_do_not_crash(self):
# Guards the isinstance(value, str) checks in _overlaps: odd taxonomy
# values must degrade to a valid default, never raise.
for tax in [
{"superkingdom": 123, "kingdom": ["Fungi"]},
{"superkingdom": {"x": 1}, "kingdom": None},
{"superkingdom": "", "kingdom": ""},
]:
assert busco_lineage_from_taxonomy(tax) in busco_taxonomy
Loading