diff --git a/Makefile b/Makefile index a2245a76..94e68a79 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ lint: $(RUFF) check src tests scripts ci: lint test - @echo "CI passed: lint + 1168-test suite green" + @echo "CI passed: lint + 1172-test suite green" bench-leakage: PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \ diff --git a/src/openamp_foundry/scoring/safety.py b/src/openamp_foundry/scoring/safety.py index 19fc28f0..dd9572e0 100644 --- a/src/openamp_foundry/scoring/safety.py +++ b/src/openamp_foundry/scoring/safety.py @@ -17,7 +17,9 @@ def safety_score(features: dict) -> float: typical AMP-like candidates (μH ≈ 0.35–0.50). """ length = features["length"] - charge_density = abs(features["charge_density"]) + # Prefer pH 7.4 charge (same key-preference as activity scorer); abs() applied because + # charge magnitude, not sign, drives hemolysis risk. Fall back for pre-PR-47 callers. + charge_density = abs(features.get("charge_density_ph74", features["charge_density"])) hydrophobic = features["hydrophobic_fraction"] cys = features["cysteine_fraction"] repeat_run = features["longest_repeat_run"] diff --git a/src/openamp_foundry/selection/pilot.py b/src/openamp_foundry/selection/pilot.py index 9398af77..6384a983 100644 --- a/src/openamp_foundry/selection/pilot.py +++ b/src/openamp_foundry/selection/pilot.py @@ -18,30 +18,42 @@ def _pilot_priority(scores: dict) -> float: Formula: ensemble − 0.30×disagreement + 0.05×serum_stability + 0.05×novelty - + 0.05×selectivity_proxy + + 0.05×selectivity_proxy − cytotox_penalty + + The cytotox_penalty is non-zero only for candidates in the HIGH_CYTOTOX_RISK tier + (selectivity_proxy < 0.5). It applies an additional −0.05 × (0.5 − proxy) / 0.5 + penalty on top of the reduced bonus, totalling a max additional demerit of −0.05 vs + the soft-bonus formulation. Weights are intentionally equal for the three bonus terms (0.05 each) so no single physicochemical axis dominates over the ensemble activity prediction. The ensemble score remains the dominant term. selectivity_proxy [0,1]: likelihood of selective bacterial killing without mammalian - cytotoxicity (Dathe & Wieprecht 1999 BBA). Weighted the same as stability and novelty - so low-selectivity candidates (e.g. temporin-like, high GRAVY) are gently demoted - relative to equivalently-scored but more selective peptides. The maximum range of the - selectivity term is 0.05, which breaks ties within families without overriding ensemble. - - A candidate with novelty=0.467 (SEED-005 max) gains at most +0.023. - A candidate with selectivity_proxy=0.30 (SEED-004 temporin) gets +0.015 instead of - +0.05 maximum — a relative demerrit of 0.035 vs a fully selective peptide. + cytotoxicity (Dathe & Wieprecht 1999 BBA). Low-selectivity candidates (proxy < 0.5, + e.g. temporin-like temporins with charge=+1.0, GRAVY > +1.0) are doubly penalised: + they receive less bonus (0.05 × 0.30 = +0.015 for SEED-004) AND a cytotox_penalty + (−0.05 × 0.40 = −0.02 for proxy=0.30), for a net swing of −0.055 vs fully selective + candidates. This better reflects that Stage 2 failure (hemolysis/cytotoxicity) is the + largest single contributor to missed "breaking news" candidates in the current panel. + Literature: Dathe & Wieprecht (1999) BBA 1462:71-87; Carotenuto et al. (2008) J Med Chem. + + Example impact: + proxy=1.00: +0.05 bonus, 0.00 penalty → net +0.050 + proxy=0.50: +0.025 bonus, 0.00 penalty → net +0.025 + proxy=0.30: +0.015 bonus, −0.020 penalty → net −0.005 (HIGH_CYTOTOX_RISK) + proxy=0.00: +0.000 bonus, −0.050 penalty → net −0.050 (most toxic) """ ensemble = scores.get("ensemble", 0.0) disagreement = scores.get("disagreement", 0.5) stability = scores.get("serum_stability", 0.5) novelty = scores.get("novelty", 0.0) selectivity = scores.get("selectivity_proxy", 1.0) + # Additional penalty for HIGH_CYTOTOX_RISK tier (proxy < 0.5) + cytotox_penalty = 0.0 if selectivity >= 0.5 else 0.05 * (0.5 - selectivity) / 0.5 return round( ensemble - 0.3 * disagreement + 0.05 * stability + 0.05 * novelty - + 0.05 * selectivity, + + 0.05 * selectivity - cytotox_penalty, 6, ) diff --git a/tests/test_pilot_panel.py b/tests/test_pilot_panel.py index ca2f1027..cd697b32 100644 --- a/tests/test_pilot_panel.py +++ b/tests/test_pilot_panel.py @@ -123,12 +123,13 @@ def test_selectivity_proxy_higher_is_better(self): ) assert p_selective > p_cytotoxic - def test_selectivity_proxy_max_delta_is_0_05(self): - # Full selectivity (1.0) adds 0.05; zero selectivity adds 0.00 - # Maximum difference between any two candidates from this term alone = 0.05 + def test_selectivity_proxy_max_delta_is_0_10(self): + # proxy=1.0: +0.05 bonus, 0.00 penalty → net +0.05 + # proxy=0.0: +0.00 bonus, -0.05 penalty → net -0.05 + # Maximum difference between any two candidates from selectivity alone = 0.10 p_max = _pilot_priority({"ensemble": 0.0, "disagreement": 0.0, "selectivity_proxy": 1.0}) p_min = _pilot_priority({"ensemble": 0.0, "disagreement": 0.0, "selectivity_proxy": 0.0}) - assert p_max - p_min == pytest.approx(0.05, abs=1e-6) + assert p_max - p_min == pytest.approx(0.10, abs=1e-6) def test_selectivity_proxy_absent_defaults_to_1(self): # When selectivity_proxy is not in scores dict, assume fully selective (default=1.0) @@ -141,11 +142,45 @@ def test_selectivity_proxy_absent_defaults_to_1(self): def test_seed004_temporin_demoted_vs_selective_equal_ensemble(self): # SEED-004 (temporin, proxy=0.30) should rank below a fully selective candidate - # with equal ensemble/disagreement/stability/novelty + # with equal ensemble/disagreement/stability/novelty. + # New formula: proxy=0.30 gets +0.015 bonus − 0.020 cytotox_penalty = −0.005 net + # proxy=1.00 gets +0.050 bonus − 0.000 cytotox_penalty = +0.050 net + # Expected delta: 0.050 − (−0.005) = 0.055 temporin = _pilot_priority({"ensemble": 0.72, "disagreement": 0.10, "selectivity_proxy": 0.30}) selective = _pilot_priority({"ensemble": 0.72, "disagreement": 0.10, "selectivity_proxy": 1.0}) assert selective > temporin - assert selective - temporin == pytest.approx(0.05 * (1.0 - 0.30), abs=1e-6) + assert selective - temporin == pytest.approx(0.055, abs=1e-6) + + def test_cytotox_penalty_not_applied_at_boundary(self): + # proxy=0.50 is exactly at the boundary — no cytotox_penalty applied + # Use serum_stability=0.0 to isolate the selectivity term (avoid 0.5 default) + p_boundary = _pilot_priority( + {"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.5} + ) + p_above = _pilot_priority( + {"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.51} + ) + p_below = _pilot_priority( + {"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.49} + ) + # At boundary: bonus = 0.05 * 0.5 = 0.025, penalty = 0 + assert p_boundary == pytest.approx(0.025, abs=1e-6) + assert p_above > p_boundary # higher proxy → higher priority (no penalty) + assert p_boundary > p_below # penalty kicks in just below threshold — monotonic + + def test_cytotox_penalty_max_value(self): + # proxy=0.0: bonus=0, penalty=0.05*(0.5/0.5)=0.05 → net selectivity term = −0.05 + p_zero = _pilot_priority( + {"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.0} + ) + assert p_zero == pytest.approx(-0.05, abs=1e-6) + + def test_cytotox_penalty_at_proxy_025(self): + # proxy=0.25: bonus=0.05*0.25=0.0125, penalty=0.05*(0.5-0.25)/0.5=0.025 → net=−0.0125 + p = _pilot_priority( + {"ensemble": 0.0, "disagreement": 0.0, "serum_stability": 0.0, "selectivity_proxy": 0.25} + ) + assert p == pytest.approx(-0.0125, abs=1e-6) class TestSelectPilotPanel: diff --git a/tests/test_safety_moment.py b/tests/test_safety_moment.py index 18a0abc2..4b13f49a 100644 --- a/tests/test_safety_moment.py +++ b/tests/test_safety_moment.py @@ -84,6 +84,32 @@ def test_seed005_variants_flagged(self): f"(μH={feat['hydrophobic_moment']:.4f} > 0.55 threshold)" ) + def test_charge_density_ph74_used_when_available(self): + # safety_score should use charge_density_ph74 (physiologically accurate) over proxy. + # Construct a feature dict where the two values differ: charge_density=0.70 (proxy) + # vs charge_density_ph74=0.30 (pH-adjusted — His residues are uncharged at pH 7.4). + # Using the proxy (0.70 > 0.55) would trigger a charge risk penalty; pH74 (0.30) would not. + feat_with_ph74 = { + "length": 15, "charge_density": 0.70, "charge_density_ph74": 0.30, + "hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0, + "longest_repeat_run": 1, "hydrophobic_moment": 0.0, + } + feat_without_ph74 = { + "length": 15, "charge_density": 0.70, + "hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0, + "longest_repeat_run": 1, "hydrophobic_moment": 0.0, + } + score_with = safety_score(feat_with_ph74) + score_without = safety_score(feat_without_ph74) + # When pH74 is present, the 0.70 proxy should be ignored → no charge risk → higher score + assert score_with > score_without, ( + "When charge_density_ph74=0.30 is present, the high proxy (0.70) should be ignored" + ) + # Without pH74 key, falls back to the proxy (0.70 > 0.55 → charge risk penalty) + assert score_without < 1.0, "Fallback to charge_density=0.70 should trigger risk penalty" + # With pH74 key (0.30 < 0.55), no charge risk penalty + assert score_with == 1.0, "charge_density_ph74=0.30 should produce no charge risk" + def test_seed003_variants_lower_risk(self): # SEED-003 tryptophan-rich variants: shorter, highly charged, moderate μH # Should have higher safety than SEED-005 amphipathic variants