From 8ab81fd2b88b711e8588869dcc5b083ec12c095d Mon Sep 17 00:00:00 2001 From: Ruyi Chen Date: Fri, 30 Oct 2026 12:23:00 +1000 Subject: [PATCH 1/6] Drop the token-only sw rerank mode Ranking now has two modes: tm_a (default, R_str = S_SW x TM_a) and tm (US-align). S_SW is still computed for every tm_a hit and still drives the E-value. Databases without C4' coordinates can no longer be searched; rebuild them from structures. The CLI and web API reject rerank=sw. Tests that used sw to check E-values now give their fixtures coordinates and use the default mode. --- README.md | 12 +++---- rfoldsearch/cli.py | 35 +++++++----------- rfoldsearch/search.py | 39 +++++++-------------- tests/test_experimental_empirical_evalue.py | 7 ++-- tests/test_paper_db.py | 10 +++--- tests/test_predicted_empirical_evalue.py | 14 ++++---- tests/test_search_tm.py | 10 +++--- tests/test_structure_score.py | 11 ++---- tests/test_tiebreak.py | 4 +-- webserver/README.md | 2 +- webserver/app/main.py | 4 +-- webserver/static/app.js | 5 +-- 12 files changed, 60 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index cb567da..1b06110 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,6 @@ then a reranking of the candidates. What differs is how the candidates are reran **Rstr = SSW × TMa**. `TM_a` is the mean of query- and target-length-normalised TM terms for the fixed token alignment. The E-value is still calculated from `S_SW`, not from `R_str`. -- **`sw`**: token-only Smith-Waterman ranking. It can search databases - without coordinates and reports the same Smith-Waterman E-value. - **`tm`** (RFoldSearch-TM): the candidate pool is handed to [US-align](https://github.com/pylelab/USalign) and ranked by the mean of its two length-normalised TM-scores. This measures structural similarity directly instead of scoring a token alignment, at a large cost in speed. @@ -160,7 +158,7 @@ rank target TM_mean TM_query TM_target len | Option | Category | Description | | ------------ | ----------- | ---------------------------------------------------------------------------------- | | `--top_k` | Output | Number of top hits to return (default: 10) | -| `--rerank` | Scoring | `tm_a` (default), `sw`, or `tm` | +| `--rerank` | Scoring | `tm_a` (default) or `tm` | | `--top_n` | Sensitivity | Candidate-pool depth passed to the reranker (default: 200) | | `--min_diag` | Sensitivity | k-mer hits required on a common diagonal to admit a target (default: 2) | | `--query_id` | Input | Chain id to look up inside `--query` when it is a database file | @@ -176,8 +174,8 @@ identity) expected to score at least as high as a hit by chance. Smaller values mean the hit is less likely to be a chance match. E-values are computed from the raw Smith--Waterman token-alignment score, with a composition adjustment for predicted-structure databases (below). They are -reported in the default mode and with `--rerank sw`, and they do not measure -the structure score Rstr used for ranking in the default mode. +reported in the default mode, and they do not measure the structure score +Rstr used for ranking. RFoldSearch-TM (`--rerank tm`) aligns with US-align instead and reports no E-values. @@ -284,8 +282,8 @@ import numpy as np hits = s.search(np.asarray(enc["tokens"], np.int64), query_entry=enc, top_k=10) ``` -`query_entry` is required by the default `rerank="tm_a"` and by `rerank="tm"`, -because both use query coordinates. Plain `rerank="sw"` can leave it out. +`query_entry` is required by both `rerank="tm_a"` (default) and `rerank="tm"`, +because both use query coordinates. `search_id` forwards its keyword arguments to `search`, so `top_n`, `min_diag`, `rerank` and `tie_seed` are available from both. `bits` and `evalue` are present when E-value parameters matching the database are loaded (by default, only for the diff --git a/rfoldsearch/cli.py b/rfoldsearch/cli.py index 086b767..87afb63 100644 --- a/rfoldsearch/cli.py +++ b/rfoldsearch/cli.py @@ -111,7 +111,7 @@ def cmd_search(a): head += " E-values: power-calibrated" if a.rerank == "tm": head += " rerank: RFoldSearch-TM" - elif a.rerank == "tm_a": + else: head += " rerank: R_str = S_SW x TM_a" print(head) if not hits: @@ -129,29 +129,18 @@ def cmd_search(a): f" {h['length']:>5}") return has_ev = "evalue" in hits[0] - if "structure_score" in hits[0]: - header = (f"{'rank':>4} {'target':<24}{'R_str':>10}{'TM_a':>9}" - f"{'S_SW':>10}") - if has_ev: - header += f"{'E-value':>11}" - print(header + f"{'aligned':>10} {'len':>5}") - for h in hits: - line = (f"{h['rank']:>4} {h['target']:<24}" - f"{h['structure_score']:>10.2f}{h['tm_a']:>9.4f}" - f"{h['sw_score']:>10.2f}") - if has_ev: - line += f"{h['evalue']:>11.2g}" - print(line + f"{h['aligned']:>10} {h['length']:>5}") - return - header = f"{'rank':>4} {'target':<24}{'SW_score':>10}" + header = (f"{'rank':>4} {'target':<24}{'R_str':>10}{'TM_a':>9}" + f"{'S_SW':>10}") if has_ev: - header += f"{'bits':>8}{'E-value':>11}" - print(header + f" {'len':>5}") + header += f"{'E-value':>11}" + print(header + f"{'aligned':>10} {'len':>5}") for h in hits: - line = f"{h['rank']:>4} {h['target']:<24}{h['sw_score']:>10.2f}" + line = (f"{h['rank']:>4} {h['target']:<24}" + f"{h['structure_score']:>10.2f}{h['tm_a']:>9.4f}" + f"{h['sw_score']:>10.2f}") if has_ev: - line += f"{h['bits']:>8.1f}{h['evalue']:>11.2g}" - print(line + f" {h['length']:>5}") + line += f"{h['evalue']:>11.2g}" + print(line + f"{h['aligned']:>10} {h['length']:>5}") def main(argv=None): @@ -265,8 +254,8 @@ def main(argv=None): p.add_argument("--top_n", type=int, default=DEFAULT_TOP_N) p.add_argument("--min_diag", type=int, default=DEFAULT_MIN_DIAG) p.add_argument("--rerank", choices=RERANKERS, default=DEFAULT_RERANK, - help="rerank by R_str = S_SW x TM_a (tm_a, default), " - "token Smith-Waterman (sw), or US-align TM-score (tm)") + help="rerank by R_str = S_SW x TM_a (tm_a, default) " + "or US-align TM-score (tm)") p.add_argument("--usalign", default=None, help="path to the USalign binary (default: $USALIGN, then PATH)") p.add_argument("--tie_seed", type=int, default=None, diff --git a/rfoldsearch/search.py b/rfoldsearch/search.py index 803d272..a90b4af 100644 --- a/rfoldsearch/search.py +++ b/rfoldsearch/search.py @@ -1,9 +1,8 @@ """The search pipeline: encode -> prefilter -> rerank. -Three reranking backends share the token prefilter. ``tm_a`` (default) aligns +Two reranking backends share the token prefilter. ``tm_a`` (default) aligns candidates by affine-gap token Smith-Waterman, superposes the aligned C4' -atoms and ranks by R_str = S_SW * TM_a. ``sw`` keeps the token-only -Smith-Waterman ranking. Both report the E-value of S_SW. ``tm`` +atoms, ranks by R_str = S_SW * TM_a and reports the E-value of S_SW. ``tm`` (RFoldSearch-TM) hands the candidate pool to US-align and ranks by the mean of its two length-normalised TM-scores; that backend reports no E-value. """ @@ -31,7 +30,7 @@ DEFAULT_TOP_N = 200 # candidate-pool size after the prefilter (validation-selected) DEFAULT_MIN_DIAG = 2 # Foldseek double-match rule DEFAULT_RERANK = "tm_a" -RERANKERS = ("tm_a", "sw", "tm") +RERANKERS = ("tm_a", "tm") _NO_CUT = 1 << 62 # ask the prefilter for every candidate, uncut _ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") @@ -232,8 +231,7 @@ def search(self, qtok, top_k=10, top_n=DEFAULT_TOP_N, ``rerank="tm_a"`` (default) ranks by ``structure_score`` (R_str = S_SW * TM_a) and reports ``tm_a``, ``aligned`` (aligned nucleotide pairs), and the S_SW-derived fields ``sw_score``, ``bits``, - and E-value. ``rerank="sw"`` retains the token-only Smith-Waterman - ranking. ``rerank="tm"`` ranks by US-align TM-score and reports + and E-value. ``rerank="tm"`` ranks by US-align TM-score and reports ``tm_mean``/``tm_query``/``tm_target`` instead. ``tie_seed`` makes equal scores break by a hash of the chain id @@ -265,21 +263,12 @@ def search(self, qtok, top_k=10, top_n=DEFAULT_TOP_N, ) query_entry = self.ents[self_idx] return self._rerank_tm(query_entry, cand, top_k, usalign, tie_seed) - tm_a = aligned = None - if rerank == "tm_a": - (toks, offsets, xyz), qxyz = self._tm_a_inputs( - qtok, query_entry, self_idx) - sw, tm_a, aligned = _ss.score_candidates( - qtok, qxyz, np.asarray(cand, np.int64), toks, offsets, xyz, - self.sub, np.float32(GAP_OPEN), np.float32(GAP_EXTEND)) - score = sw * tm_a - else: - sw = np.array([ - smith_waterman_numba(qtok, self.toks[t], self.sub, - GAP_OPEN, GAP_EXTEND) - for t in cand - ]) - score = sw + (toks, offsets, xyz), qxyz = self._tm_a_inputs( + qtok, query_entry, self_idx) + sw, tm_a, aligned = _ss.score_candidates( + qtok, qxyz, np.asarray(cand, np.int64), toks, offsets, xyz, + self.sub, np.float32(GAP_OPEN), np.float32(GAP_EXTEND)) + score = sw * tm_a if tie_seed is None: order = np.argsort(-score, kind="stable") else: @@ -296,11 +285,9 @@ def search(self, qtok, top_k=10, top_n=DEFAULT_TOP_N, for rank, oi in enumerate(order[:top_k], 1): t, sc = int(cand[oi]), float(sw[oi]) hit = {"rank": rank, "target": self.ids[t], "sw_score": sc, - "length": len(self.toks[t])} - if tm_a is not None: - hit["structure_score"] = float(score[oi]) - hit["tm_a"] = float(tm_a[oi]) - hit["aligned"] = int(aligned[oi]) + "length": len(self.toks[t]), + "structure_score": float(score[oi]), + "tm_a": float(tm_a[oi]), "aligned": int(aligned[oi])} if self.ev_par is not None: if self.experimental_ev is not None or self.predicted_ev is not None: if self.predicted_ev is not None: diff --git a/tests/test_experimental_empirical_evalue.py b/tests/test_experimental_empirical_evalue.py index baee79a..01cad25 100644 --- a/tests/test_experimental_empirical_evalue.py +++ b/tests/test_experimental_empirical_evalue.py @@ -4,6 +4,7 @@ import pytest import torch +from helpers import add_coords from rfoldsearch import experimental_evalue as EV from rfoldsearch.database import (calibration_context_fingerprint, database_fingerprint) @@ -61,8 +62,8 @@ def test_fitter_uses_clean_background_for_both_stages(): def test_searcher_uses_matching_experimental_empirical_parameters(tmp_path): - entries = [{"id": f"t{i}", "tokens": [i % 5, (i + 1) % 5] * 15, - "cluster": f"c{i}"} for i in range(5)] + entries = add_coords([{"id": f"t{i}", "tokens": [i % 5, (i + 1) % 5] * 15, + "cluster": f"c{i}"} for i in range(5)]) target = database_fingerprint(entries, [e["cluster"] for e in entries], 80) context = calibration_context_fingerprint( target, load_matrix(), GAP_OPEN, GAP_EXTEND) @@ -88,7 +89,7 @@ def test_searcher_uses_matching_experimental_empirical_parameters(tmp_path): "evalue_params": params}, db) searcher = Searcher(str(db)) hit = searcher.search(np.asarray(entries[0]["tokens"]), - rerank="sw", top_k=1)[0] + query_entry=entries[0], top_k=1)[0] assert hit["raw_evalue"] == pytest.approx(EV.initial_evalue( hit["sw_score"], 30, EV.token_entropy(entries[0]["tokens"]), params["target_aware_gumbel"])) diff --git a/tests/test_paper_db.py b/tests/test_paper_db.py index 46e8ded..b7ecb3d 100644 --- a/tests/test_paper_db.py +++ b/tests/test_paper_db.py @@ -15,8 +15,8 @@ @pytest.mark.skipif(not DB, reason="RFOLDSEARCH_PAPER_DB not set") def test_readme_example(): s = Searcher(DB) - hits = s.search_id("PDB_00001FFZ_1_A", top_k=3, rerank="sw") - got = [(h["target"], round(h["sw_score"], 2)) for h in hits] - assert got == [("PDB_00001FFK_1_0", 203.54), - ("PDB_00005AN9_1_N", 180.02), - ("PDB_00004V9F_1_0", 168.13)] + hits = s.search_id("PDB_00001FFZ_1_A", top_k=200) + sw = {h["target"]: round(h["sw_score"], 2) for h in hits} + assert sw["PDB_00001FFK_1_0"] == 203.54 + assert sw["PDB_00005AN9_1_N"] == 180.02 + assert sw["PDB_00004V9F_1_0"] == 168.13 diff --git a/tests/test_predicted_empirical_evalue.py b/tests/test_predicted_empirical_evalue.py index d95b15d..9ca6136 100644 --- a/tests/test_predicted_empirical_evalue.py +++ b/tests/test_predicted_empirical_evalue.py @@ -5,6 +5,7 @@ import pytest import torch +from helpers import add_coords from rfoldsearch import compbias, experimental_evalue as EV from rfoldsearch.cli import main from rfoldsearch.database import (calibration_context_fingerprint, @@ -65,8 +66,8 @@ def test_compressed_gumbel_likelihood_matches_uncompressed_fit(): def test_search_uses_compbias_only_for_evalue_and_rejects_wrong_context(tmp_path): - entries = [{"id": f"t{i}", "tokens": [i % 5, (i + 1) % 5] * 15, - "cluster": f"c{i}"} for i in range(5)] + entries = add_coords([{"id": f"t{i}", "tokens": [i % 5, (i + 1) % 5] * 15, + "cluster": f"c{i}"} for i in range(5)]) tokens = [entry["tokens"] for entry in entries] matrix = load_matrix() background = compbias.background_frequencies(tokens, matrix.shape[0]) @@ -97,7 +98,7 @@ def test_search_uses_compbias_only_for_evalue_and_rejects_wrong_context(tmp_path "evalue_params": params}, db) searcher = Searcher(str(db)) query = np.asarray(tokens[0]) - hit = searcher.search(query, rerank="sw", top_k=1)[0] + hit = searcher.search(query, query_entry=entries[0], top_k=1)[0] positions, profile = compbias.query_profile(query, matrix, background) target_index = searcher.id2i[hit["target"]] adjusted = smith_waterman_numba(positions, np.asarray(tokens[target_index]), @@ -119,8 +120,8 @@ def test_search_uses_compbias_only_for_evalue_and_rejects_wrong_context(tmp_path def test_fitting_cli_writes_attachable_predicted_parameters(tmp_path): rng = np.random.default_rng(30) - entries = [{"id": f"t{i}", "tokens": rng.integers(0, 15, 25 + i % 8).tolist(), - "cluster": f"c{i}"} for i in range(40)] + entries = add_coords([{"id": f"t{i}", "tokens": rng.integers(0, 15, 25 + i % 8).tolist(), + "cluster": f"c{i}"} for i in range(40)]) tokens = [entry["tokens"] for entry in entries] db = tmp_path / "db.pt" torch.save({"n_codes": 80, "entries": entries, "n_clusters": 40}, db) @@ -170,7 +171,8 @@ def test_fitting_cli_writes_attachable_predicted_parameters(tmp_path): assert params["empirical_calibration"]["background_scaling"] == "complete-eligible-clusters" searcher = Searcher(str(db), matrix_path=str(matrix_path), evalue_params_path=str(output)) - assert searcher.search(np.asarray(tokens[0]), rerank="sw", top_k=1)[0]["evalue"] >= 0 + assert searcher.search(np.asarray(tokens[0]), query_entry=entries[0], + top_k=1)[0]["evalue"] >= 0 clusters = tmp_path / "clusters.tsv" clusters.write_text("".join(f"t{i}\tc{i}\n" for i in range(40))) attached = tmp_path / "attached.pt" diff --git a/tests/test_search_tm.py b/tests/test_search_tm.py index 545c123..fff0262 100644 --- a/tests/test_search_tm.py +++ b/tests/test_search_tm.py @@ -98,14 +98,12 @@ def test_cli_prints_the_tm_table_and_names_the_backend(db_path, monkeypatch, cap assert "0.5000" in out -def test_cli_sw_output_is_unchanged(db_path, capsys): +def test_cli_rejects_sw_rerank(db_path): from rfoldsearch import cli - cli.main(["search", "--query", "chain_000", "--db", db_path, - "--top_k", "2", "--rerank", "sw"]) - - out = capsys.readouterr().out - assert "SW_score" in out and "rerank:" not in out + with pytest.raises(SystemExit): + cli.main(["search", "--query", "chain_000", "--db", db_path, + "--rerank", "sw"]) def test_cli_default_prints_structure_score_table(db_path, capsys): diff --git a/tests/test_structure_score.py b/tests/test_structure_score.py index 66d6181..58ca5aa 100644 --- a/tests/test_structure_score.py +++ b/tests/test_structure_score.py @@ -96,14 +96,9 @@ def test_tm_a_is_default_and_ranks_by_product(db_path): assert len({hit["target"] for hit in hits[:19]} & same_fold) >= 17 -def test_sw_backend_still_works_without_coordinates(tmp_path): - tokens = np.arange(40, dtype=np.int16) % 10 - entries = [{"id": f"c{i}", "tokens": tokens.copy()} for i in range(5)] - path = tmp_path / "token_only.pt" - torch.save({"n_codes": 80, "entries": entries, - "checkpoint": "synthetic"}, path) - hits = Searcher(str(path)).search_id("c0", top_k=2, rerank="sw") - assert hits and "structure_score" not in hits[0] +def test_sw_backend_is_gone(db_path): + with pytest.raises(ValueError, match="unknown rerank backend"): + Searcher(db_path).search_id("chain_000", rerank="sw") def test_tm_a_requires_database_coordinates(tmp_path): diff --git a/tests/test_tiebreak.py b/tests/test_tiebreak.py index 2418b8a..e8fc04e 100644 --- a/tests/test_tiebreak.py +++ b/tests/test_tiebreak.py @@ -64,9 +64,9 @@ def test_tie_seed_changes_which_candidates_survive_top_n(db_path): def test_index_order_admits_the_lowest_indices_when_everything_ties(db_path): s = Searcher(db_path) - hits = s.search_id("chain_000", top_k=3, top_n=3, rerank="sw") + hits = s.search_id("chain_000", top_k=3, top_n=3) - assert [h["target"] for h in hits] == ["chain_001", "chain_002", "chain_003"] + assert sorted(h["target"] for h in hits) == ["chain_001", "chain_002", "chain_003"] def test_same_tie_seed_gives_the_same_ranking(db_path): diff --git a/webserver/README.md b/webserver/README.md index 58038b8..57fa381 100644 --- a/webserver/README.md +++ b/webserver/README.md @@ -95,7 +95,7 @@ POST /api/search multipart form top_k int = 50 hits returned per database top_n int = 200 candidate depth; 200 is the deployed default min_diag int = 2 k-mer hits on a common diagonal; 1 is more sensitive - rerank tm_a | sw | tm structure score (default), token SW, or US-align + rerank tm_a | tm structure score (default) or US-align tie_seed int break score ties by a hash of the chain id GET /api/result/{id} the stored record GET /api/result/{id}/{db}/{rank}/alignment alignment of one hit diff --git a/webserver/app/main.py b/webserver/app/main.py index 905df48..e0fe0ba 100644 --- a/webserver/app/main.py +++ b/webserver/app/main.py @@ -183,8 +183,8 @@ def field(key, default=None, kind=str): if unknown: raise HTTPException(400, f"unknown database {unknown[0]!r}; choose from " f"{sorted(engine.databases)}") - if rerank not in ("tm_a", "sw", "tm"): - raise HTTPException(400, "rerank must be 'tm_a', 'sw', or 'tm'") + if rerank not in ("tm_a", "tm"): + raise HTTPException(400, "rerank must be 'tm_a' or 'tm'") if not 1 <= top_k <= 1000: raise HTTPException(400, "top_k must be between 1 and 1000") if not 1 <= top_n <= TOP_N_CAP: diff --git a/webserver/static/app.js b/webserver/static/app.js index 3ff999d..0390faa 100644 --- a/webserver/static/app.js +++ b/webserver/static/app.js @@ -401,7 +401,7 @@ function targetCell(h) { // columns of the hit table; `get` gives the value used for sorting let sortKey = null, sortDir = -1; function tableColumns(res, noSeq) { - const tm = res.rerank === "tm", sw = res.rerank === "sw"; + const tm = res.rerank === "tm"; const num = (key, label, get, text, title) => ({ key, label, num: true, get, text, title }); const cols = []; if (noSeq) cols.push(num("plddt", "pLDDT", (h) => h.plddt, (h) => h.plddt == null ? "—" : h.plddt.toFixed(1), @@ -416,9 +416,6 @@ function tableColumns(res, noSeq) { if (tm) { cols.push(num("tm", "TM-score", (h) => h.tm_mean, (h) => h.tm_mean.toFixed(3), "Mean of the query- and target-normalised US-align TM-scores")); - } else if (sw) { - cols.push(num("evalue", "E-value", (h) => h.evalue, (h) => fmtE(h.evalue)), - num("bits", "Bits", (h) => h.bits ?? h.sw_score, (h) => (h.bits ?? h.sw_score).toFixed(1))); } else { cols.push(num("rstr", "Structure score", (h) => h.structure_score, (h) => h.structure_score.toFixed(2), "S_SW × TM-score"), From 1912aa0ac6c8f4507036fa92dea6678f236af4d2 Mon Sep 17 00:00:00 2001 From: Ruyi Chen Date: Fri, 30 Oct 2026 12:26:27 +1000 Subject: [PATCH 2/6] CLI: print warnings as one line on stderr Python's default handler echoes the source line after each warning, so every search of an uncalibrated database printed 's = Searcher(a.db, ...' under the E-value notice. --- rfoldsearch/cli.py | 17 +++++++++++++---- tests/test_search_tm.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/rfoldsearch/cli.py b/rfoldsearch/cli.py index 87afb63..c975f1e 100644 --- a/rfoldsearch/cli.py +++ b/rfoldsearch/cli.py @@ -3,6 +3,7 @@ import glob import json import sys +import warnings import numpy as np import torch @@ -143,6 +144,10 @@ def cmd_search(a): print(line + f"{h['aligned']:>10} {h['length']:>5}") +def _show_warning(message, category, filename, lineno, file=None, line=None): + print(f"rfoldsearch: warning: {message}", file=sys.stderr) + + def main(argv=None): ap = argparse.ArgumentParser(prog="rfoldsearch", description="Fast RNA structural search with the 3Dr alphabet") @@ -265,10 +270,14 @@ def main(argv=None): p.set_defaults(fn=cmd_search) a = ap.parse_args(argv) - try: - a.fn(a) - except (USalignNotFound, USalignFailed) as e: - raise SystemExit(str(e)) + with warnings.catch_warnings(): + # One line per warning on stderr, without the source line Python + # would otherwise echo after it. + warnings.showwarning = _show_warning + try: + a.fn(a) + except (USalignNotFound, USalignFailed) as e: + raise SystemExit(str(e)) if __name__ == "__main__": diff --git a/tests/test_search_tm.py b/tests/test_search_tm.py index fff0262..e8c34fd 100644 --- a/tests/test_search_tm.py +++ b/tests/test_search_tm.py @@ -130,3 +130,13 @@ def test_cli_reports_a_missing_usalign_without_a_traceback(db_path, monkeypatch, "--rerank", "tm"]) assert "USalign" in str(e.value) + + +def test_cli_prints_warnings_as_one_line(db_path, capsys): + from rfoldsearch import cli + + cli.main(["search", "--query", "chain_000", "--db", db_path, "--top_k", "2"]) + + err = capsys.readouterr().err + assert "rfoldsearch: warning: " in err + assert "Searcher(" not in err From 76a28b25a8ab681a57de9a53858a31035132c5f0 Mon Sep 17 00:00:00 2001 From: Ruyi Chen Date: Fri, 30 Oct 2026 12:26:27 +1000 Subject: [PATCH 3/6] README: match the search header and hit keys to the output The header names the database size, and the hit dicts carry 'length'. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1b06110..eb00ae4 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ A comment line names the query, database, and ranking mode, followed by one row per hit. Under the default mode, hits are ranked by `R_str`: ``` -# query: query_chain_id (128 tokens) database: target_db.pt rerank: R_str = S_SW x TM_a +# query: query_chain_id (128 tokens) database: target_db.pt (2000 chains, 1500 clusters) rerank: R_str = S_SW x TM_a rank target R_str TM_a S_SW E-value aligned len 1 4V9F_A 358.44 0.8700 412.00 2.1e-30 124 129 2 6XU8_B 221.38 0.7700 287.50 5.4e-19 111 121 @@ -146,7 +146,7 @@ rank target R_str TM_a S_SW E-value aligned Under `--rerank tm` — RFoldSearch-TM — the comment line names the mode and the score columns change: ``` -# query: query_chain_id (74 tokens) database: target_db.pt rerank: RFoldSearch-TM +# query: query_chain_id (74 tokens) database: target_db.pt (2000 chains) rerank: RFoldSearch-TM rank target TM_mean TM_query TM_target len 1 PDB_00008B2L_1_W2 0.7076 0.7143 0.7008 76 2 PDB_00008KD9_1_M 0.6724 0.6674 0.6773 72 @@ -270,8 +270,8 @@ from rfoldsearch import Searcher s = Searcher("target_db.pt") hits = s.search_id("target_chain_id", top_k=10) -# [{'rank': 1, 'target': …, 'structure_score': …, 'tm_a': …, -# 'aligned': …, 'sw_score': …, 'bits': …, 'evalue': …}, …] +# [{'rank': 1, 'target': …, 'sw_score': …, 'length': …, +# 'structure_score': …, 'tm_a': …, 'aligned': …, 'bits': …, 'evalue': …}, …] ``` Anything else is searched by handing `search` the tokens from above: From a9dd6984a0878e0eb145d7b9a0faf385e9b49627 Mon Sep 17 00:00:00 2001 From: Ruyi Chen Date: Fri, 30 Oct 2026 12:26:27 +1000 Subject: [PATCH 4/6] Add a GitHub Actions workflow that runs the tests Python 3.9 and 3.11 on Ubuntu, with CPU-only torch. Tests that need US-align or the paper database skip themselves. --- .github/workflows/tests.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4eb04d2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + # CPU-only torch keeps the install small; the tests never touch a GPU. + - name: Install + run: | + python -m pip install --upgrade pip + pip install --extra-index-url https://download.pytorch.org/whl/cpu . pytest + - name: Test + run: python -m pytest -q tests From a45a678c651eb562c5c9275cac76663c5d75c6c9 Mon Sep 17 00:00:00 2001 From: Ruyi Chen Date: Fri, 30 Oct 2026 12:34:48 +1000 Subject: [PATCH 5/6] Require Python 3.10 or newer The last biotite release for 3.9 is built against NumPy 1, which forced a numpy<2 marker, and pip took minutes to resolve the 3.9 install in CI. CI now tests 3.10 and 3.11. --- .github/workflows/tests.yml | 2 +- README.md | 2 +- pyproject.toml | 4 +--- webserver/README.md | 7 ------- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4eb04d2..19cc6f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.11"] + python-version: ["3.10", "3.11"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/README.md b/README.md index eb00ae4..bb76589 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

License: Apache 2.0 - Python >= 3.9 + Python >= 3.10 Install with conda

diff --git a/pyproject.toml b/pyproject.toml index 4c4cd9f..f4814d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,9 @@ version = "0.1.0" description = "Fast RNA tertiary-structure search with the learned 3Dr structural alphabet" readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "numpy>=1.21", - # The last biotite for Python 3.9 (0.40) is built against NumPy 1. - "numpy<2; python_version < '3.10'", "torch>=2.0", "numba>=0.55", # SciPy 1.15.x wheels fail to load on current macOS (dyld rejects their diff --git a/webserver/README.md b/webserver/README.md index 57fa381..5992922 100644 --- a/webserver/README.md +++ b/webserver/README.md @@ -150,10 +150,3 @@ E = 0 followed by other large-subunit rRNAs, in 5.2 s; a 24-token query returns in 0.5 s. Malformed uploads, unknown database keys, out-of-range parameters and a missing `USalign` binary each return a useful message rather than a traceback. - -One packaging note found while testing: on Python 3.9, `pip install -rfoldsearch` resolves `biotite` to 0.40, which is built against numpy 1.x, -while `torch` pulls numpy 2. The import then fails inside biotite. The -`python:3.11-slim` base image in the Dockerfile avoids this; a 3.9 -install needs `numpy<2` pinned. The package declares `>=3.9`, so this is -worth either pinning or raising in `pyproject.toml`. From 5329ac40b83de6a4acd3dc9cadfaa4d40d1f3590 Mon Sep 17 00:00:00 2001 From: Ruyi Chen Date: Fri, 30 Oct 2026 12:38:25 +1000 Subject: [PATCH 6/6] evalue: pick calibration queries tied on length by index calibrate_clustermax chose its query pool with numpy's default argsort, which orders ties differently across array sizes and CPUs. The same database could therefore be calibrated from different queries on macOS and on Linux x86, and test_a_cluster_contributes_one_draw_however_many_members failed in CI (beta moved by 8.6%). With a stable sort the swamped and unswamped fits now give the same beta. --- rfoldsearch/evalue.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rfoldsearch/evalue.py b/rfoldsearch/evalue.py index 5617c16..a8f1991 100644 --- a/rfoldsearch/evalue.py +++ b/rfoldsearch/evalue.py @@ -73,7 +73,9 @@ def calibrate_clustermax( raise ValueError("need at least two clusters to calibrate") lens = np.array([len(t) for t in toks]) - order = np.argsort(np.abs(lens - float(length))) + # Stable, so chains tied on length are picked by index: numpy's default + # sort orders ties differently across array sizes and CPUs. + order = np.argsort(np.abs(lens - float(length)), kind="stable") pool = [int(i) for i in order[: max(2, min(len(order), n_queries))]] maxima = [] @@ -135,7 +137,7 @@ def calibrate_clustermax_length_dependent( table, skipped = [], [] n_clusters = len(set(np.asarray(clusters, dtype=object).tolist())) for L in lengths: - order = np.argsort(np.abs(lens - float(L))) + order = np.argsort(np.abs(lens - float(L)), kind="stable") pool = lens[order[: max(2, min(len(order), n_queries))]] med = float(np.median(pool)) if med > max_length_gap * L or L > max_length_gap * med: