From 812cdd33fe92650ddc21240dc3c929a209a2c9b6 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 15:27:10 +0700 Subject: [PATCH 1/2] feat: safety pH74 charge consistency + stronger selectivity penalty (PR #53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safety_score(): use charge_density_ph74 (physiologically accurate pH 7.4) preferentially over the proxy, matching activity_likeness_score() behaviour. Prevents His-rich sequences from being incorrectly flagged for charge risk at the physiological pH where His is uncharged. Backward compat: falls back to charge_density when charge_density_ph74 is absent. - _pilot_priority(): add cytotox_penalty term for HIGH_CYTOTOX_RISK candidates (selectivity_proxy < 0.5). Candidates in this tier now receive both reduced bonus AND an additional linear penalty, totalling up to −0.05 extra demerit vs the old soft-bonus formulation. Max total selectivity swing: ±0.05 → 0.10. SEED-004 temporin (proxy=0.30): net selectivity −0.005 (was +0.015 before). Literature: Dathe & Wieprecht (1999) BBA 1462:71-87; Carotenuto et al. (2008). - 4 new tests: charge_density_ph74 preference in safety, cytotox_penalty boundary, max value, and intermediate value - 3 updated tests: max_delta 0.05→0.10, SEED-004 demerit 0.035→0.055 - Makefile: bump test count to 1172 --- Makefile | 2 +- src/openamp_foundry/scoring/safety.py | 3 +- src/openamp_foundry/selection/pilot.py | 32 +++++++++++++------ tests/test_pilot_panel.py | 43 ++++++++++++++++++++++---- tests/test_safety_moment.py | 26 ++++++++++++++++ 5 files changed, 88 insertions(+), 18 deletions(-) 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..0ef547a8 100644 --- a/src/openamp_foundry/scoring/safety.py +++ b/src/openamp_foundry/scoring/safety.py @@ -17,7 +17,8 @@ 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 as activity scorer); fall back to proxy for older 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..b9ab8c74 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 − 0.05×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..9746fd88 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,41 @@ 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} + ) + # 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) + + 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 From 636cd8d8310006c5b07c8caa9a94c4e651012694 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 15:33:27 +0700 Subject: [PATCH 2/2] fix: address code review issues on PR #53 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pilot.py docstring formula: correct − 0.05×cytotox_penalty → − cytotox_penalty (the variable already has 0.05 factored in; the summary line was misleading) - safety.py comment: clarify abs() is deliberate (charge magnitude = hemolysis risk) - test_pilot_panel.py: add monotonicity assertion at proxy=0.49 (below threshold) --- src/openamp_foundry/scoring/safety.py | 3 ++- src/openamp_foundry/selection/pilot.py | 2 +- tests/test_pilot_panel.py | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/openamp_foundry/scoring/safety.py b/src/openamp_foundry/scoring/safety.py index 0ef547a8..dd9572e0 100644 --- a/src/openamp_foundry/scoring/safety.py +++ b/src/openamp_foundry/scoring/safety.py @@ -17,7 +17,8 @@ def safety_score(features: dict) -> float: typical AMP-like candidates (μH ≈ 0.35–0.50). """ length = features["length"] - # Prefer pH 7.4 charge (same as activity scorer); fall back to proxy for older callers. + # 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"] diff --git a/src/openamp_foundry/selection/pilot.py b/src/openamp_foundry/selection/pilot.py index b9ab8c74..6384a983 100644 --- a/src/openamp_foundry/selection/pilot.py +++ b/src/openamp_foundry/selection/pilot.py @@ -18,7 +18,7 @@ def _pilot_priority(scores: dict) -> float: Formula: ensemble − 0.30×disagreement + 0.05×serum_stability + 0.05×novelty - + 0.05×selectivity_proxy − 0.05×cytotox_penalty + + 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 diff --git a/tests/test_pilot_panel.py b/tests/test_pilot_panel.py index 9746fd88..cd697b32 100644 --- a/tests/test_pilot_panel.py +++ b/tests/test_pilot_panel.py @@ -160,9 +160,13 @@ def test_cytotox_penalty_not_applied_at_boundary(self): 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