From ba728eda297b7e5bbae35127b30763d9860a96b2 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 09:52:07 +0700 Subject: [PATCH 1/2] test: proline edge cases + ZwitterAMP trap end-to-end scorer divergence tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proline edge cases (test_boman_scorer.py): - test_all_proline_returns_zero: P has Boman potential 0.0 → boman_index("PPPPPPPP")=0.0; matches G (both are conformational residues with no side-chain charge contribution) - test_proline_same_as_glycine_in_boman: G and P share the same Boman table entry (0.0) - test_all_proline_boman_activity_is_midpoint: boman_index=0 → tanh(0)=0 → score=0.5 ZwitterAMP trap (test_pipeline_filters.py): - KDKDKDKD is designed to fool the Boman scorer: K and D each have Boman potential +2.465 (highest table value), so boman_index=2.465 → boman_activity≈0.92. But net charge=0 (K and D cancel) and no hydrophobicity, so activity_likeness≈0.19. Disagreement≈0.73. - test_zwitteramp_trap_scorer_divergence: pins actual computed values (act<0.25, bom>0.85, dis>0.60) to verify the two scoring systems diverge as expected on this false positive - test_zwitteramp_trap_blocked_by_pipeline: full run_ranking_pipeline integration test confirms selected=False for KDKDKDKD against the default pipeline.yaml gate (max_dis=0.40) - test_all_proline_pipeline_scores_and_disagreement: PPPPPPPP has dis≈0.33, which passes pipeline.yaml gate (0.40) but would fail phase3.yaml gate (0.30); both config thresholds tested explicitly against live computed values Why this matters for synthesis: these tests document that the two independent scorers serve as cross-check, not just redundancy — a sequence that is systematically biased in one scoring paradigm (all-polar, no-net-charge) still gets caught by the other scorer and by disagreement. --- tests/test_boman_scorer.py | 14 ++++++ tests/test_pipeline_filters.py | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/tests/test_boman_scorer.py b/tests/test_boman_scorer.py index fa08d97f..1d97686b 100644 --- a/tests/test_boman_scorer.py +++ b/tests/test_boman_scorer.py @@ -78,6 +78,15 @@ def test_all_non_canonical_returns_zero(self): assert boman_index("XXX") == pytest.approx(0.0, abs=1e-4) assert boman_index("BBBBB") == pytest.approx(0.0, abs=1e-4) + def test_all_proline_returns_zero(self): + # P has Boman potential 0.0 → mean = 0.0 for any poly-Pro sequence + assert boman_index("P") == pytest.approx(0.0, abs=1e-4) + assert boman_index("PPPPPPPP") == pytest.approx(0.0, abs=1e-4) + + def test_proline_same_as_glycine_in_boman(self): + # Both G and P have Boman potential 0.0 (conformational residues, no charge) + assert boman_index("GGGG") == boman_index("PPPP") + class TestBomanActivityScore: def test_returns_between_zero_and_one(self): @@ -98,6 +107,11 @@ def test_zero_boman_maps_to_near_half(self): score = boman_activity_score("GGGG") assert score == pytest.approx(0.5, abs=1e-2) + def test_all_proline_boman_activity_is_midpoint(self): + # P has Boman potential 0.0 → boman_index = 0.0 → tanh(0) = 0 → score = 0.5 + score = boman_activity_score("PPPPPPPP") + assert score == pytest.approx(0.5, abs=1e-2) + def test_monotone_with_boman_index(self): seqs = ["WWWW", "LLLL", "GGGG", "SSSS", "KKKK"] indices = [boman_index(s) for s in seqs] diff --git a/tests/test_pipeline_filters.py b/tests/test_pipeline_filters.py index 7ed53284..1122a93b 100644 --- a/tests/test_pipeline_filters.py +++ b/tests/test_pipeline_filters.py @@ -4,6 +4,8 @@ import json from pathlib import Path +import pytest + from openamp_foundry.config import load_config from openamp_foundry.pipeline import _passes_length_filter, run_ranking_pipeline, score_candidates @@ -253,3 +255,85 @@ def test_phase3_config_has_stricter_safety_filter_than_pipeline(): candidate_safety = 0.50 assert candidate_safety >= pipeline_min_safety, "Should pass pipeline filter" assert candidate_safety < phase3_min_safety, "Should fail phase3 filter" + + +def test_zwitteramp_trap_scorer_divergence(): + """KDKDKDKD is the 'ZwitterAMP trap': Boman scores it high (K+D each = +2.465 Boman + potential → high interaction energy), but activity_likeness scores it low (net charge = 0, + no hydrophobicity). Disagreement ≈ 0.73, well above both gate thresholds (0.40/0.30). + + This test pins the end-to-end scorer divergence computation, ensuring that the two + independent scoring systems and the disagreement gate together catch this false positive. + """ + from openamp_foundry.features.physchem import compute_features + from openamp_foundry.scoring.activity import activity_likeness_score + from openamp_foundry.scoring.boman import boman_activity_score, model_disagreement + + seq = "KDKDKDKD" + features = compute_features(seq) + + act = activity_likeness_score(features) + bom = boman_activity_score(seq) + dis = model_disagreement(act, bom) + + # Boman sees K+D interaction potential → high score (K and D share same Boman potential) + assert bom > 0.85, f"boman_activity expected > 0.85 for KDKDKDKD, got {bom}" + # Activity scorer correctly penalises: net charge = 0, no hydrophobicity + assert act < 0.25, f"activity_likeness expected < 0.25 for KDKDKDKD (no net charge), got {act}" + # Disagreement exceeds BOTH gate thresholds (pipeline=0.40, phase3=0.30) + assert dis > 0.60, f"disagreement expected > 0.60 for KDKDKDKD, got {dis}" + assert dis > 0.40, "KDKDKDKD must exceed pipeline.yaml max_disagreement=0.40" + assert dis > 0.30, "KDKDKDKD must exceed phase3.yaml max_disagreement=0.30" + + +def test_zwitteramp_trap_blocked_by_pipeline(tmp_path): + """KDKDKDKD reaches selected=False via the disagreement gate in the full pipeline run.""" + csv_path = tmp_path / "candidates.csv" + refs_path = tmp_path / "refs.csv" + csv_path.write_text("id,sequence,source\nZWITTER-001,KDKDKDKD,test\n") + refs_path.write_text("id,sequence,source\n") # empty refs → novelty=1.0 + + out = tmp_path / "ranked.jsonl" + run_ranking_pipeline( + candidate_path=csv_path, + reference_path=refs_path, + out_path=out, + ) + rows = [json.loads(ln) for ln in out.read_text().splitlines() if ln.strip()] + assert len(rows) == 1 + row = rows[0] + # Both scorer values should be captured + assert row["scores"]["disagreement"] > 0.60 + # The default pipeline.yaml has max_disagreement=0.40 → this candidate is blocked + assert row["selected"] is False, ( + f"KDKDKDKD should be blocked (disagreement={row['scores']['disagreement']:.3f} " + f"> pipeline max_disagreement=0.40)" + ) + + +def test_all_proline_pipeline_scores_and_disagreement(tmp_path): + """PPPPPPPP: Boman(P)=0 → boman_activity=0.5; activity_likeness ≈ 0.17 (no charge). + Disagreement ≈ 0.33 — below pipeline gate (0.40) but above phase3 gate (0.30). + Pins this edge case so proline handling is explicit. + """ + from openamp_foundry.features.physchem import compute_features + from openamp_foundry.scoring.activity import activity_likeness_score + from openamp_foundry.scoring.boman import boman_activity_score, model_disagreement + + seq = "PPPPPPPP" + features = compute_features(seq) + act = activity_likeness_score(features) + bom = boman_activity_score(seq) + dis = model_disagreement(act, bom) + + # P has Boman potential 0.0 → neutral Boman activity (0.5) + assert bom == pytest.approx(0.5, abs=0.01) + # Activity is low: no charge, not hydrophobic + assert act < 0.25 + # Disagreement ≈ 0.33: passes pipeline gate (0.40) but fails phase3 gate (0.30) + assert 0.25 < dis < 0.45, f"PPPPPPPP disagreement expected ~0.33, got {dis}" + repo_root = Path(__file__).parents[1] + pipeline_max = float(load_config(repo_root / "configs" / "pipeline.yaml")["selection"]["max_disagreement"]) + phase3_max = float(load_config(repo_root / "configs" / "phase3.yaml")["selection"]["max_disagreement"]) + assert dis < pipeline_max, "PPPPPPPP should pass the pipeline disagreement gate" + assert dis > phase3_max, "PPPPPPPP should fail the stricter phase3 disagreement gate" From 113360bf85b87247915f4a3e70902a3800d8905e Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 09:55:40 +0700 Subject: [PATCH 2/2] fix: move scorer imports to module level, strengthen proline test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_pipeline_filters.py: move compute_features, activity_likeness_score, boman_activity_score, model_disagreement to module-level imports (good practice even though PLC0415 is not enabled in this project's ruff config); remove redundant dis>0.40 / dis>0.30 assertions that were logically implied by dis>0.60 - test_boman_scorer.py: replace test_proline_same_as_glycine_in_boman (compared two computed values against each other — would survive an equal-but-wrong implementation) with test_proline_boman_index_is_zero_like_glycine asserting both against pytest.approx(0.0) --- tests/test_boman_scorer.py | 8 +++++--- tests/test_pipeline_filters.py | 15 ++++----------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/test_boman_scorer.py b/tests/test_boman_scorer.py index 1d97686b..381e1446 100644 --- a/tests/test_boman_scorer.py +++ b/tests/test_boman_scorer.py @@ -83,9 +83,11 @@ def test_all_proline_returns_zero(self): assert boman_index("P") == pytest.approx(0.0, abs=1e-4) assert boman_index("PPPPPPPP") == pytest.approx(0.0, abs=1e-4) - def test_proline_same_as_glycine_in_boman(self): - # Both G and P have Boman potential 0.0 (conformational residues, no charge) - assert boman_index("GGGG") == boman_index("PPPP") + def test_proline_boman_index_is_zero_like_glycine(self): + # Both G and P have Boman potential 0.0 (conformational residues, no side-chain charge) + # Assert against the ground truth (0.0) rather than comparing two computed values. + assert boman_index("PPPP") == pytest.approx(0.0, abs=1e-4) + assert boman_index("GGGG") == pytest.approx(0.0, abs=1e-4) class TestBomanActivityScore: diff --git a/tests/test_pipeline_filters.py b/tests/test_pipeline_filters.py index 1122a93b..e1dce505 100644 --- a/tests/test_pipeline_filters.py +++ b/tests/test_pipeline_filters.py @@ -7,7 +7,10 @@ import pytest from openamp_foundry.config import load_config +from openamp_foundry.features.physchem import compute_features from openamp_foundry.pipeline import _passes_length_filter, run_ranking_pipeline, score_candidates +from openamp_foundry.scoring.activity import activity_likeness_score +from openamp_foundry.scoring.boman import boman_activity_score, model_disagreement def test_length_filter_within_range(): @@ -265,10 +268,6 @@ def test_zwitteramp_trap_scorer_divergence(): This test pins the end-to-end scorer divergence computation, ensuring that the two independent scoring systems and the disagreement gate together catch this false positive. """ - from openamp_foundry.features.physchem import compute_features - from openamp_foundry.scoring.activity import activity_likeness_score - from openamp_foundry.scoring.boman import boman_activity_score, model_disagreement - seq = "KDKDKDKD" features = compute_features(seq) @@ -280,10 +279,8 @@ def test_zwitteramp_trap_scorer_divergence(): assert bom > 0.85, f"boman_activity expected > 0.85 for KDKDKDKD, got {bom}" # Activity scorer correctly penalises: net charge = 0, no hydrophobicity assert act < 0.25, f"activity_likeness expected < 0.25 for KDKDKDKD (no net charge), got {act}" - # Disagreement exceeds BOTH gate thresholds (pipeline=0.40, phase3=0.30) + # Disagreement is well above both gate thresholds (pipeline=0.40, phase3=0.30) assert dis > 0.60, f"disagreement expected > 0.60 for KDKDKDKD, got {dis}" - assert dis > 0.40, "KDKDKDKD must exceed pipeline.yaml max_disagreement=0.40" - assert dis > 0.30, "KDKDKDKD must exceed phase3.yaml max_disagreement=0.30" def test_zwitteramp_trap_blocked_by_pipeline(tmp_path): @@ -316,10 +313,6 @@ def test_all_proline_pipeline_scores_and_disagreement(tmp_path): Disagreement ≈ 0.33 — below pipeline gate (0.40) but above phase3 gate (0.30). Pins this edge case so proline handling is explicit. """ - from openamp_foundry.features.physchem import compute_features - from openamp_foundry.scoring.activity import activity_likeness_score - from openamp_foundry.scoring.boman import boman_activity_score, model_disagreement - seq = "PPPPPPPP" features = compute_features(seq) act = activity_likeness_score(features)