From 5ad9e16260357b2a90b078bd9369dfde3efad709 Mon Sep 17 00:00:00 2001 From: wilke Date: Fri, 21 Aug 2026 17:04:32 -0500 Subject: [PATCH] Fix #114: select the OpenFold query dir by content, not by exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openfold3 0.4.5 writes msas/ into the output dir whenever it generates MSAs (ColabFold server, RNA dummy MSA). The normalizer took the first directory that wasn't raw/ from an UNORDERED iterdir(), so O02 and O04 failed on mango with 'No seed_* directories in .../msas' while the four upload-path cases passed. Only a directory containing seed_* children now qualifies; the error message lists what was found instead. The regression test plants a msas/ distractor that sorts before the query dir, so it reproduces the failure deterministically instead of depending on filesystem order — verified failing on the old code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DEWo4xvL59PHC1QUvtTV9F --- predict_structure/normalizers.py | 22 ++++++++---- tests/test_normalizers.py | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/predict_structure/normalizers.py b/predict_structure/normalizers.py index c19f370..db73083 100644 --- a/predict_structure/normalizers.py +++ b/predict_structure/normalizers.py @@ -821,16 +821,26 @@ def normalize_openfold_output(raw_dir: Path, output_dir: Path) -> Path: """ output_dir.mkdir(parents=True, exist_ok=True) - # Find the first query subdirectory - query_dirs = [d for d in raw_dir.iterdir() if d.is_dir() and d.name != "raw"] + # Find the query subdirectory BY CONTENT: only a directory holding + # seed_* subdirectories is a query dir. Excluding known names is not + # enough — openfold3 0.4.5 writes a msas/ directory into the output + # dir whenever it generates MSAs (ColabFold server, RNA dummy MSA), + # and iterdir() is unordered, so "first dir that isn't raw/" grabbed + # msas/ non-deterministically and normalization died (#114). + candidates = sorted(d for d in raw_dir.iterdir() if d.is_dir() and d.name != "raw") + query_dirs = [ + d for d in candidates + if any(c.is_dir() and c.name.startswith("seed_") for c in d.iterdir()) + ] if not query_dirs: - raise FileNotFoundError(f"No query output directories in {raw_dir}") + found = ", ".join(d.name for d in candidates) or "" + raise FileNotFoundError( + f"No query output directory with seed_* subdirectories in " + f"{raw_dir} (directories found: {found})" + ) query_dir = query_dirs[0] - # Find the first seed subdirectory seed_dirs = sorted(d for d in query_dir.iterdir() if d.is_dir() and d.name.startswith("seed_")) - if not seed_dirs: - raise FileNotFoundError(f"No seed_* directories in {query_dir}") seed_dir = seed_dirs[0] # Find model CIF files and select the best sample by ranking score diff --git a/tests/test_normalizers.py b/tests/test_normalizers.py index c443151..05619e3 100644 --- a/tests/test_normalizers.py +++ b/tests/test_normalizers.py @@ -546,6 +546,64 @@ def test_bfactor_scaling(self, tmp_path, tmp_output): class TestNormalizeOpenFoldOutput: + @staticmethod + def _make_query_output(raw, query_name="prediction"): + """Minimal valid OF3 query output: /seed_42/ with model + confidences.""" + import json as _json + seed_dir = raw / query_name / "seed_42" + seed_dir.mkdir(parents=True) + pdb_content = ( + "ATOM 1 CA ALA A 1 1.000 2.000 3.000 1.00 85.00 C\n" + "ATOM 2 CA GLY A 2 4.000 5.000 6.000 1.00 72.00 C\n" + "END\n" + ) + pdb_tmp = seed_dir / "temp.pdb" + pdb_tmp.write_text(pdb_content) + from predict_structure.converters import pdb_to_mmcif + pdb_to_mmcif(pdb_tmp, seed_dir / "prediction_seed_42_sample_1_model.cif") + pdb_tmp.unlink() + (seed_dir / "prediction_seed_42_sample_1_confidences_aggregated.json").write_text( + _json.dumps({"avg_plddt": 78.5, "ptm": 0.88, "iptm": 0.85, + "sample_ranking_score": 0.75}) + ) + (seed_dir / "prediction_seed_42_sample_1_confidences.json").write_text( + _json.dumps({"plddt": [85.0, 72.0]}) + ) + + def test_msas_dir_is_not_mistaken_for_query_dir(self, tmp_path, tmp_output): + """openfold3 0.4.5 writes msas/ into the output dir (#114). + + 'msas' sorts before 'prediction', so with the old first-directory + selection this reproduces the O02/O04 production failure + deterministically rather than depending on iterdir() order. + """ + import json + from predict_structure.normalizers import normalize_openfold_output + + raw = tmp_path / "raw" + self._make_query_output(raw) + msas = raw / "msas" + msas.mkdir() + (msas / "colabfold_output.a3m").write_text(">query\nACDEF\n") + + normalize_openfold_output(raw, tmp_output) + + assert (tmp_output / "predictions" / "model_1.cif").exists() + data = json.loads((tmp_output / "predictions" / "confidence.json").read_text()) + assert data["plddt_mean"] == 78.5 + + def test_no_query_dir_error_lists_what_was_found(self, tmp_path, tmp_output): + """A tree with only msas/ (no seed_* anywhere) must fail with a + message naming the directories present, not a bare path.""" + import pytest + from predict_structure.normalizers import normalize_openfold_output + + raw = tmp_path / "raw" + (raw / "msas").mkdir(parents=True) + + with pytest.raises(FileNotFoundError, match="seed_.*msas"): + normalize_openfold_output(raw, tmp_output) + def test_normalize(self, tmp_path, tmp_output): from predict_structure.normalizers import normalize_openfold_output