Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
4 changes: 3 additions & 1 deletion src/openamp_foundry/scoring/safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
32 changes: 22 additions & 10 deletions src/openamp_foundry/selection/pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
47 changes: 41 additions & 6 deletions tests/test_pilot_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_safety_moment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading