diff --git a/src/alphajudge/interface.py b/src/alphajudge/interface.py index 036aa16..485bcfd 100644 --- a/src/alphajudge/interface.py +++ b/src/alphajudge/interface.py @@ -174,6 +174,45 @@ def _lis_dir(idx_src: np.ndarray, idx_dst: np.ndarray) -> float: b = _lis_dir(self._idx2, self._idx1) return float(0.5 * (a + b)) + def clis(self) -> float: + """ + Contact-restricted LIS (cLIS): the LIS PAE transform averaged only over + residue pairs that are also in direct physical contact (representative + atom, CB-else-CA, within contact_thresh; default Cβ-Cβ <= 8 A). Both + chain directions are scored separately and averaged, mirroring lis(). + Returns 0.0 when no contacts or no valid PAE pairs. + """ + if not self._pairs: + return 0.0 + ab: list[float] = [] + ba: list[float] = [] + for r1, r2 in self._pairs: + i = self._rim.get((r1.get_parent().id, r1.id)) + j = self._rim.get((r2.get_parent().id, r2.id)) + if i is None or j is None: + continue + p_ab = float(self._pae[i, j]) + p_ba = float(self._pae[j, i]) + if p_ab < 12.0: + ab.append((12.0 - p_ab) / 12.0) + if p_ba < 12.0: + ba.append((12.0 - p_ba) / 12.0) + a = float(np.mean(ab)) if ab else 0.0 + b = float(np.mean(ba)) if ba else 0.0 + return float(0.5 * (a + b)) + + def ilis(self) -> float: + """ + Integrated LIS (iLIS = sqrt(LIS * cLIS); Kim et al., AFM-LIS). The + geometric mean forces the score to 0 unless the interface has both broad + PAE confidence (LIS) and confident direct contacts (cLIS). + """ + lis = self.lis() + clis = self.clis() + if lis <= 0.0 or clis <= 0.0: + return 0.0 + return float(math.sqrt(lis * clis)) + @property def polar(self) -> float: return self._frac(POLAR_RES) diff --git a/src/alphajudge/runner.py b/src/alphajudge/runner.py index dae9273..9b74441 100644 --- a/src/alphajudge/runner.py +++ b/src/alphajudge/runner.py @@ -117,6 +117,8 @@ def process( "interface_pDockQ2": pd2, "interface_ipSAE": iface.ipsae(), "interface_LIS": iface.lis(), + "interface_cLIS": iface.clis(), + "interface_iLIS": iface.ilis(), } # Add expensive metrics only if not skipped diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index 0b10fa5..b4e15be 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -41,6 +41,8 @@ "interface_pDockQ2", "interface_ipSAE", "interface_LIS", + "interface_cLIS", + "interface_iLIS", "interface_hb", "interface_sb", "interface_ss", @@ -332,6 +334,62 @@ def test_af2_runner_outputs_have_expected_scores(tmp_path: Path, af2_dir_src: Pa assert nearly_equal(got_iptm_ptm, float(exp_ptm)), f"AF2 monomer iptm_ptm should be NaN or ptm for {m}" +class _FakeResidue: + """Minimal hashable Bio.PDB-like residue with .id and .get_parent().id.""" + + def __init__(self, chain_id: str, res_id: Any): + self.id = res_id + self._parent = type("_Chain", (), {"id": chain_id})() + + def get_parent(self): + return self._parent + + +def _make_residue(chain_id: str, res_id: Any) -> _FakeResidue: + return _FakeResidue(chain_id, res_id) + + +def test_clis_ilis_math_is_deterministic(): + """ + cLIS restricts the LIS PAE transform to contacting residue pairs and iLIS is + the geometric mean sqrt(LIS * cLIS) (AFM-LIS, Kim et al.). Build a tiny 2+2 + complex with a known PAE matrix so the arithmetic is checkable by hand. + """ + from alphajudge.interface import Interface + + a1, a2 = _make_residue("A", (" ", 1, " ")), _make_residue("A", (" ", 2, " ")) + b1, b2 = _make_residue("B", (" ", 1, " ")), _make_residue("B", (" ", 2, " ")) + + # PAE indices: A1->0, A2->1, B1->2, B2->3. Intra-chain PAE is irrelevant. + pae = np.array( + [ + [0.0, 0.0, 0.0, 6.0], + [0.0, 0.0, 24.0, 24.0], + [0.0, 24.0, 0.0, 0.0], + [6.0, 24.0, 0.0, 0.0], + ] + ) + + iface = object.__new__(Interface) # bypass __init__; set only what we exercise + iface._pae = pae + iface._idx1 = np.array([0, 1]) + iface._idx2 = np.array([2, 3]) + iface._rim = {("A", a1.id): 0, ("A", a2.id): 1, ("B", b1.id): 2, ("B", b2.id): 3} + iface._pairs = {(a1, b1)} # only A1-B1 is in physical contact + + # LIS: A->B valid entries (12-pae)/12 = [1.0, 0.5]; B->A = [1.0, 0.5]; mean 0.75. + assert nearly_equal(iface.lis(), 0.75) + # cLIS: only the A1-B1 contact, pae 0 both directions -> 1.0. + assert nearly_equal(iface.clis(), 1.0) + # iLIS = sqrt(0.75 * 1.0). + assert nearly_equal(iface.ilis(), math.sqrt(0.75)) + + # No contacts -> cLIS 0 -> iLIS 0 regardless of LIS. + iface._pairs = set() + assert iface.clis() == 0.0 + assert iface.ilis() == 0.0 + + # ------------------------- # AF3 runner: best/all + score checks # -------------------------