diff --git a/Makefile b/Makefile index d3487f50..a2245a76 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ lint: $(RUFF) check src tests scripts ci: lint test - @echo "CI passed: lint + 1158-test suite green" + @echo "CI passed: lint + 1168-test suite green" bench-leakage: PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \ diff --git a/src/openamp_foundry/scoring/activity.py b/src/openamp_foundry/scoring/activity.py index f92e87fd..6729d648 100644 --- a/src/openamp_foundry/scoring/activity.py +++ b/src/openamp_foundry/scoring/activity.py @@ -53,11 +53,15 @@ def activity_likeness_score(features: dict) -> float: # Normalizer 0.15 = typical_cd (0.30) × typical_mu_h (0.50). cross_bonus = clamp01(charge_density * mu_h / 0.15) * 0.02 - # Helix propensity: Chou-Fasman Pα > 1.03 rewards helix-forming sequences - # Scale: indifferent (1.0) → 0.0; strong helix-former (1.20+) → 1.0 - helix_bonus = clamp01((helix_pa - 1.0) / 0.20) * 0.01 + # Helix propensity: Chou-Fasman Pα > 1.00 rewards helix-forming sequences. + # Scale: indifferent (1.0) → 0.0; strong helix-former (1.20+) → 1.0 (capped at 1.0). + # Weight increased 0.01 → 0.03: helical AMPs (LL-37, magainin, mastoparan) represent + # ~70% of membrane-active AMP families; this bonus better distinguishes them from + # structurally disordered or beta-sheet peptides at otherwise equal composition scores. + # Literature: Huang (2000) Biochim Biophys Acta; Tossi et al. (2000) Biopolymers. + helix_bonus = clamp01((helix_pa - 1.0) / 0.20) * 0.03 - # ceiling = 0.24+0.27+0.17+0.10+0.14+0.01+0.02 = 0.95 < 1.0 + # ceiling = 0.24+0.27+0.17+0.10+0.14+0.03+0.02 = 0.97 < 1.0 score = ( 0.24 * length_score + 0.27 * charge_score diff --git a/src/openamp_foundry/scoring/synthesis.py b/src/openamp_foundry/scoring/synthesis.py index 26c269c6..a95593f4 100644 --- a/src/openamp_foundry/scoring/synthesis.py +++ b/src/openamp_foundry/scoring/synthesis.py @@ -14,6 +14,11 @@ def synthesis_feasibility_score(features: dict, valid_sequence: bool = True) -> - Aggregation propensity > 0: interior hydrophobic runs (VILMFW ≥ 4) and high beta-branched density (Val/Ile/Thr) cause on-resin aggregation and poor solubility. References: Quittot et al. (2017) Protein Sci; Wurth et al. (2006) J Mol Biol. + - Proline fraction > 15%: N-methylated backbone requires extended activation (slow couplings + at XP junctions); DKP formation risk at N-terminal dipeptides containing Pro during + piperidine Fmoc deprotection; overall coupling efficiency reduced. References: Barlos et + al. (1989) Int J Peptide Protein Res; Quibell et al. (1994) J Am Chem Soc; Fischer (2003) + Curr Opin Drug Discov Devel. """ if not valid_sequence: return 0.0 @@ -21,6 +26,7 @@ def synthesis_feasibility_score(features: dict, valid_sequence: bool = True) -> repeat_run = features["longest_repeat_run"] cys = features["cysteine_fraction"] agg = features.get("aggregation_propensity", 0.0) + pro = features.get("proline_fraction", 0.0) score = 1.0 if length > 30: @@ -33,4 +39,6 @@ def synthesis_feasibility_score(features: dict, valid_sequence: bool = True) -> score -= 0.15 if agg > 0.0: score -= min(agg * 0.25, 0.20) + if pro > 0.15: + score -= 0.10 return round(clamp01(score), 4) diff --git a/tests/test_scoring_activity.py b/tests/test_scoring_activity.py index c6d2cbbd..b290c264 100644 --- a/tests/test_scoring_activity.py +++ b/tests/test_scoring_activity.py @@ -6,10 +6,10 @@ - hydro_score (weight 0.17): 1 - min(|hf - 0.45| / 0.45, 1.0) - aromatic_bonus (max 0.10): min(aromatic / 0.20, 1.0) * 0.10 - amphipathicity (max 0.14): clamp01(mu_h / 0.8) * 0.14 - - helix_bonus (max 0.01): clamp01((helix_pa - 1.0) / 0.20) * 0.01 + - helix_bonus (max 0.03): clamp01((helix_pa - 1.0) / 0.20) * 0.03 - cross_bonus (max 0.02): clamp01(charge_density * mu_h / 0.15) * 0.02 -Total max = 0.24+0.27+0.17+0.10+0.14+0.01+0.02 = 0.95. Result is clamped to [0, 1] and rounded to 4 dp. +Total max = 0.24+0.27+0.17+0.10+0.14+0.03+0.02 = 0.97. Result is clamped to [0, 1] and rounded to 4 dp. """ from __future__ import annotations @@ -255,6 +255,37 @@ def test_dual_property_amps_score_higher_than_single_property(self): assert dual > only_amph +class TestHelixBonusWeight: + def test_helix_bonus_weight_0_03(self): + # helix_pa=1.20 → clamp01((1.20-1.0)/0.20)=1.0 → bonus=1.0*0.03=0.03 + base = { + "length": 18, "charge_density": 0.30, "hydrophobic_fraction": 0.45, + "aromatic_fraction": 0.0, "hydrophobic_moment": 0.0, + } + s_no_helix = activity_likeness_score({**base, "helix_propensity": 1.0}) + s_full_helix = activity_likeness_score({**base, "helix_propensity": 1.20}) + assert abs((s_full_helix - s_no_helix) - 0.03) < 0.001 + + def test_helix_bonus_partial_at_pa_110(self): + # helix_pa=1.10 → clamp01(0.10/0.20)=0.5 → bonus=0.5*0.03=0.015 + base = { + "length": 18, "charge_density": 0.30, "hydrophobic_fraction": 0.45, + "aromatic_fraction": 0.0, "hydrophobic_moment": 0.0, + } + s_no_helix = activity_likeness_score({**base, "helix_propensity": 1.0}) + s_half_helix = activity_likeness_score({**base, "helix_propensity": 1.10}) + assert abs((s_half_helix - s_no_helix) - 0.015) < 0.001 + + def test_score_ceiling_is_0_97(self): + # All components at maximum: ceiling = 0.24+0.27+0.17+0.10+0.14+0.03+0.02 = 0.97 + all_max = { + "length": 18, "charge_density": 0.50, "hydrophobic_fraction": 0.45, + "aromatic_fraction": 0.20, "hydrophobic_moment": 0.80, + "helix_propensity": 1.20, + } + assert activity_likeness_score(all_max) == 0.97 + + class TestKnownAMPs: def test_magainin_like_scores_well(self): # KWKLFKKIGAVLKVL — canonical AMP-like sequence diff --git a/tests/test_scoring_synthesis.py b/tests/test_scoring_synthesis.py index 5ce91ffd..cb24e3a9 100644 --- a/tests/test_scoring_synthesis.py +++ b/tests/test_scoring_synthesis.py @@ -97,3 +97,49 @@ def test_missing_repeat_run_key_raises_key_error(self): def test_missing_cysteine_fraction_key_raises_key_error(self): with pytest.raises(KeyError): synthesis_feasibility_score({"length": 15, "longest_repeat_run": 1}) + + +class TestProlinePenalty: + def _pro_feat(self, pro: float = 0.0) -> dict: + return {"length": 15, "longest_repeat_run": 1, "cysteine_fraction": 0.0, "proline_fraction": pro} + + def test_high_proline_penalised(self): + # proline_fraction > 0.15 → -0.10 penalty + score_low = synthesis_feasibility_score(self._pro_feat(pro=0.0)) + score_high = synthesis_feasibility_score(self._pro_feat(pro=0.20)) + assert score_high < score_low + + def test_proline_above_threshold_penalty_value(self): + # 15-mer, no other defects, pro=0.20 (>0.15) → 1.0 - 0.10 = 0.90 + score = synthesis_feasibility_score(self._pro_feat(pro=0.20)) + assert abs(score - 0.90) < 0.001 + + def test_proline_at_threshold_no_penalty(self): + # condition is > 0.15, so exactly 0.15 must not be penalised + score = synthesis_feasibility_score(self._pro_feat(pro=0.15)) + assert score == 1.0 + + def test_proline_just_above_threshold_penalised(self): + # 0.16 > 0.15 → penalty applied + score = synthesis_feasibility_score(self._pro_feat(pro=0.16)) + assert abs(score - 0.90) < 0.001 + + def test_backward_compat_missing_proline_key(self): + # Features without proline_fraction (older callers) should default to 0.0 → no penalty + feat = {"length": 15, "longest_repeat_run": 1, "cysteine_fraction": 0.0} + assert synthesis_feasibility_score(feat) == 1.0 + + def test_proline_penalty_stacks_with_other_penalties(self): + # repeat_run=5 (-0.10) + cys=0.25 (-0.15) + pro=0.20 (-0.10) → 1.0 - 0.35 = 0.65 + feat = {"length": 15, "longest_repeat_run": 5, "cysteine_fraction": 0.25, "proline_fraction": 0.20} + score = synthesis_feasibility_score(feat) + assert abs(score - 0.65) < 0.001 + + def test_proline_stacks_with_aggregation_propensity(self): + # agg=0.4: min(0.4*0.25, 0.20)=0.10 penalty; pro=0.20: 0.10 penalty → 1.0-0.20=0.80 + feat = { + "length": 15, "longest_repeat_run": 1, "cysteine_fraction": 0.0, + "aggregation_propensity": 0.4, "proline_fraction": 0.20, + } + score = synthesis_feasibility_score(feat) + assert abs(score - 0.80) < 0.001