|
| 1 | +"""Regression test for the opt-in Hamiltonian replica exchange |
| 2 | +path in sample_alchemical_windows. |
| 3 | +
|
| 4 | +Why: the hand-rolled independent-replica path fails MBAR overlap |
| 5 | +on tight intramolecular charge networks (acetic_acid, acetamide, |
| 6 | +biotin/streptavidin). The replica-exchange path (opt-in via |
| 7 | +use_replica_exchange=True) is the fix. Without a regression test |
| 8 | +pinning that path, a future refactor could silently break it and |
| 9 | +we wouldn't notice until the next overnight bench failed.""" |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import math |
| 13 | +import sys |
| 14 | +from pathlib import Path |
| 15 | + |
| 16 | +REPO_ROOT = Path(__file__).resolve().parents[2] |
| 17 | +sys.path.insert(0, str(REPO_ROOT)) |
| 18 | + |
| 19 | + |
| 20 | +def test_replica_exchange_flag_wired_through_compute_hydration_dg(): |
| 21 | + """compute_hydration_dg must accept use_replica_exchange and |
| 22 | + pass it down. Code-level inspection, no MD.""" |
| 23 | + import inspect |
| 24 | + from src.fep import compute_hydration_dg |
| 25 | + sig = inspect.signature(compute_hydration_dg) |
| 26 | + assert "use_replica_exchange" in sig.parameters |
| 27 | + assert sig.parameters["use_replica_exchange"].default is False |
| 28 | + |
| 29 | + |
| 30 | +def test_replica_exchange_flag_wired_through_compute_binding_dg(): |
| 31 | + """compute_absolute_binding_dg must accept use_replica_exchange.""" |
| 32 | + import inspect |
| 33 | + from src.fep.binding import compute_absolute_binding_dg |
| 34 | + sig = inspect.signature(compute_absolute_binding_dg) |
| 35 | + assert "use_replica_exchange" in sig.parameters |
| 36 | + assert sig.parameters["use_replica_exchange"].default is False |
| 37 | + |
| 38 | + |
| 39 | +def test_sample_alchemical_windows_accepts_replica_exchange_flag(): |
| 40 | + import inspect |
| 41 | + from src.fep.sampling import sample_alchemical_windows |
| 42 | + sig = inspect.signature(sample_alchemical_windows) |
| 43 | + assert "use_replica_exchange" in sig.parameters |
| 44 | + assert sig.parameters["use_replica_exchange"].default is False |
| 45 | + |
| 46 | + |
| 47 | +def test_replica_exchange_runs_methane_vacuum_end_to_end(): |
| 48 | + """Run sample_alchemical_windows with use_replica_exchange=True |
| 49 | + on methane vacuum. Tiny sampling (3 windows × 5 iter × 50 steps, |
| 50 | + ~30 s CPU). Just verifies the pipeline runs end-to-end on the |
| 51 | + opt-in path; numerical accuracy is irrelevant at this budget.""" |
| 52 | + from src.fep import _build_alchemical_legs |
| 53 | + from src.fep.sampling import sample_alchemical_windows |
| 54 | + |
| 55 | + (vac_alch, _solv_alch, vac_top, _solv_top, |
| 56 | + vac_pos, _solv_pos, _n) = _build_alchemical_legs("C") |
| 57 | + |
| 58 | + r = sample_alchemical_windows( |
| 59 | + vac_alch, vac_top, vac_pos, |
| 60 | + n_windows=3, |
| 61 | + n_equilibration_steps=20, |
| 62 | + n_production_steps=250, |
| 63 | + sample_stride=50, |
| 64 | + seed=1, |
| 65 | + use_replica_exchange=True, |
| 66 | + ) |
| 67 | + print(f" {r.summary()}") |
| 68 | + assert r.ok, ( |
| 69 | + f"replica-exchange path must run end-to-end on methane " |
| 70 | + f"vacuum; got: {r.reason}") |
| 71 | + assert r.dG_kcalmol is not None |
| 72 | + assert math.isfinite(r.dG_kcalmol) |
| 73 | + assert math.isfinite(r.dG_uncertainty_kcalmol) |
| 74 | + # Methane vacuum (no intramolecular nonbondeds) → dG ≈ 0 at |
| 75 | + # ANY sampling budget. Generous bound for noise. |
| 76 | + assert abs(r.dG_kcalmol) < 5.0, ( |
| 77 | + f"methane vacuum dG should be ~0 (no intra nonbondeds); " |
| 78 | + f"got {r.dG_kcalmol:+.3f}") |
| 79 | + |
| 80 | + |
| 81 | +def test_cli_bench_has_replica_exchange_flag(): |
| 82 | + """`cellsim fep-binding bench --help` must expose |
| 83 | + --replica-exchange so biologists can enable the fix without |
| 84 | + editing code.""" |
| 85 | + import subprocess |
| 86 | + r = subprocess.run( |
| 87 | + ["python", "-m", "src.fep.binding", "bench", "--help"], |
| 88 | + cwd=REPO_ROOT, capture_output=True, text=True) |
| 89 | + assert r.returncode == 0, r.stderr |
| 90 | + assert "--replica-exchange" in r.stdout, ( |
| 91 | + f"--replica-exchange missing from bench --help; got:\n" |
| 92 | + f"{r.stdout[:1000]}") |
| 93 | + |
| 94 | + |
| 95 | +if __name__ == "__main__": |
| 96 | + funcs = [ |
| 97 | + test_replica_exchange_flag_wired_through_compute_hydration_dg, |
| 98 | + test_replica_exchange_flag_wired_through_compute_binding_dg, |
| 99 | + test_sample_alchemical_windows_accepts_replica_exchange_flag, |
| 100 | + test_cli_bench_has_replica_exchange_flag, |
| 101 | + test_replica_exchange_runs_methane_vacuum_end_to_end, |
| 102 | + ] |
| 103 | + fails = [] |
| 104 | + for f in funcs: |
| 105 | + try: |
| 106 | + f() |
| 107 | + print(f"[PASS] {f.__name__}") |
| 108 | + except AssertionError as e: |
| 109 | + print(f"[FAIL] {f.__name__}: {e}") |
| 110 | + fails.append(f.__name__) |
| 111 | + except Exception as e: |
| 112 | + import traceback |
| 113 | + traceback.print_exc() |
| 114 | + print(f"[ERROR] {f.__name__}: {e}") |
| 115 | + fails.append(f.__name__) |
| 116 | + print(f"{len(funcs) - len(fails)}/{len(funcs)} PASS") |
| 117 | + sys.exit(0 if not fails else 1) |
0 commit comments