diff --git a/apps/cli/run_subfamily_confirm.py b/apps/cli/run_subfamily_confirm.py index 273c5497b..b2df7a7ef 100755 --- a/apps/cli/run_subfamily_confirm.py +++ b/apps/cli/run_subfamily_confirm.py @@ -118,7 +118,7 @@ def _atomic_promote_pair(curated_id: Path, curated_pep: Path, final_id: Path, fi tmp_pep.write_text(curated_pep.read_text(encoding="utf-8", errors="ignore"), encoding="utf-8") tmp_ids = _read_id_list(tmp_id) tmp_fasta_ids = _read_fasta_ids(tmp_pep) - if tmp_ids != tmp_fasta_ids: + if sorted(tmp_ids) != sorted(tmp_fasta_ids): tmp_id.unlink(missing_ok=True) tmp_pep.unlink(missing_ok=True) raise RuntimeError(f"Temporary final member validation failed for {final_id.stem}") @@ -160,7 +160,7 @@ def _promote_curated_members_to_final(final_dir: Path, prefixes: Iterable[str], if not curated_ids: raise ValueError(f"Curated member ID file is empty: {curated_id}") fasta_ids = _read_fasta_ids(curated_pep) - if curated_ids != fasta_ids: + if sorted(curated_ids) != sorted(fasta_ids): raise ValueError( f"Curated member ID/FASTA mismatch for {member_prefix}: " f"{len(curated_ids)} IDs vs {len(fasta_ids)} FASTA records" @@ -179,7 +179,7 @@ def _promote_curated_members_to_final(final_dir: Path, prefixes: Iterable[str], promoted_ids = _read_id_list(final_id) promoted_fasta_ids = _read_fasta_ids(final_pep) - if promoted_ids != curated_ids or promoted_fasta_ids != curated_ids: + if sorted(promoted_ids) != sorted(curated_ids) or sorted(promoted_fasta_ids) != sorted(curated_ids): raise RuntimeError(f"Post-promotion final member validation failed for {member_prefix}") promotions.append({ "prefix": member_prefix, diff --git a/gfa_core/mas/effective_members.py b/gfa_core/mas/effective_members.py index 2c7efac23..d44b9d2b1 100755 --- a/gfa_core/mas/effective_members.py +++ b/gfa_core/mas/effective_members.py @@ -259,7 +259,7 @@ def _build_result( id_list = _read_ids(path_id) if path_id else [] pep_ids = _read_fasta_ids(path_pep) if path_pep else [] - if path_id and path_pep and id_list != pep_ids: + if path_id and path_pep and sorted(id_list) != sorted(pep_ids): raise MemberResolutionError(f"Member ID/FASTA mismatch: {path_id} vs {path_pep}") member_count = len(id_list or pep_ids) warnings = [] diff --git a/tests/unit/test_effective_member_resolver.py b/tests/unit/test_effective_member_resolver.py index 433f30b83..7f3fc16be 100755 --- a/tests/unit/test_effective_member_resolver.py +++ b/tests/unit/test_effective_member_resolver.py @@ -116,6 +116,49 @@ def test_pre_confirm_prefers_curated_before_identified(tmp_path: Path): assert result.member_count == 2 +def test_pre_confirm_accepts_same_curated_members_in_different_order(tmp_path: Path): + ws = tmp_path / "workspace" / "job1" + final_dir = ws / "final" + input_dir = tmp_path / "input" / "job1" + prefix = "Target.Sl.GRAS" + + _write(final_dir / f"{prefix}.curated_members.id", "gene1\ngene2\n") + _write(final_dir / f"{prefix}.curated_members.pep.fa", _pep(["gene2", "gene1"])) + + result = resolve_effective_members( + workspace=ws, + final_dir=final_dir, + input_dir=input_dir, + prefix=prefix, + phase="pre_confirm", + require="pair", + ) + + assert result.member_count == 2 + assert result.path_id == final_dir / f"{prefix}.curated_members.id" + assert result.path_pep == final_dir / f"{prefix}.curated_members.pep.fa" + + +def test_pre_confirm_rejects_different_curated_members_with_equal_counts(tmp_path: Path): + ws = tmp_path / "workspace" / "job1" + final_dir = ws / "final" + input_dir = tmp_path / "input" / "job1" + prefix = "Target.Sl.GRAS" + + _write(final_dir / f"{prefix}.curated_members.id", "gene1\ngene2\n") + _write(final_dir / f"{prefix}.curated_members.pep.fa", _pep(["gene1", "gene3"])) + + with pytest.raises(MemberResolutionError, match="ID/FASTA mismatch"): + resolve_effective_members( + workspace=ws, + final_dir=final_dir, + input_dir=input_dir, + prefix=prefix, + phase="pre_confirm", + require="pair", + ) + + def test_subfamily_evidence_required_rejects_unreviewed_identified_members(tmp_path: Path): ws = tmp_path / "workspace" / "job1" final_dir = ws / "final" diff --git a/tests/unit/test_subfamily_confirm_member_promotion.py b/tests/unit/test_subfamily_confirm_member_promotion.py index 17912eca0..82547a8e6 100755 --- a/tests/unit/test_subfamily_confirm_member_promotion.py +++ b/tests/unit/test_subfamily_confirm_member_promotion.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from apps.cli.run_subfamily_confirm import _promote_curated_members_to_final @@ -25,3 +27,37 @@ def test_promote_curated_members_to_final_records_checksums(tmp_path: Path): assert promotion["source_curated_id_checksum"] assert (final_dir / f"{prefix}.final_members.id").read_text(encoding="utf-8").splitlines() == curated_ids assert (final_dir / f"{prefix}.final_members.pep.fa").exists() + + +def test_promote_curated_members_accepts_same_members_in_different_order(tmp_path: Path): + final_dir = tmp_path / "final" + final_dir.mkdir() + prefix = "Target.Sl.GRAS" + (final_dir / f"{prefix}.curated_members.id").write_text("gene1\ngene2\n", encoding="utf-8") + curated_pep = ">gene2\nMT\n>gene1\nMA\n" + (final_dir / f"{prefix}.curated_members.pep.fa").write_text(curated_pep, encoding="utf-8") + labels = { + "gene1": {"confirmed_subfamily": "GRAS-a"}, + "gene2": {"confirmed_subfamily": "GRAS-b"}, + } + + promotions = _promote_curated_members_to_final(final_dir, [prefix], labels) + + assert promotions[0]["count"] == 2 + assert (final_dir / f"{prefix}.final_members.id").read_text(encoding="utf-8") == "gene1\ngene2\n" + assert (final_dir / f"{prefix}.final_members.pep.fa").read_text(encoding="utf-8") == curated_pep + + +def test_promote_curated_members_rejects_different_members_with_equal_counts(tmp_path: Path): + final_dir = tmp_path / "final" + final_dir.mkdir() + prefix = "Target.Sl.GRAS" + (final_dir / f"{prefix}.curated_members.id").write_text("gene1\ngene2\n", encoding="utf-8") + (final_dir / f"{prefix}.curated_members.pep.fa").write_text(">gene1\nMA\n>gene3\nMT\n", encoding="utf-8") + labels = { + "gene1": {"confirmed_subfamily": "GRAS-a"}, + "gene2": {"confirmed_subfamily": "GRAS-b"}, + } + + with pytest.raises(ValueError, match="ID/FASTA mismatch"): + _promote_curated_members_to_final(final_dir, [prefix], labels)