Files
PlantAI-dev/tests/unit/test_ppi_id_resolution.py
T
2026-08-03 18:42:47 +08:00

686 lines
22 KiB
Python
Executable File

from __future__ import annotations
import importlib.util
import multiprocessing
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
def load_ppi_module():
module_name = "ppi_skill_task3"
sys.modules.pop(module_name, None)
spec = importlib.util.spec_from_file_location(
module_name, REPO_ROOT / "skills/ppi/run.py"
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
class FakeStringClient:
def __init__(self, mapping=None, network=None, network_error=None):
self.mapping = dict(mapping or {})
self.network = [] if network is None else network
self.network_error = network_error
self.mapping_calls = []
self.network_calls = []
self.species_id = "3562"
def get_string_ids(self, identifiers):
self.mapping_calls.append(list(identifiers))
rows = []
for query_index, identifier in enumerate(identifiers):
match = self.mapping.get(identifier)
if match is None:
continue
string_id, preferred_name = (
match if isinstance(match, tuple) else (match, identifier)
)
rows.append(
{
"queryIndex": query_index,
"queryItem": identifier,
"stringId": string_id,
"preferredName": preferred_name,
"ncbiTaxonId": 3562,
}
)
return rows
def get_network(self, identifiers, required_score=200, network_type="functional"):
self.network_calls.append((list(identifiers), required_score, network_type))
if self.network_error is not None:
raise self.network_error
return self.network
class FakeResponse:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self._payload = payload
def json(self):
if isinstance(self._payload, Exception):
raise self._payload
return self._payload
class FakeHttpClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def _next(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
if not self.responses:
raise AssertionError("unexpected HTTP request")
response = self.responses.pop(0)
if not hasattr(response, "url"):
response.url = url
return response
def post(self, url, **kwargs):
return self._next("POST", url, **kwargs)
def write_id_map(tmp_path: Path, rows: list[tuple[str, str, str]]) -> Path:
path = tmp_path / "soe.pep_cds_idmap.tsv"
lines = [
"species_code\tprotein_id\tpep_id\tcds_id\tgene_id\tcds_header\tncbi_gene_id"
]
for accession, gene_id, symbol in rows:
lines.append(
"\t".join(
[
"soe",
accession,
accession,
accession,
symbol,
f"{accession} [gene={symbol}] [db_xref=GeneID:{gene_id}]",
gene_id,
]
)
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
def origin(module, canonical, reference):
return module.PpiQueryOrigin(
canonical_target_id=canonical,
ortholog_reference_id=reference,
raw_query_id=reference,
relationship_source="orthologs.tsv",
)
def test_parse_ortholog_mapping_preserves_every_exact_relationship(tmp_path):
module = load_ppi_module()
mapping = tmp_path / "orthologs.tsv"
mapping.write_text(
"Ft1\tXP_000001.1\nFt1\tXP_000002.3\nFt2\tXP_000001.1\n",
encoding="utf-8",
)
origins = module.parse_ortholog_mapping(mapping)
assert len(origins) == 3
assert [(item.canonical_target_id, item.ortholog_reference_id) for item in origins] == [
("Ft1", "XP_000001.1"),
("Ft1", "XP_000002.3"),
("Ft2", "XP_000001.1"),
]
assert all(item.raw_query_id == item.ortholog_reference_id for item in origins)
def make_skill(module, client):
return module.PpiSkill(string_api_factory=lambda _species: client)
def test_cli_forwards_id_map_to_ppi_skill(tmp_path, monkeypatch):
module = load_ppi_module()
captured = {}
class FakePpiSkill:
def __init__(self, config_path=None):
captured["config_path"] = config_path
def run(self, **kwargs):
captured.update(kwargs)
return {"status": "success", "manifest_path": "manifest.json", "metrics": {}}
monkeypatch.setattr(module, "PpiSkill", FakePpiSkill)
monkeypatch.setattr(
sys,
"argv",
[
"run.py",
"--gene-ids",
str(tmp_path / "genes.txt"),
"--species",
"soe",
"--prefix",
"Ft.SPL",
"--output",
str(tmp_path / "ppi"),
"--id-map",
"/tmp/soe.pep_cds_idmap.tsv",
],
)
with pytest.raises(SystemExit) as exc_info:
module.main()
assert exc_info.value.code == 0
assert captured["id_map_file"] == Path("/tmp/soe.pep_cds_idmap.tsv")
def test_owner_taxon_query_uses_normalization_label_for_strict_alias_species(tmp_path):
module = load_ppi_module()
id_map = tmp_path / "string_taxon_4555.pep_cds_idmap.tsv"
id_map.write_text(
"species_code\tprotein_id\tpep_id\tcds_id\tgene_id\tcds_header\n"
"string_taxon_4555\tSeita.1\tSeita.1\tSeita.1\tSeita1\tSeita.1 [gene=Seita1]\n",
encoding="utf-8",
)
class OwnerTaxonClient:
species_id = "4555"
def get_string_ids(self, identifiers):
index = identifiers.index("Seita.1")
return [
{
"queryIndex": index,
"queryItem": "Seita.1",
"stringId": "4555.Seita.1",
"preferredName": "Seita.1",
"ncbiTaxonId": 4555,
}
]
result = make_skill(module, OwnerTaxonClient()).resolve_query_ids(
[origin(module, "SvGene1", "Seita.1")],
"4555",
id_map,
normalization_species_label="string_taxon_4555",
)
assert result.status == "success"
assert result.selected_string_ids == ["4555.Seita.1"]
def test_raw_xp_failure_falls_back_to_ncbi_geneid_and_selects_string_id(tmp_path):
module = load_ppi_module()
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
client = FakeStringClient({"1001": ("3562.PROT1", "PROT1")})
result = make_skill(module, client).resolve_query_ids(
[origin(module, "Ft1", "XP_000001.1")], "soe", id_map
)
assert result.selected_string_ids == ["3562.PROT1"]
assert result.rows[0].mapping_status == "resolved"
assert result.rows[0].selected_query_id == "1001"
assert result.rows[0].relationship_source == "orthologs.tsv"
assert result.rows[0].evidence_source == str(id_map)
assert result.metrics["geneid_fallback_resolved_query_count"] == 1
def test_raw_and_geneid_mapping_to_same_string_id_is_not_ambiguous(tmp_path):
module = load_ppi_module()
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
client = FakeStringClient(
{"XP_000001.1": "3562.PROT1", "1001": "3562.PROT1"}
)
result = make_skill(module, client).resolve_query_ids(
[origin(module, "Ft1", "XP_000001.1")], "soe", id_map
)
assert result.rows[0].mapping_status == "resolved"
assert result.metrics["ambiguous_member_count"] == 0
def test_one_input_mapping_to_two_distinct_string_ids_is_ambiguous(tmp_path):
module = load_ppi_module()
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
client = FakeStringClient(
{"XP_000001.1": "3562.PROT1", "1001": "3562.PROT2"}
)
result = make_skill(module, client).resolve_query_ids(
[origin(module, "Ft1", "XP_000001.1")], "soe", id_map
)
assert result.selected_string_ids == []
assert result.rows[0].mapping_status == "ambiguous"
assert result.rows[0].reason == "candidate_string_ids_conflict"
def test_partial_resolution_continues_with_explicit_unresolved_rows():
module = load_ppi_module()
client = FakeStringClient({"XP_1.1": "3562.PROT1"})
origins = [
origin(module, "Ft1", "XP_1.1"),
origin(module, "Ft2", "XP_2.1"),
]
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
assert [row.mapping_status for row in result.rows] == ["resolved", "unresolved"]
assert result.metrics["probe_status"] == "partial"
assert result.selected_string_ids == ["3562.PROT1"]
def test_zero_resolved_ids_returns_id_resolution_failure():
module = load_ppi_module()
result = make_skill(module, FakeStringClient()).resolve_query_ids(
[origin(module, "Ft1", "XP_1.1")], "soe", None
)
assert result.status == "id_resolution_failure"
assert result.issue_code == "ppi_string_id_resolution_failed"
assert result.metrics["probe_status"] == "failed"
def test_network_empty_list_is_success_empty_network():
module = load_ppi_module()
client = FakeStringClient(
{"XP_1.1": "3562.PROT1"}, network=[]
)
result = make_skill(module, client).analyze_network(
"soe",
origins=[origin(module, "Ft1", "XP_1.1")],
canonical_target_ids=["Ft1"],
)
assert result["interactions"] == []
assert result["stats"]["network_status"] == "empty_network"
def test_network_http_error_is_external_api_failure():
module = load_ppi_module()
error = module.StringApiError("network failed", kind="http", status_code=503)
client = FakeStringClient(
{"XP_1.1": "3562.PROT1"}, network_error=error
)
with pytest.raises(module.StringApiError) as exc_info:
make_skill(module, client).analyze_network(
"soe",
origins=[origin(module, "Ft1", "XP_1.1")],
canonical_target_ids=["Ft1"],
)
assert exc_info.value.kind == "http"
assert exc_info.value.status_code == 503
def test_get_network_classifies_http_response_failure():
module = load_ppi_module()
api = module.StringAPI(
"soe",
http_client=FakeHttpClient([FakeResponse(status_code=503)]),
stable_api_base="https://stable.string-db.org/api",
rate_gate=SimpleNamespace(wait=lambda: None),
)
with pytest.raises(module.StringApiError) as exc_info:
api.get_network(["3562.PROT1"])
assert exc_info.value.kind == "http"
assert exc_info.value.status_code == 503
def test_get_network_classifies_json_decode_failure():
module = load_ppi_module()
api = module.StringAPI(
"soe",
http_client=FakeHttpClient([FakeResponse(payload=ValueError("invalid JSON"))]),
stable_api_base="https://stable.string-db.org/api",
rate_gate=SimpleNamespace(wait=lambda: None),
)
with pytest.raises(module.StringApiError) as exc_info:
api.get_network(["3562.PROT1"])
assert exc_info.value.kind == "json"
def test_get_string_ids_uses_query_index_not_response_position(tmp_path):
module = load_ppi_module()
response = FakeResponse(
payload=[
{
"queryIndex": 1,
"queryItem": "B",
"stringId": "3562.B",
"preferredName": "B",
"ncbiTaxonId": 3562,
},
{
"queryIndex": 0,
"queryItem": "A",
"stringId": "3562.A",
"preferredName": "A",
"ncbiTaxonId": 3562,
},
]
)
http = FakeHttpClient([response])
api = module.StringAPI(
"soe",
http_client=http,
stable_api_base="https://stable.string-db.org/api",
rate_gate=SimpleNamespace(wait=lambda: None),
)
rows = api.get_string_ids(["A", "B"])
assert [row["queryItem"] for row in rows] == ["A", "B"]
method, url, kwargs = http.calls[0]
assert method == "POST"
assert url == "https://stable.string-db.org/api/json/get_string_ids"
assert kwargs["data"]["echo_query"] == 1
assert kwargs["data"]["caller_identity"] == "PlantAI"
assert kwargs["timeout"] == 30
def test_24_origins_collapse_to_12_queries_without_losing_provenance(tmp_path):
module = load_ppi_module()
aliases = []
origins = []
mapping = {}
for index in range(12):
accession = f"XP_{index:06d}.1"
gene_id = str(1000 + index)
aliases.append((accession, gene_id, f"LOC{gene_id}"))
string_id = f"3562.PROT{index}"
mapping[gene_id] = string_id
if index < 5:
mapping[accession] = string_id
origins.extend(
[
origin(module, f"Ft{index * 2 + 1}", accession),
origin(module, f"Ft{index * 2 + 2}", accession),
]
)
id_map = write_id_map(tmp_path, aliases)
client = FakeStringClient(mapping, network=[])
result = make_skill(module, client).resolve_query_ids(origins, "soe", id_map)
assert len(result.rows) == 24
assert [
(row.canonical_target_id, row.ortholog_reference_id) for row in result.rows
] == [
(item.canonical_target_id, item.ortholog_reference_id) for item in origins
]
assert len(client.mapping_calls) == 1
assert result.metrics == {
"input_member_count": 24,
"relationship_count": 24,
"unique_reference_query_count": 12,
"raw_resolved_query_count": 5,
"geneid_fallback_resolved_query_count": 7,
"resolved_reference_query_count": 12,
"selected_string_query_count": 12,
"resolved_member_count": 24,
"unresolved_member_count": 0,
"ambiguous_member_count": 0,
"member_mapping_rate": 1.0,
"query_mapping_rate": 1.0,
"probe_status": "complete",
}
def test_run_resolves_relationships_submits_selected_string_ids_and_writes_provenance(
tmp_path,
):
module = load_ppi_module()
gene_file = tmp_path / "members.id"
gene_file.write_text("Ft1\nFt2\n", encoding="utf-8")
ortholog_file = tmp_path / "orthologs.tsv"
ortholog_file.write_text(
"Ft1\tXP_000001.1\nFt2\tXP_000001.1\n", encoding="utf-8"
)
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
client = FakeStringClient({"1001": ("3562.PROT1", "PROT1")}, network=[])
output_dir = tmp_path / "ppi"
result = module.PpiSkill(
string_api_factory=lambda _species: client
).run(
gene_ids_file=gene_file,
species="soe",
prefix="Ft.SPL",
output_dir=output_dir,
required_score=200,
mapping_file=ortholog_file,
id_map_file=id_map,
)
assert result["status"] == "success"
assert client.network_calls == [(["3562.PROT1"], 200, "functional")]
assert result["metrics"]["network_status"] == "empty_network"
assert result["metrics"]["id_resolution"]["relationship_count"] == 2
assert (output_dir / "Ft.SPL_network.tsv").read_text(encoding="utf-8") == (
"protein1\tprotein2\tscore\n"
)
report = output_dir / "Ft.SPL_id_resolution.tsv"
lines = report.read_text(encoding="utf-8").splitlines()
assert lines[0].split("\t") == [
"canonical_target_id",
"ortholog_reference_id",
"raw_query_id",
"candidate_gene_id",
"selected_query_id",
"string_id",
"string_preferred_name",
"mapping_status",
"method",
"evidence_source",
"relationship_source",
"species",
"taxon_id",
"reason",
]
assert [line.split("\t")[:3] for line in lines[1:]] == [
["Ft1", "XP_000001.1", "XP_000001.1"],
["Ft2", "XP_000001.1", "XP_000001.1"],
]
assert all(line.split("\t")[10] == str(ortholog_file) for line in lines[1:])
def test_run_direct_target_raw_candidate_uses_canonical_member_evidence(tmp_path):
module = load_ppi_module()
gene_file = tmp_path / "members.id"
gene_file.write_text("DIRECT1\n", encoding="utf-8")
client = FakeStringClient({"DIRECT1": "3562.DIRECT1"}, network=[])
output_dir = tmp_path / "direct"
result = module.PpiSkill(string_api_factory=lambda _species: client).run(
gene_ids_file=gene_file,
species="soe",
prefix="Soe.Direct",
output_dir=output_dir,
)
assert result["status"] == "success"
report_lines = (output_dir / "Soe.Direct_id_resolution.tsv").read_text(
encoding="utf-8"
).splitlines()
header = report_lines[0].split("\t")
row = dict(zip(header, report_lines[1].split("\t")))
assert row["ortholog_reference_id"] == ""
assert row["evidence_source"] == "canonical_member_input"
assert row["relationship_source"] == str(gene_file)
def test_run_counts_canonical_member_missing_ortholog_as_unresolved(tmp_path):
module = load_ppi_module()
gene_file = tmp_path / "members.id"
gene_file.write_text("Ft1\nFt2\n", encoding="utf-8")
ortholog_file = tmp_path / "orthologs.tsv"
ortholog_file.write_text("Ft1\tXP_000001.1\n", encoding="utf-8")
id_map = write_id_map(tmp_path, [("XP_000001.1", "1001", "LOC1001")])
client = FakeStringClient({"1001": "3562.PROT1"}, network=[])
result = module.PpiSkill(string_api_factory=lambda _species: client).run(
gene_ids_file=gene_file,
species="soe",
prefix="Ft.SPL",
output_dir=tmp_path / "partial",
mapping_file=ortholog_file,
id_map_file=id_map,
)
metrics = result["metrics"]["id_resolution"]
assert metrics["input_member_count"] == 2
assert metrics["relationship_count"] == 1
assert metrics["resolved_member_count"] == 1
assert metrics["unresolved_member_count"] == 1
assert metrics["member_mapping_rate"] == 0.5
def test_many_canonical_targets_sharing_one_reference_keep_separate_rows():
module = load_ppi_module()
client = FakeStringClient({"XP_1.1": "3562.PROT1"})
origins = [origin(module, f"Ft{i}", "XP_1.1") for i in range(4)]
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
assert len(result.rows) == 4
assert {row.canonical_target_id for row in result.rows} == {
"Ft0",
"Ft1",
"Ft2",
"Ft3",
}
assert client.mapping_calls == [["XP_1.1", "XP_1"]]
def test_one_canonical_source_with_conflicting_references_is_excluded():
module = load_ppi_module()
client = FakeStringClient(
{"XP_1.1": "3562.PROT1", "XP_2.1": "3562.PROT2"}
)
origins = [
origin(module, "Ft1", "XP_1.1"),
origin(module, "Ft1", "XP_2.1"),
]
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
assert [row.mapping_status for row in result.rows] == [
"ambiguous_ortholog",
"ambiguous_ortholog",
]
assert result.selected_string_ids == []
def test_mapping_404_and_200_empty_both_mean_zero_resolution():
module = load_ppi_module()
for response in [FakeResponse(status_code=404), FakeResponse(payload=[])]:
http = FakeHttpClient([response])
api = module.StringAPI(
"soe",
http_client=http,
stable_api_base="https://stable.string-db.org/api",
rate_gate=SimpleNamespace(wait=lambda: None),
)
assert api.get_string_ids(["missing"]) == []
def test_request_throttle_uses_injected_clock_and_sleeper(tmp_path):
from gfa_core.string_rate_gate import StringRateGate
class Clock:
now = 100.0
def __init__(self):
self.sleeps = []
def time(self):
return self.now
def sleep(self, seconds):
self.sleeps.append(seconds)
self.now += seconds
clock = Clock()
gate = StringRateGate(
path=tmp_path / "gate.timestamp",
min_interval=1.0,
clock=clock.time,
sleeper=clock.sleep,
)
gate.wait()
gate.wait()
assert clock.sleeps == [pytest.approx(1.0)]
def test_two_reference_queries_converging_on_one_string_id_are_both_resolved():
module = load_ppi_module()
client = FakeStringClient(
{"XP_1.1": "3562.PROT1", "XP_2.1": "3562.PROT1"}
)
origins = [
origin(module, "Ft1", "XP_1.1"),
origin(module, "Ft1", "XP_2.1"),
]
result = make_skill(module, client).resolve_query_ids(origins, "soe", None)
assert [row.mapping_status for row in result.rows] == ["resolved", "resolved"]
assert result.selected_string_ids == ["3562.PROT1"]
assert result.metrics["resolved_reference_query_count"] == 2
assert result.metrics["selected_string_query_count"] == 1
def _gate_worker(path, interval, ready, output):
from gfa_core.string_rate_gate import StringRateGate
gate = StringRateGate(path=Path(path), min_interval=interval)
ready.wait()
gate.wait()
output.put(time.time())
def test_interprocess_rate_gate_serializes_concurrent_clients(tmp_path):
context = multiprocessing.get_context("fork")
ready = context.Event()
output = context.Queue()
interval = 0.12
processes = [
context.Process(
target=_gate_worker,
args=(str(tmp_path / "shared.timestamp"), interval, ready, output),
)
for _ in range(2)
]
for process in processes:
process.start()
ready.set()
timestamps = sorted(output.get(timeout=5) for _ in processes)
for process in processes:
process.join(timeout=5)
assert process.exitcode == 0
assert timestamps[1] - timestamps[0] >= interval * 0.85